diff --git a/README.md b/README.md index 84e28f7..5a325fd 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,10 @@ agentpane watching a fan-out -Another terminal UI harness. Written in Go. With a tree in it. I know - how -original. +Another terminal UI harness. Written in Go. With a tree in it. Yes, I know. Claude Code fans out to eight subagents and the transcript turns into a wall of -scrolling text. Worse: one of them hits a permission prompt and then just waits, +scrolling text. Worse, one of them hits a permission prompt and then just waits, silently, while you are looking at a different window. agentpane draws the tree live in a second pane. One row per agent, what it is @@ -23,14 +22,16 @@ agentpane install A binary into `~/.local/bin`, then the hooks into `~/.claude/settings.json`. The installer shows you the diff and asks first. Add `--autopane` and the pane opens -itself with every session. Undo it all with `agentpane install --uninstall`. +itself with every session, in iTerm2, tmux, WezTerm or kitty. Undo it all with +`agentpane install --uninstall`. Then run `agentpane` in a second pane next to your session. -* Just want a look? `agentpane --demo` needs no install and no Claude. -* Something wrong? `agentpane doctor` says what. -* Prefer Go? `go install github.com/simoncoombes/agentpane/cmd/agentpane@latest` -* Using Codex? `agentpane --source codex` +* `agentpane --demo` replays a fan-out. No install, no Claude. +* `agentpane doctor` says what is wrong when nothing shows up. +* `go install github.com/simoncoombes/agentpane/cmd/agentpane@latest` if you + would rather build it. +* `agentpane --source codex` watches an OpenAI Codex run instead. ## The marks @@ -40,18 +41,23 @@ nothing · `▪▪▫·` how far it got · the number on the right is tokens `⏎` inspects a row, `⇥` jumps to the session pane to answer whoever is blocked, `a` shows the agents that finished, `?` lists the rest. +The tree draws at whatever width the pane is, up to 100 columns, and redraws +when you drag the divider. `w` fixes it at 64 or 44 instead. + Nothing on a row is a guess. The two marks that are inferred say so, and an agent that reports nothing gets a dim row rather than an invention. ## Docs -* [READING-THE-PANE.md](docs/READING-THE-PANE.md) — every glyph, every - animation, and what the pane won't tell you -* [CLI.md](docs/CLI.md) — every flag, key and config setting -* [INSTALL.md](docs/INSTALL.md) — the installer, the hook snippet, auto-open -* [ITERM2.md](docs/ITERM2.md) — iTerm2 profile and split -* [CODEX.md](docs/CODEX.md) — watching a Codex run -* [DATA-SOURCES.md](docs/DATA-SOURCES.md) — what it reads, and how fast +* [READING-THE-PANE.md](docs/READING-THE-PANE.md). Every glyph, every + animation, and what the pane won't tell you. +* [CLI.md](docs/CLI.md). Every flag, key and config setting. +* [INSTALL.md](docs/INSTALL.md). The installer, the hook snippet, auto-open. +* [TERMINALS.md](docs/TERMINALS.md). Which terminals can open the pane for + you, and what to do in the ones that cannot. +* [ITERM2.md](docs/ITERM2.md). iTerm2 profiles and the split. +* [CODEX.md](docs/CODEX.md). Watching a Codex run. +* [DATA-SOURCES.md](docs/DATA-SOURCES.md). What it reads, and how fast. * [CONTRIBUTING.md](CONTRIBUTING.md) · [SECURITY.md](SECURITY.md) MIT licensed. See [LICENSE](LICENSE). diff --git a/cmd/agentpane/adapters.go b/cmd/agentpane/adapters.go index 600aed2..0393043 100644 --- a/cmd/agentpane/adapters.go +++ b/cmd/agentpane/adapters.go @@ -178,10 +178,34 @@ func installedHookEvents() []string { // uiPrefs persists the w and t toggles under the state dir — never in the // user's config.toml, which agentpane treats as read-only. type uiPrefs struct { + Schema int `json:"schema"` Width string `json:"width"` RightCol string `json:"right_col"` } +// uiPrefsSchema is the current shape. Anything older goes through +// migratePrefs on the way in. +const uiPrefsSchema = 1 + +// migratePrefs upgrades a prefs file written before fill existed. +// +// `w` used to be a two-way toggle between wide and narrow, and wide was the +// default, so a stored "wide" is ambiguous: it almost always means "I toggled +// back to normal", not "hold this pane at 64 columns however wide it gets". +// Honouring it would leave everyone who had ever pressed `w` twice with a pane +// that still refuses the room, and nothing on screen to explain why. It is +// dropped once, and a deliberate "wide" chosen after the upgrade is written +// with the current schema and kept from then on. +// +// A stored "narrow" says something the new default does not, so it survives. +func migratePrefs(p uiPrefs) uiPrefs { + if p.Schema < uiPrefsSchema && p.Width == "wide" { + p.Width = "" + } + p.Schema = uiPrefsSchema + return p +} + func prefsPath() string { dir, err := statefile.DefaultDir() if err != nil { @@ -197,7 +221,7 @@ func loadPrefs() uiPrefs { return p } json.Unmarshal(data, &p) //nolint:errcheck // malformed prefs = defaults (C9) - return p + return migratePrefs(p) } func savePrefs(p uiPrefs) { diff --git a/cmd/agentpane/autopane.go b/cmd/agentpane/autopane.go index 2020554..0821e49 100644 --- a/cmd/agentpane/autopane.go +++ b/cmd/agentpane/autopane.go @@ -51,12 +51,12 @@ import ( ) const ( - // autopaneOsascriptTimeout bounds the osascript call. On expiry the - // helper is ABANDONED, never killed — no code path may signal a process - // (same pattern as editor.go's runQuick post-fix); whatever survives is - // reaped by the harness's hook-tree cleanup, which is fine because the - // pane iTerm2 already created belongs to iTerm2. - autopaneOsascriptTimeout = 3 * time.Second + // autopaneSplitTimeout bounds the command that does the splitting. On + // expiry the helper is ABANDONED, never killed — no code path may signal a + // process — and whatever survives is reaped by the harness's hook-tree + // cleanup, which is fine because the pane the terminal already created + // belongs to the terminal, not to this hook's process tree. + autopaneSplitTimeout = 3 * time.Second // autopaneLockStale is the age past which a leftover lockfile is // reclaimed (a crash between lock and split, or a very old session id @@ -244,23 +244,15 @@ func runAutopaneMode(stdin io.Reader, extraArgs []string, ppid int, explain io.W } autopanePass(explain, "(b) kill switch: not set") - // (c) the session must live in an iTerm2 pane we can target. - // ITERM_SESSION_ID carries "wNtNpN:"; the AppleScript matches the - // uuid against `id of session`, so strip through the last ":" — the same - // technique as it2-pane-hook.sh's `${ITERM_SESSION_ID##*:}`. - if os.Getenv("TERM_PROGRAM") != "iTerm.app" { - autopaneSkip(explain, "(c) TERM_PROGRAM=%q — auto-open needs iTerm.app (the split is an iTerm2 AppleScript)", os.Getenv("TERM_PROGRAM")) + // (c) the session must live in a terminal agentpane knows how to split. + // pane.go decides which, and why — a multiplexer beats the terminal + // hosting it, and AGENTPANE_TERMINAL overrides both. + backend := detectBackend(os.Getenv) + if backend == backendNone { + autopaneSkip(explain, "(c) no terminal to split: %s", backendReason(os.Getenv)) return } - paneUUID := os.Getenv("ITERM_SESSION_ID") - if i := strings.LastIndex(paneUUID, ":"); i >= 0 { - paneUUID = paneUUID[i+1:] - } - if paneUUID == "" { - autopaneSkip(explain, "(c) ITERM_SESSION_ID empty — no pane to target") - return - } - autopanePass(explain, "(c) iTerm2 pane: %s", paneUUID) + autopanePass(explain, "(c) terminal: %s", backend) // (d) nested Claude session: an interactive session spawned from inside // another Claude session inherits CLAUDE_CODE_CHILD_SESSION @@ -336,28 +328,47 @@ func runAutopaneMode(stdin io.Reader, extraArgs []string, ppid int, explain io.W return } cmdline := paneCommand(exe, p.SessionID, extraArgs) - script := autopaneScript(paneUUID, autopaneProfile(), cmdline) - osascript := os.Getenv("AGENTPANE_OSASCRIPT") - if osascript == "" { - osascript = "osascript" + chain := splitCommands(backend, os.Getenv, cmdline, autopaneColumns()) + if len(chain) == 0 { + autopaneSkip(explain, "act: the %s backend has no pane to target from here", backend) + if !dry { + os.Remove(lock) //nolint:errcheck // best-effort release, stay silent + } + return } if dry { - fmt.Fprintf(explain, "\nWOULD OPEN a pane: profile %q running %s\n", autopaneProfile(), cmdline) - fmt.Fprintf(explain, "via %s -e \n", osascript, paneUUID) - if _, err := exec.LookPath(osascript); err != nil { - fmt.Fprintf(explain, "SKIP act: %s not found on PATH: %v\n", osascript, err) + fmt.Fprintf(explain, "\nWOULD OPEN a %s pane %d columns wide, running %s\n", + backend, autopaneColumns(), cmdline) + for _, c := range chain { + fmt.Fprintf(explain, "via %s\n", summariseArgv(c.argv)) + if _, err := exec.LookPath(c.argv[0]); err != nil { + fmt.Fprintf(explain, "SKIP act: %s not found on PATH: %v\n", c.argv[0], err) + } } return } - autopanePass(explain, "act: running %s to split pane %s (profile %q)", osascript, paneUUID, autopaneProfile()) - if !runAbandoning(osascript, autopaneOsascriptTimeout, "-e", script) { - autopaneSkip(explain, "act: %s failed to start — lock released", osascript) + autopanePass(explain, "act: splitting via %s (%s)", backend, summariseArgv(chain[0].argv)) + if !runPaneChain(chain, autopaneSplitTimeout) { + autopaneSkip(explain, "act: every %s split command failed — lock released", backend) os.Remove(lock) //nolint:errcheck // best-effort release, stay silent } else { - autopanePass(explain, "act: osascript started — pane should appear") + autopanePass(explain, "act: split issued — pane should appear") } } +// summariseArgv renders a command for the --explain trace without pasting a +// forty-line AppleScript into it. +func summariseArgv(argv []string) string { + out := make([]string, 0, len(argv)) + for _, a := range argv { + if len(a) > 60 || strings.Contains(a, "\n") { + a = "<" + strconv.Itoa(len(a)) + " bytes of script>" + } + out = append(out, a) + } + return strings.Join(out, " ") +} + // socketState is the guard-(f) probe result for the per-session TUI socket. type socketState int @@ -553,29 +564,6 @@ func autopaneLockReclaim(path string, now time.Time) bool { return autopaneLockCreate(path) } -// runAbandoning starts name and waits at most timeout, reporting whether the -// process STARTED (false only when exec itself failed — binary missing, not -// executable). On expiry the process is abandoned WITHOUT being killed — no -// code path anywhere may signal a process (§5.3, PART 10) — and the Wait -// goroutine reaps it whenever it eventually finishes. stdout/stderr stay nil -// (silent on every path). -func runAbandoning(name string, timeout time.Duration, args ...string) bool { - cmd := exec.Command(name, args...) - if err := cmd.Start(); err != nil { - return false - } - done := make(chan struct{}) - go func() { - cmd.Wait() //nolint:errcheck // silent: outcome does not matter - close(done) - }() - select { - case <-done: - case <-time.After(timeout): - } - return true -} - // autopaneDebugPath is the trace log written when the debug marker exists. func autopaneDebugPath() (marker, logPath string, err error) { dir, err := os.UserHomeDir() diff --git a/cmd/agentpane/autopane_test.go b/cmd/agentpane/autopane_test.go index f50d709..eb9b61e 100644 --- a/cmd/agentpane/autopane_test.go +++ b/cmd/agentpane/autopane_test.go @@ -635,3 +635,69 @@ func TestAutopaneScriptEscapesHostileInput(t *testing.T) { t.Errorf("the pane id literal reads %q, want the input back verbatim", got) } } + +// The whole guard chain, all the way through the act, on a backend that is not +// iTerm2. This is what proves auto-open is not macOS-only: the same fixture +// that drives the AppleScript path drives tmux, with a fake tmux recording the +// argv it was handed. +func TestAutopaneTmuxSplit(t *testing.T) { + argv := setupAutopaneEnv(t) + // Leave iTerm2 detectable: tmux must win anyway (a session inside tmux + // inside iTerm2 lives in a tmux pane). + t.Setenv("TMUX", "/tmp/tmux-501/default,4242,0") + t.Setenv("TMUX_PANE", "%9") + t.Setenv("AGENTPANE_TMUX", strings.TrimSuffix(argv, "osascript-argv.txt")+"fake-osascript") + t.Setenv("AGENTPANE_COLUMNS", "72") + + in := strings.NewReader(autopanePayloadJSON("SessionStart", "startup", autopaneTestSession)) + if code := cmdAutopane(in, nil); code != 0 { + t.Fatalf("exit %d, want 0", code) + } + raw, err := os.ReadFile(argv) + if err != nil { + t.Fatalf("tmux not invoked on the all-pass path: %v", err) + } + got := string(raw) + exe, _ := os.Executable() + for _, want := range []string{ + "split-window", + "-d", // focus stays in the session pane + "-l", // ...at a width we chose + "72", // ...which is AGENTPANE_COLUMNS + "%9", // the session's own pane + exe, // the TUI, by absolute path + "--session", autopaneTestSession, // pinned to this session + } { + if !strings.Contains(got, want) { + t.Fatalf("tmux argv missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "iTerm2") { + t.Fatalf("an AppleScript reached tmux:\n%s", got) + } + if _, err := os.Stat(autopaneLockPath(autopaneTestSession)); err != nil { + t.Fatalf("lockfile not left in place: %v", err) + } +} + +// A terminal agentpane cannot drive is a skip, not a failure, and nothing is +// run at all. +func TestAutopaneUnsupportedTerminal(t *testing.T) { + argv := setupAutopaneEnv(t) + t.Setenv("TERM_PROGRAM", "Apple_Terminal") + t.Setenv("ITERM_SESSION_ID", "") + + var explain bytes.Buffer + in := strings.NewReader(autopanePayloadJSON("SessionStart", "startup", autopaneTestSession)) + runAutopaneMode(in, nil, os.Getpid(), &explain, false) + + if autopaneInvoked(argv) { + t.Fatal("something was run in a terminal with no split backend") + } + if !strings.Contains(explain.String(), "(c) no terminal to split") { + t.Fatalf("guard (c) did not report the reason:\n%s", explain.String()) + } + if _, err := os.Stat(autopaneLockPath(autopaneTestSession)); err == nil { + t.Fatal("a skipped event took the lock") + } +} diff --git a/cmd/agentpane/doctor.go b/cmd/agentpane/doctor.go index 9a00dc4..536a4da 100644 --- a/cmd/agentpane/doctor.go +++ b/cmd/agentpane/doctor.go @@ -5,6 +5,7 @@ import ( "io" "net" "os" + "os/exec" "path/filepath" "runtime" "sort" @@ -275,10 +276,44 @@ func doctorReport(w io.Writer, session string) int { fmt.Fprintln(w, " ● ⚑ ○ ◇ ◌ ◍ ✔ ✖ ⚠ ▪▫· ▇ ▣ ▌ ▏ █ ◐ ↺ ▸ │├╰─╮ ⠹⠿") fmt.Fprintln(w) + // The terminal backend: what auto-open and ⇥ can drive from here, and + // whether the program that does the driving is actually installed. This + // is the check that answers "why does nothing happen when a session + // starts", which used to need `autopane --explain` to find out. + fmt.Fprintln(w) + backend := detectBackend(os.Getenv) + if backend == backendNone { + warn("terminal: no split backend — %s", backendReason(os.Getenv)) + fmt.Fprintln(w, " the TUI works here; auto-open (agentpane install --autopane) and ⇥ do not") + fmt.Fprintln(w, " supported: iTerm2, tmux, WezTerm, kitty — see docs/TERMINALS.md") + fmt.Fprintln(w, " run agentpane in a second pane you split yourself, and answer prompts in the session pane") + } else { + chain := splitCommands(backend, os.Getenv, "agentpane", 64) + var missing []string + for _, c := range chain { + if _, err := exec.LookPath(c.argv[0]); err != nil { + missing = append(missing, c.argv[0]) + } + } + switch { + case len(chain) == 0: + bad("terminal: %s detected, but nothing here names a pane to split", backend) + case len(missing) == len(chain): + bad("terminal: %s detected, but none of %s is on PATH — the split cannot run", + backend, strings.Join(missing, ", ")) + default: + ok("terminal: %s — auto-open and ⇥ can drive this one", backend) + if len(missing) > 0 { + fmt.Fprintf(w, " %s not on PATH; the next alternative in the chain is used instead\n", + strings.Join(missing, ", ")) + } + } + } + // Capabilities that cannot be probed one-shot. warn("focus reporting (CSI ?1004): cannot verify from a script — check via the pane title test in docs/ITERM2.md") warn("OSC 8 hyperlinks: cannot verify from a script — ⌘-click a path in the pane to confirm") - warn("OSC 52 clipboard: cannot verify from a script — press y in the pane and check the clipboard (needs iTerm2's clipboard-access preference)") + warn("OSC 52 clipboard: cannot verify from a script — press y in the pane and check the clipboard (iTerm2 gates this behind a preference)") return 0 } diff --git a/cmd/agentpane/editor.go b/cmd/agentpane/editor.go index f93c33d..aed35f9 100644 --- a/cmd/agentpane/editor.go +++ b/cmd/agentpane/editor.go @@ -3,19 +3,19 @@ package main import ( "fmt" "os" - "os/exec" "strings" "time" ) -// osascriptTimeout bounds every AppleScript call so a hung iTerm2 can never -// wedge a background helper (the UI thread never waits on these at all). -const osascriptTimeout = 5 * time.Second +// paneCmdTimeout bounds every terminal-driving command — an AppleScript, a +// tmux or wezterm CLI call — so a hung terminal can never wedge a background +// helper. The UI thread never waits on these at all. +const paneCmdTimeout = 5 * time.Second // openInEditor returns the `o` handler (§5.3): open the agent's current file -// in $EDITOR in a NEW iTerm2 tab — never in the agentpane pane itself. The -// work happens in a goroutine so the UI never blocks; failures degrade to -// `open -a `, then `open `, and are only ever logged. +// in $EDITOR in a NEW tab — never in the agentpane pane itself. The work +// happens in a goroutine so the UI never blocks; a terminal that cannot open +// a tab degrades to the desktop opener, and failures are only ever logged. func openInEditor(configEditor string, dbg *debugLog) func(path string) error { return func(path string) error { if path == "" { @@ -29,19 +29,17 @@ func openInEditor(configEditor string, dbg *debugLog) func(path string) error { editor = "vi" } go func() { - if _, err := exec.LookPath("osascript"); err == nil { - script := iTermTabScript(editor, path) - if err := runOsascript(script); err == nil { + backend := currentBackend() + cmdline := "/bin/sh -c " + shellQuote(editor+" "+shellQuote(path)) + if chain := tabCommands(backend, os.Getenv, cmdline); len(chain) > 0 { + if runPaneChain(chain, paneCmdTimeout) { return } - dbg.printf("o: iTerm2 tab failed, falling back to open -a") + dbg.printf("o: %s tab failed, falling back to the desktop opener", backend) } - // Degraded path: hand the file to the editor app, then to the - // system default. - if err := runQuick("open", "-a", editor, path); err == nil { - return - } - if err := runQuick("open", path); err != nil { + // Degraded path: hand the file to the editor app, then to + // whatever the desktop uses. + if !runPaneChain(desktopOpenCommands(editor, path), paneCmdTimeout) { dbg.printf("o: every open path failed for %s", path) } }() @@ -49,57 +47,36 @@ func openInEditor(configEditor string, dbg *debugLog) func(path string) error { } } -// iTermTabScript builds the AppleScript for a new tab running the editor. -func iTermTabScript(editor, path string) string { - cmd := editor + " " + shellQuote(path) +// iTermTabScript builds the AppleScript for a new tab running cmdline, which +// is already a complete `/bin/sh -c '…'` string. +func iTermTabScript(cmdline string) string { return `tell application "iTerm2" tell current window - create tab with default profile command "` + appleScriptEscape("/bin/sh -c "+shellQuote(cmd)) + `" + create tab with default profile command "` + appleScriptEscape(cmdline) + `" end tell end tell` } -// focusLeftPane returns the ⏎-on-band handler (§5.3): jump focus to the -// session pane via the iTerm2 AppleScript interface, fire-and-forget. +// focusLeftPane returns the ⇥ handler (§5.3): jump focus to the session pane, +// fire-and-forget, through whichever terminal backend this pane is running +// under. A terminal agentpane cannot drive returns an error, which the key +// turns into the "no focus jump" toast rather than silence. func focusLeftPane(dbg *debugLog) func() error { return func() error { - if _, err := exec.LookPath("osascript"); err != nil { - return err + backend := currentBackend() + chain := focusCommands(backend, os.Getenv) + if len(chain) == 0 { + return fmt.Errorf("no focus jump for %s", backend) } go func() { - script := `tell application "iTerm2" to select first session of current tab of current window` - if err := runOsascript(script); err != nil { - dbg.printf("focus jump failed: %v", err) + if !runPaneChain(chain, paneCmdTimeout) { + dbg.printf("focus jump failed under %s", backend) } }() return nil } } -// runOsascript executes one AppleScript with a hard timeout. -func runOsascript(script string) error { - return runQuick("osascript", "-e", script) -} - -// runQuick runs a command, reporting a timeout after osascriptTimeout. The -// timed-out helper is abandoned, never killed — no code path anywhere may -// signal a process (§5.3, PART 10) — and the Wait goroutine reaps it whenever -// it eventually finishes. -func runQuick(name string, args ...string) error { - cmd := exec.Command(name, args...) - if err := cmd.Start(); err != nil { - return err - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case err := <-done: - return err - case <-time.After(osascriptTimeout): - return fmt.Errorf("%s timed out", name) - } -} - // shellQuote single-quotes s for /bin/sh. func shellQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" diff --git a/cmd/agentpane/main.go b/cmd/agentpane/main.go index 2fb37c3..dbd48f2 100644 --- a/cmd/agentpane/main.go +++ b/cmd/agentpane/main.go @@ -34,13 +34,13 @@ agentpane --demo-speed 2.0 agentpane --demo-seek 90 jump to t=90s agentpane install wire the hooks into ~/.claude/settings.json (asks first; --dry-run, --uninstall, --autopane) agentpane hook read one hook JSON on stdin, forward, exit 0 always -agentpane autopane SessionStart hook: auto-open the TUI in an iTerm2 split, exit 0 always -agentpane doctor check hooks installed, socket reachable, iTerm2 escapes, font glyphs +agentpane autopane SessionStart hook: auto-open the TUI in a split, exit 0 always (--explain says why not) +agentpane doctor check hooks installed, socket reachable, terminal backend, font glyphs agentpane doctor --session the same checks for exactly that session's pane (no cwd guess) agentpane runs list persisted runs (every session) agentpane runs --session list only that session's runs agentpane runs show print one run summary -agentpane --width narrow +agentpane --width wide|narrow fix the layout at 64 or 44 columns (default: fill the pane) agentpane --oneline single-row status output for a tmux/iTerm2 status bar (§3.20) agentpane --oneline --session status of exactly that session's pane @@ -182,7 +182,7 @@ func parseFlags(args []string, stderr io.Writer) (cliFlags, error) { fs.IntVar(&fl.cols, "cols", 64, "columns for --render-once") fs.IntVar(&fl.rows, "rows", 40, "rows for --render-once") fs.BoolVar(&fl.oneline, "oneline", false, "single-row status output (§3.20)") - fs.StringVar(&fl.width, "width", "", "layout width: wide|narrow") + fs.StringVar(&fl.width, "width", "", "layout width: fill|wide|narrow") fs.StringVar(&fl.accent, "accent", "", "live accent: auto|2|3|4|5|6") fs.BoolVar(&fl.noColor, "no-color", false, "disable all color (like NO_COLOR)") fs.BoolVar(&fl.noBell, "no-bell", false, "disable the ask bell") @@ -306,7 +306,7 @@ func configPath() string { // values warn and keep the config value (PART 7: never exit). func applyOverrides(cfg *config.Config, fl cliFlags, warnings *[]config.Warning) { if fl.width != "" { - if fl.width == "wide" || fl.width == "narrow" { + if fl.width == "wide" || fl.width == "narrow" || fl.width == "fill" { cfg.Width = fl.width } else { *warnings = append(*warnings, config.Warning{Key: "width", Msg: fmt.Sprintf("invalid --width %q - using %q", fl.width, cfg.Width)}) diff --git a/cmd/agentpane/main_test.go b/cmd/agentpane/main_test.go index ced6f88..7b6beb8 100644 --- a/cmd/agentpane/main_test.go +++ b/cmd/agentpane/main_test.go @@ -422,7 +422,7 @@ func TestApplyOverrides(t *testing.T) { } }}, {"width invalid warns", cliFlags{width: "huge"}, func(t *testing.T, c cfgLike, w int) { - if c.width != "wide" || w != 1 { + if c.width != "fill" || w != 1 { t.Fatalf("got %q/%d", c.width, w) } }}, @@ -702,3 +702,34 @@ func TestDoctorSessionAware(t *testing.T) { t.Fatalf("doctor with a stray argument: exit %d, want 2", code) } } + +// A prefs file written before fill existed must not pin an upgrading pane to +// 64 columns. `w` was a two-way toggle then, and wide was the default, so a +// stored "wide" mostly meant "back to normal". +func TestMigratePrefsDropsTheOldDefault(t *testing.T) { + cases := []struct { + name string + in uiPrefs + want string + }{ + {"pre-fill wide is dropped", uiPrefs{Width: "wide", RightCol: "rate"}, ""}, + {"pre-fill narrow is kept", uiPrefs{Width: "narrow"}, "narrow"}, + {"nothing stored stays nothing", uiPrefs{}, ""}, + {"wide chosen after the upgrade is kept", + uiPrefs{Schema: uiPrefsSchema, Width: "wide"}, "wide"}, + {"fill is kept", uiPrefs{Schema: uiPrefsSchema, Width: "fill"}, "fill"}, + } + for _, c := range cases { + got := migratePrefs(c.in) + if got.Width != c.want { + t.Errorf("%s: width = %q, want %q", c.name, got.Width, c.want) + } + if got.Schema != uiPrefsSchema { + t.Errorf("%s: schema = %d, want %d", c.name, got.Schema, uiPrefsSchema) + } + } + // The migration must not touch the other toggle. + if got := migratePrefs(uiPrefs{Width: "wide", RightCol: "since"}); got.RightCol != "since" { + t.Errorf("the right-column pref was lost: %q", got.RightCol) + } +} diff --git a/cmd/agentpane/pane.go b/cmd/agentpane/pane.go new file mode 100644 index 0000000..445ce4c --- /dev/null +++ b/cmd/agentpane/pane.go @@ -0,0 +1,341 @@ +package main + +// pane.go — the terminal backends agentpane drives. +// +// Three things in this program ask a terminal to do something rather than +// merely writing escapes at it: `agentpane autopane` splits the session's pane +// and runs the TUI in the new one, ⇥ moves focus back to the session pane, and +// `o` opens a file in $EDITOR somewhere that is not the agentpane pane. All +// three were iTerm2-and-AppleScript only, which made every one of them a macOS +// feature by accident rather than by decision. +// +// Each backend is a pure function from the environment to a command chain, so +// the exact argv is unit-testable on a machine that has none of these +// terminals installed (pane_test.go). Running the chain is the only impure +// part, and it lives in runPaneChain. +// +// Detection order matters and is not the obvious one: a MULTIPLEXER wins over +// the terminal hosting it. A Claude session inside tmux inside iTerm2 lives in +// a tmux pane; asking iTerm2 to split would divide the whole tmux viewport and +// leave the new pane outside the multiplexer entirely, next to a tmux window +// rather than next to the session. + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +// The supported backends. backendNone means "this terminal cannot be driven", +// which is a normal answer, not a failure: the TUI itself needs none of this, +// and every caller degrades to doing nothing. +const ( + backendNone = "none" + backendITerm2 = "iterm2" + backendTmux = "tmux" + backendWezTerm = "wezterm" + backendKitty = "kitty" +) + +// paneBackends are the names detectBackend can return, in the order they are +// tried, for the messages that have to list them. +var paneBackends = []string{backendITerm2, backendTmux, backendWezTerm, backendKitty} + +// envLookup is os.Getenv, injected so the detector is testable. +type envLookup func(string) string + +// detectBackend names the terminal this process can drive. +// +// AGENTPANE_TERMINAL overrides everything, including with "none" — the escape +// hatch for a terminal that is detected correctly and behaves badly, and for +// anyone who wants the pane opened by hand. +func detectBackend(env envLookup) string { + if forced := strings.TrimSpace(env("AGENTPANE_TERMINAL")); forced != "" { + for _, b := range append([]string{backendNone}, paneBackends...) { + if strings.EqualFold(forced, b) { + return b + } + } + // An unrecognised override is not silently ignored — it becomes + // "none", so a typo disables auto-open loudly (doctor and --explain + // both print the detected backend) instead of quietly falling through + // to a terminal the user was trying to override. + return backendNone + } + // The multiplexer first: see the file comment. + if env("TMUX") != "" && env("TMUX_PANE") != "" { + return backendTmux + } + if env("TERM_PROGRAM") == "iTerm.app" && env("ITERM_SESSION_ID") != "" { + return backendITerm2 + } + if env("WEZTERM_PANE") != "" { + return backendWezTerm + } + if env("KITTY_WINDOW_ID") != "" { + return backendKitty + } + return backendNone +} + +// backendReason explains a backendNone verdict in the terms the user can act +// on: what was looked at, and what would have satisfied it. +func backendReason(env envLookup) string { + if forced := strings.TrimSpace(env("AGENTPANE_TERMINAL")); forced != "" { + if strings.EqualFold(forced, backendNone) { + return "AGENTPANE_TERMINAL=none — turned off deliberately" + } + return fmt.Sprintf("AGENTPANE_TERMINAL=%q is not a backend name (want one of: none, %s)", + forced, strings.Join(paneBackends, ", ")) + } + return fmt.Sprintf("TERM_PROGRAM=%q TERM=%q, and none of $TMUX/$WEZTERM_PANE/$KITTY_WINDOW_ID is set", + env("TERM_PROGRAM"), env("TERM")) +} + +// iTermPaneUUID is the session id from ITERM_SESSION_ID with iTerm2's +// "wNtNpN:" position prefix stripped — the AppleScript matches the uuid half +// against `id of session`. Same technique as it2-pane-hook.sh's +// `${ITERM_SESSION_ID##*:}`. +func iTermPaneUUID(env envLookup) string { + id := env("ITERM_SESSION_ID") + if i := strings.LastIndex(id, ":"); i >= 0 { + id = id[i+1:] + } + return id +} + +// paneProgram resolves a command name to the binary that actually runs it, +// honouring an AGENTPANE_ override: AGENTPANE_OSASCRIPT, AGENTPANE_TMUX, +// AGENTPANE_WEZTERM, AGENTPANE_KITTY, AGENTPANE_KITTEN. +// +// AGENTPANE_OSASCRIPT has always been the seam the autopane tests use to watch +// a split without opening a real pane; every backend now has the same one, so +// each is testable on a machine with none of these terminals installed. It +// doubles as the way to point at a wrapper or a non-PATH install. +func paneProgram(env envLookup, name string) string { + if v := strings.TrimSpace(env("AGENTPANE_" + strings.ToUpper(name))); v != "" { + return v + } + return name +} + +// paneArgv builds one command with the program-name override applied. +func paneArgv(env envLookup, name string, args ...string) []string { + return append([]string{paneProgram(env, name)}, args...) +} + +// paneCmd is one attempt at a pane operation: a command, plus follow-ups that +// run only when it succeeded and whose own failure does not fail the attempt. +// +// The follow-up exists for WezTerm, whose split always takes focus, so the +// focus has to be handed straight back. If that hand-back fails you still have +// the pane you asked for — just with the cursor in it — which is a far better +// outcome than treating the whole split as failed and retrying it. +type paneCmd struct { + argv []string + after [][]string +} + +// splitCommands is the command chain that splits the current pane vertically, +// runs cmdline in the new pane at about cols columns wide, and leaves focus +// where it was. Entries are alternatives, tried in order until one succeeds; +// nil means this backend cannot split from here. +// +// cmdline is already a complete `/bin/sh -c '…'` string (paneCommand), so +// every backend hands it to a shell the same way and none of them has to +// re-quote it. +func splitCommands(backend string, env envLookup, cmdline string, cols int) []paneCmd { + switch backend { + case backendITerm2: + uuid := iTermPaneUUID(env) + if uuid == "" { + return nil + } + return []paneCmd{{argv: paneArgv(env, "osascript", "-e", autopaneScript(uuid, autopaneProfile(), cmdline))}} + + case backendTmux: + target := env("TMUX_PANE") + if target == "" { + return nil + } + // -h splits left/right (tmux calls the DIVIDER horizontal, the + // opposite of iTerm2's "split vertically" for the same result). + // -d leaves focus on the session pane, which is the whole point: the + // pane you were typing in is the pane you should still be typing in. + // -l sizes the new pane directly, so unlike iTerm2 there is no + // resize-and-verify dance afterwards. A cell count for -l needs tmux + // 3.1; older tmux rejects the flag and splits nothing, so the second + // alternative drops the sizing and takes tmux's own 50/50 split, which + // is a pane you can drag rather than no pane at all. + return []paneCmd{ + {argv: paneArgv(env, "tmux", + "split-window", "-h", "-d", + "-l", strconv.Itoa(cols), + "-t", target, + cmdline, + )}, + {argv: paneArgv(env, "tmux", + "split-window", "-h", "-d", + "-t", target, + cmdline, + )}, + } + + case backendWezTerm: + target := env("WEZTERM_PANE") + if target == "" { + return nil + } + // wezterm has no "split without focusing", so focus is taken and + // handed straight back as a follow-up. + return []paneCmd{{ + argv: paneArgv(env, "wezterm", + "cli", "split-pane", + "--pane-id", target, + "--right", "--cells", strconv.Itoa(cols), + "--", "/bin/sh", "-c", cmdline, + ), + after: [][]string{paneArgv(env, "wezterm", "cli", "activate-pane", "--pane-id", target)}, + }} + + case backendKitty: + // kitty's remote control is off by default (allow_remote_control in + // kitty.conf), and the launcher was renamed from `kitty @` to + // `kitten @` in 0.29 — hence two alternatives. + args := []string{ + "@", "launch", + "--type=window", "--location=vsplit", + "--cwd=current", "--dont-take-focus", + "/bin/sh", "-c", cmdline, + } + return []paneCmd{ + {argv: paneArgv(env, "kitten", args...)}, + {argv: paneArgv(env, "kitty", args...)}, + } + } + return nil +} + +// focusCommands moves focus from the agentpane pane to the session pane — the +// pane you type in, and the only place a permission request can be answered. +// +// Every backend aims LEFT rather than at a remembered pane id: agentpane is +// split to the right of the session by construction (splitCommands above, and +// the manual setups in docs/ITERM2.md), and the TUI is a separate process that +// never saw the id of the pane it was split from. +func focusCommands(backend string, env envLookup) []paneCmd { + switch backend { + case backendITerm2: + return []paneCmd{{argv: paneArgv(env, "osascript", "-e", + `tell application "iTerm2" to select first session of current tab of current window`)}} + + case backendTmux: + // {left-of} is the pane sharing our left edge, which is the session + // pane in the standard layout. A stacked or otherwise unusual layout + // has no left neighbour and the command fails, so `-l` (the + // last-active pane) is the fallback: you got here from the session + // pane, so that is almost always the same pane. + return []paneCmd{ + {argv: paneArgv(env, "tmux", "select-pane", "-t", "{left-of}")}, + {argv: paneArgv(env, "tmux", "select-pane", "-l")}, + } + + case backendWezTerm: + return []paneCmd{{argv: paneArgv(env, "wezterm", "cli", "activate-pane-direction", "--direction", "Left")}} + + case backendKitty: + return []paneCmd{ + {argv: paneArgv(env, "kitten", "@", "focus-window", "--match", "neighbor:left")}, + {argv: paneArgv(env, "kitty", "@", "focus-window", "--match", "neighbor:left")}, + } + } + return nil +} + +// tabCommands opens cmdline in a new tab or window — where `o` sends $EDITOR, +// so a file never opens over the tree. nil means the backend cannot, and +// openInEditor falls back to the desktop opener. +func tabCommands(backend string, env envLookup, cmdline string) []paneCmd { + switch backend { + case backendITerm2: + return []paneCmd{{argv: paneArgv(env, "osascript", "-e", iTermTabScript(cmdline))}} + case backendTmux: + return []paneCmd{{argv: paneArgv(env, "tmux", "new-window", cmdline)}} + case backendWezTerm: + return []paneCmd{{argv: paneArgv(env, "wezterm", "cli", "spawn", "--", "/bin/sh", "-c", cmdline)}} + case backendKitty: + args := []string{"@", "launch", "--type=tab", "--cwd=current", "/bin/sh", "-c", cmdline} + return []paneCmd{ + {argv: paneArgv(env, "kitten", args...)}, + {argv: paneArgv(env, "kitty", args...)}, + } + } + return nil +} + +// runPaneChain runs the alternatives in order and reports whether one worked. +// +// "Worked" is exit 0 within timeout, or still running at timeout. The second +// case is not optimism: osascript against a busy iTerm2 can outlast any bound +// worth waiting for, and the pane it was asked to create gets created anyway. +// A timed-out process is ABANDONED, never killed — no code path in agentpane +// may signal a process (§5.3, PART 10) — and its Wait goroutine reaps it. +// +// A command that is not installed (kitten on a machine with only kitty) or +// exits non-zero (kitty's remote control disabled) falls through to the next +// alternative, and an exhausted chain returns false so the caller can release +// its lock and stay quiet. +func runPaneChain(chain []paneCmd, timeout time.Duration) bool { + for _, c := range chain { + if !runPaneOnce(c.argv, timeout) { + continue + } + for _, follow := range c.after { + runPaneOnce(follow, timeout) // best effort; see paneCmd + } + return true + } + return false +} + +// runPaneOnce runs one command under the abandon-on-timeout rule. +func runPaneOnce(argv []string, timeout time.Duration) bool { + if len(argv) == 0 { + return false + } + cmd := exec.Command(argv[0], argv[1:]...) + if err := cmd.Start(); err != nil { + return false + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + return err == nil + case <-time.After(timeout): + return true // still going; abandon it and assume it lands + } +} + +// desktopOpenCommands is the last resort for `o`: hand the file to whatever +// the desktop uses. It opens somewhere other than this pane by definition, so +// it satisfies the rule even though it cannot honour $EDITOR on every path. +func desktopOpenCommands(editor, path string) []paneCmd { + if runtime.GOOS == "darwin" { + return []paneCmd{ + {argv: []string{"open", "-a", editor, path}}, + {argv: []string{"open", path}}, + } + } + return []paneCmd{ + {argv: []string{"xdg-open", path}}, + } +} + +// currentBackend is detectBackend against the real environment, as a var so +// tests and `doctor` share one entry point. +var currentBackend = func() string { return detectBackend(os.Getenv) } diff --git a/cmd/agentpane/pane_test.go b/cmd/agentpane/pane_test.go new file mode 100644 index 0000000..f643208 --- /dev/null +++ b/cmd/agentpane/pane_test.go @@ -0,0 +1,304 @@ +package main + +// pane_test.go — the terminal backends, tested as pure functions. +// +// None of tmux, WezTerm or kitty is installed on the machine that runs these, +// and that is the point: splitCommands/focusCommands/tabCommands turn an +// environment into an exact argv, so the argv is what gets asserted. The one +// thing these cannot prove is that the argv is the argv those programs want — +// that is a claim about their CLIs, checked against their docs and marked in +// docs/TERMINALS.md, not something a unit test can settle. + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeEnv is an envLookup over a map: an environment with nothing inherited. +func fakeEnv(kv map[string]string) envLookup { + return func(k string) string { return kv[k] } +} + +func TestDetectBackend(t *testing.T) { + cases := []struct { + name string + env map[string]string + want string + }{ + {"bare shell", map[string]string{}, backendNone}, + {"iTerm2", map[string]string{ + "TERM_PROGRAM": "iTerm.app", "ITERM_SESSION_ID": "w0t0p0:UUID"}, backendITerm2}, + {"iTerm2 without a session id", map[string]string{ + "TERM_PROGRAM": "iTerm.app"}, backendNone}, + {"tmux", map[string]string{ + "TMUX": "/tmp/tmux-501/default,123,0", "TMUX_PANE": "%3"}, backendTmux}, + {"wezterm", map[string]string{"WEZTERM_PANE": "0"}, backendWezTerm}, + {"kitty", map[string]string{"KITTY_WINDOW_ID": "1"}, backendKitty}, + {"Terminal.app", map[string]string{"TERM_PROGRAM": "Apple_Terminal"}, backendNone}, + {"Ghostty", map[string]string{"TERM_PROGRAM": "ghostty"}, backendNone}, + + // The multiplexer wins over the terminal hosting it: splitting the + // iTerm2 pane would divide the whole tmux viewport and put the new + // pane outside tmux entirely. + {"tmux inside iTerm2", map[string]string{ + "TMUX": "/tmp/tmux-501/default,123,0", "TMUX_PANE": "%3", + "TERM_PROGRAM": "iTerm.app", "ITERM_SESSION_ID": "w0t0p0:UUID"}, backendTmux}, + + // The override beats detection in both directions. + {"forced off", map[string]string{ + "AGENTPANE_TERMINAL": "none", + "TERM_PROGRAM": "iTerm.app", "ITERM_SESSION_ID": "w0t0p0:UUID"}, backendNone}, + {"forced tmux", map[string]string{ + "AGENTPANE_TERMINAL": "tmux", "TMUX_PANE": "%1"}, backendTmux}, + {"forced, case-insensitive", map[string]string{ + "AGENTPANE_TERMINAL": "WezTerm"}, backendWezTerm}, + // A typo disables rather than falling through to what was being + // overridden — a silent fall-through would be indistinguishable from + // the override not working. + {"typo in the override", map[string]string{ + "AGENTPANE_TERMINAL": "iterm", "TERM_PROGRAM": "iTerm.app", + "ITERM_SESSION_ID": "w0t0p0:UUID"}, backendNone}, + } + for _, c := range cases { + if got := detectBackend(fakeEnv(c.env)); got != c.want { + t.Errorf("%s: detectBackend = %q, want %q", c.name, got, c.want) + } + } +} + +// argvOf flattens one attempt for substring assertions. +func argvOf(t *testing.T, chain []paneCmd, i int) string { + t.Helper() + if len(chain) <= i { + t.Fatalf("chain has %d entries, wanted entry %d", len(chain), i) + } + return strings.Join(chain[i].argv, " ") +} + +func TestSplitCommands(t *testing.T) { + const cmdline = "/bin/sh -c '/usr/local/bin/agentpane --session abc'" + + t.Run("tmux sizes the pane and keeps focus", func(t *testing.T) { + chain := splitCommands(backendTmux, fakeEnv(map[string]string{"TMUX_PANE": "%7"}), cmdline, 64) + got := argvOf(t, chain, 0) + for _, want := range []string{ + "tmux split-window", + "-h", // left/right, tmux's naming for iTerm2's "vertically" + "-d", // focus stays on the session pane + "-l 64", // the pane is born the right width, no resize dance + "-t %7", // the session's own pane, not "the current one" + "--session", // the pin travels in cmdline + } { + if !strings.Contains(got, want) { + t.Errorf("tmux split missing %q:\n%s", want, got) + } + } + }) + + t.Run("tmux falls back for versions without -l", func(t *testing.T) { + chain := splitCommands(backendTmux, fakeEnv(map[string]string{"TMUX_PANE": "%7"}), cmdline, 64) + if len(chain) != 2 { + t.Fatalf("tmux chain has %d entries, want 2 (sized, then unsized)", len(chain)) + } + if got := argvOf(t, chain, 1); strings.Contains(got, "-l") { + t.Errorf("the fallback still passes -l, so pre-3.1 tmux still rejects it:\n%s", got) + } + }) + + t.Run("tmux with no pane id cannot split", func(t *testing.T) { + if chain := splitCommands(backendTmux, fakeEnv(nil), cmdline, 64); chain != nil { + t.Errorf("split without TMUX_PANE returned %v", chain) + } + }) + + t.Run("wezterm hands focus back", func(t *testing.T) { + chain := splitCommands(backendWezTerm, fakeEnv(map[string]string{"WEZTERM_PANE": "3"}), cmdline, 64) + got := argvOf(t, chain, 0) + for _, want := range []string{"wezterm cli split-pane", "--pane-id 3", "--right", "--cells 64"} { + if !strings.Contains(got, want) { + t.Errorf("wezterm split missing %q:\n%s", want, got) + } + } + if len(chain[0].after) != 1 { + t.Fatalf("wezterm split has %d follow-ups, want 1 (focus back)", len(chain[0].after)) + } + if back := strings.Join(chain[0].after[0], " "); !strings.Contains(back, "activate-pane --pane-id 3") { + t.Errorf("wezterm does not return focus to pane 3: %s", back) + } + }) + + t.Run("kitty tries kitten then kitty", func(t *testing.T) { + chain := splitCommands(backendKitty, fakeEnv(map[string]string{"KITTY_WINDOW_ID": "1"}), cmdline, 64) + if len(chain) != 2 { + t.Fatalf("kitty chain has %d entries, want 2 (kitten, then kitty)", len(chain)) + } + if chain[0].argv[0] != "kitten" || chain[1].argv[0] != "kitty" { + t.Errorf("kitty chain order = %q, %q", chain[0].argv[0], chain[1].argv[0]) + } + if got := argvOf(t, chain, 0); !strings.Contains(got, "--dont-take-focus") { + t.Errorf("kitty split takes focus:\n%s", got) + } + }) + + t.Run("iterm2 strips the position prefix", func(t *testing.T) { + chain := splitCommands(backendITerm2, fakeEnv(map[string]string{ + "ITERM_SESSION_ID": "w0t2p0:ABCD-1234"}), cmdline, 64) + got := argvOf(t, chain, 0) + if !strings.Contains(got, `"ABCD-1234"`) { + t.Errorf("AppleScript does not target the stripped uuid:\n%s", got) + } + if strings.Contains(got, "w0t2p0") { + t.Errorf("AppleScript kept the position prefix:\n%s", got) + } + }) + + t.Run("none splits nothing", func(t *testing.T) { + if chain := splitCommands(backendNone, fakeEnv(nil), cmdline, 64); chain != nil { + t.Errorf("backendNone returned %v", chain) + } + }) +} + +// The focus jump aims LEFT on every backend: agentpane is split to the right +// of the session by construction, and the TUI never saw the id of the pane it +// was split from. +func TestFocusCommandsAimLeft(t *testing.T) { + cases := map[string][]string{ + backendTmux: {"{left-of}", "select-pane -l"}, + backendWezTerm: {"Left"}, + backendKitty: {"neighbor:left"}, + backendITerm2: {"first session of current tab"}, + } + for backend, wants := range cases { + chain := focusCommands(backend, fakeEnv(nil)) + if len(chain) == 0 { + t.Errorf("%s: no focus command", backend) + continue + } + var all []string + for _, c := range chain { + all = append(all, strings.Join(c.argv, " ")) + } + joined := strings.Join(all, "\n") + for _, want := range wants { + if !strings.Contains(joined, want) { + t.Errorf("%s focus missing %q:\n%s", backend, want, joined) + } + } + } + if chain := focusCommands(backendNone, fakeEnv(nil)); chain != nil { + t.Errorf("backendNone has a focus command: %v", chain) + } +} + +func TestTabCommands(t *testing.T) { + const cmdline = "/bin/sh -c 'vim /src/main.go'" + for _, backend := range paneBackends { + chain := tabCommands(backend, fakeEnv(nil), cmdline) + if len(chain) == 0 { + t.Errorf("%s: no way to open a tab", backend) + continue + } + if got := argvOf(t, chain, 0); !strings.Contains(got, "vim /src/main.go") { + t.Errorf("%s tab command loses the editor command:\n%s", backend, got) + } + } + if chain := tabCommands(backendNone, fakeEnv(nil), cmdline); chain != nil { + t.Errorf("backendNone has a tab command: %v", chain) + } +} + +// Every backend's program name is overridable, which is both the test seam and +// the way to point at a wrapper or a non-PATH install. +func TestPaneProgramOverride(t *testing.T) { + env := fakeEnv(map[string]string{ + "TMUX_PANE": "%1", + "AGENTPANE_TMUX": "/opt/fake/tmux", + "KITTY_WINDOW_ID": "1", + "AGENTPANE_KITTY": "/opt/fake/kitty", + }) + if got := splitCommands(backendTmux, env, "cmd", 64)[0].argv[0]; got != "/opt/fake/tmux" { + t.Errorf("AGENTPANE_TMUX ignored: %q", got) + } + chain := splitCommands(backendKitty, env, "cmd", 64) + if got := chain[0].argv[0]; got != "kitten" { + t.Errorf("kitten should be untouched by AGENTPANE_KITTY: %q", got) + } + if got := chain[1].argv[0]; got != "/opt/fake/kitty" { + t.Errorf("AGENTPANE_KITTY ignored: %q", got) + } +} + +// An exhausted chain reports failure, and a later alternative is reached when +// an earlier one is missing or fails — which is what makes the kitten/kitty +// pair and the tmux focus fallback work. +func TestRunPaneChainFallsThrough(t *testing.T) { + tmp := t.TempDir() + marker := filepath.Join(tmp, "ran") + good := filepath.Join(tmp, "good") + if err := os.WriteFile(good, []byte("#!/bin/sh\ntouch \"$1\"\n"), 0o755); err != nil { + t.Fatal(err) + } + + if runPaneChain(nil, time.Second) { + t.Error("an empty chain reported success") + } + if runPaneChain([]paneCmd{{argv: []string{filepath.Join(tmp, "nope")}}}, time.Second) { + t.Error("a missing binary reported success") + } + chain := []paneCmd{ + {argv: []string{filepath.Join(tmp, "nope")}}, // not installed + {argv: []string{"/usr/bin/false"}}, // installed, fails + {argv: []string{good, marker}}, // works + {argv: []string{good, filepath.Join(tmp, "extra")}}, // must not run + } + if !runPaneChain(chain, 5*time.Second) { + t.Fatal("the chain did not reach the working alternative") + } + if _, err := os.Stat(marker); err != nil { + t.Errorf("the working alternative did not run: %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "extra")); err == nil { + t.Error("the chain kept going after an alternative succeeded") + } +} + +// A follow-up runs after its command succeeds, and its own failure does not +// fail the attempt — a WezTerm split whose focus hand-back fails still left +// you with the pane you asked for. +func TestRunPaneChainFollowUps(t *testing.T) { + tmp := t.TempDir() + good := filepath.Join(tmp, "good") + if err := os.WriteFile(good, []byte("#!/bin/sh\ntouch \"$1\"\n"), 0o755); err != nil { + t.Fatal(err) + } + after := filepath.Join(tmp, "after") + chain := []paneCmd{{ + argv: []string{good, filepath.Join(tmp, "main")}, + after: [][]string{{good, after}, {"/usr/bin/false"}}, + }} + if !runPaneChain(chain, 5*time.Second) { + t.Fatal("a failing follow-up failed the whole attempt") + } + if _, err := os.Stat(after); err != nil { + t.Errorf("the follow-up did not run: %v", err) + } +} + +// backendReason has to name what was actually looked at, or a skipped +// auto-open is undiagnosable. +func TestBackendReason(t *testing.T) { + got := backendReason(fakeEnv(map[string]string{"TERM_PROGRAM": "Apple_Terminal", "TERM": "xterm-256color"})) + for _, want := range []string{"Apple_Terminal", "xterm-256color", "TMUX"} { + if !strings.Contains(got, want) { + t.Errorf("reason missing %q: %s", want, got) + } + } + got = backendReason(fakeEnv(map[string]string{"AGENTPANE_TERMINAL": "iterm"})) + if !strings.Contains(got, "iterm") || !strings.Contains(got, "wezterm") { + t.Errorf("an override typo must quote it and list the valid names: %s", got) + } +} diff --git a/cmd/agentpane/renderonce.go b/cmd/agentpane/renderonce.go index f72e410..dcf1398 100644 --- a/cmd/agentpane/renderonce.go +++ b/cmd/agentpane/renderonce.go @@ -54,7 +54,7 @@ drain: if fl.noColor || os.Getenv("NO_COLOR") != "" { cfg.Color = false } - if fl.width == "wide" || fl.width == "narrow" { + if fl.width == "wide" || fl.width == "narrow" || fl.width == "fill" { cfg.Width = fl.width } diff --git a/cmd/agentpane/tui.go b/cmd/agentpane/tui.go index 0d705e0..0881fd2 100644 --- a/cmd/agentpane/tui.go +++ b/cmd/agentpane/tui.go @@ -33,7 +33,7 @@ func runTUI(fl cliFlags, stderr io.Writer) int { applyOverrides(&cfg, fl, &warnings) prefs := loadPrefs() - if fl.width == "" && (prefs.Width == "wide" || prefs.Width == "narrow") { + if fl.width == "" && (prefs.Width == "wide" || prefs.Width == "narrow" || prefs.Width == "fill") { cfg.Width = prefs.Width // §5.1: the w toggle is persisted } diff --git a/docs/CLI.md b/docs/CLI.md index fd1891f..e5a2d3e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -13,7 +13,8 @@ handful you need on day one. | `agentpane --demo-speed 2.0` | demo playback speed multiplier | | `agentpane --demo-seek 90` | jump the demo to t=90s | | `agentpane hook` | read one hook JSON on stdin, forward, exit 0 always | -| `agentpane autopane` | SessionStart hook: auto-open the TUI in an iTerm2 split (see [INSTALL.md](INSTALL.md)), exit 0 always | +| `agentpane autopane` | SessionStart hook: auto-open the TUI in a terminal split (see [INSTALL.md](INSTALL.md)), exit 0 always | +| `agentpane autopane --explain` | run the same guard chain against the current environment and print each verdict, without opening anything | | `agentpane doctor` | check hooks, competing settings (visualizers, alerting, symlinks), socket, terminal escapes, glyphs; print the hook snippet if missing | | `agentpane doctor --session ` | the same checks aimed at one pinned pane: that session's attach target and socket, no cwd guess | | `agentpane runs` | list persisted runs from every session | @@ -21,7 +22,7 @@ handful you need on day one. | `agentpane runs show ` | print one run summary | | `agentpane --oneline` | single-row status output for a tmux/iTerm2 status bar, then exit | | `agentpane --oneline --session ` | the same row for exactly one session's pane | -| `agentpane --width narrow` | start in the 44-column layout (`wide` is the 64-column default) | +| `agentpane --width wide\|narrow` | fix the layout at 64 or 44 columns instead of the default, which is the pane's own width up to `max_width` | | `agentpane --accent auto\|2\|3\|4\|5\|6` | force the live-accent ANSI index instead of the auto-pick | | `agentpane --no-color` | disable all color (the `NO_COLOR` env var does the same) | | `agentpane --no-bell --no-badge --no-notify --no-links` | switch off individual side effects | @@ -37,7 +38,8 @@ optional; these are the defaults. Invalid values warn once in the footer and fall back to the default, never exit. ```toml -width = "wide" # wide | narrow +width = "fill" # fill (the pane) | wide (64) | narrow (44) +max_width = 100 # where fill stops; at least 44 stuck_after = "45s" long_running_after = "3m" long_running_allow = ["test", "build", "install", "compile", "bundle", "migrate"] @@ -77,13 +79,13 @@ color = true # false == NO_COLOR | `space` | pin/unpin this agent's history onto the tree - `⏎` inspects instead (no-op on decayed/queued) | | `⏎` | open the inspector; on the `RETURNED` header, toggle those rows; on the condensed screen's alert row, jump to the session pane instead | | click | select a row and open it (a click inside an already-open inspector keeps your scroll position) | -| `⇥` | jump to the session pane - the only place a permission request can be answered | +| `⇥` | jump to the session pane, the only place a permission request can be answered (needs a terminal from [TERMINALS.md](TERMINALS.md)) | | `esc` / `q` | close the inspector - never kills anything; `q` never quits the pane | | `f` | follow mode: inspector tracks the most recently active agent | -| `o` | open the selected agent's current file in `$EDITOR` (new iTerm2 tab) | +| `o` | open the selected agent's current file in `$EDITOR` in a new tab (see [TERMINALS.md](TERMINALS.md)) | | `y` | yank the agent's error, else its failing command, else its activity line; on `main`, the full workstream label | | `Y` | yank the whole inspector log; on `main` with an empty log, the full workstream label | -| `w` | toggle 64/44 layout (persisted) | +| `w` | cycle the layout: fill the pane, wide 64, narrow 44 (persisted) | | `a` | returned agents: the list at the foot of the tree (default) ↔ full rows on it | | `1`-`9` | switch attached session (idle screen, only when unpinned and more than one session is live; no-op otherwise) | | `t` | cycle the right column: since-last-event, token burn rate (persisted) | diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 6004152..c95aa34 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -70,14 +70,18 @@ Prefer to do it by hand? The exact snippet is in [The hook snippet](#the-hook-snippet) below, and `agentpane doctor` prints it too when the hooks are absent. -## Auto-open (iTerm2) +## Auto-open `agentpane install --autopane` adds one extra `SessionStart` hook group (matcher `startup|resume`) running `agentpane autopane`. From then on, starting an -interactive Claude Code session in an iTerm2 pane automatically splits that -pane vertically and runs the agentpane TUI in the new pane — no arrangement, -no manual split. This is the recommended iTerm2 setup; see -[ITERM2.md](ITERM2.md) for the full walkthrough. +interactive Claude Code session splits the pane it started in and runs the +agentpane TUI in the new pane, with no arrangement to restore and no manual +split. + +This works in iTerm2, tmux, WezTerm and kitty. [TERMINALS.md](TERMINALS.md) +covers what each one needs and how the terminal is detected; +[ITERM2.md](ITERM2.md) is the longer iTerm2 walkthrough, including the +profiles and the no-hooks alternative. `agentpane autopane` is silent and exits 0 on every path. It opens a pane only when ALL of these hold, and skips silently otherwise: @@ -86,9 +90,10 @@ only when ALL of these hold, and skips silently otherwise: (`clear`/`compact`/`fork` re-fire mid-session and never open panes); - `AGENTPANE_AUTOPANE` is not `"0"` — the kill switch: `export AGENTPANE_AUTOPANE=0` disables auto-open without uninstalling; -- the session runs in an iTerm2 pane (`TERM_PROGRAM` is `iTerm.app` and - `ITERM_SESSION_ID` is set — that id is how the split targets the exact - pane the session lives in, even if you have focused another window); +- the session runs in a terminal agentpane can split, and one that names the + pane it is in: iTerm2, tmux, WezTerm or kitty. See + [TERMINALS.md](TERMINALS.md) for the environment variables each is detected + by, and for `AGENTPANE_TERMINAL`, which overrides the detection; - the session is not nested inside another Claude session (`CLAUDE_CODE_CHILD_SESSION` unset); - the `claude` process has a controlling terminal (a fully headless run — @@ -99,9 +104,16 @@ only when ALL of these hold, and skips silently otherwise: - a per-session lockfile wasn't just taken by a duplicate event (stale locks older than 1h are reclaimed). -The new pane uses the iTerm2 profile named by `AGENTPANE_PROFILE` (default -`AgentPane`, the one docs/ITERM2.md ships via Dynamic Profiles) and falls -back to the current session's own profile when it does not exist. +The new pane is `AGENTPANE_COLUMNS` wide (default 64, floored at 44, and never +so wide that the session pane drops below 80). On iTerm2 it uses the profile +named by `AGENTPANE_PROFILE` (default `AgentPane`, the one docs/ITERM2.md ships +via Dynamic Profiles) and falls back to the current session's own profile when +that one does not exist. + +When nothing happens, `agentpane autopane --explain` runs the same guard chain +against your current environment and prints a verdict per guard, opening +nothing. To see the same trace from a real hook firing, run `agentpane autopane +--debug-on` and read `~/.local/state/agentpane/autopane.log`. **Autopane panes are pinned.** The pane runs `agentpane --session ` by absolute binary path, with the session id from the SessionStart payload, so diff --git a/docs/ITERM2.md b/docs/ITERM2.md index 0344528..8294989 100644 --- a/docs/ITERM2.md +++ b/docs/ITERM2.md @@ -3,6 +3,10 @@ Goal: a window that opens with Claude Code in a large left pane and `agentpane` in a 64-column right pane, automatically, every time. +iTerm2 is one of four terminals agentpane can split from a hook; tmux, WezTerm +and kitty work too, and [TERMINALS.md](TERMINALS.md) covers those. This page is +the full iTerm2 walkthrough. + There are two routes: - **Autopane (recommended).** `agentpane install --autopane` adds a `SessionStart` diff --git a/docs/READING-THE-PANE.md b/docs/READING-THE-PANE.md index e1886a1..85c488e 100644 --- a/docs/READING-THE-PANE.md +++ b/docs/READING-THE-PANE.md @@ -54,14 +54,24 @@ That is the whole row, for every agent. A tree's job is to say what everything is doing at once, and eight agents saying it now fit in the rows that used to hold three. -**The history is a drill-in.** `⏎` or a click opens the inspector, which holds -that agent's whole log — every tool call, its output, its errors — not the last -two lines of it. Rows the tree spends on history are rows it cannot spend on -agents, and history is a question you ask about one agent at a time. +**History fills the rows nothing else wants.** When the tree has room to spare, +every agent grows a strip of its recent tool calls above its activity line, one +call at a time, up to `max_calls_shown`. One agent working alone in a tall pane +is the case this exists for: it used to draw three lines and leave thirty blank. -If you do want a row's history on the tree, `space` pins it open: an explicit, -per-row choice, showing up to `max_calls_shown` calls above the activity line, -newest at the bottom, with `+n more` for what was cut. +The strip is the first thing given back. As agents arrive it shrinks for +everybody at once, so no row is singled out, and it reaches zero before any row +loses the line saying what it is doing. Growth also stops three rows short of +the bottom, so the next agent to spawn has somewhere to land without collapsing +every strip on the frame it appears. + +`space` pins a row's strip open regardless: an explicit per-row choice that +survives the pressure the shared strip gives way to. Either way the strip shows +up to `max_calls_shown` calls, newest at the bottom, with `+n more` for what was +cut. + +For the whole log rather than the last few calls, `⏎` or a click opens the +inspector: every tool call, its output and its errors, for one agent at a time. A history line says **what the call did**, not how it was spelled. When the tool wrote a description of its own - the Bash tool's `description`, the same text @@ -137,6 +147,36 @@ Under 12 rows the condensed screen still draws a single one-line alert directly under the header (`⚑ permission ×3`), because at that height the tree cannot say it. `⏎` there jumps focus to the left pane. +## How wide the pane draws + +The pane draws at whatever width it has, and redraws when you drag the divider. +There is nothing to switch on. + +The extra columns go to text. The activity line, the tool-call labels and main's +workstream label are all budgeted against the width, so above 64 columns they +stop being truncated and things that had to be dropped to fit, like +`✖ verify failed` on the row it belongs to, come back. + +It stops at 100 columns, because a row much wider than that is scanned twice +rather than read once. `max_width` in `config.toml` moves that ceiling. + +`w` cycles to the two fixed budgets and back: + +| Layout | Width | +|---|---| +| fill (default) | the pane, up to `max_width` (100) | +| wide | 64 columns | +| narrow | 44 columns | + +Those budgets are ceilings, never floors, so every layout narrows with the +pane. Only widening past 64 or 44 is what they refuse, which is the point: the +tree was drawn to be read at a glance, and a fixed width means the row you +learned yesterday is the row you read today. The choice is remembered in +`~/.local/state/agentpane/ui.json`. + +Below 40 columns the pane becomes a single-column screen whatever the layout, +and under 12 rows it becomes the condensed screen. + ## Motion §3.13 allowed one moving thing, the spinner, because an animated monitor draws diff --git a/docs/TERMINALS.md b/docs/TERMINALS.md new file mode 100644 index 0000000..fea0ce6 --- /dev/null +++ b/docs/TERMINALS.md @@ -0,0 +1,85 @@ +# Terminals + +The TUI runs in any terminal. Two features need to drive the terminal itself, +and those are the ones with a list: + +- auto-open, where a `SessionStart` hook splits the pane your Claude session + just started in and runs the TUI in the new pane +- `⇥`, which moves focus back to the session pane so you can answer whoever is + blocked + +Both work in iTerm2, tmux, WezTerm and kitty. Everywhere else the TUI is fine, +auto-open does nothing, and `⇥` says so. + +## What each terminal needs + +| Terminal | Detected by | Needs | Tested | +|---|---|---|---| +| tmux | `$TMUX` and `$TMUX_PANE` | tmux on `PATH`. 3.1 or newer gets a 64-column pane; older tmux gets tmux's own 50/50 split, because `split-window -l` only takes a cell count from 3.1 | argv only | +| iTerm2 | `TERM_PROGRAM=iTerm.app` and `$ITERM_SESSION_ID` | `osascript`, and iTerm2 allowed under System Settings > Privacy & Security > Automation | live | +| WezTerm | `$WEZTERM_PANE` | `wezterm` on `PATH` | argv only | +| kitty | `$KITTY_WINDOW_ID` | `allow_remote_control yes` in `kitty.conf`, plus `kitten` or `kitty` on `PATH` | argv only | + +"argv only" means the exact command agentpane builds is covered by tests +(`cmd/agentpane/pane_test.go`) but has not been run against that terminal on a +real machine. If one of them misbehaves, `agentpane autopane --explain` prints +the command it would run, so you can run it yourself and see what the terminal +says. + +Ghostty, Alacritty, Terminal.app and VS Code's terminal have no way to script a +split from outside, so there is no backend for them. Running Claude inside tmux +inside any of them does work, because tmux is what gets driven. + +## Detection order + +tmux wins over the terminal hosting it. A session running in tmux inside iTerm2 +lives in a tmux pane, and asking iTerm2 to split would divide the whole tmux +viewport and leave the new pane outside tmux, beside a tmux window rather than +beside the session. + +After tmux the order is iTerm2, WezTerm, kitty. Each is checked by the +environment variable in the table above, so a terminal that does not set its own +variable is not detected even if `TERM` looks right. + +## Overrides + +`AGENTPANE_TERMINAL` replaces the detection: `none`, `iterm2`, `tmux`, +`wezterm` or `kitty`. `AGENTPANE_TERMINAL=none` turns auto-open and `⇥` off +without uninstalling the hook. A value that is not one of those names also +turns them off rather than falling through to detection, so a typo fails where +you can see it; `agentpane doctor` prints the value it did not recognise. + +Each backend's program name can be pointed somewhere else: +`AGENTPANE_OSASCRIPT`, `AGENTPANE_TMUX`, `AGENTPANE_WEZTERM`, +`AGENTPANE_KITTY`, `AGENTPANE_KITTEN`. Use these for a non-`PATH` install or a +wrapper script. + +## Diagnosis + +`agentpane doctor` names the detected backend and whether the program that +drives it is on `PATH`. + +`agentpane autopane --explain` runs the full guard chain against the current +environment, prints a verdict for each guard, and opens nothing. Guard (c) is +the terminal check; it prints what it looked at when it finds nothing. + +To trace a real hook firing rather than a hand-run one, `agentpane autopane +--debug-on` writes the same trace to `~/.local/state/agentpane/autopane.log` +on every session start. `--debug-off` stops it. + +## Without a supported terminal + +Split the pane yourself and run `agentpane` in it. The TUI is identical: it +reads the same hook socket and transcripts, and the only things missing are the +automatic split and the focus jump. Answer permission prompts by moving to the +session pane however your terminal does that. + +The escapes the TUI writes degrade on their own. OSC 8 hyperlinks, OSC 52 +clipboard, OSC 9 notifications, the OSC 4/10 palette probe and the pane title +are all ignored by a terminal that does not implement them, and `y` also pipes +to `pbcopy` on macOS in case OSC 52 is disabled. The iTerm2 badge (OSC 1337 +`SetBadgeFormat`) is the one output that only ever appears in iTerm2. + +Linux builds ship for amd64 and arm64. `o` (open in `$EDITOR`) uses the +terminal's own new-tab command where there is one, and falls back to `open` on +macOS and `xdg-open` on Linux. diff --git a/internal/config/config.go b/internal/config/config.go index e4a7815..9864cc8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,7 +13,8 @@ import ( // Config mirrors the PART 7 key set. type Config struct { - Width string `toml:"width"` // wide | narrow + Width string `toml:"width"` // fill | wide | narrow + MaxWidth int `toml:"max_width"` // the ceiling fill stops at StuckAfter time.Duration `toml:"stuck_after"` LongRunningAfter time.Duration `toml:"long_running_after"` LongRunningAllow []string `toml:"long_running_allow"` @@ -54,7 +55,8 @@ func (w Warning) String() string { // Default returns the PART 7 defaults. func Default() Config { return Config{ - Width: "wide", + Width: "fill", + MaxWidth: 100, StuckAfter: 45 * time.Second, LongRunningAfter: 3 * time.Minute, LongRunningAllow: []string{"test", "build", "install", "compile", "bundle", "migrate"}, @@ -105,7 +107,8 @@ func Load(path string) (Config, []Warning) { var warns []Warning l := loader{md: md, raw: raw, warns: &warns} - l.enum("width", &cfg.Width, "wide", "narrow") + l.enum("width", &cfg.Width, "fill", "wide", "narrow") + l.count("max_width", &cfg.MaxWidth, 44) l.duration("stuck_after", &cfg.StuckAfter) l.duration("long_running_after", &cfg.LongRunningAfter) l.strings("long_running_allow", &cfg.LongRunningAllow) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0cf13ec..a689ac7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -34,7 +34,8 @@ func TestDefaults(t *testing.T) { got any want any }{ - {"width", d.Width, "wide"}, + {"width", d.Width, "fill"}, + {"max_width", d.MaxWidth, 100}, {"stuck_after", d.StuckAfter, 45 * time.Second}, {"long_running_after", d.LongRunningAfter, 3 * time.Minute}, {"long_running_allow", d.LongRunningAllow, []string{"test", "build", "install", "compile", "bundle", "migrate"}}, @@ -99,6 +100,7 @@ color = false } want := Config{ Width: "narrow", + MaxWidth: 100, StuckAfter: 90 * time.Second, LongRunningAfter: 10 * time.Minute, LongRunningAllow: []string{"deploy"}, @@ -147,8 +149,9 @@ func TestInvalidValuesFallBack(t *testing.T) { key string check func(Config) bool }{ - {"width bad enum", `width = "huge"`, "width", func(c Config) bool { return c.Width == "wide" }}, - {"width wrong type", `width = 3`, "width", func(c Config) bool { return c.Width == "wide" }}, + {"width bad enum", `width = "huge"`, "width", func(c Config) bool { return c.Width == "fill" }}, + {"width wrong type", `width = 3`, "width", func(c Config) bool { return c.Width == "fill" }}, + {"max_width below the narrow layout", `max_width = 20`, "max_width", func(c Config) bool { return c.MaxWidth == 100 }}, {"stuck_after not a duration", `stuck_after = "soonish"`, "stuck_after", func(c Config) bool { return c.StuckAfter == 45*time.Second }}, {"stuck_after wrong type", `stuck_after = 45`, "stuck_after", func(c Config) bool { return c.StuckAfter == 45*time.Second }}, {"stuck_after negative", `stuck_after = "-5s"`, "stuck_after", func(c Config) bool { return c.StuckAfter == 45*time.Second }}, @@ -238,7 +241,7 @@ slug_max = 40 if len(warns) != 2 { t.Fatalf("want 2 warnings, got %d: %v", len(warns), warns) } - if cfg.Width != "wide" || cfg.Spark != "tokens" { + if cfg.Width != "fill" || cfg.Spark != "tokens" { t.Errorf("invalid values not defaulted: width=%q spark=%q", cfg.Width, cfg.Spark) } if cfg.SlugMax != 40 { diff --git a/internal/installer/install.sh b/internal/installer/install.sh index cbe34f9..4ed0cad 100755 --- a/internal/installer/install.sh +++ b/internal/installer/install.sh @@ -23,7 +23,7 @@ into Claude Code's settings.json without touching anything else. --yes skip the y/N confirmation (required when stdin is not a tty) --uninstall remove agentpane entries only (hook + autopane), pruning emptied groups and events but never the hooks key itself - --autopane also auto-open the TUI in an iTerm2 split when a session + --autopane also auto-open the TUI in a terminal split when a session starts: appends one extra SessionStart group (matcher "startup|resume") running "agentpane autopane"; combined with --uninstall it removes ONLY that entry diff --git a/internal/ui/autoexpand_test.go b/internal/ui/autoexpand_test.go index e9bfd37..31f8f23 100644 --- a/internal/ui/autoexpand_test.go +++ b/internal/ui/autoexpand_test.go @@ -3,6 +3,9 @@ package ui import ( "strings" "testing" + + "github.com/simoncoombes/agentpane/internal/event" + "github.com/simoncoombes/agentpane/internal/state" ) // A tall pane must not sit two-thirds empty while every agent hides what it @@ -36,10 +39,11 @@ func TestSpareRoomShowsWhatAgentsAreDoing(t *testing.T) { } } -// A row is one line of what the agent is DOING. Its history is not on the tree -// at all — it is one ⏎ away in the inspector — unless the row is pinned, which -// is an explicit per-row choice. -func TestHistoryIsADrillInNotARow(t *testing.T) { +// A row's first job is one line of what the agent is DOING, and under +// pressure that is all it is: history is one ⏎ away in the inspector. But rows +// that would otherwise be padding go back into the strip, so a quiet pane +// watching one agent says more than a name and a timer. +func TestHistoryGrowsIntoSpareRooms(t *testing.T) { m, events := demoMachine(t) v, w := demoView(t, m, events) render := func(rows int) string { @@ -48,17 +52,23 @@ func TestHistoryIsADrillInNotARow(t *testing.T) { } countCalls := func(s string) int { return strings.Count(s, contMid+histIndent) } - for _, rows := range []int{18, 30, 60} { - if n := countCalls(render(rows)); n != 0 { - t.Errorf("rows=%d: %d history lines on the tree, want none", rows, n) - } + // Tight: every row is spoken for, so no agent carries history. + if n := countCalls(render(18)); n != 0 { + t.Errorf("rows=18: %d history lines on a tree with no room for them", n) } - // A tall pane spends its rows on AGENTS, not on history for a few of them. - if a, b := strings.Count(render(60), nowMark), strings.Count(render(18), nowMark); a <= b { - t.Errorf("a tall pane must show more agents doing things: tall=%d short=%d", a, b) + // Roomy: the padding becomes history instead. + if n := countCalls(render(60)); n == 0 { + t.Errorf("rows=60: no history lines in a pane with rows to spare:\n%s", render(60)) + } + + // Breadth still beats depth: a tall pane shows more agents doing things, + // and never fewer than a short one. + if a, b := strings.Count(render(60), nowMark), strings.Count(render(18), nowMark); a < b { + t.Errorf("a tall pane shows fewer agents doing things: tall=%d short=%d", a, b) } - // Pinning one row brings its history back, and only its own. + // Pinning one row brings its history back even when there is no room to + // grow, and only its own. for _, a := range w.Agents { if expandable(a) { v.PinSeq++ @@ -77,3 +87,43 @@ func TestHistoryIsADrillInNotARow(t *testing.T) { } } } + +// One agent working alone in a tall pane is the common case, and the case the +// old "spare rows are spare" rule served worst: three lines of tree and thirty +// of blank. It must now spend those rows on what that agent has been doing. +func TestLoneAgentFillsATallPane(t *testing.T) { + now := demoNow() + m, events := demoMachine(t) + v, w := demoView(t, m, events) + v.SelID = event.MainAgentID + + // Keep the busiest agent and drop the rest. + var lone state.Agent + for _, a := range w.Agents { + if expandable(a) && len(a.CallHistory) > len(lone.CallHistory) { + lone = a + } + } + if lone.ID == "" { + t.Fatal("no expandable agent with call history in the demo world") + } + w.Agents = []state.Agent{lone} + + frame, meta := renderFrame(w, v, 64, 40, now, NewPalette(2, false)) + lines := strings.Split(stripANSI(frame), "\n") + var block []string + for i, id := range meta.lineIDs { + if id == lone.ID { + block = append(block, lines[i]) + } + } + // branch + row head + at least one history line + the activity line. + if len(block) < 4 { + t.Errorf("a lone agent in a 40-row pane got %d lines:\n%s", + len(block), strings.Join(block, "\n")) + } + if len(block) > 0 && !strings.Contains(block[len(block)-1], nowMark+" ") { + t.Errorf("the block does not end on its activity line:\n%s", + strings.Join(block, "\n")) + } +} diff --git a/internal/ui/finished_test.go b/internal/ui/finished_test.go index 596cdcb..609f296 100644 --- a/internal/ui/finished_test.go +++ b/internal/ui/finished_test.go @@ -305,8 +305,9 @@ func TestIdleShowsOnlyTheLastRun(t *testing.T) { } } -// Every agent is one line of what it is DOING — not a name, and not a strip of -// what it has already done. The block is the branch, the row, and the activity. +// Every agent block ENDS on a line of what it is DOING — not on a name, and +// not on the strip of what it has already done. The strip's height varies with +// the room the pane has; the last line does not. func TestEveryAgentShowsWhatItIsDoing(t *testing.T) { now := demoNow() m, events := demoMachine(t) @@ -328,12 +329,12 @@ func TestEveryAgentShowsWhatItIsDoing(t *testing.T) { block = append(block, lines[i]) } } - if len(block) != 3 { - t.Errorf("agent %q got %d lines, want branch + row + activity:\n%s", + if len(block) < 3 { + t.Errorf("agent %q got %d lines, want at least branch + row + activity:\n%s", v.slugFor(a), len(block), strings.Join(block, "\n")) continue } - if !strings.Contains(block[2], nowMark+" ") { + if !strings.Contains(block[len(block)-1], nowMark+" ") { t.Errorf("agent %q does not end on its activity line:\n%s", v.slugFor(a), strings.Join(block, "\n")) } diff --git a/internal/ui/geometry_test.go b/internal/ui/geometry_test.go index 0592818..e628734 100644 --- a/internal/ui/geometry_test.go +++ b/internal/ui/geometry_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/mattn/go-runewidth" + "github.com/simoncoombes/agentpane/internal/event" "github.com/simoncoombes/agentpane/internal/state" ) @@ -241,3 +243,84 @@ func TestTreeTopIsInvariantAcrossScenes(t *testing.T) { } } } + +// The three §3.1 layouts: narrow and wide are fixed budgets, fill takes the +// pane it is given and stops at fillMaxColumns. +func TestFrameWidthModes(t *testing.T) { + cases := []struct { + mode string + max int + cols int + want int + }{ + {"narrow", 0, 120, 44}, + {"narrow", 0, 30, 30}, + {"wide", 0, 120, 64}, + {"wide", 0, 50, 50}, + {"fill", 100, 98, 98}, + {"fill", 100, 200, 100}, + {"fill", 100, 50, 50}, + // Unset is fill, and an unset ceiling is the default one, so a pane + // that never configured anything is still responsive and still capped. + {"", 0, 200, fillMaxColumns}, + {"", 0, 98, 98}, + // max_width moves the ceiling in both directions. + {"fill", 140, 200, 140}, + {"fill", 50, 200, 50}, + } + for _, c := range cases { + v := &UIState{WidthMode: c.mode, MaxWidth: c.max} + if got := frameWidth(v, c.cols); got != c.want { + t.Errorf("frameWidth(%q, max %d, %d cols) = %d, want %d", + c.mode, c.max, c.cols, got, c.want) + } + } +} + +// The pane is responsive with no configuration at all: the default config, +// rendered into panes of different widths, draws to each one. +func TestDefaultLayoutFollowsThePane(t *testing.T) { + m, events := demoMachine(t) + v, w := demoView(t, m, events) + if v.WidthMode != "fill" { + t.Fatalf("default width mode = %q, want fill", v.WidthMode) + } + for _, c := range []struct{ cols, want int }{{44, 44}, {64, 64}, {80, 80}, {110, 100}} { + out, _ := renderFrame(w, v, c.cols, 30, demoNow(), NewPalette(2, true)) + max := 0 + for _, ln := range strings.Split(stripANSI(out), "\n") { + if n := runewidth.StringWidth(ln); n > max { + max = n + } + } + if max != c.want { + t.Errorf("a %d-column pane drew %d columns, want %d", c.cols, max, c.want) + } + } +} + +// A wider pane in fill mode must actually USE the columns: the same world +// rendered at 96 columns has to reach further right than at 64. +func TestFillModeWidensFrame(t *testing.T) { + m, events := demoMachine(t) + v, w := demoView(t, m, events) + + widest := func(mode string) int { + v.WidthMode = mode + out, _ := renderFrame(w, v, 96, 30, demoNow(), NewPalette(2, true)) + max := 0 + for _, ln := range strings.Split(stripANSI(out), "\n") { + if n := runewidth.StringWidth(ln); n > max { + max = n + } + } + return max + } + if got := widest("fill"); got != 96 { + t.Fatalf("fill mode rendered %d columns at cols=96, want 96", got) + } + // The fixed budgets are still reachable, and still refuse the extra room. + if got := widest("wide"); got != 64 { + t.Errorf("wide mode rendered %d columns at cols=96, want 64", got) + } +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 791137d..d56b6e1 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -104,13 +105,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) tea.Cmd { m.yankLogKey() case "w": - if m.v.WidthMode == "wide" { - m.v.WidthMode = "narrow" - m.setToast("layout: narrow 44", false) - } else { - m.v.WidthMode = "wide" - m.setToast("layout: wide 64", false) - } + // A cycle rather than a toggle: fill is a third layout, not a mode + // switch, and the key that owns width should reach all three. + m.v.WidthMode = nextWidthMode(m.v.WidthMode) + m.setToast(widthToast(m.v.WidthMode, m.v.MaxWidth), false) if m.cfg.PersistWidth != nil { m.cfg.PersistWidth(m.v.WidthMode) } @@ -485,3 +483,31 @@ func (m *Model) handleMouse(msg tea.MouseMsg) { } } } + +// nextWidthMode is the `w` cycle: fill → wide → narrow → fill. It starts at +// the default, so the two fixed budgets sit one and two presses away and a +// third press is always the way back to the pane's own width. +func nextWidthMode(cur string) string { + switch cur { + case "wide": + return "narrow" + case "narrow": + return "fill" + default: + return "wide" + } +} + +// widthToast names the layout `w` just moved to. fill has no number to quote +// because its width is whatever the pane is, so it names the ceiling instead, +// which is the only part the reader cannot see for themselves. +func widthToast(mode string, maxWidth int) string { + switch mode { + case "narrow": + return "layout: narrow 44" + case "wide": + return "layout: wide 64" + default: + return fmt.Sprintf("layout: fills the pane (max %d)", maxWidth) + } +} diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index a07a626..f90814f 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -241,9 +241,21 @@ func TestKeymapToasts(t *testing.T) { t.Errorf("x toast = %+v", m.v.Toast) } - m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) - if m.v.WidthMode != "narrow" || m.v.Toast.Text != "layout: narrow 44" { - t.Errorf("w toggle = %q toast %q", m.v.WidthMode, m.v.Toast.Text) + // `w` cycles fill → wide → narrow → fill, each press naming where it + // landed. fill is where a pane starts. + if m.v.WidthMode != "fill" { + t.Errorf("a pane starts in %q, want fill", m.v.WidthMode) + } + for _, want := range []struct{ mode, toast string }{ + {"wide", "layout: wide 64"}, + {"narrow", "layout: narrow 44"}, + {"fill", "layout: fills the pane (max 100)"}, + } { + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("w")}) + if m.v.WidthMode != want.mode || m.v.Toast.Text != want.toast { + t.Errorf("w cycle = %q toast %q, want %q / %q", + m.v.WidthMode, m.v.Toast.Text, want.mode, want.toast) + } } // The right column starts on the token burn rate, so the first t swaps diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go index 36138e3..af4557f 100644 --- a/internal/ui/overlay.go +++ b/internal/ui/overlay.go @@ -31,7 +31,7 @@ func renderOverlay(w state.World, v *UIState, cols, rows int, pal Palette) [][]s add(key("⇞/⇟", "page the inspector when it's open")) add(key("a", "returned agents: list at the foot ↔ rows on the tree")) add(key("t", "right column: since ↔ token rate")) - add(key("w", "toggle wide 64 / narrow 44")) + add(key("w", "cycle fill / wide 64 / narrow 44")) // 1–9 is listed only when it does something: the picker is withheld on a // pinned pane and when there is a single candidate (sessionPicker). if v.sessionPicker() { diff --git a/internal/ui/render.go b/internal/ui/render.go index f6c562a..ecfd6c1 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -45,15 +45,49 @@ func renderFrame(w state.World, v *UIState, cols, rows int, now time.Time, pal P return finishMeta(renderActive(w, v, width, rows, now, pal), width, rows) } -// frameWidth is the rendered width for a pane of cols columns: the §3.1 wide-64 -// or narrow-44 budget, never the raw terminal width. +// Column budgets for the three §3.1 layouts. wide-64 and narrow-44 are the +// spec's; fill is the addition below. +const ( + narrowColumns = 44 + wideColumns = 64 + // fillMaxColumns is where fill stops by default. A pane can be dragged to + // 200 columns, and a row of text that wide is not read, it is scanned + // twice, so fill takes the pane it is given up to a limit that keeps one + // row one eyeful. `max_width` in config.toml moves it. + fillMaxColumns = 100 +) + +// frameWidth is the rendered width for a pane of cols columns. +// +// fill is the default and needs no switching on: the pane draws at whatever +// width it has been given, up to v.MaxWidth. Everything above 64 columns goes +// to text, because the activity line, the tool-call labels and main's +// workstream label are all budgeted against this number and simply stop being +// truncated. +// +// wide and narrow are the FIXED budgets the tree was originally drawn +// against, still reachable with `w`. They are a ceiling, never a floor, so +// narrowing a pane below the budget reflows in every mode; only widening past +// it is what they refuse. func frameWidth(v *UIState, cols int) int { width := cols - if v.WidthMode == "narrow" && width > 44 { - return 44 - } - if width > 64 { - return 64 + switch v.WidthMode { + case "narrow": + if width > narrowColumns { + return narrowColumns + } + case "wide": + if width > wideColumns { + return wideColumns + } + default: + max := v.MaxWidth + if max < narrowColumns { + max = fillMaxColumns + } + if width > max { + return max + } } return width } diff --git a/internal/ui/testdata/demo_narrow_44.golden b/internal/ui/testdata/demo_narrow_44.golden index ba8d828..1b06fa3 100644 --- a/internal/ui/testdata/demo_narrow_44.golden +++ b/internal/ui/testdata/demo_narrow_44.golden @@ -5,18 +5,28 @@ AGENTS 7 · 3f9c ⚑ 2 NEEDS YOU ├─╮ │ ╰⚑ fix-ts2345-fallout ↺2 ▇▇▇▇· 52s │ Fix the TS2345 fallout in api handlers +│ ✔ Edit handlers.ts +5 more +│ ⚑ permission request 52s │ ▸ blocked · asked to clear the TS… ▪▪▫· ├─╮ │ ╰⚑ typecheck-works… 2m50s +│ ✔ Edit tsconfig.json +2 more +│ ✔ Edit session.ts │ ▸ no call open · silent 2m50s ▪▪▫· ├─╮ │ ╰● run-auth-tests ◐ in Bash 3m12s +│ ✔ Read session.test.ts +│ ✔ Bash pnpm test --filter auth --run │ ▸ Bash pnpm test --watch ▪▫·· ├─╮ │ ╰● audit-users-schema 0.1k/s +│ ✔ Edit users.ts +4 more +│ ✔ Read indexes.ts │ ▸ Grep "createUser" src/** ▪▪▫· ├─╮ │ ╰● write-email-index 0.3k/s +│ ✔ Read indexes.ts +3 more +│ ✔ Edit 0042.sql │ ▸ Edit 0042.sql ▪▪▫· ├─╮ │ ╰○ document-constr… queued @@ -27,16 +37,6 @@ AGENTS 7 · 3f9c ⚑ 2 NEEDS YOU - - - - - - - - - - RETURNED 1 ────────────────────────────────────────────  lint-and-format 3:56 47k diff --git a/internal/ui/testdata/demo_nocolor.golden b/internal/ui/testdata/demo_nocolor.golden index b53ce98..453f243 100644 --- a/internal/ui/testdata/demo_nocolor.golden +++ b/internal/ui/testdata/demo_nocolor.golden @@ -5,18 +5,28 @@ AGENTS 7 · 3f9c ▇····· 1/7  ├─╮ │ ╰⚑ fix-ts2345-fallout ↺2 ▇▇▇▇· 52s │ Fix the TS2345 fallout in api handlers +│ ✔ Edit handlers.ts +6 −2 +5 more +│ ⚑ permission request 52s │ ▸ blocked · asked to clear the TS cache ▪▪▫· 44k ├─╮ │ ╰⚑ typecheck-works… 2m50s +│ ✔ Edit tsconfig.json +2 −0 +2 more +│ ✔ Edit session.ts +11 −2 │ ▸ no call open · silent 2m50s ▪▪▫· 33k ├─╮ │ ╰● run-auth-tests ◐ in Bash 3m12s +│ ✔ Read session.test.ts +│ ✔ Bash pnpm test --filter auth --run │ ▸ Bash pnpm test --watch ▪▫·· 38k ├─╮ │ ╰● audit-users-schema 0.1k/s +│ ✔ Edit users.ts +18 −6 +4 more +│ ✔ Read indexes.ts │ ▸ Grep "createUser" src/** ▪▪▫· 52k ├─╮ │ ╰● write-email-index 0.3k/s +│ ✔ Read indexes.ts +3 more +│ ✔ Edit 0042.sql +30 −2 │ ▸ Edit 0042.sql ▪▪▫· 86k ├─╮ │ ╰○ document-constr… queued @@ -27,16 +37,6 @@ AGENTS 7 · 3f9c ▇····· 1/7  - - - - - - - - - - RETURNED 1 ──────────────────────────────────────────────────────────────── lint-and-format 3:56 47k diff --git a/internal/ui/testdata/demo_wide_64.golden b/internal/ui/testdata/demo_wide_64.golden index dc4090a..c95b372 100644 --- a/internal/ui/testdata/demo_wide_64.golden +++ b/internal/ui/testdata/demo_wide_64.golden @@ -5,18 +5,28 @@ AGENTS 7 · 3f9c ▇····· 1/7 ├─╮ │ ╰⚑ fix-ts2345-fallout ↺2 ▇▇▇▇· 52s │ Fix the TS2345 fallout in api handlers +│ ✔ Edit handlers.ts +6 −2 +5 more +│ ⚑ permission request 52s │ ▸ blocked · asked to clear the TS cache ▪▪▫· 44k ├─╮ │ ╰⚑ typecheck-works… 2m50s +│ ✔ Edit tsconfig.json +2 −0 +2 more +│ ✔ Edit session.ts +11 −2 │ ▸ no call open · silent 2m50s ▪▪▫· 33k ├─╮ │ ╰● run-auth-tests ◐ in Bash 3m12s +│ ✔ Read session.test.ts +│ ✔ Bash pnpm test --filter auth --run │ ▸ Bash pnpm test --watch ▪▫·· 38k ├─╮ │ ╰● audit-users-schema 0.1k/s +│ ✔ Edit users.ts +18 −6 +4 more +│ ✔ Read indexes.ts │ ▸ Grep "createUser" src/** ▪▪▫· 52k ├─╮ │ ╰● write-email-index 0.3k/s +│ ✔ Read indexes.ts +3 more +│ ✔ Edit 0042.sql +30 −2 │ ▸ Edit 0042.sql ▪▪▫· 86k ├─╮ │ ╰○ document-constr… queued @@ -27,16 +37,6 @@ AGENTS 7 · 3f9c ▇····· 1/7 - - - - - - - - - - RETURNED 1 ────────────────────────────────────────────────────────────────  lint-and-format 3:56 47k diff --git a/internal/ui/testdata/disconnected.golden b/internal/ui/testdata/disconnected.golden index 1e50880..51ae7e2 100644 --- a/internal/ui/testdata/disconnected.golden +++ b/internal/ui/testdata/disconnected.golden @@ -5,18 +5,28 @@ AGENTS 7 · 3f9c ▇····· 1/7 ├─╮ │ ╰⚑ fix-ts2345-fallout ↺2 ▇▇▇▇· 52s │ Fix the TS2345 fallout in api handlers +│ ✔ Edit handlers.ts +6 −2 +5 more +│ ⚑ permission request 52s │ ▸ blocked · asked to clear the TS cache ▪▪▫· 44k ├─╮ │ ╰⚑ typecheck-works… 2m50s +│ ✔ Edit tsconfig.json +2 −0 +2 more +│ ✔ Edit session.ts +11 −2 │ ▸ no call open · silent 2m50s ▪▪▫· 33k ├─╮ │ ╰● run-auth-tests ◐ in Bash 3m12s +│ ✔ Read session.test.ts +│ ✔ Bash pnpm test --filter auth --run │ ▸ Bash pnpm test --watch ▪▫·· 38k ├─╮ │ ╰● audit-users-schema 0.1k/s +│ ✔ Edit users.ts +18 −6 +4 more +│ ✔ Read indexes.ts │ ▸ Grep "createUser" src/** ▪▪▫· 52k ├─╮ │ ╰● write-email-index 0.3k/s +│ ✔ Read indexes.ts +3 more +│ ✔ Edit 0042.sql +30 −2 │ ▸ Edit 0042.sql ▪▪▫· 86k ├─╮ │ ╰○ document-constr… queued  @@ -27,16 +37,6 @@ AGENTS 7 · 3f9c ▇····· 1/7 - - - - - - - - - - RETURNED 1 ────────────────────────────────────────────────────────────────  lint-and-format 3:56 47k diff --git a/internal/ui/testdata/main_title_44.golden b/internal/ui/testdata/main_title_44.golden index a588102..7012d68 100644 --- a/internal/ui/testdata/main_title_44.golden +++ b/internal/ui/testdata/main_title_44.golden @@ -5,18 +5,28 @@ AGENTS 7 · 3f9c ⚑ 2 NEEDS YOU ├─╮ │ ╰⚑ fix-ts2345-fallout ↺2 ▇▇▇▇· 52s │ Fix the TS2345 fallout in api handlers +│ ✔ Edit handlers.ts +5 more +│ ⚑ permission request 52s │ ▸ blocked · asked to clear the TS… ▪▪▫· ├─╮ │ ╰⚑ typecheck-works… 2m50s +│ ✔ Edit tsconfig.json +2 more +│ ✔ Edit session.ts │ ▸ no call open · silent 2m50s ▪▪▫· ├─╮ │ ╰● run-auth-tests ◐ in Bash 3m12s +│ ✔ Read session.test.ts +│ ✔ Bash pnpm test --filter auth --run │ ▸ Bash pnpm test --watch ▪▫·· ├─╮ │ ╰● audit-users-schema 0.1k/s +│ ✔ Edit users.ts +4 more +│ ✔ Read indexes.ts │ ▸ Grep "createUser" src/** ▪▪▫· ├─╮ │ ╰● write-email-index 0.3k/s +│ ✔ Read indexes.ts +3 more +│ ✔ Edit 0042.sql │ ▸ Edit 0042.sql ▪▪▫· ├─╮ │ ╰○ document-constr… queued @@ -27,16 +37,6 @@ AGENTS 7 · 3f9c ⚑ 2 NEEDS YOU - - - - - - - - - - RETURNED 1 ────────────────────────────────────────────  lint-and-format 3:56 47k diff --git a/internal/ui/testdata/main_title_64.golden b/internal/ui/testdata/main_title_64.golden index 6f5f512..a19b58d 100644 --- a/internal/ui/testdata/main_title_64.golden +++ b/internal/ui/testdata/main_title_64.golden @@ -5,18 +5,28 @@ AGENTS 7 · 3f9c ▇····· 1/7 ├─╮ │ ╰⚑ fix-ts2345-fallout ↺2 ▇▇▇▇· 52s │ Fix the TS2345 fallout in api handlers +│ ✔ Edit handlers.ts +6 −2 +5 more +│ ⚑ permission request 52s │ ▸ blocked · asked to clear the TS cache ▪▪▫· 44k ├─╮ │ ╰⚑ typecheck-works… 2m50s +│ ✔ Edit tsconfig.json +2 −0 +2 more +│ ✔ Edit session.ts +11 −2 │ ▸ no call open · silent 2m50s ▪▪▫· 33k ├─╮ │ ╰● run-auth-tests ◐ in Bash 3m12s +│ ✔ Read session.test.ts +│ ✔ Bash pnpm test --filter auth --run │ ▸ Bash pnpm test --watch ▪▫·· 38k ├─╮ │ ╰● audit-users-schema 0.1k/s +│ ✔ Edit users.ts +18 −6 +4 more +│ ✔ Read indexes.ts │ ▸ Grep "createUser" src/** ▪▪▫· 52k ├─╮ │ ╰● write-email-index 0.3k/s +│ ✔ Read indexes.ts +3 more +│ ✔ Edit 0042.sql +30 −2 │ ▸ Edit 0042.sql ▪▪▫· 86k ├─╮ │ ╰○ document-constr… queued @@ -27,16 +37,6 @@ AGENTS 7 · 3f9c ▇····· 1/7 - - - - - - - - - - RETURNED 1 ────────────────────────────────────────────────────────────────  lint-and-format 3:56 47k diff --git a/internal/ui/tree.go b/internal/ui/tree.go index bc547a5..f6252ff 100644 --- a/internal/ui/tree.go +++ b/internal/ui/tree.go @@ -26,6 +26,12 @@ import ( // max_calls_shown of its history on the tree. const defaultHistory = 0 +// growReserve is the room the growth pass (renderTree step 4) leaves unspent: +// enough rows for one more agent block to arrive — its branch, its row head +// and its activity line — so a strip that grew into a quiet pane is not torn +// back down by the very next spawn. +const growReserve = 3 + // Trunk geometry (§3.2): main owns one continuous vertical line at a fixed // column; every subagent leaves it on a curved branch at the same column; the // last rendered row's branch terminates at its elbow. @@ -268,9 +274,48 @@ func renderTree(w state.World, v *UIState, width, avail int, now time.Time, pal blocks = build(callsShown, expanded, noRoom) } - // Nothing grows the strip back. Spare rows are spare: a tall pane shows more - // AGENTS, which is the thing a tree is for, rather than more history about - // the few it already had room for. + // 4. The ladder run backwards: spare rows go back into the history strip. + // + // The rule used to be "spare rows are spare" — a tall pane shows more + // AGENTS, not more history about the few it already had. That is right + // under pressure and wrong at the other end of the range, which is where + // most sessions actually live: one subagent working alone in a + // forty-row pane drew three lines and left thirty-five blank, and the one + // question the pane exists to answer — what is this thing DOING — got a + // single line of answer with a screenful of nothing under it. + // + // So the strip grows into rows that are otherwise going to be padding, + // one call at a time, for every agent at once (the same evenness the + // degrade ladder has). growReserve is what keeps it from thrashing: the + // growth stops short of the last few rows, so the next agent to arrive + // has somewhere to land without collapsing everybody's strip on the frame + // it appears. It can still collapse — a fan-out of eight will take every + // row back, which is correct — but it takes an arrival that genuinely + // needs the space, not merely the first one. + // Depth is never bought with breadth. If step 3 had to take an activity + // line off any row, the tree is already short of space to say what things + // are doing, and history — what they have already done — cannot be the + // thing that gets rows next. + breadthHeld := true + for _, a := range agents { + if expandable(a) && !expanded[a.ID] { + breadthHeld = false + break + } + } + for breadthHeld && history < callsShown { + try := history + 1 + prev := blocks + history = try + setCalls() + blocks = build(callsShown, expanded, noRoom) + if fixed+count(blocks)+growReserve > avail { + history = try - 1 + setCalls() + blocks = prev + break + } + } // The stroke down to a new agent starts ABOVE it. // @@ -281,7 +326,7 @@ func renderTree(w state.World, v *UIState, width, avail int, now time.Time, pal // carefully the new block below it is drawn. So the stroke travels: down // through the previous block, a line at a time, un-drawing its extension // ahead of itself, and only then into the arriving block. - descend(w, v, agents, blocks, callsShown, expanded, noRoom, inner, wide, now, pal) + descend(w, v, agents, blocks, callsShown, calls, expanded, noRoom, inner, wide, now, pal) res := treeResult{breadth: true} for _, a := range autoExpandOrder(agents) { @@ -1235,9 +1280,15 @@ func transition(b block, a state.Agent, v *UIState, last bool, // (it is no longer last), and reverting a line to its pre-arrival form is one // substitution on its first rune — `├`→`╰`, `│`→` `, which is exactly what the // last block in a tree looks like. +// +// calls is the SAME per-agent history budget build() applied, not the shared +// ceiling: rebuilding an arriving block with the ceiling made it taller than +// the height the fit ladder had just counted, and the frame's last-resort clip +// would then cut the tree. It only ever showed on an agent that reached the +// tree already carrying tool calls, which is why it went unnoticed. func descend(w state.World, v *UIState, agents []state.Agent, blocks []block, - callsShown int, expanded, noRoom map[string]bool, width int, wide bool, - now time.Time, pal Palette) { + callsShown int, calls map[string]int, expanded, noRoom map[string]bool, + width int, wide bool, now time.Time, pal Palette) { for i := range blocks { d, ok := entering(agents[i], v) @@ -1258,8 +1309,12 @@ func descend(w state.World, v *UIState, agents []state.Agent, blocks []block, } } // This agent's own block waits for the stroke to reach it. + n := callsShown + if c, ok := calls[agents[i].ID]; ok && c < n { + n = c + } blocks[i] = agentBlockAfter(w, v, agents[i], i == len(agents)-1, - expanded[agents[i].ID], callsShown, noRoom[agents[i].ID], width, wide, + expanded[agents[i].ID], n, noRoom[agents[i].ID], width, wide, time.Duration(len(prev.lines))*rowDescendLine, now, pal) } } diff --git a/internal/ui/uistate.go b/internal/ui/uistate.go index b60abc8..0632163 100644 --- a/internal/ui/uistate.go +++ b/internal/ui/uistate.go @@ -99,8 +99,10 @@ type digest struct { // it (except the idempotent slug-table Assign). type UIState struct { // Layout. - WidthMode string // "wide" (64) | "narrow" (44), toggled with w (§3.1) - RightCol string // "since" | "rate" (§5.1 t) + WidthMode string // "fill" (the pane, up to MaxWidth) | "wide" (64) | "narrow" (44), cycled with w (§3.1) + // MaxWidth is where fill stops (config max_width). 0 means the default. + MaxWidth int + RightCol string // "since" | "rate" (§5.1 t) // Selection and expansion (§3.3, §3.17). SelID string @@ -199,6 +201,7 @@ type UIState struct { func newUIState(cfg config.Config) *UIState { v := &UIState{ WidthMode: cfg.Width, + MaxWidth: cfg.MaxWidth, // The right column defaults to the token burn rate. Time-since-last- // event answers "has this gone quiet?", which the stall machinery // already shouts about on its own; the rate answers "what is this @@ -216,8 +219,13 @@ func newUIState(cfg config.Config) *UIState { SparkMetric: cfg.Spark, LinksOn: cfg.Links, } - if v.WidthMode != "narrow" { - v.WidthMode = "wide" + switch v.WidthMode { + case "narrow", "wide": + default: + v.WidthMode = "fill" + } + if v.MaxWidth < 44 { + v.MaxWidth = fillMaxColumns } if v.MaxCalls <= 0 { v.MaxCalls = 4