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/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`: diff --git a/internal/sandbox/manager.go b/internal/sandbox/manager.go index 03a69109..8cc78474 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,55 @@ 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). +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 isExecutable(fi) { + return candidate, nil + } + } + } + return "", errors.New("executable file not found in $PATH") + } + fi, err := os.Stat(name) + if err != nil { + return "", err + } + if isExecutable(fi) { + 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": 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) + } +}