Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fa97be8
feat(agents): add model selection support for all providers
cedricfarinazzo Feb 17, 2026
ec6557a
remove trash .md created by copilot
cedricfarinazzo Feb 20, 2026
f09f35f
feat(setup): add model selection step for each provider
cedricfarinazzo Feb 20, 2026
999f1d2
fix(lint): resolve all golangci-lint staticcheck and unused warnings
cedricfarinazzo Feb 20, 2026
2f8756a
Merge branch 'main' into model-selection
cedricfarinazzo Feb 28, 2026
ded1f2c
revert: undo WriteString→fmt.Fprintf migration for non-model-selectio…
cedricfarinazzo Feb 28, 2026
df79ba6
revert: undo WriteString→fmt.Fprintf migration in setup.go
cedricfarinazzo Feb 28, 2026
86f6ff7
Merge remote-tracking branch 'origin/main' into model-selection
cedricfarinazzo Feb 28, 2026
61ac39a
style: remove trailing whitespace in copilot.go
cedricfarinazzo Feb 28, 2026
3a17c09
docs: add source comments to model lists in setup.go
cedricfarinazzo Feb 28, 2026
c28977d
fix: add model selection support and fix gh copilot args
cedricfarinazzo Feb 28, 2026
38642cd
fix: wire --model flag for gh copilot mode
cedricfarinazzo Feb 28, 2026
8d359b5
test: add WithCopilotModel option tests
cedricfarinazzo Feb 28, 2026
54f31b1
test: add WithModel option tests for Claude and Codex agents
cedricfarinazzo Feb 28, 2026
546d309
style: fix indentation in claude and codex model tests
cedricfarinazzo Feb 28, 2026
5aa13b7
Merge remote-tracking branch 'origin/main' into model-selection
cedricfarinazzo Feb 28, 2026
8185ac4
Merge branch 'main' into model-selection; fix review notes from #32
cedricfarinazzo Mar 8, 2026
c928aaa
fix: persist model selection to config in writeGlobalConfigToPath
cedricfarinazzo Mar 8, 2026
0a5691f
feat(preview): show model in provider budget summary
cedricfarinazzo Mar 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions cmd/nightshift/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,13 @@ func newClaudeAgentFromConfig(cfg *config.Config) *agents.ClaudeAgent {
if cfg == nil {
return agents.NewClaudeAgent()
}
return agents.NewClaudeAgent(
opts := []agents.ClaudeOption{
agents.WithDangerouslySkipPermissions(cfg.Providers.Claude.DangerouslySkipPermissions),
)
}
if cfg.Providers.Claude.Model != "" {
opts = append(opts, agents.WithModel(cfg.Providers.Claude.Model))
}
return agents.NewClaudeAgent(opts...)
}

func newCodexAgentFromConfig(cfg *config.Config) *agents.CodexAgent {
Expand All @@ -65,6 +69,9 @@ func newCodexAgentFromConfig(cfg *config.Config) *agents.CodexAgent {
if cfg.Providers.Codex.DangerouslyBypassApprovalsAndSandbox {
opts = append(opts, agents.WithDangerouslyBypassApprovalsAndSandbox(true))
}
if cfg.Providers.Codex.Model != "" {
opts = append(opts, agents.WithCodexModel(cfg.Providers.Codex.Model))
}
return agents.NewCodexAgent(opts...)
}

Expand All @@ -91,5 +98,8 @@ func newCopilotAgentFromConfig(cfg *config.Config, binaryPath ...string) *agents
agents.WithCopilotBinaryPath(binary),
agents.WithCopilotDangerouslySkipPermissions(cfg.Providers.Copilot.DangerouslySkipPermissions),
}
if cfg.Providers.Copilot.Model != "" {
opts = append(opts, agents.WithCopilotModel(cfg.Providers.Copilot.Model))
}
return agents.NewCopilotAgent(opts...)
}
4 changes: 4 additions & 0 deletions cmd/nightshift/commands/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,7 @@ func buildPreviewResult(cfg *config.Config, database *db.DB, projects []string,

type providerBudgetSummary struct {
name string
model string
allowance *budget.AllowanceResult
err error
}
Expand All @@ -420,6 +421,7 @@ func collectProviderBudgets(cfg *config.Config, budgetMgr *budget.Manager) []pro
allowance, err := budgetMgr.CalculateAllowance("claude")
summaries = append(summaries, providerBudgetSummary{
name: "claude",
model: cfg.Providers.Claude.Model,
allowance: allowance,
err: err,
})
Expand All @@ -428,6 +430,7 @@ func collectProviderBudgets(cfg *config.Config, budgetMgr *budget.Manager) []pro
allowance, err := budgetMgr.CalculateAllowance("codex")
summaries = append(summaries, providerBudgetSummary{
name: "codex",
model: cfg.Providers.Codex.Model,
allowance: allowance,
err: err,
})
Expand All @@ -436,6 +439,7 @@ func collectProviderBudgets(cfg *config.Config, budgetMgr *budget.Manager) []pro
allowance, err := budgetMgr.CalculateAllowance("copilot")
summaries = append(summaries, providerBudgetSummary{
name: "copilot",
model: cfg.Providers.Copilot.Model,
allowance: allowance,
err: err,
})
Expand Down
9 changes: 7 additions & 2 deletions cmd/nightshift/commands/preview_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,17 @@ func renderPreviewText(result *previewResult, opts previewTextOptions) string {
fmt.Fprintf(b, " - %s: budget error: %v\n", summary.name, summary.err)
continue
}
line := fmt.Sprintf(" - %s: %s available (%.1f%% used, weekly=%s, source=%s)",
model := summary.model
if model == "" {
model = "default"
}
line := fmt.Sprintf(" - %s: %s available (%.1f%% used, weekly=%s, source=%s, model=%s)",
summary.name,
formatTokens64(summary.allowance.Allowance),
summary.allowance.UsedPercent,
formatTokens64(summary.allowance.WeeklyBudget),
summary.allowance.BudgetSource)
summary.allowance.BudgetSource,
model)
if summary.allowance.Allowance == 0 && summary.allowance.PredictedUsage > 0 {
line += fmt.Sprintf(" [daytime reserve: %s]", formatTokens64(summary.allowance.PredictedUsage))
}
Expand Down
165 changes: 164 additions & 1 deletion cmd/nightshift/commands/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const (
stepProjects
stepBudget
stepSafety
stepModel
stepTaskPreset
stepTaskSelect
stepSchedule
Expand All @@ -73,6 +74,62 @@ const (
nightshiftPlanIgnoreComment = "# Nightshift plan artifacts (keep out of version control)"
)

type modelOption struct {
label string
value string // empty = use CLI default
}

// modelProviderLists holds the model option slice for each provider in cursor order
// (claude=0, codex=1, copilot=2). Used to bound modelCursor in handleModelInput.
var modelProviderLists = []*[]modelOption{&claudeModels, &codexModels, &copilotModels}

// claudeModels lists available Claude models.
// Source: https://platform.claude.com/docs/en/about-claude/models/overview (Claude API aliases)
var claudeModels = []modelOption{
{label: "default", value: ""},
{label: "claude-opus-4-6", value: "claude-opus-4-6"},
{label: "claude-sonnet-4-6", value: "claude-sonnet-4-6"},
{label: "claude-haiku-4-5", value: "claude-haiku-4-5"},
}

// codexModels lists available Codex models.
// Source: https://developers.openai.com/codex/models/
var codexModels = []modelOption{
{label: "default", value: ""},
{label: "gpt-5.3-codex", value: "gpt-5.3-codex"},
{label: "gpt-5.3-codex-spark", value: "gpt-5.3-codex-spark"},
{label: "gpt-5.2-codex", value: "gpt-5.2-codex"},
{label: "gpt-5.2", value: "gpt-5.2"},
{label: "gpt-5.1-codex-max", value: "gpt-5.1-codex-max"},
{label: "gpt-5.1-codex", value: "gpt-5.1-codex"},
{label: "gpt-5.1", value: "gpt-5.1"},
{label: "gpt-5-codex", value: "gpt-5-codex"},
{label: "gpt-5", value: "gpt-5"},
}

// copilotModels lists available Copilot models.
// Source: `copilot --help`, see the --model flag description for the full list.
var copilotModels = []modelOption{
{label: "default", value: ""},
{label: "claude-sonnet-4.6", value: "claude-sonnet-4.6"},
{label: "claude-sonnet-4.5", value: "claude-sonnet-4.5"},
{label: "claude-haiku-4.5", value: "claude-haiku-4.5"},
{label: "claude-opus-4.6", value: "claude-opus-4.6"},
{label: "claude-opus-4.6-fast", value: "claude-opus-4.6-fast"},
{label: "claude-opus-4.5", value: "claude-opus-4.5"},
{label: "claude-sonnet-4", value: "claude-sonnet-4"},
{label: "gemini-3-pro-preview", value: "gemini-3-pro-preview"},
{label: "gpt-5.3-codex", value: "gpt-5.3-codex"},
{label: "gpt-5.2-codex", value: "gpt-5.2-codex"},
{label: "gpt-5.2", value: "gpt-5.2"},
{label: "gpt-5.1-codex-max", value: "gpt-5.1-codex-max"},
{label: "gpt-5.1-codex", value: "gpt-5.1-codex"},
{label: "gpt-5.1", value: "gpt-5.1"},
{label: "gpt-5.1-codex-mini", value: "gpt-5.1-codex-mini"},
{label: "gpt-5-mini", value: "gpt-5-mini"},
{label: "gpt-4.1", value: "gpt-4.1"},
}

type setupModel struct {
step setupStep

Expand All @@ -97,6 +154,11 @@ type setupModel struct {

safetyCursor int

modelCursor int
claudeModelIdx int
codexModelIdx int
copilotModelIdx int

taskPresetCursor int
taskCursor int
taskItems []taskItem
Expand Down Expand Up @@ -246,6 +308,9 @@ func newSetupModel() (*setupModel, error) {
scheduleInput: scheduleInput,
spinner: spin,
nightshiftInPath: nightshiftInPath,
claudeModelIdx: modelIndex(claudeModels, cfg.Providers.Claude.Model),
codexModelIdx: modelIndex(codexModels, cfg.Providers.Codex.Model),
copilotModelIdx: modelIndex(copilotModels, cfg.Providers.Copilot.Model),
}

return model, nil
Expand Down Expand Up @@ -283,6 +348,8 @@ func (m *setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleBudgetInput(msg)
case stepSafety:
return m.handleSafetyInput(msg)
case stepModel:
return m.handleModelInput(msg)
case stepTaskPreset:
return m.handlePresetInput(msg)
case stepTaskSelect:
Expand Down Expand Up @@ -405,6 +472,12 @@ func (m *setupModel) View() string {
b.WriteString("Use ↑/↓ to select, space to toggle.\n\n")
renderSafetyFields(&b, m)
b.WriteString("\nPress Enter to continue.\n")
case stepModel:
b.WriteString(styleAccent.Render("Model selection"))
b.WriteString("\n")
b.WriteString("Choose the model for each provider. Use ↑/↓ to select a row, ←/→ to cycle models.\n\n")
renderModelFields(&b, m)
b.WriteString("\nPress Enter to continue.\n")
case stepTaskPreset:
b.WriteString(styleAccent.Render("Task presets (derived from registry)"))
b.WriteString("\n")
Expand Down Expand Up @@ -813,7 +886,7 @@ func (m *setupModel) handleSafetyInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.cfg.Providers.Copilot.DangerouslySkipPermissions = !m.cfg.Providers.Copilot.DangerouslySkipPermissions
}
case "enter":
return m, m.setStep(stepTaskPreset)
return m, m.setStep(stepModel)
}
return m, nil
}
Expand Down Expand Up @@ -1592,6 +1665,92 @@ func renderSafetyFields(b *strings.Builder, m *setupModel) {
b.WriteString("\n")
}

func renderModelFields(b *strings.Builder, m *setupModel) {
rows := []struct {
label string
models []modelOption
idx int
available bool
}{
{"Claude ", claudeModels, m.claudeModelIdx, m.cfg.Providers.Claude.Enabled},
{"Codex ", codexModels, m.codexModelIdx, m.cfg.Providers.Codex.Enabled},
{"Copilot", copilotModels, m.copilotModelIdx, m.cfg.Providers.Copilot.Enabled},
}
for i, row := range rows {
cursor := " "
if i == m.modelCursor {
cursor = ">"
}
selected := row.models[row.idx].label
avail := ""
if !row.available {
avail = " (provider disabled)"
}
fmt.Fprintf(b, " %s %s ← %s →%s\n", cursor, row.label, selected, avail)
}
b.WriteString(styleNote.Render("Tip: 'default' lets the CLI pick its built-in model."))
b.WriteString("\n")
}

func (m *setupModel) handleModelInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.modelCursor > 0 {
m.modelCursor--
}
case "down", "j":
if m.modelCursor < len(modelProviderLists)-1 {
m.modelCursor++
}
case "left", "h":
switch m.modelCursor {
case 0:
if m.claudeModelIdx > 0 {
m.claudeModelIdx--
}
case 1:
if m.codexModelIdx > 0 {
m.codexModelIdx--
}
case 2:
if m.copilotModelIdx > 0 {
m.copilotModelIdx--
}
}
case "right", "l":
switch m.modelCursor {
case 0:
if m.claudeModelIdx < len(claudeModels)-1 {
m.claudeModelIdx++
}
case 1:
if m.codexModelIdx < len(codexModels)-1 {
m.codexModelIdx++
}
case 2:
if m.copilotModelIdx < len(copilotModels)-1 {
m.copilotModelIdx++
}
}
case "enter":
m.cfg.Providers.Claude.Model = claudeModels[m.claudeModelIdx].value
m.cfg.Providers.Codex.Model = codexModels[m.codexModelIdx].value
m.cfg.Providers.Copilot.Model = copilotModels[m.copilotModelIdx].value
return m, m.setStep(stepTaskPreset)
}
return m, nil
}

// modelIndex returns the index of the given model value in a model list, defaulting to 0.
func modelIndex(models []modelOption, value string) int {
for i, m := range models {
if m.value == value {
return i
}
}
return 0
}

func renderScheduleFields(b *strings.Builder, m *setupModel) {
fields := []string{
fmt.Sprintf("Start time: %s", m.scheduleStart),
Expand Down Expand Up @@ -1783,6 +1942,7 @@ func setupSteps(includePathStep bool) []setupStepInfo {
{step: stepProjects, label: "Projects"},
{step: stepBudget, label: "Budget"},
{step: stepSafety, label: "Safety"},
{step: stepModel, label: "Models"},
{step: stepTaskPreset, label: "Task presets"},
{step: stepTaskSelect, label: "Task selection"},
{step: stepSchedule, label: "Schedule"},
Expand Down Expand Up @@ -1949,14 +2109,17 @@ func writeGlobalConfigToPath(cfg *config.Config, configPath string) error {
// Providers: set fields individually to match mapstructure tag names (fixes #20)
v.Set("providers.claude.enabled", cfg.Providers.Claude.Enabled)
v.Set("providers.claude.data_path", cfg.Providers.Claude.DataPath)
v.Set("providers.claude.model", cfg.Providers.Claude.Model)
v.Set("providers.claude.dangerously_skip_permissions", cfg.Providers.Claude.DangerouslySkipPermissions)
v.Set("providers.claude.dangerously_bypass_approvals_and_sandbox", cfg.Providers.Claude.DangerouslyBypassApprovalsAndSandbox)
v.Set("providers.codex.enabled", cfg.Providers.Codex.Enabled)
v.Set("providers.codex.data_path", cfg.Providers.Codex.DataPath)
v.Set("providers.codex.model", cfg.Providers.Codex.Model)
v.Set("providers.codex.dangerously_skip_permissions", cfg.Providers.Codex.DangerouslySkipPermissions)
v.Set("providers.codex.dangerously_bypass_approvals_and_sandbox", cfg.Providers.Codex.DangerouslyBypassApprovalsAndSandbox)
v.Set("providers.copilot.enabled", cfg.Providers.Copilot.Enabled)
v.Set("providers.copilot.data_path", cfg.Providers.Copilot.DataPath)
v.Set("providers.copilot.model", cfg.Providers.Copilot.Model)
v.Set("providers.copilot.dangerously_skip_permissions", cfg.Providers.Copilot.DangerouslySkipPermissions)
v.Set("providers.copilot.dangerously_bypass_approvals_and_sandbox", cfg.Providers.Copilot.DangerouslyBypassApprovalsAndSandbox)
v.Set("providers.preference", cfg.Providers.Preference)
Expand Down
1 change: 1 addition & 0 deletions internal/agents/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type ExecuteOptions struct {
WorkDir string // Working directory for execution
Files []string // Optional file paths to include as context
Timeout time.Duration // Execution timeout (0 = default)
Model string // Model to use (optional, uses agent default if empty)
}

// ExecuteResult holds the outcome of an agent execution.
Expand Down
17 changes: 17 additions & 0 deletions internal/agents/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type ClaudeAgent struct {
timeout time.Duration // Default timeout
runner CommandRunner // Command executor (for testing)
skipPerms bool // Pass --dangerously-skip-permissions
model string // Default model to use
}

// ClaudeOption configures a ClaudeAgent.
Expand All @@ -87,6 +88,13 @@ func WithDangerouslySkipPermissions(enabled bool) ClaudeOption {
}
}

// WithModel sets the default model to use.
func WithModel(model string) ClaudeOption {
return func(a *ClaudeAgent) {
a.model = model
}
}

// WithRunner sets a custom command runner (for testing).
func WithRunner(r CommandRunner) ClaudeOption {
return func(a *ClaudeAgent) {
Expand Down Expand Up @@ -133,6 +141,15 @@ func (a *ClaudeAgent) Execute(ctx context.Context, opts ExecuteOptions) (*Execut
args = append(args, "--dangerously-skip-permissions")
}

// Add model if specified
model := opts.Model
if model == "" {
model = a.model
}
if model != "" {
args = append(args, "--model", model)
}

// Add prompt directly as argument
if opts.Prompt != "" {
args = append(args, opts.Prompt)
Expand Down
Loading
Loading