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
8 changes: 4 additions & 4 deletions internal/tui/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ func (m *model) handleConfigManagerKey(msg tea.KeyMsg) tea.Cmd {
m.closeConfigManager(false)
}
case "up", "k":
if m.configManager.selected > 0 {
m.configManager.selected--
if len(m.configManager.matches) > 0 {
m.configManager.selected = wrapSelection(m.configManager.selected, len(m.configManager.matches), -1)
}
case "down", "j":
if m.configManager.selected < len(m.configManager.matches)-1 {
m.configManager.selected++
if len(m.configManager.matches) > 0 {
m.configManager.selected = wrapSelection(m.configManager.selected, len(m.configManager.matches), 1)
}
case " ", "space":
m.toggleSelectedConfigSetting()
Expand Down
8 changes: 2 additions & 6 deletions internal/tui/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,9 @@ func (m *model) handleHelpKey(msg tea.KeyMsg) tea.Cmd {
}
switch msg.String() {
case "up", "k":
if m.help.selected > 0 {
m.help.selected--
}
m.help.selected = wrapSelection(m.help.selected, len(commands), -1)
case "down", "j":
if m.help.selected < len(commands)-1 {
m.help.selected++
}
m.help.selected = wrapSelection(m.help.selected, len(commands), 1)
case "pgup", "ctrl+u":
m.help.selected = max(0, m.help.selected-m.helpVisibleCount())
case "pgdown", "ctrl+d":
Expand Down
16 changes: 8 additions & 8 deletions internal/tui/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,19 @@ func (m *model) handleHooksManagerKey(msg tea.KeyMsg) tea.Cmd {
m.toggleSelectedHook()
case "up", "k":
if m.hooksManager.page == hooksPageEvents {
if m.hooksManager.selectedEvent > 0 {
m.hooksManager.selectedEvent--
if len(m.hooksManager.state.Events) > 0 {
m.hooksManager.selectedEvent = wrapSelection(m.hooksManager.selectedEvent, len(m.hooksManager.state.Events), -1)
}
} else if m.hooksManager.selectedHandler > 0 {
m.hooksManager.selectedHandler--
} else if len(m.hooksForSelectedEvent()) > 0 {
m.hooksManager.selectedHandler = wrapSelection(m.hooksManager.selectedHandler, len(m.hooksForSelectedEvent()), -1)
}
case "down", "j":
if m.hooksManager.page == hooksPageEvents {
if m.hooksManager.selectedEvent < len(m.hooksManager.state.Events)-1 {
m.hooksManager.selectedEvent++
if len(m.hooksManager.state.Events) > 0 {
m.hooksManager.selectedEvent = wrapSelection(m.hooksManager.selectedEvent, len(m.hooksManager.state.Events), 1)
}
} else if m.hooksManager.selectedHandler < len(m.hooksForSelectedEvent())-1 {
m.hooksManager.selectedHandler++
} else if len(m.hooksForSelectedEvent()) > 0 {
m.hooksManager.selectedHandler = wrapSelection(m.hooksManager.selectedHandler, len(m.hooksForSelectedEvent()), 1)
}
case " ":
if m.hooksManager.page == hooksPageHandlers {
Expand Down
24 changes: 8 additions & 16 deletions internal/tui/model_keys_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,17 @@ func (m *model) handleChatModeKey(msg tea.KeyMsg) (tea.Cmd, bool) {
}
case "up":
if m.hasSlashSuggestions() {
if m.slash.selected > 0 {
m.slash.selected--
}
m.slash.selected = wrapSelection(m.slash.selected, len(m.slash.matches), -1)
return nil, true
}
if m.hasFilePanel() {
if m.hasFileSuggestions() && m.files.selected > 0 {
m.files.selected--
if m.hasFileSuggestions() {
m.files.selected = wrapSelection(m.files.selected, len(m.files.matches), -1)
}
return nil, true
}
if m.hasSkillSuggestions() {
if m.skills.selected > 0 {
m.skills.selected--
}
m.skills.selected = wrapSelection(m.skills.selected, len(m.skills.matches), -1)
return nil, true
}
if m.shouldHandleHistoryNavigation() {
Expand All @@ -66,21 +62,17 @@ func (m *model) handleChatModeKey(msg tea.KeyMsg) (tea.Cmd, bool) {
}
case "down":
if m.hasSlashSuggestions() {
if m.slash.selected < len(m.slash.matches)-1 {
m.slash.selected++
}
m.slash.selected = wrapSelection(m.slash.selected, len(m.slash.matches), 1)
return nil, true
}
if m.hasFilePanel() {
if m.hasFileSuggestions() && m.files.selected < len(m.files.matches)-1 {
m.files.selected++
if m.hasFileSuggestions() {
m.files.selected = wrapSelection(m.files.selected, len(m.files.matches), 1)
}
return nil, true
}
if m.hasSkillSuggestions() {
if m.skills.selected < len(m.skills.matches)-1 {
m.skills.selected++
}
m.skills.selected = wrapSelection(m.skills.selected, len(m.skills.matches), 1)
return nil, true
}
if m.shouldHandleHistoryNavigation() {
Expand Down
40 changes: 16 additions & 24 deletions internal/tui/model_keys_modals.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,24 +207,24 @@ func (m *model) handleModelPickerKey(msg tea.KeyMsg) tea.Cmd {
m.mode = modeChat
}
case "up", "k":
if m.modelPicker.stage == 0 && m.modelPicker.modelIx > 0 {
m.modelPicker.modelIx--
if m.modelPicker.stage == 0 {
m.modelPicker.modelIx = wrapSelection(m.modelPicker.modelIx, len(m.modelPicker.models), -1)
}
if m.modelPicker.stage == 1 && m.modelPicker.effIx > 0 {
m.modelPicker.effIx--
if m.modelPicker.stage == 1 {
m.modelPicker.effIx = wrapSelection(m.modelPicker.effIx, len(m.modelPicker.efforts), -1)
}
if m.modelPicker.stage == 2 && m.modelPicker.thinkIx > 0 {
m.modelPicker.thinkIx--
if m.modelPicker.stage == 2 {
m.modelPicker.thinkIx = wrapSelection(m.modelPicker.thinkIx, len(m.modelPicker.thinkings), -1)
}
case "down", "j":
if m.modelPicker.stage == 0 && m.modelPicker.modelIx < len(m.modelPicker.models)-1 {
m.modelPicker.modelIx++
if m.modelPicker.stage == 0 {
m.modelPicker.modelIx = wrapSelection(m.modelPicker.modelIx, len(m.modelPicker.models), 1)
}
if m.modelPicker.stage == 1 && m.modelPicker.effIx < len(m.modelPicker.efforts)-1 {
m.modelPicker.effIx++
if m.modelPicker.stage == 1 {
m.modelPicker.effIx = wrapSelection(m.modelPicker.effIx, len(m.modelPicker.efforts), 1)
}
if m.modelPicker.stage == 2 && m.modelPicker.thinkIx < len(m.modelPicker.thinkings)-1 {
m.modelPicker.thinkIx++
if m.modelPicker.stage == 2 {
m.modelPicker.thinkIx = wrapSelection(m.modelPicker.thinkIx, len(m.modelPicker.thinkings), 1)
}
case "enter":
if m.modelPicker.stage == 0 {
Expand Down Expand Up @@ -252,13 +252,9 @@ func (m *model) handlePermissionsMenuKey(msg tea.KeyMsg) tea.Cmd {
case "esc":
m.mode = modeChat
case "up", "k", "left", "h":
if m.permissionsMenu.selected > 0 {
m.permissionsMenu.selected--
}
m.permissionsMenu.selected = wrapSelection(m.permissionsMenu.selected, 4, -1)
case "down", "j", "right", "l", "tab":
if m.permissionsMenu.selected < 3 {
m.permissionsMenu.selected++
}
m.permissionsMenu.selected = wrapSelection(m.permissionsMenu.selected, 4, 1)
case "enter":
current := permissionsMode(m.autoAccept, m.autoReviewEnabled)
switch m.permissionsMenu.selected {
Expand All @@ -285,13 +281,9 @@ func (m *model) handlePlanImplementationKey(msg tea.KeyMsg) tea.Cmd {
case "esc":
m.declinePlanImplementation()
case "up", "k", "left", "h":
if m.planImplementation.index > 0 {
m.planImplementation.index--
}
m.planImplementation.index = wrapSelection(m.planImplementation.index, 2, -1)
case "down", "j", "right", "l", "tab":
if m.planImplementation.index < 1 {
m.planImplementation.index++
}
m.planImplementation.index = wrapSelection(m.planImplementation.index, 2, 1)
case "enter":
if m.localSubmitPending > 0 {
m.status = "wait for command to finish"
Expand Down
22 changes: 22 additions & 0 deletions internal/tui/model_picker_menu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1077,3 +1077,25 @@ func TestFileDiffMetadataPreviewAllowsLargeTranslationDiff(t *testing.T) {
t.Fatalf("expected translation-size diff to fit in preview:\n%s", got)
}
}
func TestPermissionsMenuNavigationWrapsAround(t *testing.T) {
m, _ := newModelWithDispatchSpy()
next, _ := m.Update(svcMsg(protocol.Event{Kind: protocol.EventPermissionsSelectionRequested, AutoAccept: false, AutoReview: false}))
m = next.(model)
if m.mode != modePermissionsMenu || m.permissionsMenu.selected != 0 {
t.Fatalf("expected permissions menu with ask selected, mode=%v selected=%d", m.mode, m.permissionsMenu.selected)
}

// Up from the first item (ask) wraps to the last (cancel).
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = next.(model)
if m.permissionsMenu.selected != 3 {
t.Fatalf("expected up at ask to wrap to cancel, got %d", m.permissionsMenu.selected)
}

// Down from the last item wraps back to the first.
next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = next.(model)
if m.permissionsMenu.selected != 0 {
t.Fatalf("expected down at cancel to wrap to ask, got %d", m.permissionsMenu.selected)
}
}
93 changes: 93 additions & 0 deletions internal/tui/model_suggestions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1495,3 +1495,96 @@ func TestBtwExactSlashEnterShowsUsage(t *testing.T) {
t.Fatalf("unexpected intent: %+v", got)
}
}
func TestSlashSuggestionNavigationWrapsAround(t *testing.T) {
m, _ := newModelWithDispatchSpy()
m.input.SetValue("/")
m.updateSlashMatches()
count := len(m.slash.matches)
if count < 2 {
t.Fatalf("expected at least two slash suggestions, got %d", count)
}

// Down from the last item wraps back to the first.
m.slash.selected = count - 1
m, _ = updateTestModel(t, m, tea.KeyMsg{Type: tea.KeyDown})
if m.slash.selected != 0 {
t.Fatalf("expected down at last to wrap to first, got %d", m.slash.selected)
}

// Up from the first item wraps to the last.
m, _ = updateTestModel(t, m, tea.KeyMsg{Type: tea.KeyUp})
if m.slash.selected != count-1 {
t.Fatalf("expected up at first to wrap to last, got %d", m.slash.selected)
}
}
func TestSkillSuggestionNavigationWrapsAround(t *testing.T) {
m, _ := newModelWithDispatchSpy()
m.skills.all = []skillSuggestion{
{Name: "code-review", Description: "Review local changes"},
{Name: "git-worktree", Description: "Create an isolated worktree"},
{Name: "grill-me", Description: "Interview the user relentlessly"},
{Name: "skill-creator", Description: "Create or update skills"},
}
m.input.SetValue("$")
m.updateSlashMatches()
if len(m.skills.matches) != 4 {
t.Fatalf("expected four skill matches, got %+v", m.skills.matches)
}

// Up from the first item wraps to the last.
m.skills.selected = 0
m, _ = updateTestModel(t, m, tea.KeyMsg{Type: tea.KeyUp})
if m.skills.selected != 3 {
t.Fatalf("expected up at first to wrap to last, got %d", m.skills.selected)
}

// Down from the last item wraps back to the first.
m, _ = updateTestModel(t, m, tea.KeyMsg{Type: tea.KeyDown})
if m.skills.selected != 0 {
t.Fatalf("expected down at last to wrap to first, got %d", m.skills.selected)
}
}
func TestFileSuggestionNavigationWrapsAround(t *testing.T) {
m, _ := newModelWithDispatchSpy()
m.files.matches = []fileSuggestion{
{Path: "a.go"},
{Path: "b.go"},
{Path: "c.go"},
}
m.files.selected = 2

// Down from the last item wraps back to the first.
m, _ = updateTestModel(t, m, tea.KeyMsg{Type: tea.KeyDown})
if m.files.selected != 0 {
t.Fatalf("expected down at last to wrap to first, got %d", m.files.selected)
}

// Up from the first item wraps to the last.
m, _ = updateTestModel(t, m, tea.KeyMsg{Type: tea.KeyUp})
if m.files.selected != 2 {
t.Fatalf("expected up at first to wrap to last, got %d", m.files.selected)
}
}
func TestHelpCommandNavigationWrapsAround(t *testing.T) {
m, _ := newModelWithDispatchSpy()
m.width = 100
m.height = 18
m.openHelp()
count := len(helpCommands())
if count < 2 {
t.Fatalf("expected at least two help commands, got %d", count)
}

// Up from the first item wraps to the last.
m.help.selected = 0
m, _ = updateTestModel(t, m, tea.KeyMsg{Type: tea.KeyUp})
if m.help.selected != count-1 {
t.Fatalf("expected up at first to wrap to last, got %d", m.help.selected)
}

// Down from the last item wraps back to the first.
m, _ = updateTestModel(t, m, tea.KeyMsg{Type: tea.KeyDown})
if m.help.selected != 0 {
t.Fatalf("expected down at last to wrap to first, got %d", m.help.selected)
}
}
8 changes: 4 additions & 4 deletions internal/tui/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,14 @@ func (m *model) handlePluginsManagerKey(msg tea.KeyMsg) tea.Cmd {
if m.pluginsManager.offset > 0 {
m.pluginsManager.offset--
}
} else if m.pluginsManager.selected > 0 {
m.pluginsManager.selected--
} else if len(m.pluginsManager.matches) > 0 {
m.pluginsManager.selected = wrapSelection(m.pluginsManager.selected, len(m.pluginsManager.matches), -1)
}
case "down", "j":
if m.pluginsManager.detail {
m.pluginsManager.offset++
} else if m.pluginsManager.selected < len(m.pluginsManager.matches)-1 {
m.pluginsManager.selected++
} else if len(m.pluginsManager.matches) > 0 {
m.pluginsManager.selected = wrapSelection(m.pluginsManager.selected, len(m.pluginsManager.matches), 1)
}
case " ", "space":
if m.pluginsManager.detail {
Expand Down
8 changes: 2 additions & 6 deletions internal/tui/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,9 @@ func (m *model) handleReviewMenuKey(msg tea.KeyMsg) tea.Cmd {
case "esc", "ctrl+c":
m.closeReviewMenu()
case "up", "k":
if m.reviewMenu.selected > 0 {
m.reviewMenu.selected--
}
m.reviewMenu.selected = wrapSelection(m.reviewMenu.selected, len(items), -1)
case "down", "j":
if m.reviewMenu.selected < len(items)-1 {
m.reviewMenu.selected++
}
m.reviewMenu.selected = wrapSelection(m.reviewMenu.selected, len(items), 1)
case "enter":
if m.reviewMenu.selected < 0 || m.reviewMenu.selected >= len(items) {
return nil
Expand Down
8 changes: 2 additions & 6 deletions internal/tui/review_picker.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,9 @@ func (m *model) handleReviewTargetPickerKey(msg tea.KeyMsg) tea.Cmd {
return nil
}
case "up", "k":
if m.reviewTargetPicker.selected > 0 {
m.reviewTargetPicker.selected--
}
m.reviewTargetPicker.selected = wrapSelection(m.reviewTargetPicker.selected, m.reviewTargetPickerChoiceCount(), -1)
case "down", "j":
if m.reviewTargetPicker.selected < m.reviewTargetPickerChoiceCount()-1 {
m.reviewTargetPicker.selected++
}
m.reviewTargetPicker.selected = wrapSelection(m.reviewTargetPicker.selected, m.reviewTargetPickerChoiceCount(), 1)
case "enter":
return m.submitSelectedReviewTarget()
case "/":
Expand Down
12 changes: 12 additions & 0 deletions internal/tui/selection_nav.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package tui

// wrapSelection cycles a list selection by delta within [0, count).
// Moving up (-1) from the first item lands on the last item, and moving
// down (+1) from the last item lands on the first item. Empty lists
// (count <= 0) always yield 0 so callers can wrap without extra guards.
func wrapSelection(selected, count, delta int) int {
if count <= 0 {
return 0
}
return ((selected+delta)%count + count) % count
}
30 changes: 30 additions & 0 deletions internal/tui/selection_nav_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package tui

import "testing"

func TestWrapSelectionCyclesWithinBounds(t *testing.T) {
tests := []struct {
name string
selected int
count int
delta int
want int
}{
{name: "down from first", selected: 0, count: 4, delta: 1, want: 1},
{name: "up from middle", selected: 2, count: 4, delta: -1, want: 1},
{name: "down from last wraps to first", selected: 3, count: 4, delta: 1, want: 0},
{name: "up from first wraps to last", selected: 0, count: 4, delta: -1, want: 3},
{name: "single item stays", selected: 0, count: 1, delta: -1, want: 0},
{name: "single item down stays", selected: 0, count: 1, delta: 1, want: 0},
{name: "multiple steps wrap", selected: 2, count: 3, delta: 2, want: 1},
{name: "empty list yields zero", selected: 0, count: 0, delta: -1, want: 0},
{name: "negative count yields zero", selected: 3, count: -2, delta: 1, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := wrapSelection(tt.selected, tt.count, tt.delta); got != tt.want {
t.Fatalf("wrapSelection(%d, %d, %d) = %d, want %d", tt.selected, tt.count, tt.delta, got, tt.want)
}
})
}
}
Loading
Loading