From 73d69ea9dfc848b8e9c9ad1b606a639239471946 Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Sun, 5 Jul 2026 09:00:07 +0000 Subject: [PATCH 1/4] fix(tui): TracerPid PRoot detection, Ctrl+U/D direction, composer guard - Replace Termux env var detection with isRunningUnderPRoot() via /proc/self/status TracerPid, cached with sync.OnceValue - Mouse mode falls back to CellMotion (not AllMotion) under PRoot to fix broken touch scrolling - Add Shift+Up/Shift+Down before plain KeyDown/KeyUp for transcript scroll with permission/askuser cursor + composer guard - Add Ctrl+U (scroll up) and Ctrl+D (scroll down) with proper direction: Ctrl+U=move up, Ctrl+D=move down - Add guards for active modals (wizard, picker, spec review, etc.) to avoid intercepting their Ctrl+U/D keybindings - Tests: TestPermissionCursorCtrlU/D (direction regression), TestShiftUp/DownComposerGuard (composer guard) - References issue #507 --- internal/tui/model.go | 139 +++++++++++++++++++++++-- internal/tui/mouse.go | 32 +++++- internal/tui/mouse_test.go | 32 ++++++ internal/tui/permission_prompt_test.go | 67 ++++++++++++ 4 files changed, 261 insertions(+), 9 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index acfafc9c..4799a287 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -1389,6 +1389,78 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { } m = m.clearHover() return m.scrollChat(-m.chatPageScrollLines()), nil + case keyShift(msg) && keyIs(msg, tea.KeyUp): + // Shift+Up scrolls the transcript up one line. Must be checked before + // plain KeyUp so shifted arrows aren't consumed by the composer-path. + 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 { + if m.modelPickerIsLoading() { + return m, nil + } + m.pickerMoved(-1) + return m, nil + } + if m.suggestionsActive() { + break + } + if m.composerValue() != "" { + break // let the input handle multiline navigation + } + m = m.clearHover() + return m.scrollChat(1), nil + case keyShift(msg) && keyIs(msg, tea.KeyDown): + // Shift+Down scrolls the transcript down one line. Must be checked + // before plain KeyDown so shifted arrows aren't consumed. + 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 { + if m.modelPickerIsLoading() { + return m, nil + } + m.pickerMoved(1) + return m, nil + } + if m.suggestionsActive() { + break + } + if m.composerValue() != "" { + break // let the input handle multiline navigation + } + m = m.clearHover() + return m.scrollChat(-1), nil case keyIs(msg, tea.KeyDown): if m.transcriptDetailed { return m, nil @@ -1467,6 +1539,48 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) { if m.historyRecallActive() { return m.recallHistory(-1), nil } + case keyCtrl(msg, 'u'): + // Ctrl+U scrolls up half a page, or moves the cursor up in + // permission/ask-user prompts. Falls through to the active modal + // (wizard, etc.) when none of the above are in focus. + 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 || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil { + break + } + if m.composerValue() != "" { + break // let the input handle its own Ctrl+U (delete-to-bol) + } + m = m.clearHover() + return m.scrollChat(m.chatPageScrollLines()), nil + case keyCtrl(msg, 'd'): + // Ctrl+D scrolls down half a page, or moves the cursor down in + // permission/ask-user prompts. Falls through to the active modal + // when none of the above are in focus. + 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 || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil { + break + } + if m.composerValue() != "" { + break // let the input handle its own Ctrl+D (delete-next-char) + } + m = m.clearHover() + return m.scrollChat(-m.chatPageScrollLines()), nil } if m.transcriptDetailed { return m, nil @@ -2165,14 +2279,23 @@ func (m model) View() tea.View { } view.ReportFocus = m.notifier != nil if m.wantsMouseCapture() { - // AllMotion (not CellMotion) is required for hover highlighting: it - // reports cursor movement even with no button pressed. CellMotion only - // reports motion while a button is held (drag) — see bubbletea's - // 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 + if isRunningUnderPRoot() { + // Under PRoot the AllMotion (1003) sequence doesn't work + // reliably, breaking touch-gesture scrolling. Fall back to + // CellMotion which still delivers wheel events, clicks, and + // drag — the only thing lost is hover-highlighting. + view.MouseMode = tea.MouseModeCellMotion + } else { + // AllMotion (not CellMotion) is required for hover highlighting: + // it reports cursor movement even with no button pressed. + // CellMotion only reports motion while a button is held (drag) — + // see bubbletea's 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 + } } return view } diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 2572ab67..e25c105a 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -1,6 +1,36 @@ package tui -import tea "charm.land/bubbletea/v2" +import ( + "os" + "strings" + "sync" + + tea "charm.land/bubbletea/v2" +) + +// parseTracerPid returns true when /proc/self/status contains a non-zero +// TracerPid. This detects any ptrace tracer (PRoot, gdb, strace, dlv, etc.), +// not PRoot specifically. That's intentional: under any ptrace tracer the +// AllMotion (1003) sequence is unreliable, so CellMotion is the safer +// fallback. The only loss is hover-highlighting, which has no functional +// impact. +func parseTracerPid(data []byte) bool { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "TracerPid:") { + pid := strings.TrimSpace(line[10:]) + return pid != "0" + } + } + return false +} + +var isRunningUnderPRoot = sync.OnceValue(func() bool { + data, err := os.ReadFile("/proc/self/status") + if err != nil { + return false + } + return parseTracerPid(data) +}) type mouseOverlayHit struct { x int diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index 758f528a..6af203a8 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -986,6 +986,38 @@ func withSidebarContent(m model) model { return m } +func TestParseTracerPidMissing(t *testing.T) { + if got := parseTracerPid([]byte("Pid: 123\nName: foo\n")); got { + t.Fatal("missing TracerPid should return false") + } +} + +func TestParseTracerPidZero(t *testing.T) { + if got := parseTracerPid([]byte("TracerPid: 0\nName: foo\n")); got { + t.Fatal("TracerPid: 0 should return false") + } +} + +func TestParseTracerPidNonZero(t *testing.T) { + if got := parseTracerPid([]byte("Name: foo\nTracerPid: 42\n")); !got { + t.Fatal("TracerPid: 42 should return true") + } +} + +func TestParseTracerPidMalformed(t *testing.T) { + // Non-numeric values are treated as non-zero since any non-"0" string + // indicates a tracer is attached, matching the function's semantics. + if got := parseTracerPid([]byte("TracerPid: abc\n")); !got { + t.Fatal("malformed TracerPid should return true (non-zero string)") + } +} + +func TestParseTracerPidEmpty(t *testing.T) { + if got := parseTracerPid([]byte{}); got { + t.Fatal("empty input should return false") + } +} + func setupMouseTestModel() model { m := newModel(context.Background(), Options{ Setup: SetupOptions{ diff --git a/internal/tui/permission_prompt_test.go b/internal/tui/permission_prompt_test.go index 0b89ec02..34d3df54 100644 --- a/internal/tui/permission_prompt_test.go +++ b/internal/tui/permission_prompt_test.go @@ -309,3 +309,70 @@ func TestPermissionRenderShowsNetworkTargetAndHostScopedAlways(t *testing.T) { t.Fatalf("network prompt should render target label, got %q", got) } } + +// TestPermissionCursorCtrlU ensures Ctrl+U moves the permission cursor UP +// (toward the first option). Regression: the original implementation moved +// the cursor DOWN on Ctrl+U and UP on Ctrl+D. +func TestPermissionCursorCtrlU(t *testing.T) { + m := pendingPermissionModel(t, func(agent.PermissionDecision) {}) + m.pendingPermission.cursor = 2 // middle option + + updated, _ := m.Update(testKeyCtrl('u')) + next := updated.(model) + if next.pendingPermission.cursor != 1 { + t.Fatalf("Ctrl+U moved cursor to %d, want 1 (one step up)", next.pendingPermission.cursor) + } +} + +// TestPermissionCursorCtrlD ensures Ctrl+D moves the permission cursor DOWN +// (toward the last option). Regression: the original implementation moved +// the cursor UP on Ctrl+D and DOWN on Ctrl+U. +func TestPermissionCursorCtrlD(t *testing.T) { + m := pendingPermissionModel(t, func(agent.PermissionDecision) {}) + m.pendingPermission.cursor = 0 // first option + + updated, _ := m.Update(testKeyCtrl('d')) + next := updated.(model) + if next.pendingPermission.cursor != 1 { + t.Fatalf("Ctrl+D moved cursor to %d, want 1 (one step down)", next.pendingPermission.cursor) + } +} + +// TestShiftUpComposerGuard ensures Shift+Up does NOT scroll the transcript +// when the composer has text, so it falls through to the input's own line +// navigation. +func TestShiftUpComposerGuard(t *testing.T) { + m := mouseTestModel() + // Add enough transcript rows so scrolling is valid. + for i := 0; i < 20; i++ { + m.transcript = appendRow(m.transcript, rowAssistant, "line") + } + m.input.SetValue("some text") + m.chatScrollOffset = 5 + + updated, cmd := m.Update(testKeyShift(tea.KeyUp)) + next := updated.(model) + _ = cmd + if got := next.chatScrollOffset; got != 5 { + t.Fatalf("Shift+Up with non-empty composer scrolled offset to %d, want 5 (unchanged)", got) + } +} + +// TestShiftDownComposerGuard ensures Shift+Down does NOT scroll the transcript +// when the composer has text, falling through to the input's navigation. +func TestShiftDownComposerGuard(t *testing.T) { + m := mouseTestModel() + // Add enough transcript rows so scrolling is valid. + for i := 0; i < 20; i++ { + m.transcript = appendRow(m.transcript, rowAssistant, "line") + } + m.input.SetValue("some text") + m.chatScrollOffset = 3 + + updated, cmd := m.Update(testKeyShift(tea.KeyDown)) + next := updated.(model) + _ = cmd + if got := next.chatScrollOffset; got != 3 { + t.Fatalf("Shift+Down with non-empty composer scrolled offset to %d, want 3 (unchanged)", got) + } +} From c8f899d159a45bf5ed1dd6e4f09779a3920b6ab3 Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Sun, 5 Jul 2026 13:06:30 +0000 Subject: [PATCH 2/4] fix(sandbox): avoid faccessat2 for Termux/Android portability Replace exec.LookPath with lookupExecutable that uses os.Stat instead of the faccessat2 syscall. The faccessat2 syscall (439) is blocked by the Samsung seccomp filter on Android, causing SIGSYS termination. os.Stat uses the newfstatat/statx syscalls which are universally permitted. This is part of broader Termux/Android support for Zero. --- .gitignore | 4 ++++ internal/sandbox/manager.go | 31 +++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7978ffab..b3880564 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only .superpowers/ docs/superpowers/ + +# Termux/Android build artifacts +zero-termux-arm64 +zero-linux-sandbox diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 03a69109..3b33e4cb 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -3,8 +3,9 @@ package sandbox import ( "errors" "os" - "os/exec" + "path/filepath" "runtime" + "strings" ) // errWindowsSandboxNotInitialized is returned only to a caller that explicitly @@ -121,9 +122,35 @@ func (manager SandboxManager) Backend() Backend { return manager.backend } +// lookupExecutable checks whether a named binary exists and is executable, +// using os.Stat instead of exec.LookPath. exec.LookPath internally uses the +// faccessat2 syscall, which is blocked by Android's seccomp filter (SIGSYS). +func lookupExecutable(name string) (string, error) { + if !strings.Contains(name, string(os.PathSeparator)) { + path := os.Getenv("PATH") + for _, dir := range strings.Split(path, string(os.PathListSeparator)) { + candidate := filepath.Join(dir, name) + if fi, err := os.Stat(candidate); err == nil { + if fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 { + return candidate, nil + } + } + } + return "", errors.New("executable file not found in $PATH") + } + fi, err := os.Stat(name) + if err != nil { + return "", err + } + if fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 { + return name, nil + } + return "", errors.New("executable file not found") +} + func selectPlatformBackend(goos string, lookup func(string) (string, error)) Backend { if lookup == nil { - lookup = exec.LookPath + lookup = lookupExecutable } switch goos { case "linux": From 9696920aab3de01a9a83a2905796f001fddf128d Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Sun, 5 Jul 2026 13:07:00 +0000 Subject: [PATCH 3/4] docs(install): add Termux/Android build and configuration guide Document how to build Zero for native Android/Termux: GOOS=android to avoid the SIGSYS crash, proot bind-mount for DNS resolution, PRoot scroll fix, and free-tier provider setup. --- docs/INSTALL.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 1ccf6728..5bec9843 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -173,6 +173,61 @@ Linux native sandboxing also requires Bubblewrap to be installed. macOS uses the system sandbox and does not need an extra helper binary. +### Termux (Android) + +Zero can run natively on Android via [Termux](https://termux.dev/). Build with +`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's +seccomp filter on Android: + +```bash +# Install Go in Termux +pkg install golang + +# Build Zero for Android +git clone https://github.com/Gitlawb/zero.git +cd zero +CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero + +# Move into PATH +mv zero ~/.local/bin/ +``` + +> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips +> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to +> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and +> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.). + +**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver +needs one. Use `proot` to bind-mount Termux's resolver config: + +```bash +pkg install proot +proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero +``` + +Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time: + +```bash +#!/data/data/com.termux/files/usr/bin/bash +exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@" +``` + +**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the +box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero +inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion` +to avoid PRoot's ptrace interference with the 1003 escape sequence. + +**Providers.** Zero works with any OpenAI-compatible provider on Termux. For +example, to use OpenCode Zen's free tier: + +```bash +zero providers add opencode \ + --name opencode \ + --model deepseek-v4-flash-free \ + --base-url https://opencode.ai/zen/v1 \ + --set-active +``` + Windows source builds can use the main `zero.exe` as the command runner and setup helper through Zero's built-in self-dispatch path. If you want a release-style layout anyway, build the standalone helper executables next to `zero.exe`: From 85c23cc69fc822bafdec0bfeeada480c531525c5 Mon Sep 17 00:00:00 2001 From: PatrickNoFilter Date: Sun, 5 Jul 2026 13:20:09 +0000 Subject: [PATCH 4/4] fix(sandbox): skip empty PATH segments, use platform-aware executable check --- internal/sandbox/manager.go | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 3b33e4cb..8cc78474 100644 --- a/internal/sandbox/manager.go +++ b/internal/sandbox/manager.go @@ -122,6 +122,23 @@ func (manager SandboxManager) Backend() Backend { return manager.backend } +// isExecutable checks whether a file is executable. On Unix, this checks the +// execute permission bits. On Windows, Go's os.FileMode does not set execute +// bits, so we check for common executable extensions instead. +func isExecutable(fi os.FileInfo) bool { + if !fi.Mode().IsRegular() { + return false + } + if runtime.GOOS == "windows" { + name := strings.ToLower(fi.Name()) + return strings.HasSuffix(name, ".exe") || + strings.HasSuffix(name, ".com") || + strings.HasSuffix(name, ".bat") || + strings.HasSuffix(name, ".cmd") + } + return fi.Mode()&0o111 != 0 +} + // lookupExecutable checks whether a named binary exists and is executable, // using os.Stat instead of exec.LookPath. exec.LookPath internally uses the // faccessat2 syscall, which is blocked by Android's seccomp filter (SIGSYS). @@ -129,9 +146,12 @@ func lookupExecutable(name string) (string, error) { if !strings.Contains(name, string(os.PathSeparator)) { path := os.Getenv("PATH") for _, dir := range strings.Split(path, string(os.PathListSeparator)) { + if dir == "" { + continue // skip empty entries (e.g. trailing colon) + } candidate := filepath.Join(dir, name) if fi, err := os.Stat(candidate); err == nil { - if fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 { + if isExecutable(fi) { return candidate, nil } } @@ -142,7 +162,7 @@ func lookupExecutable(name string) (string, error) { if err != nil { return "", err } - if fi.Mode().IsRegular() && fi.Mode()&0o111 != 0 { + if isExecutable(fi) { return name, nil } return "", errors.New("executable file not found")