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