diff --git a/internal/tui/config.go b/internal/tui/config.go index ba5b6160..76d65124 100644 --- a/internal/tui/config.go +++ b/internal/tui/config.go @@ -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() diff --git a/internal/tui/help.go b/internal/tui/help.go index b0807cbb..b90ecb96 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -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": diff --git a/internal/tui/hooks.go b/internal/tui/hooks.go index feaa91cf..e2fb5228 100644 --- a/internal/tui/hooks.go +++ b/internal/tui/hooks.go @@ -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 { diff --git a/internal/tui/model_keys_chat.go b/internal/tui/model_keys_chat.go index 756b7f41..13512f3e 100644 --- a/internal/tui/model_keys_chat.go +++ b/internal/tui/model_keys_chat.go @@ -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() { @@ -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() { diff --git a/internal/tui/model_keys_modals.go b/internal/tui/model_keys_modals.go index a9217aba..bafdea77 100644 --- a/internal/tui/model_keys_modals.go +++ b/internal/tui/model_keys_modals.go @@ -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 { @@ -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 { @@ -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" diff --git a/internal/tui/model_picker_menu_test.go b/internal/tui/model_picker_menu_test.go index 5e9bc0cc..557b870c 100644 --- a/internal/tui/model_picker_menu_test.go +++ b/internal/tui/model_picker_menu_test.go @@ -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) + } +} diff --git a/internal/tui/model_suggestions_test.go b/internal/tui/model_suggestions_test.go index 16423483..a4d19904 100644 --- a/internal/tui/model_suggestions_test.go +++ b/internal/tui/model_suggestions_test.go @@ -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) + } +} diff --git a/internal/tui/plugins.go b/internal/tui/plugins.go index f15c154c..64b233d0 100644 --- a/internal/tui/plugins.go +++ b/internal/tui/plugins.go @@ -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 { diff --git a/internal/tui/review.go b/internal/tui/review.go index 1421ff3c..e2f32723 100644 --- a/internal/tui/review.go +++ b/internal/tui/review.go @@ -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 diff --git a/internal/tui/review_picker.go b/internal/tui/review_picker.go index c6e6134d..d6298917 100644 --- a/internal/tui/review_picker.go +++ b/internal/tui/review_picker.go @@ -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 "/": diff --git a/internal/tui/selection_nav.go b/internal/tui/selection_nav.go new file mode 100644 index 00000000..ea578032 --- /dev/null +++ b/internal/tui/selection_nav.go @@ -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 +} diff --git a/internal/tui/selection_nav_test.go b/internal/tui/selection_nav_test.go new file mode 100644 index 00000000..9895b34a --- /dev/null +++ b/internal/tui/selection_nav_test.go @@ -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) + } + }) + } +} diff --git a/internal/tui/session_picker.go b/internal/tui/session_picker.go index 907d8736..21462eff 100644 --- a/internal/tui/session_picker.go +++ b/internal/tui/session_picker.go @@ -94,6 +94,12 @@ func prevSessionChoiceIndex(rows []string, cur int) int { return i } } + // Wrap around: nothing selectable above, continue from the bottom. + for i := len(rows) - 1; i > cur; i-- { + if sessionChoiceNumberAt(rows, i) > 0 { + return i + } + } return cur } @@ -106,5 +112,11 @@ func nextSessionChoiceIndex(rows []string, cur int) int { return i } } + // Wrap around: nothing selectable below, continue from the top. + for i := 0; i < cur; i++ { + if sessionChoiceNumberAt(rows, i) > 0 { + return i + } + } return cur } diff --git a/internal/tui/session_picker_test.go b/internal/tui/session_picker_test.go index 0dcba7d1..44a89cf9 100644 --- a/internal/tui/session_picker_test.go +++ b/internal/tui/session_picker_test.go @@ -56,3 +56,27 @@ func TestParseSessionChoiceDisplayHandlesNonASCIIBranch(t *testing.T) { t.Fatalf("rendered session row contains replacement characters:\n%s", rendered) } } +func TestSessionChoiceNavigationWrapsAround(t *testing.T) { + rows := []string{ + "recent sessions:", + "* 1) 4s ago - who are you", + " 2) 1m ago main hello", + " 3) 5m ago feat/x git status", + } + // Down from the last selectable row wraps to the first selectable row. + if got := nextSessionChoiceIndex(rows, 3); got != 1 { + t.Fatalf("expected down at last to wrap to first, got %d", got) + } + // Up from the first selectable row wraps to the last selectable row. + if got := prevSessionChoiceIndex(rows, 1); got != 3 { + t.Fatalf("expected up at first to wrap to last, got %d", got) + } + // A single selectable row stays put on both directions. + only := []string{"recent sessions:", " 1) 4s ago - who are you"} + if got := prevSessionChoiceIndex(only, 1); got != 1 { + t.Fatalf("expected up to stay on only selectable row, got %d", got) + } + if got := nextSessionChoiceIndex(only, 1); got != 1 { + t.Fatalf("expected down to stay on only selectable row, got %d", got) + } +} diff --git a/internal/tui/skills.go b/internal/tui/skills.go index 88b0d02d..5be9ff5a 100644 --- a/internal/tui/skills.go +++ b/internal/tui/skills.go @@ -188,13 +188,9 @@ func (m *model) handleSkillsMenuKey(msg tea.KeyMsg) tea.Cmd { m.skillsMenu.selected = 0 m.status = "ready" case "up", "k": - if m.skillsMenu.selected > 0 { - m.skillsMenu.selected-- - } + m.skillsMenu.selected = wrapSelection(m.skillsMenu.selected, len(items), -1) case "down", "j": - if m.skillsMenu.selected < len(items)-1 { - m.skillsMenu.selected++ - } + m.skillsMenu.selected = wrapSelection(m.skillsMenu.selected, len(items), 1) case "enter": switch m.skillsMenu.selected { case 0: @@ -319,12 +315,12 @@ func (m *model) handleSkillsManagerKey(msg tea.KeyMsg) tea.Cmd { m.skillsManager.selected = 0 m.status = "ready" case "up", "k": - if m.skillsManager.selected > 0 { - m.skillsManager.selected-- + if len(m.skillsManager.matches) > 0 { + m.skillsManager.selected = wrapSelection(m.skillsManager.selected, len(m.skillsManager.matches), -1) } case "down", "j": - if m.skillsManager.selected < len(m.skillsManager.matches)-1 { - m.skillsManager.selected++ + if len(m.skillsManager.matches) > 0 { + m.skillsManager.selected = wrapSelection(m.skillsManager.selected, len(m.skillsManager.matches), 1) } case "backspace": if m.skillsManager.query != "" { diff --git a/internal/tui/workflow_launch.go b/internal/tui/workflow_launch.go index e36b3593..7caa9679 100644 --- a/internal/tui/workflow_launch.go +++ b/internal/tui/workflow_launch.go @@ -33,12 +33,12 @@ func (m *model) handleWorkflowLaunchKey(msg tea.KeyMsg) tea.Cmd { m.mode = modeChat m.status = "ready" case "up", "k": - if len(actions) > 0 && m.workflowLaunch.selected > 0 { - m.workflowLaunch.selected-- + if len(actions) > 0 { + m.workflowLaunch.selected = wrapSelection(m.workflowLaunch.selected, len(actions), -1) } case "down", "j": - if len(actions) > 0 && m.workflowLaunch.selected < len(actions)-1 { - m.workflowLaunch.selected++ + if len(actions) > 0 { + m.workflowLaunch.selected = wrapSelection(m.workflowLaunch.selected, len(actions), 1) } case "enter": if len(actions) == 0 { diff --git a/internal/tui/workflow_launch_test.go b/internal/tui/workflow_launch_test.go index bb40d510..9e713797 100644 --- a/internal/tui/workflow_launch_test.go +++ b/internal/tui/workflow_launch_test.go @@ -538,3 +538,32 @@ func workflowLaunchTestResult() *protocol.LocalResult { }, } } +func TestWorkflowLaunchNavigationWrapsAround(t *testing.T) { + m := model{ + assembler: tuirender.NewAssembler(), + mode: modeWorkflowLaunch, + width: 100, + height: 30, + workflowLaunch: struct { + result *protocol.LocalResult + selected int + rawScroll int + }{result: workflowLaunchTestResult()}, + } + actions := workflowLaunchActions(m.workflowLaunch.result) + if len(actions) < 2 { + t.Fatalf("expected at least two launch actions, got %d", len(actions)) + } + + // Up from the first action wraps to the last. + m.handleWorkflowLaunchKey(tea.KeyMsg{Type: tea.KeyUp}) + if m.workflowLaunch.selected != len(actions)-1 { + t.Fatalf("expected up at first to wrap to last, got %d", m.workflowLaunch.selected) + } + + // Down from the last action wraps back to the first. + m.handleWorkflowLaunchKey(tea.KeyMsg{Type: tea.KeyDown}) + if m.workflowLaunch.selected != 0 { + t.Fatalf("expected down at last to wrap to first, got %d", m.workflowLaunch.selected) + } +} diff --git a/internal/tui/workflow_panel_keys.go b/internal/tui/workflow_panel_keys.go index fabf6265..284b7f44 100644 --- a/internal/tui/workflow_panel_keys.go +++ b/internal/tui/workflow_panel_keys.go @@ -21,21 +21,21 @@ func (m *model) handleWorkflowPanelKey(msg tea.KeyMsg) tea.Cmd { case "up", "k": if workflowPanelHasSnapshot(m.workflowPanel.result) { m.moveWorkflowPanelSnapshotSelection(-1) - } else if m.workflowPanel.selected > 0 { - m.workflowPanel.selected-- + } else if runs := workflowPanelRunSections(m.workflowPanel.result); len(runs) > 0 { + m.workflowPanel.selected = wrapSelection(m.workflowPanel.selected, len(runs), -1) } case "down", "j": if workflowPanelHasSnapshot(m.workflowPanel.result) { m.moveWorkflowPanelSnapshotSelection(1) - } else if runs := workflowPanelRunSections(m.workflowPanel.result); m.workflowPanel.selected < len(runs)-1 { - m.workflowPanel.selected++ + } else if runs := workflowPanelRunSections(m.workflowPanel.result); len(runs) > 0 { + m.workflowPanel.selected = wrapSelection(m.workflowPanel.selected, len(runs), 1) } case "tab", "right", "l": if workflowPanelHasSnapshot(m.workflowPanel.result) { m.workflowPanel.focus = workflowPanelFocusTask m.clampWorkflowPanelSnapshotSelection() - } else if runs := workflowPanelRunSections(m.workflowPanel.result); msg.String() == "tab" && m.workflowPanel.selected < len(runs)-1 { - m.workflowPanel.selected++ + } else if runs := workflowPanelRunSections(m.workflowPanel.result); msg.String() == "tab" && len(runs) > 0 { + m.workflowPanel.selected = wrapSelection(m.workflowPanel.selected, len(runs), 1) } case "enter": if workflowPanelHasSnapshot(m.workflowPanel.result) { @@ -148,13 +148,8 @@ func (m *model) handleWorkflowPanelDetailKey(msg tea.KeyMsg) tea.Cmd { } func (m *model) moveWorkflowPanelDetailSection(delta int) { - next := int(m.workflowPanel.detailSection) + delta - if next < int(workflowPanelDetailPrompt) { - next = int(workflowPanelDetailPrompt) - } - if next > int(workflowPanelDetailOutcome) { - next = int(workflowPanelDetailOutcome) - } + count := int(workflowPanelDetailOutcome) - int(workflowPanelDetailPrompt) + 1 + next := wrapSelection(int(m.workflowPanel.detailSection), count, delta) if workflowPanelDetailSection(next) != m.workflowPanel.detailSection { m.workflowPanel.detailSection = workflowPanelDetailSection(next) m.workflowPanel.detailExpanded = false @@ -192,13 +187,20 @@ func (m *model) clampWorkflowPanelSelection() { } func (m *model) moveWorkflowPanelSnapshotSelection(delta int) { + snapshot := workflowPanelSnapshot(m.workflowPanel.result) + if snapshot == nil || len(snapshot.Phases) == 0 { + m.workflowPanel.selectedPhase = 0 + m.workflowPanel.selectedTask = 0 + m.workflowPanel.focus = workflowPanelFocusPhase + return + } if m.workflowPanel.focus == workflowPanelFocusTask { - m.workflowPanel.selectedTask += delta + tasks := snapshot.Phases[m.workflowPanel.selectedPhase].Tasks + m.workflowPanel.selectedTask = wrapSelection(m.workflowPanel.selectedTask, len(tasks), delta) } else { - m.workflowPanel.selectedPhase += delta + m.workflowPanel.selectedPhase = wrapSelection(m.workflowPanel.selectedPhase, len(snapshot.Phases), delta) m.workflowPanel.selectedTask = 0 } - m.clampWorkflowPanelSnapshotSelection() } func (m *model) clampWorkflowPanelSnapshotSelection() {