Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
64 changes: 51 additions & 13 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,17 @@ func (m Model) handleKeyPress(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}

if m.Screen == ScreenProfileCreate && m.ProfileCreateStep == 1 &&
m.ModelPicker.Mode == screens.ModePhaseList && keyStr == "backspace" &&
len(m.ModelPicker.AvailableIDs) > 0 {
rows := screens.ModelPickerRowsForProfile()
if m.Cursor < len(rows) && m.Cursor != screens.SeparatorRowIdx() {
m.ModelPicker.SelectedPhaseIdx = m.Cursor
m.Selection.ModelAssignments = screens.ClearModelPickerAssignment(&m.ModelPicker, m.Selection.ModelAssignments)
return m, nil
}
}

if m.Screen == ScreenClaudeModelPicker {
wasInCustomMode := m.ClaudeModelPicker.InCustomMode
previousMode := m.ClaudeModelPicker.Mode
Expand Down Expand Up @@ -1259,7 +1270,7 @@ func (m Model) handleKeyPress(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
// Skip separator row in model picker β€” it is not selectable.
if m.Screen == ScreenModelPicker && !m.ModelPicker.ForProfile && m.Cursor == screens.SeparatorRowIdx() && m.Cursor > 0 {
if m.shouldSkipModelPickerSeparator() && m.Cursor == screens.SeparatorRowIdx() && m.Cursor > 0 {
m.Cursor--
}
return m, nil
Expand All @@ -1283,7 +1294,7 @@ func (m Model) handleKeyPress(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
// Skip separator row in model picker β€” it is not selectable.
if m.Screen == ScreenModelPicker && !m.ModelPicker.ForProfile && m.Cursor == screens.SeparatorRowIdx() {
if m.shouldSkipModelPickerSeparator() && m.Cursor == screens.SeparatorRowIdx() {
if m.Cursor+1 < count {
m.Cursor++
}
Expand All @@ -1306,7 +1317,7 @@ func (m Model) handleKeyPress(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
// Skip separator row in model picker β€” it is not selectable.
if m.Screen == ScreenModelPicker && !m.ModelPicker.ForProfile && m.Cursor == screens.SeparatorRowIdx() && m.Cursor > 0 {
if m.shouldSkipModelPickerSeparator() && m.Cursor == screens.SeparatorRowIdx() && m.Cursor > 0 {
m.Cursor--
}
return m, nil
Expand All @@ -1325,7 +1336,7 @@ func (m Model) handleKeyPress(key tea.KeyMsg) (tea.Model, tea.Cmd) {
}
}
// Skip separator row in model picker β€” it is not selectable.
if m.Screen == ScreenModelPicker && !m.ModelPicker.ForProfile && m.Cursor == screens.SeparatorRowIdx() {
if m.shouldSkipModelPickerSeparator() && m.Cursor == screens.SeparatorRowIdx() {
if m.Cursor+1 < count {
m.Cursor++
}
Expand Down Expand Up @@ -1423,6 +1434,14 @@ func (m Model) handleKeyPress(key tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}

func (m Model) shouldSkipModelPickerSeparator() bool {
if len(m.ModelPicker.AvailableIDs) == 0 {
return false
}
return (m.Screen == ScreenModelPicker && !m.ModelPicker.ForProfile) ||
(m.Screen == ScreenProfileCreate && m.ProfileCreateStep == 1 && m.ModelPicker.Mode == screens.ModePhaseList)
}

func (m Model) confirmSelection() (tea.Model, tea.Cmd) {
switch m.Screen {
case ScreenWelcome:
Expand Down Expand Up @@ -3094,7 +3113,7 @@ func (m Model) optionCount() int {
return 1
case ScreenModelPicker:
if len(m.ModelPicker.AvailableIDs) == 0 {
return 2 // Continue with defaults + Back to SDD mode
return 2 // Continue with defaults + Back
}
return len(screens.ModelPickerRows()) + 2 // rows + Continue + Back
case ScreenDependencyTree:
Expand Down Expand Up @@ -3912,9 +3931,27 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) {
case 1:
// Model assignment picker: orchestrator + all sub-agent phases in one screen.
// Reuse the same enter-on-row logic as ScreenModelPicker.
// Profile creation uses filtered rows (no JD agents).
// Profile creation uses the profile-specific row list.
if len(m.ModelPicker.AvailableIDs) == 0 {
switch m.Cursor {
case 0:
m.ProfileCreateStep = 2
m.Cursor = 0
case 1:
if m.ProfileEditMode {
m.setScreen(ScreenProfiles)
} else {
m.ProfileCreateStep = 0
m.Cursor = 0
}
}
return m, nil
}
rows := screens.ModelPickerRowsForProfile()
if m.Cursor < len(rows) {
if m.Cursor == screens.SeparatorRowIdx() {
return m, nil
}
// Enter sub-selection: pick provider then model.
m.ModelPicker.SelectedPhaseIdx = m.Cursor
m.ModelPicker.Mode = screens.ModeProviderSelect
Expand All @@ -3925,20 +3962,21 @@ func (m Model) confirmProfileCreate() (tea.Model, tea.Cmd) {
if m.Cursor == len(rows) {
// "Continue": extract orchestrator + phase assignments, advance to confirm.
assignments := sanitizeKnownModelEfforts(m.Selection.ModelAssignments, m.ModelPicker.SDDModels)
if assignments != nil {
// Extract orchestrator model.
m.ProfileDraft.OrchestratorModel = model.ModelAssignment{}
m.ProfileDraft.PhaseAssignments = nil
if len(assignments) > 0 {
if orch, ok := assignments[screens.SDDOrchestratorPhase]; ok {
m.ProfileDraft.OrchestratorModel = orch
}
// Copy all phase assignments (excluding orchestrator).
if m.ProfileDraft.PhaseAssignments == nil {
m.ProfileDraft.PhaseAssignments = make(map[string]model.ModelAssignment)
}
phaseAssignments := make(map[string]model.ModelAssignment)
for k, v := range assignments {
if k != screens.SDDOrchestratorPhase {
m.ProfileDraft.PhaseAssignments[k] = v
phaseAssignments[k] = v
}
}
if len(phaseAssignments) > 0 {
m.ProfileDraft.PhaseAssignments = phaseAssignments
}
}
m.ProfileCreateStep = 2
m.Cursor = 0
Expand Down
122 changes: 121 additions & 1 deletion internal/tui/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ func TestProfileCreateContinueSanitizesStaleEffort(t *testing.T) {
m.ProfileDraft = model.Profile{Name: "work"}
m.Cursor = len(screens.ModelPickerRowsForProfile())
m.ModelPicker = screens.ModelPickerState{
AvailableIDs: []string{"anthropic"},
SDDModels: map[string][]opencode.Model{
"anthropic": {{ID: "claude-sonnet-4", Variants: []string{"low", "medium"}}},
},
Expand Down Expand Up @@ -154,6 +155,7 @@ func TestProfileEditContinueSanitizesStaleEffort(t *testing.T) {
m.ProfileDraft = model.Profile{Name: "work"}
m.Cursor = len(screens.ModelPickerRowsForProfile())
m.ModelPicker = screens.ModelPickerState{
AvailableIDs: []string{"anthropic"},
SDDModels: map[string][]opencode.Model{
"anthropic": {{ID: "claude-sonnet-4", Variants: []string{"low", "medium"}}},
},
Expand All @@ -180,7 +182,7 @@ func TestProfileCreateContinuePreservesEffortWhenVariantDataUnknown(t *testing.T
m.ProfileCreateStep = 1
m.ProfileDraft = model.Profile{Name: "work"}
m.Cursor = len(screens.ModelPickerRowsForProfile())
m.ModelPicker = screens.ModelPickerState{SDDModels: map[string][]opencode.Model{}}
m.ModelPicker = screens.ModelPickerState{AvailableIDs: []string{"anthropic"}, SDDModels: map[string][]opencode.Model{}}
m.Selection.ModelAssignments = map[string]model.ModelAssignment{
screens.SDDOrchestratorPhase: {ProviderID: "anthropic", ModelID: "claude-sonnet-4", Effort: "high"},
"sdd-apply": {ProviderID: "anthropic", ModelID: "claude-sonnet-4", Effort: "high"},
Expand All @@ -197,6 +199,124 @@ func TestProfileCreateContinuePreservesEffortWhenVariantDataUnknown(t *testing.T
}
}

func profileModelStep(available bool) Model {
m := NewModel(system.DetectionResult{}, "dev")
m.Screen = ScreenProfileCreate
m.ProfileCreateStep = 1
m.ModelPicker = screens.ModelPickerState{Mode: screens.ModePhaseList, ForProfile: true}
if available {
m.ModelPicker.AvailableIDs = []string{"openai"}
}
return m
}

func TestProfileCreateEmptyProviderEnterContinuesAndBacksOut(t *testing.T) {
keep := model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-sonnet-4", Effort: "high"}
orch := model.ModelAssignment{ProviderID: "openai", ModelID: "gpt-5"}

m := profileModelStep(false)
m.ProfileDraft = model.Profile{
Name: "work",
OrchestratorModel: orch,
PhaseAssignments: map[string]model.ModelAssignment{"sdd-apply": keep},
}

updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
state := updated.(Model)

if state.ProfileCreateStep != 2 || state.Cursor != 0 {
t.Fatalf("step/cursor = %d/%d, want 2/0", state.ProfileCreateStep, state.Cursor)
}
if state.ProfileDraft.OrchestratorModel != orch {
t.Fatalf("orchestrator = %+v, want unchanged %+v", state.ProfileDraft.OrchestratorModel, orch)
}
if got := state.ProfileDraft.PhaseAssignments["sdd-apply"]; got != keep {
t.Fatalf("sdd-apply assignment = %+v, want unchanged %+v", got, keep)
}

back := profileModelStep(false)
back.Cursor = 1
updated, _ = back.Update(tea.KeyMsg{Type: tea.KeyEnter})
state = updated.(Model)

if state.Screen != ScreenProfileCreate || state.ProfileCreateStep != 0 || state.Cursor != 0 {
t.Fatalf("screen/step/cursor = %v/%d/%d, want ScreenProfileCreate/0/0", state.Screen, state.ProfileCreateStep, state.Cursor)
}
}

func TestProfileCreateSeparatorIsIgnoredAndSkipped(t *testing.T) {
sepIdx := screens.SeparatorRowIdx()
if sepIdx < 0 {
t.Skip("no separator row defined")
}

m := profileModelStep(true)
m.Cursor = sepIdx

updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
state := updated.(Model)

if state.ModelPicker.Mode != screens.ModePhaseList {
t.Fatalf("ModelPicker.Mode = %v, want ModePhaseList", state.ModelPicker.Mode)
}
if state.ModelPicker.SelectedPhaseIdx == sepIdx {
t.Fatalf("separator row should not become selected phase index %d", sepIdx)
}

state.Cursor = sepIdx - 1
updated, _ = state.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
state = updated.(Model)

if state.Cursor != sepIdx+1 {
t.Fatalf("cursor after j from row before separator = %d, want %d", state.Cursor, sepIdx+1)
}
}

func TestProfileCreateBackspaceClearsSelectedJDAssignment(t *testing.T) {
jdPhases := opencode.JDPhases()
if len(jdPhases) == 0 {
t.Skip("no JD phases defined")
}
target := jdPhases[0]
keep := model.ModelAssignment{ProviderID: "anthropic", ModelID: "claude-sonnet-4"}

m := profileModelStep(true)
m.ProfileEditMode = true
m.ProfileDraft = model.Profile{
Name: "work",
PhaseAssignments: map[string]model.ModelAssignment{
target: {ProviderID: "openai", ModelID: "gpt-5"},
"sdd-apply": keep,
},
}
m.Cursor = screens.SeparatorRowIdx() + 1
m.Selection.ModelAssignments = map[string]model.ModelAssignment{
target: {ProviderID: "openai", ModelID: "gpt-5"},
"sdd-apply": keep,
}

updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
state := updated.(Model)

if _, exists := state.Selection.ModelAssignments[target]; exists {
t.Fatalf("%s should be cleared through the profile key handler; assignments = %v", target, state.Selection.ModelAssignments)
}
if got := state.Selection.ModelAssignments["sdd-apply"]; got != keep {
t.Fatalf("sdd-apply assignment = %+v, want unchanged %+v", got, keep)
}

state.Cursor = len(screens.ModelPickerRowsForProfile())
updated, _ = state.Update(tea.KeyMsg{Type: tea.KeyEnter})
state = updated.(Model)

if _, exists := state.ProfileDraft.PhaseAssignments[target]; exists || state.ProfileCreateStep != 2 {
t.Fatalf("%s should stay cleared after continuing to confirm; draft = %+v", target, state.ProfileDraft.PhaseAssignments)
}
if got := state.ProfileDraft.PhaseAssignments["sdd-apply"]; got != keep {
t.Fatalf("draft sdd-apply assignment = %+v, want unchanged %+v", got, keep)
}
}

func TestNavigationBackWithEscape(t *testing.T) {
m := NewModel(system.DetectionResult{}, "dev")
m.Screen = ScreenPersona
Expand Down
Loading
Loading