From f167f189684a7497c0f2c6eb2d998ac5b34f353b Mon Sep 17 00:00:00 2001 From: Simon Coombes Date: Fri, 11 Sep 2026 08:41:22 -0400 Subject: [PATCH 1/4] updated readme --- README.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) 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). From c2a1e49a2306d8bec2b8b72f7c40ca8f8df41573 Mon Sep 17 00:00:00 2001 From: Simon Coombes Date: Fri, 11 Sep 2026 08:59:46 -0400 Subject: [PATCH 2/4] tree: grow the history strip into rows nothing else wants The tree used to leave spare rows blank: a pane with room to spare showed more agents, never more about the ones it already had. One subagent working alone in a forty-row pane drew three lines and left thirty-five empty, and the question the pane exists to answer got one line with a screenful of nothing under it. Spare rows now go back into the per-agent history strip, one tool call at a time, up to max_calls_shown. Two guards keep it from thrashing: growth stops three rows short of the budget so the next agent to spawn lands without collapsing every strip, and it does not run at all if the degrade ladder had to drop any row's activity line, so depth is never bought with breadth. Also fixes descend rebuilding an arriving agent's block with the shared ceiling rather than the per-agent budget, which made that block taller than the height the fit ladder had just counted. It only showed on an agent that reached the tree already carrying tool calls, which is why it went unnoticed. --- docs/READING-THE-PANE.md | 26 +++++--- internal/ui/autoexpand_test.go | 74 ++++++++++++++++++---- internal/ui/finished_test.go | 11 ++-- internal/ui/testdata/demo_narrow_44.golden | 20 +++--- internal/ui/testdata/demo_nocolor.golden | 20 +++--- internal/ui/testdata/demo_wide_64.golden | 20 +++--- internal/ui/testdata/disconnected.golden | 20 +++--- internal/ui/testdata/main_title_44.golden | 20 +++--- internal/ui/testdata/main_title_64.golden | 20 +++--- internal/ui/tree.go | 69 ++++++++++++++++++-- 10 files changed, 208 insertions(+), 92 deletions(-) diff --git a/docs/READING-THE-PANE.md b/docs/READING-THE-PANE.md index e1886a1..e3d57b0 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. - -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. +**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. + +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 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/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) } } From 4ebba8008e476a19063f558f7cb964d901a34151 Mon Sep 17 00:00:00 2001 From: Simon Coombes Date: Fri, 11 Sep 2026 09:00:34 -0400 Subject: [PATCH 3/4] ui: draw at the pane's width by default Widening the pane did nothing. frameWidth capped every frame at the 64-column wide budget, so dragging a divider out to 98 columns left 34 columns blank with no explanation, which reads as a resize bug. The resize plumbing was always fine; the renderer refused the room. fill is now the default layout and takes the pane's own width up to max_width (100). The extra columns go to text: the activity line, the tool-call labels and main's workstream label are all budgeted against this number, so they stop being truncated and things dropped to fit at 64, like the verify marker on its own row, come back. w still reaches the two fixed budgets, cycling fill, wide 64, narrow 44. Adds max_width to config.toml for anyone who wants the ceiling somewhere else. The budgets stay ceilings and never floors, so every layout already narrowed with the pane. loadPrefs migrates a ui.json written before fill existed. w used to be a two-way toggle where wide was the default, so a stored "wide" almost always meant "back to normal" and honouring it would leave everyone who had pressed w twice with a pane that still refuses the room and nothing on screen to explain why. A stored "narrow" says something the default does not and survives; a "wide" chosen after the upgrade is written with the new schema and sticks. --- cmd/agentpane/adapters.go | 26 ++++++++++- cmd/agentpane/main.go | 6 +-- cmd/agentpane/main_test.go | 33 +++++++++++++- cmd/agentpane/renderonce.go | 2 +- cmd/agentpane/tui.go | 2 +- docs/CLI.md | 7 +-- docs/READING-THE-PANE.md | 30 ++++++++++++ internal/config/config.go | 9 ++-- internal/config/config_test.go | 11 +++-- internal/ui/geometry_test.go | 83 ++++++++++++++++++++++++++++++++++ internal/ui/keys.go | 40 +++++++++++++--- internal/ui/model_test.go | 18 ++++++-- internal/ui/overlay.go | 2 +- internal/ui/render.go | 48 +++++++++++++++++--- internal/ui/uistate.go | 16 +++++-- 15 files changed, 294 insertions(+), 39 deletions(-) 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/main.go b/cmd/agentpane/main.go index 2fb37c3..e877264 100644 --- a/cmd/agentpane/main.go +++ b/cmd/agentpane/main.go @@ -40,7 +40,7 @@ agentpane doctor --session the same checks for exactly that session's pane 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/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..ff4bbf7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -21,7 +21,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 | | `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 +37,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"] @@ -83,7 +84,7 @@ color = true # false == NO_COLOR | `o` | open the selected agent's current file in `$EDITOR` (new iTerm2 tab) | | `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/READING-THE-PANE.md b/docs/READING-THE-PANE.md index e3d57b0..85c488e 100644 --- a/docs/READING-THE-PANE.md +++ b/docs/READING-THE-PANE.md @@ -147,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/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/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/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 From 8be26066b81e378c28fce145f0541cbceca4a539 Mon Sep 17 00:00:00 2001 From: Simon Coombes Date: Fri, 11 Sep 2026 09:00:56 -0400 Subject: [PATCH 4/4] autopane: split the pane in tmux, WezTerm and kitty too Auto-open and the tab focus jump were iTerm2 AppleScript, which made both of them macOS features by accident rather than by decision. pane.go turns each terminal into a pure function from the environment to an argv, so the command is unit-testable on a machine that has none of these terminals installed, and the three callers (autopane's split, tab's focus jump, o's new tab) dispatch through it. tmux uses split-window -h -d -l, with an unsized fallback for versions before 3.1 where -l does not take a cell count. WezTerm uses cli split-pane and hands focus straight back. kitty uses launch --location=vsplit, trying kitten before kitty. The focus jump aims left on every backend, since agentpane is split to the right of the session by construction and the TUI never saw the id of the pane it came from. tmux wins over the terminal hosting it: a session in tmux inside iTerm2 lives in a tmux pane, and splitting the iTerm2 pane would divide the whole tmux viewport and leave the new pane outside tmux entirely. AGENTPANE_TERMINAL overrides the detection, including with "none". A value that is not a backend name also disables rather than falling through, so a typo fails where doctor can print it. Each backend's program name is overridable too (AGENTPANE_TMUX and friends), which is both the test seam and the way to reach a wrapper or a non-PATH install. doctor now names the detected backend and whether the program that drives it is on PATH. autopane --explain guard (c) prints what it looked at when it finds nothing. o falls back to xdg-open on Linux. Only the iTerm2 path is live-tested. The other three are covered at the argv level, which cannot prove the argv is what those CLIs want; docs/TERMINALS.md marks each one. --- cmd/agentpane/autopane.go | 100 +++++----- cmd/agentpane/autopane_test.go | 66 +++++++ cmd/agentpane/doctor.go | 37 +++- cmd/agentpane/editor.go | 81 +++----- cmd/agentpane/main.go | 4 +- cmd/agentpane/pane.go | 341 +++++++++++++++++++++++++++++++++ cmd/agentpane/pane_test.go | 304 +++++++++++++++++++++++++++++ docs/CLI.md | 9 +- docs/INSTALL.md | 34 ++-- docs/ITERM2.md | 4 + docs/TERMINALS.md | 85 ++++++++ internal/installer/install.sh | 2 +- 12 files changed, 940 insertions(+), 127 deletions(-) create mode 100644 cmd/agentpane/pane.go create mode 100644 cmd/agentpane/pane_test.go create mode 100644 docs/TERMINALS.md 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 e877264..dbd48f2 100644 --- a/cmd/agentpane/main.go +++ b/cmd/agentpane/main.go @@ -34,8 +34,8 @@ 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 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/docs/CLI.md b/docs/CLI.md index ff4bbf7..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 wide\|narrow` | fix the layout at 64 or 44 columns instead of the default, which is the pane's own width | +| `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 | @@ -78,10 +79,10 @@ 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` | cycle the layout: fill the pane, wide 64, narrow 44 (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/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/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