diff --git a/cmd/tui/selector.go b/cmd/tui/selector.go index 898c1396d..7bf8180be 100644 --- a/cmd/tui/selector.go +++ b/cmd/tui/selector.go @@ -415,6 +415,12 @@ type multiSelectorModel struct { cancelled bool confirmed bool width int + + // multi enables full multi-select editing mode. The zero value (false) + // shows a single-select picker where Enter adds the chosen model to + // the existing list. Tab toggles between modes. + multi bool + singleAdd string // model picked in single mode } func newMultiSelectorModel(title string, items []SelectItem, preChecked []string) multiSelectorModel { @@ -429,13 +435,23 @@ func newMultiSelectorModel(title string, items []SelectItem, preChecked []string m.itemIndex[item.Name] = i } - for _, name := range preChecked { - if idx, ok := m.itemIndex[name]; ok { + // Reverse order so preChecked[0] (the current default) ends up last + // in checkOrder, matching the "last checked = default" convention. + for i := len(preChecked) - 1; i >= 0; i-- { + if idx, ok := m.itemIndex[preChecked[i]]; ok { m.checked[idx] = true m.checkOrder = append(m.checkOrder, idx) } } + // Position cursor on the current default model + if len(preChecked) > 0 { + if idx, ok := m.itemIndex[preChecked[0]]; ok { + m.cursor = idx + m.updateScroll(m.otherStart()) + } + } + return m } @@ -546,14 +562,25 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.cancelled = true return m, tea.Quit + case tea.KeyTab: + m.multi = !m.multi + case tea.KeyEnter: - if len(m.checkOrder) > 0 { + if !m.multi { + if len(filtered) > 0 && m.cursor < len(filtered) { + m.singleAdd = filtered[m.cursor].Name + m.confirmed = true + return m, tea.Quit + } + } else if len(m.checkOrder) > 0 { m.confirmed = true return m, tea.Quit } case tea.KeySpace: - m.toggleItem() + if m.multi { + m.toggleItem() + } case tea.KeyUp: if m.cursor > 0 { @@ -592,7 +619,9 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // On some terminals (e.g. Windows PowerShell), space arrives as // KeyRunes instead of KeySpace. Intercept it so toggle still works. if len(msg.Runes) == 1 && msg.Runes[0] == ' ' { - m.toggleItem() + if m.multi { + m.toggleItem() + } } else { m.filter += string(msg.Runes) m.cursor = 0 @@ -604,6 +633,19 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +func (m multiSelectorModel) renderSingleItem(s *strings.Builder, item SelectItem, idx int) { + if idx == m.cursor { + s.WriteString(selectorSelectedItemStyle.Render("▸ " + item.Name)) + } else { + s.WriteString(selectorItemStyle.Render(item.Name)) + } + s.WriteString("\n") + if item.Description != "" { + s.WriteString(selectorDescLineStyle.Render(item.Description)) + s.WriteString("\n") + } +} + func (m multiSelectorModel) renderMultiItem(s *strings.Builder, item SelectItem, idx int) { origIdx := m.itemIndex[item.Name] @@ -615,7 +657,7 @@ func (m multiSelectorModel) renderMultiItem(s *strings.Builder, item SelectItem, } suffix := "" - if len(m.checkOrder) > 0 && m.checkOrder[0] == origIdx { + if len(m.checkOrder) > 0 && m.checkOrder[len(m.checkOrder)-1] == origIdx { suffix = " " + selectorDefaultTagStyle.Render("(default)") } @@ -637,6 +679,11 @@ func (m multiSelectorModel) View() string { return "" } + renderItem := m.renderSingleItem + if m.multi { + renderItem = m.renderMultiItem + } + var s strings.Builder s.WriteString(selectorTitleStyle.Render(m.title)) @@ -661,7 +708,7 @@ func (m multiSelectorModel) View() string { if idx >= len(filtered) { break } - m.renderMultiItem(&s, filtered[idx], idx) + renderItem(&s, filtered[idx], idx) } if remaining := len(filtered) - m.scrollOffset - displayCount; remaining > 0 { @@ -684,7 +731,7 @@ func (m multiSelectorModel) View() string { s.WriteString(sectionHeaderStyle.Render("Recommended")) s.WriteString("\n") for _, idx := range recItems { - m.renderMultiItem(&s, filtered[idx], idx) + renderItem(&s, filtered[idx], idx) } } @@ -704,7 +751,7 @@ func (m multiSelectorModel) View() string { if idx >= len(otherItems) { break } - m.renderMultiItem(&s, filtered[otherItems[idx]], otherItems[idx]) + renderItem(&s, filtered[otherItems[idx]], otherItems[idx]) } if remaining := len(otherItems) - m.scrollOffset - displayCount; remaining > 0 { @@ -716,15 +763,18 @@ func (m multiSelectorModel) View() string { s.WriteString("\n") - count := m.selectedCount() - if count == 0 { - s.WriteString(selectorDescStyle.Render(" Select at least one model.")) + if !m.multi { + s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • enter select • tab add multiple • esc cancel")) } else { - s.WriteString(selectorDescStyle.Render(fmt.Sprintf(" %d selected - press enter to continue", count))) + count := m.selectedCount() + if count == 0 { + s.WriteString(selectorDescStyle.Render(" Select at least one model.")) + } else { + s.WriteString(selectorDescStyle.Render(fmt.Sprintf(" %d selected - press enter to continue", count))) + } + s.WriteString("\n\n") + s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • tab select single • enter confirm • esc cancel")) } - s.WriteString("\n\n") - - s.WriteString(selectorHelpStyle.Render("↑/↓ navigate • space toggle • enter confirm • esc cancel")) result := s.String() if m.width > 0 { @@ -747,18 +797,28 @@ func SelectMultiple(title string, items []SelectItem, preChecked []string) ([]st } fm := finalModel.(multiSelectorModel) - if fm.cancelled { + if fm.cancelled || !fm.confirmed { return nil, ErrCancelled } - if !fm.confirmed { - return nil, ErrCancelled + // Single-add mode: prepend the picked model, keep existing models deduped + if fm.singleAdd != "" { + result := []string{fm.singleAdd} + for _, name := range preChecked { + if name != fm.singleAdd { + result = append(result, name) + } + } + return result, nil } - var result []string + // Multi-edit mode: last checked is default (first in result) + last := fm.checkOrder[len(fm.checkOrder)-1] + result := []string{fm.items[last].Name} for _, idx := range fm.checkOrder { - result = append(result, fm.items[idx].Name) + if idx != last { + result = append(result, fm.items[idx].Name) + } } - return result, nil } diff --git a/cmd/tui/selector_test.go b/cmd/tui/selector_test.go index f87b57aac..fa8ff4dc4 100644 --- a/cmd/tui/selector_test.go +++ b/cmd/tui/selector_test.go @@ -539,6 +539,7 @@ func TestMultiView_CursorIndicator(t *testing.T) { func TestMultiView_CheckedItemShowsX(t *testing.T) { m := newMultiSelectorModel("Pick:", items("a", "b"), []string{"a"}) + m.multi = true content := m.View() if !strings.Contains(content, "[x]") { @@ -550,11 +551,18 @@ func TestMultiView_CheckedItemShowsX(t *testing.T) { } func TestMultiView_DefaultTag(t *testing.T) { - m := newMultiSelectorModel("Pick:", items("a", "b"), []string{"a"}) + m := newMultiSelectorModel("Pick:", items("a", "b", "c"), []string{"a", "b"}) + m.multi = true content := m.View() if !strings.Contains(content, "(default)") { - t.Error("first checked item should have (default) tag") + t.Error("should have (default) tag") + } + // preChecked[0] ("a") should be the default (last in checkOrder) + aIdx := strings.Index(content, "a") + defaultIdx := strings.Index(content, "(default)") + if defaultIdx < aIdx { + t.Error("(default) tag should appear after 'a' (the current default)") } } @@ -585,6 +593,7 @@ func TestMultiView_OverflowIndicator(t *testing.T) { func TestMultiUpdate_SpaceTogglesItem(t *testing.T) { m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil) + m.multi = true m.cursor = 1 // Simulate space delivered as tea.KeySpace @@ -601,6 +610,7 @@ func TestMultiUpdate_SpaceTogglesItem(t *testing.T) { func TestMultiUpdate_SpaceRuneTogglesItem(t *testing.T) { m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil) + m.multi = true m.cursor = 1 // Simulate space delivered as tea.KeyRunes (Windows PowerShell behavior) @@ -618,6 +628,161 @@ func TestMultiUpdate_SpaceRuneTogglesItem(t *testing.T) { } } +// --- Single-add mode --- + +func TestMulti_StartsInSingleMode(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b"), nil) + if m.multi { + t.Error("should start in single mode (multi=false)") + } +} + +func TestMulti_SingleModeNoCheckboxes(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b"), nil) + content := m.View() + if strings.Contains(content, "[x]") || strings.Contains(content, "[ ]") { + t.Error("single mode should not show checkboxes") + } + if !strings.Contains(content, "▸") { + t.Error("single mode should show cursor indicator") + } +} + +func TestMulti_SingleModeEnterPicksItem(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b", "c"), nil) + m.cursor = 1 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = updated.(multiSelectorModel) + + if m.singleAdd != "b" { + t.Errorf("enter in single mode should pick cursor item, got %q", m.singleAdd) + } + if !m.confirmed { + t.Error("should set confirmed") + } +} + +func TestMulti_SingleModeSpaceIsNoop(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b"), nil) + m.cursor = 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeySpace}) + m = updated.(multiSelectorModel) + + if len(m.checked) != 0 { + t.Error("space in single mode should not toggle items") + } +} + +func TestMulti_SingleModeSpaceRuneIsNoop(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b"), nil) + m.cursor = 0 + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}}) + m = updated.(multiSelectorModel) + + if len(m.checked) != 0 { + t.Error("space rune in single mode should not toggle items") + } + if m.filter != "" { + t.Error("space rune in single mode should not add to filter") + } +} + +func TestMulti_TabTogglesMode(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a", "b"), nil) + + if m.multi { + t.Fatal("should start in single mode") + } + + updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = updated.(multiSelectorModel) + if !m.multi { + t.Error("tab should switch to multi mode") + } + + updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) + m = updated.(multiSelectorModel) + if m.multi { + t.Error("tab should switch back to single mode") + } +} + +func TestMulti_SingleModeHelpText(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a"), nil) + content := m.View() + if !strings.Contains(content, "tab add multiple") { + t.Error("single mode should show 'tab add multiple' in help") + } +} + +func TestMulti_MultiModeHelpText(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("a"), nil) + m.multi = true + content := m.View() + if !strings.Contains(content, "tab select single") { + t.Error("multi mode should show 'tab select single' in help") + } +} + +// --- preChecked initialization order --- + +func TestMulti_PreCheckedDefaultIsLast(t *testing.T) { + // preChecked[0] ("a") is the current default and should end up + // last in checkOrder so it gets the (default) tag. + m := newMultiSelectorModel("Pick:", items("a", "b", "c"), []string{"a", "b", "c"}) + + if len(m.checkOrder) != 3 { + t.Fatalf("expected 3 in checkOrder, got %d", len(m.checkOrder)) + } + lastIdx := m.checkOrder[len(m.checkOrder)-1] + if m.items[lastIdx].Name != "a" { + t.Errorf("preChecked[0] should be last in checkOrder, got %q", m.items[lastIdx].Name) + } +} + +func TestMulti_CursorOnDefaultModel(t *testing.T) { + // preChecked[0] ("b") is the default; cursor should start on it + m := newMultiSelectorModel("Pick:", items("a", "b", "c"), []string{"b", "c"}) + + if m.cursor != 1 { + t.Errorf("cursor should be on preChecked[0] ('b') at index 1, got %d", m.cursor) + } +} + +// --- Multi-mode last-checked is default --- + +func TestMulti_LastCheckedIsDefault(t *testing.T) { + m := newMultiSelectorModel("Pick:", items("alpha", "beta", "gamma"), nil) + m.multi = true + + // Check "alpha" then "gamma" + m.cursor = 0 + m.toggleItem() + m.cursor = 2 + m.toggleItem() + + // Last checked ("gamma") should be at the end of checkOrder + lastIdx := m.checkOrder[len(m.checkOrder)-1] + if m.items[lastIdx].Name != "gamma" { + t.Errorf("last checked should be 'gamma', got %q", m.items[lastIdx].Name) + } + + // The (default) tag renders based on checkOrder[len-1] + content := m.View() + if !strings.Contains(content, "(default)") { + t.Fatal("should show (default) tag") + } + // "alpha" line should NOT have the default tag + for _, line := range strings.Split(content, "\n") { + if strings.Contains(line, "alpha") && strings.Contains(line, "(default)") { + t.Error("'alpha' (first checked) should not have (default) tag") + } + } +} + // Key message helpers for testing type keyType = int diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 932af312c..b9f1ef7b1 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -429,8 +429,24 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if m.multiModalSelector.confirmed { var selected []string - for _, idx := range m.multiModalSelector.checkOrder { - selected = append(selected, m.multiModalSelector.items[idx].Name) + if m.multiModalSelector.singleAdd != "" { + // Single-add mode: prepend picked model, keep existing deduped + selected = []string{m.multiModalSelector.singleAdd} + for _, name := range config.IntegrationModels(m.items[m.cursor].integration) { + if name != m.multiModalSelector.singleAdd { + selected = append(selected, name) + } + } + } else { + // Last checked is default (first in result) + co := m.multiModalSelector.checkOrder + last := co[len(co)-1] + selected = []string{m.multiModalSelector.items[last].Name} + for _, idx := range co { + if idx != last { + selected = append(selected, m.multiModalSelector.items[idx].Name) + } + } } if len(selected) > 0 { m.changeModels = selected