Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 28 additions & 0 deletions internal/tui/ask_user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
4 changes: 3 additions & 1 deletion internal/tui/keybinding_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
145 changes: 142 additions & 3 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
33 changes: 32 additions & 1 deletion internal/tui/mouse.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
99 changes: 99 additions & 0 deletions internal/tui/mouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"context"
"os"
"strings"
"testing"

Expand Down Expand Up @@ -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)
}
}
Loading
Loading