diff --git a/.gitignore b/.gitignore index 7978ffab..068dd1e5 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only .superpowers/ docs/superpowers/ +zero-linux-sandbox diff --git a/internal/tui/ask_user_test.go b/internal/tui/ask_user_test.go index b463021c..77c3976b 100644 --- a/internal/tui/ask_user_test.go +++ b/internal/tui/ask_user_test.go @@ -417,3 +417,31 @@ func TestAskUserEmptyRequestResolvesImmediately(t *testing.T) { t.Fatalf("an empty request should resolve immediately, got %#v", answers) } } + +func TestAskUserCursorCtrlU(t *testing.T) { + var answers [][]string + next := newAskUserModel(t, askUserSingle([]string{"React", "Vue", "Svelte"}, "Vue"), &answers) + if next.pendingAskUser == nil { + t.Fatal("setup: expected a pending ask-user prompt") + } + // Ctrl+U from recommended (cursor=1) should move UP to 0 (React). + updated, _ := next.Update(testKeyCtrl('u')) + m := updated.(model) + if m.pendingAskUser.states[0].cursor != 0 { + t.Fatalf("Ctrl+U from cursor 1 should move to 0, got %d", m.pendingAskUser.states[0].cursor) + } +} + +func TestAskUserCursorCtrlD(t *testing.T) { + var answers [][]string + next := newAskUserModel(t, askUserSingle([]string{"React", "Vue", "Svelte"}, "Vue"), &answers) + if next.pendingAskUser == nil { + t.Fatal("setup: expected a pending ask-user prompt") + } + // Ctrl+D from recommended (cursor=1) should move DOWN to 2 (Svelte). + updated, _ := next.Update(testKeyCtrl('d')) + m := updated.(model) + if m.pendingAskUser.states[0].cursor != 2 { + t.Fatalf("Ctrl+D from cursor 1 should move to 2, got %d", m.pendingAskUser.states[0].cursor) + } +} diff --git a/internal/tui/keybinding_help.go b/internal/tui/keybinding_help.go index f37a50be..0ab52c92 100644 --- a/internal/tui/keybinding_help.go +++ b/internal/tui/keybinding_help.go @@ -54,7 +54,9 @@ func (m model) buildKeybindingGroups() []keybindingGroup { title: "Navigation & scrollback", bindings: []keybinding{ {"PgUp / PgDn", "scroll the transcript by a page"}, - {"\u2191 / \u2193", "scroll, or move within a popup / multi-line input"}, + {"↑ / ↓", "scroll, or move within a popup / multi-line input"}, + {"Shift+↑ / Shift+↓", "scroll line by line"}, + {"Ctrl+U / Ctrl+D", "scroll half a page (Termux-friendly)"}, {labelOr(m.keyBindings.toggleDetailed, "Ctrl+O"), "toggle the detailed (full-screen) transcript"}, {labelOr(m.keyBindings.toggleSidebar, "Ctrl+B"), "hide / show the right context sidebar"}, {labelOr(m.keyBindings.toggleMouse, "Ctrl+E"), "release the mouse to drag-select & copy text"}, diff --git a/internal/tui/model.go b/internal/tui/model.go index acfafc9c..2b30cc98 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -320,7 +320,13 @@ type model struct { // mouseReleased, when true, forces terminal mouse capture OFF so the user can // drag-select and copy text natively (Ctrl+E toggles it). App mouse features // (clickable suggestions, right-click paste, transcript select) pause while on. - mouseReleased bool + mouseReleased bool + // mouseMode is the Bubble Tea mouse-tracking mode to request while capture + // is active. AllMotion by default; falls back to CellMotion under Termux + // (where AllMotion's 1003 tracking is unreliable, especially via proot, + // breaking touch-gesture scrolling). CellMotion still reports wheel events + // correctly. Set via mouseModeForTermEnv at model construction. + mouseMode tea.MouseMode transcriptSelection transcriptSelectionState // hover identifies the single clickable row (if any) currently under the // mouse cursor with no button pressed, so it renders in a distinct style — @@ -762,6 +768,12 @@ func newModel(ctx context.Context, options Options) model { applyTheme(m.themeMode, true) } m.reducedMotion = defaultReducedMotion() + // Detect Termux / proot environments where MouseModeAllMotion (escape + // sequence \x1b[?1003h) is unreliable and breaks touch-gesture scrolling. + // CellMotion still delivers wheel events for scroll, click-for-selection, + // and drag, so it is a safe fallback that keeps the main interaction paths + // working without the hover-highlight polish that AllMotion enables. + m.mouseMode = mouseModeForTermEnv() // The streaming-text fade (a lime→ink glow on freshly streamed lines) is // disabled: it read as a distracting glow rather than a subtle liveness cue. // Streaming text always renders statically at base ink (the disabled path in @@ -1389,6 +1401,68 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } m = m.clearHover() return m.scrollChat(-m.chatPageScrollLines()), nil + // Shift+Up/Down for line-scroll, checked before plain KeyUp/KeyDown + // so shifted arrows are not swallowed by the plain-arrow cases. + case keyShift(msg) && keyIs(msg, tea.KeyUp): + if m.transcriptDetailed { + return m, nil + } + if m.pendingPermission != nil { + return m.movePermissionCursor(-1), nil + } + if m.pendingAskUser != nil { + return m.moveAskUserCursor(-1), nil + } + if m.providerWizard != nil { + return m.handleProviderWizardKey(msg) + } + if m.mcpAddWizard != nil { + return m.handleMCPAddWizardKey(msg) + } + if m.mcpManager != nil { + return m.handleMCPManagerKey(msg) + } + if m.picker != nil { + break + } + if m.suggestionsActive() { + break + } + if m.composerValue() != "" { + break + } + m = m.clearHover() + return m.scrollChat(1), nil + case keyShift(msg) && keyIs(msg, tea.KeyDown): + if m.transcriptDetailed { + return m, nil + } + if m.pendingPermission != nil { + return m.movePermissionCursor(1), nil + } + if m.pendingAskUser != nil { + return m.moveAskUserCursor(1), nil + } + if m.providerWizard != nil { + return m.handleProviderWizardKey(msg) + } + if m.mcpAddWizard != nil { + return m.handleMCPAddWizardKey(msg) + } + if m.mcpManager != nil { + return m.handleMCPManagerKey(msg) + } + if m.picker != nil { + break + } + if m.suggestionsActive() { + break + } + if m.composerValue() != "" { + break + } + m = m.clearHover() + return m.scrollChat(-1), nil case keyIs(msg, tea.KeyDown): if m.transcriptDetailed { return m, nil @@ -1467,6 +1541,69 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.historyRecallActive() { return m.recallHistory(-1), nil } + // Ctrl+U/D for half-page transcript scroll. Placed after the + // plain-arrow handlers so focused-surface keys are processed + // first via return-through-handler. + case keyCtrl(msg, 'u'): + if m.transcriptDetailed { + return m, nil + } + if m.composerValue() != "" { + break // let the input field handle it (undo in some terminals) + } + if m.pendingPermission != nil { + return m.movePermissionCursor(-1), nil + } + if m.pendingAskUser != nil { + return m.moveAskUserCursor(-1), nil + } + if m.providerWizard != nil { + return m.handleProviderWizardKey(msg) + } + if m.mcpAddWizard != nil { + return m.handleMCPAddWizardKey(msg) + } + if m.mcpManager != nil { + return m.handleMCPManagerKey(msg) + } + if m.picker != nil { + break + } + if m.suggestionsActive() { + break + } + m = m.clearHover() + return m.scrollChat(m.chatPageScrollLines()/2 + 1), nil + case keyCtrl(msg, 'd'): + if m.transcriptDetailed { + return m, nil + } + if m.composerValue() != "" { + break + } + if m.pendingPermission != nil { + return m.movePermissionCursor(1), nil + } + if m.pendingAskUser != nil { + return m.moveAskUserCursor(1), nil + } + if m.providerWizard != nil { + return m.handleProviderWizardKey(msg) + } + if m.mcpAddWizard != nil { + return m.handleMCPAddWizardKey(msg) + } + if m.mcpManager != nil { + return m.handleMCPManagerKey(msg) + } + if m.picker != nil { + break + } + if m.suggestionsActive() { + break + } + m = m.clearHover() + return m.scrollChat(-(m.chatPageScrollLines()/2 + 1)), nil } if m.transcriptDetailed { return m, nil @@ -2171,8 +2308,10 @@ func (m model) View() tea.View { // MouseMode docs. AllMotion has marginally worse terminal compatibility // but is well supported by the terminals this app targets; the existing // 15ms mouse-event throttle (mouseEventThrottleInterval) already bounds - // the redraw rate from the extra motion events. - view.MouseMode = tea.MouseModeAllMotion + // the redraw rate from the extra motion events. On Termux / proot the + // model falls back to CellMotion (see mouseModeForTermEnv) so touch- + // gesture scrolling still works there. + view.MouseMode = m.mouseMode } return view } diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 2572ab67..d0339a72 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -1,6 +1,37 @@ package tui -import tea "charm.land/bubbletea/v2" +import ( + "os" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// mouseModeForTermEnv detects Termux / proot environments where MouseModeAllMotion +// (escape sequence \x1b[?1003h) is unreliable and breaks touch-gesture scrolling. +// CellMotion still delivers wheel events for scroll, click-for-selection, and drag, +// so it is a safe fallback that keeps the main interaction paths working without +// the hover-highlight polish that AllMotion enables. +// +// Detection relies on Termux-specific environment variables: +// - TERMUX_VERSION — only set inside Termux (most reliable signal) +// - PREFIX — starts with /data/data/com.termux/files/usr +// - ANDROID_ROOT == /system — present on all Android systems +// +// PROOT_CWD and CONTAINER are NOT reliable guest-side PRoot signals and are +// intentionally omitted. +func mouseModeForTermEnv() tea.MouseMode { + switch { + case os.Getenv("TERMUX_VERSION") != "": + return tea.MouseModeCellMotion + case strings.HasPrefix(os.Getenv("PREFIX"), "/data/data/com.termux/files/usr"): + return tea.MouseModeCellMotion + case os.Getenv("ANDROID_ROOT") == "/system": + return tea.MouseModeCellMotion + default: + return tea.MouseModeAllMotion + } +} type mouseOverlayHit struct { x int diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index 758f528a..5f18358f 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -2,6 +2,7 @@ package tui import ( "context" + "os" "strings" "testing" @@ -1002,3 +1003,101 @@ func setupMouseTestModel() model { m.altScreen = true return m } + +// Helpers for mouseModeForTermEnv tests +func setenv(t *testing.T, key, value string) { + t.Helper() + prev, existed := os.LookupEnv(key) + os.Setenv(key, value) + t.Cleanup(func() { + if existed { + os.Setenv(key, prev) + } else { + os.Unsetenv(key) + } + }) +} + +func unsetenv(t *testing.T, key string) { + t.Helper() + prev, existed := os.LookupEnv(key) + if existed { + os.Unsetenv(key) + } + t.Cleanup(func() { + if existed { + os.Setenv(key, prev) + } + }) +} + +func TestMouseModeForTermEnvDefaultDesktop(t *testing.T) { + unsetenv(t, "TERMUX_VERSION") + unsetenv(t, "PREFIX") + unsetenv(t, "ANDROID_ROOT") + + if got := mouseModeForTermEnv(); got != tea.MouseModeAllMotion { + t.Fatalf("default desktop env: got %v, want MouseModeAllMotion", got) + } +} + +func TestMouseModeForTermEnvTermuxVersion(t *testing.T) { + unsetenv(t, "PREFIX") + unsetenv(t, "ANDROID_ROOT") + setenv(t, "TERMUX_VERSION", "0.118.0") + + if got := mouseModeForTermEnv(); got != tea.MouseModeCellMotion { + t.Fatalf("TERMUX_VERSION=0.118.0: got %v, want MouseModeCellMotion", got) + } +} + +func TestMouseModeForTermEnvPrefix(t *testing.T) { + unsetenv(t, "TERMUX_VERSION") + unsetenv(t, "ANDROID_ROOT") + setenv(t, "PREFIX", "/data/data/com.termux/files/usr") + + if got := mouseModeForTermEnv(); got != tea.MouseModeCellMotion { + t.Fatalf("PREFIX set: got %v, want MouseModeCellMotion", got) + } +} + +func TestMouseModeForTermEnvPrefixUnrelated(t *testing.T) { + unsetenv(t, "TERMUX_VERSION") + unsetenv(t, "ANDROID_ROOT") + setenv(t, "PREFIX", "/opt/homebrew") + + if got := mouseModeForTermEnv(); got != tea.MouseModeAllMotion { + t.Fatalf("PREFIX non-Termux: got %v, want MouseModeAllMotion", got) + } +} + +func TestMouseModeForTermEnvAndroidRoot(t *testing.T) { + unsetenv(t, "TERMUX_VERSION") + unsetenv(t, "PREFIX") + setenv(t, "ANDROID_ROOT", "/system") + + if got := mouseModeForTermEnv(); got != tea.MouseModeCellMotion { + t.Fatalf("ANDROID_ROOT=/system: got %v, want MouseModeCellMotion", got) + } +} + +func TestMouseModeForTermEnvAndroidRootUnrelated(t *testing.T) { + unsetenv(t, "TERMUX_VERSION") + unsetenv(t, "PREFIX") + setenv(t, "ANDROID_ROOT", "/var/mybogus") + + if got := mouseModeForTermEnv(); got != tea.MouseModeAllMotion { + t.Fatalf("ANDROID_ROOT non-Android: got %v, want MouseModeAllMotion", got) + } +} + +func TestMouseModeForTermEnvTermuxVersionWins(t *testing.T) { + // TERMUX_VERSION takes precedence even when PREFIX or ANDROID_ROOT is also set + setenv(t, "TERMUX_VERSION", "0.118.0") + setenv(t, "PREFIX", "/data/data/com.termux/files/usr") + setenv(t, "ANDROID_ROOT", "/system") + + if got := mouseModeForTermEnv(); got != tea.MouseModeCellMotion { + t.Fatalf("all Termux signals: got %v, want MouseModeCellMotion", got) + } +} diff --git a/internal/tui/permission_prompt_test.go b/internal/tui/permission_prompt_test.go index 0b89ec02..80ef3ebb 100644 --- a/internal/tui/permission_prompt_test.go +++ b/internal/tui/permission_prompt_test.go @@ -309,3 +309,68 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) { t.Fatalf("network prompt should render target label, got %q", got) } } + +func TestPermissionCursorCtrlU(t *testing.T) { + m := pendingPermissionModel(t, func(agent.PermissionDecision) {}) + // Ctrl+U is "up" half-page — should move cursor UP (-1), wrapping to last. + updated, _ := m.Update(testKeyCtrl('u')) + m = updated.(model) + if want := len(permissionOptions(m.pendingPermission.request)) - 1; m.pendingPermission.cursor != want { + t.Fatalf("Ctrl+U from 0 should wrap to %d, got %d", want, m.pendingPermission.cursor) + } +} + +func TestPermissionCursorCtrlD(t *testing.T) { + m := pendingPermissionModel(t, func(agent.PermissionDecision) {}) + // Ctrl+D is "down" half-page — should move cursor DOWN (+1). + updated, _ := m.Update(testKeyCtrl('d')) + m = updated.(model) + if m.pendingPermission.cursor != 1 { + t.Fatalf("Ctrl+D from 0 should advance to 1, got %d", m.pendingPermission.cursor) + } +} + +func TestShiftUpWithComposerDoesNotScroll(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width = 90 + m.height = 14 + for index := 0; index < 12; index++ { + m.transcript = appendRow(m.transcript, rowAssistant, "message "+string(rune('A'+index))) + } + m.input.SetValue("hello world") + m.input.CursorEnd() + + // Without the composer guard, Shift+Up would scroll the transcript. + // With the guard, it should break out of the switch and let the input + // field handle the key (multiline navigation). + scrollBefore := m.chatScrollOffset + updated, cmd := m.Update(testKeyShift(tea.KeyUp)) + next := updated.(model) + if cmd != nil { + t.Fatal("Shift+Up with non-empty composer should not return a command") + } + if next.chatScrollOffset != scrollBefore { + t.Fatalf("Shift+Up with non-empty composer scrolled transcript: offset %d -> %d", scrollBefore, next.chatScrollOffset) + } +} + +func TestShiftDownWithComposerDoesNotScroll(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width = 90 + m.height = 14 + for index := 0; index < 12; index++ { + m.transcript = appendRow(m.transcript, rowAssistant, "message "+string(rune('A'+index))) + } + m.input.SetValue("hello world") + m.input.CursorEnd() + + scrollBefore := m.chatScrollOffset + updated, cmd := m.Update(testKeyShift(tea.KeyDown)) + next := updated.(model) + if cmd != nil { + t.Fatal("Shift+Down with non-empty composer should not return a command") + } + if next.chatScrollOffset != scrollBefore { + t.Fatalf("Shift+Down with non-empty composer scrolled transcript: offset %d -> %d", scrollBefore, next.chatScrollOffset) + } +}