Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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
19 changes: 15 additions & 4 deletions cmd/nightshift/commands/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,26 @@ 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 {
if cfg == nil {
return agents.NewCodexAgent()
}
return agents.NewCodexAgent(
opts := []agents.CodexOption{
agents.WithDangerouslyBypassApprovalsAndSandbox(cfg.Providers.Codex.DangerouslyBypassApprovalsAndSandbox),
)
}
if cfg.Providers.Codex.Model != "" {
opts = append(opts, agents.WithCodexModel(cfg.Providers.Codex.Model))
}
return agents.NewCodexAgent(opts...)
}

// newCopilotAgentFromConfig creates a CopilotAgent from config. If binaryPath
Expand All @@ -77,5 +85,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...)
}
159 changes: 158 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,58 @@ const (
nightshiftPlanIgnoreComment = "# Nightshift plan artifacts (keep out of version control)"
)

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

// 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 +150,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 +304,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 +344,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 +468,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 +882,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 +1661,93 @@ 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 < 2 {
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 +1939,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
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
54 changes: 54 additions & 0 deletions internal/agents/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,57 @@ func TestExecRunner_Run_WithStdin(t *testing.T) {
t.Errorf("stdout = %q, want %q", stdout, "hello from stdin")
}
}

func TestClaudeAgent_Execute_WithModel(t *testing.T) {
mock := &MockRunner{Stdout: "response", ExitCode: 0}
agent := NewClaudeAgent(
WithModel("claude-sonnet-4-6"),
WithRunner(mock),
)

_, err := agent.Execute(context.Background(), ExecuteOptions{Prompt: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !containsArg(mock.CapturedArgs, "--model") {
t.Error("expected --model in args")
}
if !containsArg(mock.CapturedArgs, "claude-sonnet-4-6") {
t.Error("expected model value in args")
}
}

func TestClaudeAgent_Execute_NoModel(t *testing.T) {
mock := &MockRunner{Stdout: "response", ExitCode: 0}
agent := NewClaudeAgent(WithRunner(mock))

_, err := agent.Execute(context.Background(), ExecuteOptions{Prompt: "test"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if containsArg(mock.CapturedArgs, "--model") {
t.Error("expected no --model flag when model is not set")
}
}

func TestClaudeAgent_Execute_ModelFromOptions(t *testing.T) {
mock := &MockRunner{Stdout: "response", ExitCode: 0}
agent := NewClaudeAgent(
WithModel("claude-opus-4-6"),
WithRunner(mock),
)

_, err := agent.Execute(context.Background(), ExecuteOptions{
Prompt: "test",
Model: "claude-haiku-4-5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !containsArg(mock.CapturedArgs, "claude-haiku-4-5") {
t.Error("expected ExecuteOptions.Model to override agent default")
}
if containsArg(mock.CapturedArgs, "claude-opus-4-6") {
t.Error("expected agent default model to be overridden")
}
}
Loading
Loading