From c82b651a4244e069cdbb96a4c91af82f1a65325a Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Wed, 19 Aug 2026 12:55:30 +0200 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=92=A5=20First=20Commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/links.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/links.md diff --git a/docs/links.md b/docs/links.md new file mode 100644 index 0000000..7a21609 --- /dev/null +++ b/docs/links.md @@ -0,0 +1,10 @@ +### Claude Code +- https://code.claude.com/docs/en/hooks-guide +- https://code.claude.com/docs/en/hooks +- https://code.claude.com/docs/en/plugins-reference + +### Devin CLI +- https://docs.devin.ai/cli/extensibility/hooks/overview +- https://docs.devin.ai/cli/extensibility/hooks/lifecycle-hooks +- https://docs.devin.ai/cli/extensibility/plugins/overview + From ed0905267a533537ed03970c36b3f08e952df83f Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Wed, 19 Aug 2026 13:22:10 +0200 Subject: [PATCH 02/14] Show which pane each Claude Code / Devin session is in, and its state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux cannot answer "is an agent waiting on me". #{pane_current_command} reports `node` for Claude Code, and no process name distinguishes thinking from blocked on a permission prompt. So the agents report themselves. Both CLIs expose lifecycle hooks, and a hook process inherits $TMUX_PANE from the agent that spawned it, which is how the pane is known without guessing. `kaku-tab hook` maps the event to one of five states and writes it to a tmux *pane* option. Storing it on the pane rather than in a state directory buys two things: it rides in on the list-panes query resolve already makes, so it costs no extra process, and staleness is structural — close the pane and the record goes with it. The one leak left is an agent killed outright inside a pane that survives it, which the recorded pid covers. New invariant, and the one way to get this wrong: @kt_agent is only ever set with -p. tmux pane options inherit from window options, so a single window-scoped write would have every agent-free pane in that window report an agent. The per-window rollup is deliberately a different option name. `kaku-tab hook` is inert by contract — never stdout, never a non-zero exit. On PermissionRequest and the PreToolUse family both are decision channels, so a status reporter that got either wrong would silently veto the user's own tool calls. Surfaces: - an agent column in the picker (letter = agent, colour = state), rolled up to window rows and session headers so a blocked pane three windows deep is visible without unfolding; ^a filters to what is waiting on you - an opt-in status-right counter, @kaku-tab-agents. It renders nothing when no agent is running, and the hook pushes refresh-client -S so the count moves as it happens rather than at the next status-interval tick - `kaku-tab agents` from the shell, and an agent column in `resolve` `kaku-tab install-hooks` merges one block into ~/.claude/settings.json, which serves both CLIs — Devin CLI reads that file too, and the hook tells them apart from the environment. Written in shell form with an explicit exec: Devin's schema has no `args` field, so exec form there would invoke the binary with no arguments and open the picker, and exec replaces the wrapping shell so the recorded pid is the agent rather than a shell that exits immediately. Co-Authored-By: Claude Opus 5 (1M context) --- .testcoverage.yml | 4 + CLAUDE.md | 15 +- README.md | 29 ++++ cmd/kaku-tab/agents.go | 241 +++++++++++++++++++++++++++++++++ cmd/kaku-tab/installhooks.go | 208 ++++++++++++++++++++++++++++ cmd/kaku-tab/main.go | 39 +++++- docs/agents.md | 157 +++++++++++++++++++++ docs/configuration.md | 27 ++++ internal/agent/agent.go | 184 +++++++++++++++++++++++++ internal/agent/agent_test.go | 117 ++++++++++++++++ internal/agent/hook.go | 96 +++++++++++++ internal/agent/hook_test.go | 103 ++++++++++++++ internal/model/model.go | 11 ++ internal/resolve/agent_test.go | 80 +++++++++++ internal/resolve/resolve.go | 22 ++- internal/tmux/tmux.go | 82 ++++++++++- internal/ui/agentcol_test.go | 131 ++++++++++++++++++ internal/ui/theme.go | 12 ++ internal/ui/ui.go | 96 +++++++++++-- kaku-tab.tmux | 21 +++ 20 files changed, 1657 insertions(+), 18 deletions(-) create mode 100644 cmd/kaku-tab/agents.go create mode 100644 cmd/kaku-tab/installhooks.go create mode 100644 docs/agents.md create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agent_test.go create mode 100644 internal/agent/hook.go create mode 100644 internal/agent/hook_test.go create mode 100644 internal/resolve/agent_test.go create mode 100644 internal/ui/agentcol_test.go diff --git a/.testcoverage.yml b/.testcoverage.yml index f9f07d5..4261393 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -32,3 +32,7 @@ override: threshold: 40 - path: ^internal/mru$ threshold: 80 + # The agent event mapping: pure functions over hook payloads, and the place a + # wrong mapping would be invisible until an agent sat blocked with no badge. + - path: ^internal/agent$ + threshold: 90 diff --git a/CLAUDE.md b/CLAUDE.md index ab7091f..7925006 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,6 +24,10 @@ a ratchet set just under the current numbers: if a change drops clients and windows on the **tty**. Everything else is presentation or plumbing around the table it produces. Read [docs/design.md](docs/design.md) first. +`internal/agent` is the second, smaller story: Claude Code / Devin CLI publish +their state into a tmux pane option via `kaku-tab hook`, and it rides back in on +the `list-panes` query resolve already makes. See [docs/agents.md](docs/agents.md). + ## Invariants (all previously broken here; tests pin them) - Measure **display cells**, not bytes or runes. Use `ansi.StringWidth` for @@ -33,11 +37,18 @@ around the table it produces. Read [docs/design.md](docs/design.md) first. ambiguous once a grouped session shares the window. - Never `set-hook -g` — it replaces the user's hooks. Use `-ga`. - Never key on `$WEZTERM_PANE`; it goes stale. Join on the tty. +- Agent state (`@kt_agent`) is set with `set-option -p` only. tmux pane options + inherit from window options, so one window-scoped write has every agent-free + pane in that window report an agent. The rollup is a separate option name. +- `kaku-tab hook` must never print to stdout or exit non-zero. On + `PermissionRequest` and `PreToolUse` both are decision channels, so a status + reporter that got either wrong would silently veto the user's own tool calls. ## Verifying changes -`kaku-tab resolve` prints the join. To see the TUI without a real popup, run it -inside a detached tmux session and capture the pane: +`kaku-tab resolve` prints the join, agent column included. `kaku-tab agents` +lists agent panes without a tmux server of its own. To see the TUI without a +real popup, run it inside a detached tmux session and capture the pane: ```sh tmux new-session -d -s ui -x 150 -y 30 "$PWD/bin/kaku-tab pick '' ui" diff --git a/README.md b/README.md index e9dc518..fb14a58 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ and press Alt+L. | Shift+Tab | fold or unfold every session | | Ctrl+P | toggle window ⇄ pane rows | | Ctrl+E | hide/show detached sessions — leaves only what's on screen | +| Ctrl+A | show only windows where an agent is waiting on you | | Ctrl+/ | show/hide the preview — the popup resizes with it | | Ctrl+R | rename: the **window** on a child row, the **session** on a header | | Ctrl+X | kill window | @@ -180,6 +181,31 @@ switched to, and the window you are in now is pushed one place down — so Alt+L Enter toggles back to where you just were, alt-tab style. +## Agents + +Claude Code and Devin CLI sessions show up as a column in the picker — which +pane, which agent, and whether it is working, blocked on a permission prompt, +asking you something, finished, or failed — plus a counter on the right of the +tmux status bar. + +```sh +kaku-tab install-hooks # one block in ~/.claude/settings.json, both CLIs +``` + +```tmux +set -g @kaku-tab-agents 'on' +set -g status-interval 5 +``` + +The agents report themselves: each CLI's lifecycle hooks run `kaku-tab hook`, +which records the state on the pane it inherited via `$TMUX_PANE`. Nothing is +guessed from the process table — `#{pane_current_command}` says `node` for +Claude Code, and no process name can tell "thinking" from "waiting on you". + +Because the state lives in a tmux pane option, it rides in on the `list-panes` +query the picker already makes, and it disappears with the pane. See +[docs/agents.md](docs/agents.md). + ## Scrollback search Set `@kaku-tab-search-key` to get a live grep over every pane's scrollback in @@ -196,6 +222,8 @@ kaku-tab resolve # print the window ⇄ tab join (debugging) kaku-tab restore [--windows] # open a tab per detached session kaku-tab prune # reap orphaned satellite sessions kaku-tab titles [--dry-run] # retitle tabs after their tmux window +kaku-tab agents # which pane each Claude Code / Devin session is in +kaku-tab install-hooks # register the agent hooks with both CLIs ``` `restore` pairs well with @@ -205,6 +233,7 @@ brings the sessions back, this brings the tabs back. ## Docs - [Design](docs/design.md) — how the join works, and why grouped sessions +- [Agents](docs/agents.md) — Claude Code / Devin CLI state in the picker and status bar - [Configuration](docs/configuration.md) — every option - [Troubleshooting](docs/troubleshooting.md) diff --git a/cmd/kaku-tab/agents.go b/cmd/kaku-tab/agents.go new file mode 100644 index 0000000..244eccf --- /dev/null +++ b/cmd/kaku-tab/agents.go @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "io" + "os" + "regexp" + "sort" + "strings" + "time" + + "github.com/dsaad68/kaku-tab/internal/agent" + "github.com/dsaad68/kaku-tab/internal/model" + "github.com/dsaad68/kaku-tab/internal/resolve" + "github.com/dsaad68/kaku-tab/internal/tmux" +) + +var paneIDRe = regexp.MustCompile(`^%[0-9]+$`) + +// maxHookPayload caps what we read from a hook's stdin. The fields we want are +// in the first few hundred bytes, but a PostToolUse payload can carry a whole +// file's contents, and a hook must not be the thing that stalls the agent. +const maxHookPayload = 1 << 20 + +// hook is the publisher: it runs as a child of Claude Code or Devin CLI, learns +// the pane from the $TMUX_PANE it inherited, and records what the agent is doing +// on that pane. +// +// It is inert by contract. It never writes to stdout and never exits non-zero: +// on PermissionRequest and the PreToolUse family, stdout is a decision channel +// and a non-zero exit blocks the call, so a status reporter that got either +// wrong would start silently vetoing the user's own tool calls. +func hook() error { + // A read error leaves the payload empty or truncated; either way Decide + // rejects it and we exit quietly. The hook is never the thing that fails an + // agent's turn. + body, _ := io.ReadAll(io.LimitReader(os.Stdin, maxHookPayload)) + act, state := agent.Decide(body) + if act == agent.Ignore { + return nil + } + + // No pane means the agent is not running under tmux — an IDE session, a + // bare terminal, CI. Nothing to record, and nothing wrong. + pane := os.Getenv("TMUX_PANE") + if os.Getenv("TMUX") == "" || !paneIDRe.MatchString(pane) { + return nil + } + + if act == agent.Clear { + _ = tmux.UnsetPaneOption(pane, agent.PaneOption) + tmux.RefreshStatus() + return nil + } + + // The parent is the agent process itself, which is why the hooks are + // installed in exec form (Claude Code's `args`) and with `exec` (Devin): + // a shell wrapper would make this the pid of a shell that exits + // immediately, and the record would read as dead the moment it was written. + rec := agent.Record{ + Agent: agent.Detect(os.Getenv), + State: state, + PID: os.Getppid(), + At: time.Now().Unix(), + } + _ = tmux.SetPaneOption(pane, agent.PaneOption, agent.Format(rec)) + tmux.RefreshStatus() + return nil +} + +// counts is the tally the status segment renders. +type counts struct { + waiting int // perm + ask: blocked on you right now + done int + failed int + working int +} + +func (c counts) empty() bool { return c.waiting+c.done+c.failed+c.working == 0 } + +// sweep reads every pane's record, clearing any whose agent process is gone. +// This is the one case pane-scoped storage cannot self-heal: an agent killed +// outright never fires SessionEnd, and its pane outlives it. +func sweep() (counts, error) { + byPane, err := tmux.PaneAgents() + if err != nil { + return counts{}, err + } + var c counts + for pane, r := range byPane { + if r.Empty() { + continue + } + if !agent.Live(r) { + _ = tmux.UnsetPaneOption(pane, agent.PaneOption) + continue + } + switch r.State { + case agent.Perm, agent.Ask: + c.waiting++ + case agent.Done: + c.done++ + case agent.Err: + c.failed++ + default: + c.working++ + } + } + return c, nil +} + +// segment renders the status-right counter. +// +// It carries its own styling and returns "" when nothing is running, so the +// status bar shows no empty brackets or stray separator when there is no agent +// — which is most of the time, and the reason it is not a catppuccin module. +func segment(c counts) string { + if c.empty() { + return "" + } + var parts []string + add := func(n int, colour, icon string) { + if n > 0 { + parts = append(parts, fmt.Sprintf("#[fg=%s]%s %d#[default]", colour, icon, n)) + } + } + add(c.waiting, "yellow", "") // bell: wants a decision from you + add(c.failed, "red", "") + add(c.done, "green", "") + add(c.working, "blue", "") + return strings.Join(parts, " ") +} + +// refreshWindows writes the per-window rollup for use in tmux window formats. +// +// Deliberately a different option name from the pane record: tmux pane options +// inherit from window options, so reusing @kt_agent here would have every +// agent-free pane in the window read back an agent that is not there. +func refreshWindows() error { + ws, err := resolve.Resolve(liveSource{}, resolve.Options{ + Suffix: tmux.Option("@kaku-tab-satellite-suffix", model.DefaultSatelliteSuffix), + Scope: "all", + WithAgents: true, + }) + if err != nil { + return err + } + for _, w := range ws { + if w.Agent.Empty() { + _ = tmux.UnsetWindowOption(w.Session, w.ID, agent.WindowOption) + continue + } + _ = tmux.SetWindowOption(w.Session, w.ID, agent.WindowOption, string(w.Agent.State)) + } + return nil +} + +// agents is the reader side: `--format tmux` for the status bar, `--refresh` +// for the per-window rollup, and a plain listing that answers "which pane is +// Claude running in" straight from the shell. +func agents(args []string) error { + format, refresh := "", false + for i := 0; i < len(args); i++ { + switch args[i] { + case "--refresh": + refresh = true + case "--format": + if i+1 < len(args) { + i++ + format = args[i] + } + default: + if v, ok := strings.CutPrefix(args[i], "--format="); ok { + format = v + } + } + } + + c, err := sweep() + if err != nil { + return err + } + if refresh { + if err := refreshWindows(); err != nil { + return err + } + } + if format == "tmux" { + if s := segment(c); s != "" { + fmt.Println(s) + } + return nil + } + return listAgents() +} + +func listAgents() error { + ws, err := resolve.Resolve(liveSource{}, resolve.Options{ + Suffix: tmux.Option("@kaku-tab-satellite-suffix", model.DefaultSatelliteSuffix), + Scope: "all", + WithPanes: true, + }) + if err != nil { + return err + } + + type entry struct { + where string + rec agent.Record + } + var rows []entry + for _, w := range ws { + for _, p := range w.Panes_ { + if p.Agent.Empty() { + continue + } + rows = append(rows, entry{ + where: fmt.Sprintf("%s:%s.%s %s", w.Session, w.Index, p.Index, p.ID), + rec: p.Agent, + }) + } + } + if len(rows) == 0 { + fmt.Println("no agent sessions") + return nil + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].rec.Rank() != rows[j].rec.Rank() { + return rows[i].rec.Rank() < rows[j].rec.Rank() + } + return rows[i].where < rows[j].where + }) + for _, r := range rows { + fmt.Printf("%-6s %-5s %-28s pid=%-7d %s ago\n", + r.rec.Agent, r.rec.State, r.where, r.rec.PID, + time.Since(time.Unix(r.rec.At, 0)).Round(time.Second)) + } + return nil +} diff --git a/cmd/kaku-tab/installhooks.go b/cmd/kaku-tab/installhooks.go new file mode 100644 index 0000000..dede160 --- /dev/null +++ b/cmd/kaku-tab/installhooks.go @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// hookEvents are the lifecycle events we subscribe to, in the order they are +// written. The two CLIs are subscribed through one shared block and each +// ignores the events it does not have, so this is the union of both: +// +// Claude Code only Notification, PostToolBatch, StopFailure +// Devin CLI only PermissionRequest +// +// PreToolUse is deliberately absent. It fires on every single tool call, on the +// agent's hot path, and PostToolUse already carries what we need — including the +// transition that clears a pane out of "waiting for permission" once approved. +var hookEvents = []string{ + "SessionStart", + "UserPromptSubmit", + "PostToolUse", + "PostToolBatch", + "PermissionRequest", + "Notification", + "Stop", + "StopFailure", + "SessionEnd", +} + +// notificationMatcher limits the Notification event to the types that actually +// mean something changed for the user. Without it the hook would also fire on +// auth and elicitation-response traffic that says nothing about whether the +// agent is waiting. +const notificationMatcher = "permission_prompt|idle_prompt|agent_needs_input|" + + "agent_completed|elicitation_dialog|elicitation_complete" + +// hookCommand is the shell-form command written into the settings file. +// +// Shell form with an explicit `exec`, rather than Claude Code's exec form +// (`args`), for two reasons. Devin CLI's hook schema has no `args` field, so an +// exec-form entry there would invoke the binary with no arguments at all — and +// kaku-tab with no arguments opens the picker. And `exec` replaces the wrapping +// shell, so the hook's parent is the agent itself, which is the pid the record +// stores to know when the agent has died. +func hookCommand(bin string) string { + return "exec '" + strings.ReplaceAll(bin, "'", `'\''`) + "' hook" +} + +// encode renders the settings file. +// +// Through an Encoder with HTML escaping off, not json.MarshalIndent: the +// default turns every & < > in the user's own prose into a \u0026 escape. It +// still parses, but it silently rewrites text this tool has no business +// touching — and this file holds their permission policy. +func encode(settings map[string]any) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(settings); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func settingsPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude", "settings.json"), nil +} + +// ours reports whether a matcher group was written by us, so reinstalling +// replaces it instead of stacking a second copy beside it. +func ours(group any, bin string) bool { + g, ok := group.(map[string]any) + if !ok { + return false + } + hs, ok := g["hooks"].([]any) + if !ok || len(hs) == 0 { + return false + } + for _, h := range hs { + hm, ok := h.(map[string]any) + if !ok { + return false + } + cmd, _ := hm["command"].(string) + if !strings.Contains(cmd, bin) { + return false + } + } + return true +} + +// mergeHooks adds our entries to an existing settings object, leaving every +// other key — and every hook the user wrote themselves — untouched. +func mergeHooks(settings map[string]any, bin string) { + hooks, _ := settings["hooks"].(map[string]any) + if hooks == nil { + hooks = map[string]any{} + } + + entry := map[string]any{ + "hooks": []any{map[string]any{ + "type": "command", + "command": hookCommand(bin), + }}, + } + + for _, ev := range hookEvents { + mine := map[string]any{} + for k, v := range entry { + mine[k] = v + } + if ev == "Notification" { + mine["matcher"] = notificationMatcher + } + + var kept []any + for _, g := range asSlice(hooks[ev]) { + if !ours(g, bin) { + kept = append(kept, g) + } + } + hooks[ev] = append(kept, mine) + } + settings["hooks"] = hooks +} + +func asSlice(v any) []any { + s, _ := v.([]any) + return s +} + +// installHooks merges the agent hooks into ~/.claude/settings.json. +// +// Both CLIs read that file — Devin CLI treats it as one of its user-level hook +// sources — so a single block instruments both, and `kaku-tab hook` tells them +// apart from the environment rather than from an argument. +func installHooks(args []string) error { + dry := false + for _, a := range args { + if a == "--dry-run" || a == "-n" { + dry = true + } + } + + bin, err := os.Executable() + if err != nil { + return err + } + if bin, err = filepath.EvalSymlinks(bin); err != nil { + return err + } + + path, err := settingsPath() + if err != nil { + return err + } + settings := map[string]any{} + switch data, err := os.ReadFile(path); { + case err == nil && len(data) > 0: + if err := json.Unmarshal(data, &settings); err != nil { + // Refuse rather than clobber: this file holds permissions and + // auto-mode policy, and rewriting it from a failed parse would + // throw all of that away. + return fmt.Errorf("parse %s: %w", path, err) + } + case err != nil && !os.IsNotExist(err): + return err + } + + mergeHooks(settings, bin) + out, err := encode(settings) + if err != nil { + return err + } + + if dry { + fmt.Printf("# would write %s\n%s", path, out) + return nil + } + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + // Keep a copy of whatever was there. Rewriting the file loses key order, + // and this is the file the user's permission policy lives in. + if prev, err := os.ReadFile(path); err == nil { + _ = os.WriteFile(path+".kaku-tab.bak", prev, 0o600) + } + if err := os.WriteFile(path, out, 0o600); err != nil { + return err + } + fmt.Printf("installed agent hooks in %s\n", path) + fmt.Printf(" command: %s\n", hookCommand(bin)) + fmt.Println(" restart any running Claude Code / Devin CLI session to pick them up") + return nil +} diff --git a/cmd/kaku-tab/main.go b/cmd/kaku-tab/main.go index dc321a6..0882f1b 100644 --- a/cmd/kaku-tab/main.go +++ b/cmd/kaku-tab/main.go @@ -9,6 +9,9 @@ // kaku-tab prune reap orphaned satellite sessions // kaku-tab restore [--windows] [--dry-run] // a Kaku tab per detached session +// kaku-tab hook agent lifecycle hook (reads stdin) +// kaku-tab agents [--format tmux] agent panes, or the status counter +// kaku-tab install-hooks register the hooks with both CLIs package main import ( @@ -48,7 +51,11 @@ func opts(selfSession string, withPanes bool) resolve.Options { Scope: tmux.Option("@kaku-tab-scope", "all"), SelfSession: selfSession, WithPanes: withPanes, - Ignore: ignored(), + // The picker shows an agent column on window rows too, so the rollup is + // wanted even when the panes themselves are not. It is the same single + // list-panes query either way. + WithAgents: true, + Ignore: ignored(), } } @@ -114,6 +121,12 @@ func main() { err = restore(os.Args[2:]) case "titles": err = titles(len(os.Args) > 2 && os.Args[2] == "--dry-run") + case "hook": + err = hook() + case "agents": + err = agents(os.Args[2:]) + case "install-hooks": + err = installHooks(os.Args[2:]) case "version", "--version", "-v": fmt.Printf("kaku-tab %s\n", version) case "-h", "--help", "help": @@ -137,6 +150,11 @@ const usage = `kaku-tab — tmux window ⇄ Kaku tab picker prune reap orphaned satellite sessions restore [--windows] [--dry-run] titles [--dry-run] retitle terminal tabs after their tmux window + hook publish agent state for the current pane (reads stdin) + agents [--format tmux] [--refresh] + list agent panes, or render the status-bar counter + install-hooks [--dry-run] + add the agent hooks to ~/.claude/settings.json version ` @@ -278,6 +296,11 @@ func pick(selfTTY, selfSession string) error { hideDetached = restore.HideDetached } + // Never sticky, not even as a tmux option: this filter can empty the list + // entirely, and one that survived the popup would have you reopen it to + // find every window gone. It only rides across a preview-toggle relaunch. + agentsOnly := resumed && restore.AgentsOnly + self, _ := os.Executable() ctx := action.Ctx{SelfTTY: selfTTY, Suffix: suffix, AttachSh: self} @@ -296,6 +319,7 @@ func pick(selfTTY, selfSession string) error { MRU: mruList(sortMode), HideDetached: hideDetached, + AgentsOnly: agentsOnly, }) // The picker owns the popup's terminal; Kaku's own alt-screen is untouched. @@ -399,13 +423,22 @@ func printResolve() error { return err } for _, w := range ws { - fmt.Printf("%-16s %-5s %-14s idx=%-3s tab=%-3s gui=%-3s client=%-16s %s\n", + fmt.Printf("%-16s %-5s %-14s idx=%-3s tab=%-3s gui=%-3s client=%-16s agent=%-12s %s\n", w.Status, w.ID, w.Session, w.Index, dash(w.TabID), dash(w.GUIWin), - dash(w.ClientSession), strings.TrimSpace(w.Name)) + dash(w.ClientSession), dash(agentCol(w)), strings.TrimSpace(w.Name)) } return nil } +// agentCol formats the agent rollup for `kaku-tab resolve`, which is the +// debugging view of the join. +func agentCol(w model.Window) string { + if w.Agent.Empty() { + return "" + } + return w.Agent.Agent + "/" + string(w.Agent.State) +} + func dash(s string) string { if s == "" { return "-" diff --git a/docs/agents.md b/docs/agents.md new file mode 100644 index 0000000..7b919fd --- /dev/null +++ b/docs/agents.md @@ -0,0 +1,157 @@ +# Agents + +Which pane is Claude Code or Devin CLI running in, and is it waiting on you. + +## Why not just look at the process + +`#{pane_current_command}` reports `node` for Claude Code, which is true and +useless. And even a correct process name cannot tell "thinking" from "blocked on +a permission prompt" — the difference that decides whether you should be looking +at that pane right now. + +Both CLIs expose lifecycle hooks, and a hook process inherits `$TMUX_PANE` from +the agent that spawned it. So the agent reports its own pane and its own state, +and nothing has to be inferred. + +## The transport is a tmux pane option + +`kaku-tab hook` writes one pane-scoped user option: + +```sh +tmux set-option -p -t "$TMUX_PANE" @kt_agent 'claude:perm:41337:1787137188' +``` + +`agent:state:pid:unix_ts`. Colon-separated where the rest of this tool uses +`\x1f`, because every field here is from a fixed alphabet — two agent names, +five state names, two integers — so none of them can contain the separator. + +Two things fall out of storing it on the pane: + +- `tmux list-panes` already runs on every picker invocation. `#{@kt_agent}` is + one more field in a format string that was going to be evaluated anyway, so + agent awareness costs **no extra process**. +- **Staleness is structural.** Close the pane and the record goes with it. There + is no TTL, no sweeper thread, no directory to watch. + +The one case that does not self-heal is an agent killed outright — no +`SessionEnd` fires, and its pane outlives it. That is what the pid in the record +is for: a record whose process is gone reads as absent, and `kaku-tab agents` +clears it. + +> **`@kt_agent` is only ever set with `-p`.** From `tmux(1)`: *"Pane options +> inherit from window options."* Set it at window scope even once and every +> agent-free pane in that window reads back an agent that is not there. The +> per-window rollup is deliberately a different option, `@kt_agent_win`. + +## States + +| State | Means | Claude Code | Devin CLI | +|--------|----------------------|----------------------------------------------------------------------|--------------------| +| `busy` | working | `SessionStart` `UserPromptSubmit` `PostToolUse` `PostToolBatch` | same, less the last | +| `perm` | wants permission | `Notification:permission_prompt` | `PermissionRequest` | +| `ask` | asked you a question | `Notification:elicitation_dialog` / `agent_needs_input` | — | +| `done` | finished a turn | `Stop`, `Notification:idle_prompt` / `agent_completed` | `Stop` | +| `err` | turn failed | `StopFailure` | — | + +`busy` is the only state you do not owe a response to; everything else counts as +waiting. + +Two non-obvious choices: + +- **`PostToolUse` earns its place.** It is not a heartbeat — it is what flips a + pane out of `perm` once you approve a call and the agent resumes. Without it an + approved pane keeps counting as waiting until the turn ends. +- **`PreToolUse` is not subscribed.** It fires on every tool call, on the agent's + hot path, for information `PostToolUse` already carries. +- **Payloads carrying `agent_id` are ignored.** That field marks a subagent, and + a subagent finishing is not your turn ending — otherwise every Task call would + flash the pane green mid-turn. + +## Installing the hooks + +```sh +kaku-tab install-hooks # --dry-run to see the merge first +``` + +This merges into `~/.claude/settings.json`, keeping every other key and every +hook you wrote yourself, and backs the old file up to +`settings.json.kaku-tab.bak`. Restart any running agent session to pick it up. + +One file covers both CLIs: Devin CLI treats `~/.claude/settings.json` as one of +its user-level hook sources, and `kaku-tab hook` tells the two apart from the +environment (`DEVIN_PROJECT_DIR` vs `CLAUDE_PROJECT_DIR`) rather than from an +argument. The block is the union of both event sets; each CLI ignores the events +it does not have. + +The command is written in shell form with an explicit `exec`: + +```json +{ "type": "command", "command": "exec '/path/to/kaku-tab' hook" } +``` + +Not Claude Code's exec form (`args`), for two reasons. Devin CLI's hook schema +has no `args` field, so an exec-form entry there would invoke the binary with no +arguments — and kaku-tab with no arguments opens the picker. And `exec` replaces +the wrapping shell, which makes the hook's parent the agent itself: that is the +pid the record stores to know when the agent has died. **Re-run +`install-hooks` if you move the binary**; the path is absolute. + +`kaku-tab hook` is inert by contract. It never writes to stdout and never exits +non-zero, because on `PermissionRequest` and the `PreToolUse` family stdout is a +decision channel and a non-zero exit blocks the call — a status reporter that got +either wrong would start silently vetoing your own tool calls. + +## In the picker + +An agent column sits between the status glyph and the window index, on window +rows, pane rows and session headers alike: + +- the **letter** is the agent — `C` claude, `D` devin +- the **colour** is the state — amber `perm`, pink `ask`, red `err`, + green `done`, blue `busy` + +A window row shows the most actionable agent among its panes, and a session +header the most actionable among its windows, so a pane blocked three windows +deep is visible without unfolding anything. Switch to pane mode (^p) +for the exact pane. + +The column is one cell wide and reserved on every row, agent or not: an +indicator drawn only where there is an agent would shift every other column on +those rows and nowhere else. + +^a filters to windows with an agent that wants you — `perm`, `ask`, +`done` or `err`, but not `busy`. Typing an agent's name in the search box works +too; the name is part of each row's match text without being drawn. + +## The status-bar counter + +```tmux +set -g @kaku-tab-agents 'on' +set -g status-interval 5 +``` + +Opt-in, and off by default, because it appends to `status-right` — which most +people compose by hand. The plugin appends after whatever you have already set, +so it lands at the far right. + +It renders nothing at all when no agent is running, which is why it is a plain +`#()` emitting its own `#[fg=...]` styling rather than a themed status module: a +module would still draw its icon and separators around an empty value. + +Refresh has two halves. `status-interval` is only the ceiling on staleness — the +hook calls `refresh-client -S` on every attached client the moment an agent +changes state, so the count moves as it happens rather than up to five seconds +later. + +For a per-window badge in the window list, `kaku-tab agents --refresh` writes +`@kt_agent_win` on each window, usable in `window-status-format` as +`#{@kt_agent_win}`. + +## From the shell + +```sh +kaku-tab agents # which pane, which agent, what state, how long +kaku-tab agents --format tmux # the status segment +kaku-tab agents --refresh # write the per-window rollup +kaku-tab resolve # the full join, agent column included +``` diff --git a/docs/configuration.md b/docs/configuration.md index 56792f9..1cf43ca 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -107,6 +107,32 @@ kaku-tab titles --dry-run > If your config already has `set-tab-title` hooks, leave this `off` or remove > them first. Both fire on the same events and will fight over the title. +## Agents + +A counter on the right of the status bar for the Claude Code and Devin CLI +sessions running in your panes, and an agent column in the picker. See +[agents.md](agents.md). + +| Option | Default | Meaning | +|---|---|---| +| `@kaku-tab-agents` | `off` | append the agent counter to `status-right` | + +Off by default because it appends to `status-right`, which most people compose +by hand. Turning it on is two lines, plus installing the hooks that feed it: + +```tmux +set -g @kaku-tab-agents 'on' +set -g status-interval 5 +``` + +```sh +kaku-tab install-hooks +``` + +`status-interval` is only the ceiling on staleness — the hook calls +`refresh-client -S` the moment an agent changes state, so the count moves as it +happens. + ## Full example ```tmux @@ -117,6 +143,7 @@ set -g @kaku-tab-open-mode 'reuse' set -g @kaku-tab-ignore 'popup,scratch' set -g @kaku-tab-popup-size '90%,85%' set -g @kaku-tab-popup-size-compact '60%,70%' +set -g @kaku-tab-agents 'on' set -g @plugin 'dsaad68/kaku-tab' ``` diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..2e49812 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT + +// Package agent carries the state an AI coding agent (Claude Code or Devin CLI) +// publishes about itself, and the rules for reading it back. +// +// The transport is a tmux *pane* option, @kt_agent, written by `kaku-tab hook` +// from inside the agent's own process tree — which is how the pane is known at +// all: a hook process inherits $TMUX_PANE from the agent that spawned it. +// +// Storing it on the pane rather than in a state directory means staleness is +// structural rather than swept: close the pane and the record goes with it. +// The one leak that survives is an agent killed inside a pane that lives on, +// which is what the PID field is for. +package agent + +import ( + "strconv" + "strings" + "syscall" +) + +// PaneOption is the tmux pane option the state is published in. +// +// NEVER set this with `set-option -w`. tmux pane options inherit from window +// options, so a window-scoped @kt_agent would be read back by every agent-free +// pane in that window and the whole table would report agents. The window-level +// rollup deliberately uses a different name, see WindowOption. +const PaneOption = "@kt_agent" + +// WindowOption is the per-window rollup, written only by `kaku-tab agents +// --refresh` for use in tmux window formats. It is a distinct name precisely +// because setting PaneOption at window scope would corrupt the per-pane read. +const WindowOption = "@kt_agent_win" + +// Agent names. These are the only two values Record.Agent ever holds. +const ( + Claude = "claude" + Devin = "devin" +) + +// State is what an agent is doing right now. +type State string + +const ( + // None is the zero value: no agent, or a record we rejected. + None State = "" + // Busy: working. Not something you owe a response to. + Busy State = "busy" + // Perm: blocked asking permission for a tool call. + Perm State = "perm" + // Ask: blocked on a question it put to you. + Ask State = "ask" + // Done: finished a turn; there is output waiting for you. + Done State = "done" + // Err: the turn ended on an error. + Err State = "err" +) + +// Record is one agent's published state. +type Record struct { + Agent string // Claude or Devin + State State + PID int // the hook process's parent, i.e. the agent itself + At int64 // unix seconds, for display only +} + +// Empty reports whether there is no agent here. +func (r Record) Empty() bool { return r.State == None } + +// Attention reports whether this state is one you owe a response to. Busy is +// the only state that is not: everything else is the agent waiting on you. +func (r Record) Attention() bool { + switch r.State { + case Perm, Ask, Done, Err: + return true + default: + return false + } +} + +// Rank orders records by how much they want you, lowest first. Perm and Ask +// outrank Err because a blocked agent is burning wall-clock right now, where a +// failed turn has already stopped. Used to roll panes up to a window and +// windows up to a session header. +func (r Record) Rank() int { + switch r.State { + case Perm: + return 1 + case Ask: + return 2 + case Err: + return 3 + case Done: + return 4 + case Busy: + return 5 + default: + return 6 + } +} + +// Letter is the single display cell the picker draws for this agent: the agent +// is encoded in the letter and the state in the colour. A letter rather than a +// glyph so the column is unambiguously one cell wide with no nerd font, which +// the table's fixed column budget depends on. +func (r Record) Letter() string { + switch r.Agent { + case Devin: + return "D" + case Claude: + return "C" + default: + return "" + } +} + +// Format renders a record for the pane option. +// +// Colon-separated, where the rest of this tool uses \x1f: every field here is +// from a fixed alphabet — two agent names, five state names, two integers — so +// unlike a tmux window name none of them can contain the separator. +func Format(r Record) string { + if r.Empty() { + return "" + } + return r.Agent + ":" + string(r.State) + ":" + + strconv.Itoa(r.PID) + ":" + strconv.FormatInt(r.At, 10) +} + +// Parse reads a pane option value back. Anything malformed returns the zero +// Record: a value we cannot understand is treated as no agent rather than as an +// error, because it is user-writable tmux state and the picker must not care. +func Parse(s string) Record { + parts := strings.Split(strings.TrimSpace(s), ":") + if len(parts) != 4 { + return Record{} + } + name := parts[0] + if name != Claude && name != Devin { + return Record{} + } + st := State(parts[1]) + switch st { + case Busy, Perm, Ask, Done, Err: + default: + return Record{} + } + pid, err := strconv.Atoi(parts[2]) + if err != nil || pid <= 1 { + return Record{} + } + at, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil || at < 0 { + return Record{} + } + return Record{Agent: name, State: st, PID: pid, At: at} +} + +// Live reports whether the agent process is still running. This is the backstop +// for the one case pane-scoped storage cannot handle on its own: an agent killed +// outright, so no SessionEnd hook ever fires, inside a pane that survives it. +func Live(r Record) bool { + if r.Empty() || r.PID <= 1 { + return false + } + err := syscall.Kill(r.PID, 0) + // nil: ours and alive. EPERM: alive, just not ours to signal. + return err == nil || err == syscall.EPERM +} + +// Best returns the most actionable of a set of records, and whether there was +// one at all. +func Best(rs []Record) Record { + var best Record + for _, r := range rs { + if r.Empty() { + continue + } + if best.Empty() || r.Rank() < best.Rank() { + best = r + } + } + return best +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..654128e --- /dev/null +++ b/internal/agent/agent_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT + +package agent + +import ( + "os" + "testing" +) + +func TestFormatParseRoundTrip(t *testing.T) { + for _, r := range []Record{ + {Agent: Claude, State: Perm, PID: 4242, At: 1787137188}, + {Agent: Devin, State: Busy, PID: 2, At: 0}, + {Agent: Claude, State: Err, PID: 999999, At: 1}, + } { + got := Parse(Format(r)) + if got != r { + t.Errorf("round trip %+v -> %q -> %+v", r, Format(r), got) + } + } +} + +func TestFormatEmpty(t *testing.T) { + if s := Format(Record{}); s != "" { + t.Errorf("empty record formatted as %q, want empty", s) + } +} + +// A pane option is user-writable tmux state, so anything we cannot understand +// must read as "no agent" rather than as a half-populated record. +func TestParseRejectsGarbage(t *testing.T) { + for _, s := range []string{ + "", + "claude", + "claude:perm:1", + "claude:perm:100:1:extra", + "emacs:perm:100:1", // unknown agent + "claude:napping:100:1", // unknown state + "claude:perm:abc:1", // non-numeric pid + "claude:perm:1:1", // pid <= 1 + "claude:perm:100:x", // non-numeric timestamp + "claude:perm:100:-5", // negative timestamp + } { + if got := Parse(s); !got.Empty() { + t.Errorf("Parse(%q) = %+v, want empty", s, got) + } + } +} + +func TestParseTolerantOfWhitespace(t *testing.T) { + if got := Parse(" claude:done:77:5\n"); got.State != Done || got.PID != 77 { + t.Errorf("Parse trimmed = %+v", got) + } +} + +func TestAttention(t *testing.T) { + want := map[State]bool{Perm: true, Ask: true, Done: true, Err: true, Busy: false, None: false} + for st, w := range want { + if got := (Record{Agent: Claude, State: st}).Attention(); got != w { + t.Errorf("Attention(%q) = %v, want %v", st, got, w) + } + } +} + +// Perm and Ask outrank Err: a blocked agent is burning wall-clock right now +// where a failed turn has already stopped. +func TestRankOrder(t *testing.T) { + order := []State{Perm, Ask, Err, Done, Busy, None} + for i := 1; i < len(order); i++ { + prev := Record{Agent: Claude, State: order[i-1]}.Rank() + cur := Record{Agent: Claude, State: order[i]}.Rank() + if prev >= cur { + t.Errorf("rank(%q)=%d not before rank(%q)=%d", order[i-1], prev, order[i], cur) + } + } +} + +func TestBest(t *testing.T) { + rs := []Record{ + {Agent: Claude, State: Busy, PID: 2}, + {Agent: Devin, State: Perm, PID: 3}, + {Agent: Claude, State: Done, PID: 4}, + } + if got := Best(rs); got.State != Perm || got.Agent != Devin { + t.Errorf("Best = %+v, want the devin/perm record", got) + } + if got := Best([]Record{{}, {}}); !got.Empty() { + t.Errorf("Best of empties = %+v, want empty", got) + } + if got := Best(nil); !got.Empty() { + t.Errorf("Best(nil) = %+v, want empty", got) + } +} + +// The picker's column budget reserves exactly one cell for this, so a +// two-character or multi-cell letter would shift every column on agent rows. +func TestLetterIsOneCellOrEmpty(t *testing.T) { + for _, tc := range []struct{ agent, want string }{ + {Claude, "C"}, {Devin, "D"}, {"", ""}, {"cursor", ""}, + } { + if got := (Record{Agent: tc.agent, State: Busy}).Letter(); got != tc.want { + t.Errorf("Letter(%q) = %q, want %q", tc.agent, got, tc.want) + } + } +} + +func TestLiveRejectsUnknownAndDead(t *testing.T) { + if Live(Record{}) { + t.Error("empty record reported live") + } + if Live(Record{Agent: Claude, State: Busy, PID: 0}) { + t.Error("pid 0 reported live") + } + if !Live(Record{Agent: Claude, State: Busy, PID: os.Getpid()}) { + t.Error("our own pid reported dead") + } +} diff --git a/internal/agent/hook.go b/internal/agent/hook.go new file mode 100644 index 0000000..2f66a07 --- /dev/null +++ b/internal/agent/hook.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT + +package agent + +import "encoding/json" + +// Action is what a hook event asks us to do to the pane's record. +type Action int + +const ( + // Ignore: an event we do not track. Most events are this. + Ignore Action = iota + // Set: write State to the pane. + Set + // Clear: the agent is gone; unset the pane option. + Clear +) + +// payload is the subset of the hook JSON we read. Claude Code and Devin CLI +// share this envelope; the fields we use are common to both. +type payload struct { + Event string `json:"hook_event_name"` + NotifyOn string `json:"notification_type"` + // AgentID is present only when the event comes from a subagent or an + // `--agent` run. A subagent finishing is not your turn ending, so any + // payload carrying this is ignored outright — otherwise every Task call + // would flash the pane to "done" mid-turn. + AgentID string `json:"agent_id"` +} + +// Decide maps one hook payload to a pane-record action. +// +// Unknown events return Ignore rather than an error: both CLIs are subscribed +// through one shared hooks block, so each of them routinely delivers events the +// other does not have, and that is normal traffic rather than a fault. +func Decide(b []byte) (Action, State) { + var p payload + if json.Unmarshal(b, &p) != nil { + return Ignore, None + } + if p.AgentID != "" { + return Ignore, None + } + + switch p.Event { + case "SessionEnd": + return Clear, None + + case "SessionStart", "UserPromptSubmit", "PostToolUse", "PostToolBatch": + // The PostTool* events are not just a liveness heartbeat: they are what + // flips a pane out of Perm once you approve a call and the agent resumes. + // Without them an approved pane keeps counting as waiting until the turn + // ends. PreToolUse would do the same job but fires on the agent's hot + // path, so it is deliberately not subscribed. + return Set, Busy + + case "Stop": + return Set, Done + + case "StopFailure": + return Set, Err + + // Devin's permission-decision event. Claude Code reports the same situation + // through Notification/permission_prompt below. + case "PermissionRequest": + return Set, Perm + + case "Notification": + switch p.NotifyOn { + case "permission_prompt": + return Set, Perm + case "elicitation_dialog", "agent_needs_input": + return Set, Ask + case "idle_prompt", "agent_completed": + // "done and waiting for your next prompt" — the same meaning as + // Stop, not a distinct question. + return Set, Done + case "elicitation_complete": + // The form was answered or dismissed; the agent is working again. + return Set, Busy + } + return Ignore, None + } + return Ignore, None +} + +// Detect names the agent that invoked the hook. Both CLIs read hooks from +// ~/.claude/settings.json, so one block serves both and the agent has to be +// told apart from its environment rather than from an argv flag: each sets its +// own project-directory variable. +func Detect(getenv func(string) string) string { + if getenv("DEVIN_PROJECT_DIR") != "" { + return Devin + } + return Claude +} diff --git a/internal/agent/hook_test.go b/internal/agent/hook_test.go new file mode 100644 index 0000000..30863a5 --- /dev/null +++ b/internal/agent/hook_test.go @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT + +package agent + +import "testing" + +func decide(t *testing.T, payload string) (Action, State) { + t.Helper() + return Decide([]byte(payload)) +} + +func TestDecideClaudeEvents(t *testing.T) { + cases := []struct { + payload string + act Action + state State + }{ + {`{"hook_event_name":"SessionStart"}`, Set, Busy}, + {`{"hook_event_name":"UserPromptSubmit"}`, Set, Busy}, + {`{"hook_event_name":"PostToolUse"}`, Set, Busy}, + {`{"hook_event_name":"PostToolBatch"}`, Set, Busy}, + {`{"hook_event_name":"Stop"}`, Set, Done}, + {`{"hook_event_name":"StopFailure"}`, Set, Err}, + {`{"hook_event_name":"SessionEnd"}`, Clear, None}, + {`{"hook_event_name":"Notification","notification_type":"permission_prompt"}`, Set, Perm}, + {`{"hook_event_name":"Notification","notification_type":"elicitation_dialog"}`, Set, Ask}, + {`{"hook_event_name":"Notification","notification_type":"agent_needs_input"}`, Set, Ask}, + {`{"hook_event_name":"Notification","notification_type":"idle_prompt"}`, Set, Done}, + {`{"hook_event_name":"Notification","notification_type":"agent_completed"}`, Set, Done}, + {`{"hook_event_name":"Notification","notification_type":"elicitation_complete"}`, Set, Busy}, + } + for _, tc := range cases { + act, st := decide(t, tc.payload) + if act != tc.act || st != tc.state { + t.Errorf("%s -> (%v,%q), want (%v,%q)", tc.payload, act, st, tc.act, tc.state) + } + } +} + +// Devin CLI reports a pending permission through its own event rather than +// through Claude Code's Notification. +func TestDecideDevinPermissionRequest(t *testing.T) { + if act, st := decide(t, `{"hook_event_name":"PermissionRequest","tool_name":"exec"}`); act != Set || st != Perm { + t.Errorf("PermissionRequest -> (%v,%q), want (Set,perm)", act, st) + } +} + +// Both CLIs read one shared hooks block, so each routinely delivers events the +// other does not have. That is normal traffic, not a fault. +func TestDecideIgnoresUnknown(t *testing.T) { + for _, p := range []string{ + `{"hook_event_name":"PreToolUse"}`, + `{"hook_event_name":"PreCompact"}`, + `{"hook_event_name":"PostCompaction"}`, + `{"hook_event_name":"SubagentStop"}`, + `{"hook_event_name":"Notification","notification_type":"auth_success"}`, + `{"hook_event_name":"Notification"}`, + `{}`, + `not json at all`, + } { + if act, st := decide(t, p); act != Ignore || st != None { + t.Errorf("%s -> (%v,%q), want Ignore", p, act, st) + } + } +} + +// A subagent finishing is not the user's turn ending. Without this guard every +// Task call would flash the pane green mid-turn. +func TestDecideIgnoresSubagentPayloads(t *testing.T) { + for _, p := range []string{ + `{"hook_event_name":"Stop","agent_id":"a1","agent_type":"Explore"}`, + `{"hook_event_name":"SessionEnd","agent_id":"a1"}`, + `{"hook_event_name":"Notification","notification_type":"permission_prompt","agent_id":"a1"}`, + } { + if act, st := decide(t, p); act != Ignore || st != None { + t.Errorf("%s -> (%v,%q), want Ignore", p, act, st) + } + } +} + +func TestDetect(t *testing.T) { + devin := func(k string) string { + if k == "DEVIN_PROJECT_DIR" { + return "/repo" + } + return "" + } + if got := Detect(devin); got != Devin { + t.Errorf("Detect with DEVIN_PROJECT_DIR = %q, want devin", got) + } + claude := func(k string) string { + if k == "CLAUDE_PROJECT_DIR" { + return "/repo" + } + return "" + } + if got := Detect(claude); got != Claude { + t.Errorf("Detect with CLAUDE_PROJECT_DIR = %q, want claude", got) + } + if got := Detect(func(string) string { return "" }); got != Claude { + t.Errorf("Detect with nothing set = %q, want claude", got) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index d4f9fd1..9f3c7b5 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -13,6 +13,8 @@ package model import ( "strings" "unicode" + + "github.com/dsaad68/kaku-tab/internal/agent" ) // DefaultSatelliteSuffix separates a base session name from its satellite index. @@ -80,6 +82,11 @@ type Pane struct { Cmd string Path string Active bool + + // Agent is the AI coding agent running in this pane, published by its own + // hooks into the pane option. Zero value means none. This is why the pane + // is knowable at all: pane_current_command reports "node" for Claude Code. + Agent agent.Record } // Window is a resolved tmux window: everything needed to draw a row and to act @@ -97,6 +104,10 @@ type Window struct { // would focus the right tab while leaving it on the wrong window. ClientSession string + // Agent is the most actionable agent record among this window's panes, so a + // window row can show "something in here wants you" without pane mode. + Agent agent.Record + Panes_ []Pane // populated only in pane mode } diff --git a/internal/resolve/agent_test.go b/internal/resolve/agent_test.go new file mode 100644 index 0000000..e023b39 --- /dev/null +++ b/internal/resolve/agent_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT + +package resolve + +import ( + "testing" + + "github.com/dsaad68/kaku-tab/internal/agent" + "github.com/dsaad68/kaku-tab/internal/model" +) + +// Two windows in one session: @1 holds a working agent beside a blocked one, +// @2 holds nothing. +type agentFixture struct{ fixture } + +func (agentFixture) Windows() ([]model.RawWindow, error) { + return []model.RawWindow{ + {Session: "api", ID: "@1", Index: "1", Name: "work"}, + {Session: "api", ID: "@2", Index: "2", Name: "idle"}, + }, nil +} + +func (agentFixture) Panes() (map[string][]model.Pane, error) { + return map[string][]model.Pane{ + "@1": { + {ID: "%1", Index: "1", Cmd: "node", Agent: agent.Record{Agent: agent.Claude, State: agent.Busy, PID: 10, At: 1}}, + {ID: "%2", Index: "2", Cmd: "node", Agent: agent.Record{Agent: agent.Claude, State: agent.Perm, PID: 11, At: 2}}, + {ID: "%3", Index: "3", Cmd: "zsh"}, + }, + "@2": {{ID: "%4", Index: "1", Cmd: "zsh"}}, + }, nil +} + +// A window's row reports the pane that most wants you, so one pane blocked on a +// permission prompt is visible even when the others are merely working. +func TestWindowRollsUpMostActionableAgent(t *testing.T) { + ws, err := Resolve(agentFixture{}, Options{WithAgents: true}) + if err != nil { + t.Fatal(err) + } + got := map[string]agent.Record{} + for _, w := range ws { + got[w.ID] = w.Agent + } + if got["@1"].State != agent.Perm || got["@1"].PID != 11 { + t.Errorf("@1 agent = %+v, want the blocked pane", got["@1"]) + } + if !got["@2"].Empty() { + t.Errorf("@2 agent = %+v, want empty", got["@2"]) + } +} + +// WithAgents buys the rollup without attaching the panes themselves, so the +// default (non-pane) picker can show an agent column for the price of the one +// list-panes query. +func TestWithAgentsDoesNotAttachPanes(t *testing.T) { + ws, err := Resolve(agentFixture{}, Options{WithAgents: true}) + if err != nil { + t.Fatal(err) + } + for _, w := range ws { + if w.Panes_ != nil { + t.Errorf("%s carried %d panes; WithAgents should only roll up", w.ID, len(w.Panes_)) + } + } +} + +// Without either flag the pane query is skipped entirely, so nothing pays for +// agent awareness it did not ask for. +func TestNoPaneQueryMeansNoAgent(t *testing.T) { + ws, err := Resolve(agentFixture{}, Options{}) + if err != nil { + t.Fatal(err) + } + for _, w := range ws { + if !w.Agent.Empty() { + t.Errorf("%s got an agent without WithAgents: %+v", w.ID, w.Agent) + } + } +} diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 6245246..c2c65c7 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -18,6 +18,7 @@ import ( "sort" "strconv" + "github.com/dsaad68/kaku-tab/internal/agent" "github.com/dsaad68/kaku-tab/internal/model" ) @@ -38,6 +39,10 @@ type Options struct { Scope string // "all" | "session" | "group" SelfSession string // reference session for the session/group scopes WithPanes bool // populate Window.Panes_ + // WithAgents rolls each window's panes up into Window.Agent without + // attaching the panes themselves, so the default (non-pane) picker can show + // an agent column. It costs the same one list-panes query as WithPanes. + WithAgents bool // Ignore lists session names to omit entirely, e.g. a throwaway popup // session bound to a key. Ignore []string @@ -67,7 +72,7 @@ func Resolve(src Source, opt Options) ([]model.Window, error) { return nil, err } var panesByWindow map[string][]model.Pane - if opt.WithPanes { + if opt.WithPanes || opt.WithAgents { if panesByWindow, err = src.Panes(); err != nil { return nil, err } @@ -128,6 +133,10 @@ func Resolve(src Source, opt Options) ([]model.Window, error) { w.Status, w.TabID, w.GUIWin, w.ClientSession = model.AttachedHidden, pl.tab, pl.gui, pl.clientSession } + // The rollup runs whenever panes were fetched, for either reason: a + // window row has to be able to say "something in here wants you" + // without the user first switching to pane mode. + w.Agent = bestAgent(panesByWindow[rw.ID]) if opt.WithPanes { w.Panes_ = panesByWindow[rw.ID] } @@ -143,6 +152,17 @@ func Resolve(src Source, opt Options) ([]model.Window, error) { return out, nil } +// bestAgent picks the most actionable agent among a window's panes, so one pane +// blocked on a permission prompt surfaces even when three others are merely +// working. +func bestAgent(panes []model.Pane) agent.Record { + rs := make([]agent.Record, 0, len(panes)) + for _, p := range panes { + rs = append(rs, p.Agent) + } + return agent.Best(rs) +} + func inScope(rw model.RawWindow, opt Options) bool { switch opt.Scope { case "session": diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 0a6eaad..5c7493f 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" + "github.com/dsaad68/kaku-tab/internal/agent" "github.com/dsaad68/kaku-tab/internal/model" ) @@ -103,10 +104,15 @@ func Windows() ([]model.RawWindow, error) { } // Panes returns every pane in the server, keyed by tmux window id. +// +// The agent record rides along in this one query rather than in a second pass: +// @kt_agent is a pane option, so `list-panes` can report it as just another +// format field, and agent awareness costs no extra process. func Panes() (map[string][]model.Pane, error) { rows, err := query("list-panes", "-a", "-F", f( "#{window_id}", "#{pane_id}", "#{pane_index}", - "#{pane_current_command}", "#{pane_current_path}", "#{pane_active}")) + "#{pane_current_command}", "#{pane_current_path}", "#{pane_active}", + "#{"+agent.PaneOption+"}")) if err != nil { return nil, err } @@ -119,6 +125,7 @@ func Panes() (map[string][]model.Pane, error) { m[w] = append(m[w], model.Pane{ ID: at(r, 1), Index: at(r, 2), Cmd: at(r, 3), Path: at(r, 4), Active: boolAt(r, 5), + Agent: liveAgent(at(r, 6)), }) } return m, nil @@ -205,6 +212,79 @@ func CapturePane(target string, historyLines int) (string, error) { return string(out), nil } +// liveAgent parses a pane's agent record and drops it if the agent process is +// gone. Pane-scoped storage self-cleans when the pane closes, but an agent +// killed outright inside a surviving pane leaves a record no SessionEnd hook +// will ever clear, so the display must not believe it. `kaku-tab agents` is +// what actually removes it from tmux; see PaneAgents. +func liveAgent(v string) agent.Record { + r := agent.Parse(v) + if !agent.Live(r) { + return agent.Record{} + } + return r +} + +// PaneAgents returns every pane's agent record verbatim, keyed by pane id, with +// no liveness filtering — the sweeper needs to see dead records in order to +// clear them, which is exactly what Panes() hides. +func PaneAgents() (map[string]agent.Record, error) { + rows, err := query("list-panes", "-a", "-F", f("#{pane_id}", "#{"+agent.PaneOption+"}")) + if err != nil { + return nil, err + } + m := make(map[string]agent.Record, len(rows)) + for _, r := range rows { + if id := at(r, 0); id != "" { + m[id] = agent.Parse(at(r, 1)) + } + } + return m, nil +} + +// SetPaneOption writes a pane-scoped option. +// +// -p, always. tmux pane options inherit from window options, so writing +// @kt_agent at window scope would have every agent-free pane in that window +// read back an agent that is not there. +func SetPaneOption(pane, name, value string) error { + _, err := Run("set-option", "-p", "-t", pane, name, value) + return err +} + +// UnsetPaneOption clears a pane-scoped option. +func UnsetPaneOption(pane, name string) error { + _, err := Run("set-option", "-p", "-u", "-t", pane, name) + return err +} + +// SetWindowOption writes a window-scoped option. Session-qualified like every +// other target here. +func SetWindowOption(session, windowID, name, value string) error { + _, err := Run("set-option", "-w", "-t", Target(session, windowID), name, value) + return err +} + +// UnsetWindowOption clears a window-scoped option. +func UnsetWindowOption(session, windowID, name string) error { + _, err := Run("set-option", "-w", "-u", "-t", Target(session, windowID), name) + return err +} + +// RefreshStatus redraws the status line on every attached client, which is what +// makes the agent counter update the moment a hook fires rather than at the next +// status-interval tick. Every client is named explicitly: a bare refresh-client +// picks one, and this setup routinely has a client per terminal tab. +func RefreshStatus() { + cs, err := Clients() + if err != nil { + return + } + for _, c := range cs { + _, _ = Run("refresh-client", "-S", "-t", c.TTY) + } +} + // Option reads a global tmux option, returning def when unset. func Option(name, def string) string { out, err := Run("show-option", "-gqv", name) diff --git a/internal/ui/agentcol_test.go b/internal/ui/agentcol_test.go new file mode 100644 index 0000000..18e2633 --- /dev/null +++ b/internal/ui/agentcol_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT + +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/dsaad68/kaku-tab/internal/agent" + "github.com/dsaad68/kaku-tab/internal/model" +) + +func agentSample() []model.Window { + perm := agent.Record{Agent: agent.Claude, State: agent.Perm, PID: 100, At: 1} + busy := agent.Record{Agent: agent.Devin, State: agent.Busy, PID: 200, At: 1} + return []model.Window{ + {RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1", Name: "claude", Panes: 2, Path: "/home/u/api"}, + Status: model.AttachedHidden, TabID: "8", ClientSession: "api", Agent: perm, + Panes_: []model.Pane{ + {ID: "%1", Index: "1", Cmd: "node", Path: "/home/u/api", Active: true, Agent: perm}, + {ID: "%2", Index: "2", Cmd: "zsh", Path: "/home/u/api"}, + }}, + {RawWindow: model.RawWindow{Session: "api", ID: "@2", Index: "2", Name: "just", Panes: 1, Path: "/home/u/api/cmd"}, + Status: model.Visible, TabID: "8", ClientSession: "api", Agent: busy, + Panes_: []model.Pane{{ID: "%3", Index: "1", Cmd: "devin", Path: "/home/u", Active: true, Agent: busy}}}, + {RawWindow: model.RawWindow{Session: "scratch", ID: "@3", Index: "1", Name: "", Panes: 1, Path: "/home/u/td"}, + Status: model.Detached, + Panes_: []model.Pane{{ID: "%4", Index: "1", Cmd: "zsh", Path: "/home/u", Active: true}}}, + } +} + +// The agent column is reserved on every row, agent or not. An indicator drawn +// only where there is an agent would shift every other column on those rows and +// nowhere else — the same class of bug the badge column was built to avoid. +func TestAgentCellIsAlwaysOneCell(t *testing.T) { + for _, r := range []agent.Record{ + {}, + {Agent: agent.Claude, State: agent.Perm, PID: 1}, + {Agent: agent.Claude, State: agent.Ask, PID: 1}, + {Agent: agent.Claude, State: agent.Done, PID: 1}, + {Agent: agent.Claude, State: agent.Err, PID: 1}, + {Agent: agent.Devin, State: agent.Busy, PID: 1}, + } { + if w := ansi.StringWidth(agentCell(r)); w != 1 { + t.Errorf("agentCell(%+v) is %d cells, want 1", r, w) + } + } +} + +// The column budget in renderRow is an exact sum of every fixed cell. Getting it +// wrong pushes the row past the list width and truncateANSI eats the badge — +// the rightmost column, and the whole point of the tool. Pinned at a narrow +// width, in both list modes, where the budget is tightest. +func TestAgentColumnKeepsRowsInBudget(t *testing.T) { + for _, paneMode := range []bool{false, true} { + for _, w := range []int{60, 80, 150} { + m := New(agentSample(), Options{Tree: true, PaneMode: paneMode, SelfTab: "8"}) + m.width, m.height = w, 30 + m.refilter() + for i, r := range m.rows { + line := m.renderRow(r, i == 0) + if got := ansi.StringWidth(line); got > m.rowWidth() { + t.Errorf("panes=%v width=%d row %d: %d cells > rowWidth %d", + paneMode, w, i, got, m.rowWidth()) + } + if r.kind != kindHeader && !strings.Contains(ansi.Strip(line), "⟦") { + t.Errorf("panes=%v width=%d row %d: badge truncated away: %q", + paneMode, w, i, ansi.Strip(line)) + } + } + } + } +} + +func TestAgentLetterRendered(t *testing.T) { + m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + var claude, devin bool + for _, r := range m.rows { + switch ansi.Strip(agentCell(r.agent)) { + case "C": + claude = true + case "D": + devin = true + } + } + if !claude || !devin { + t.Errorf("expected both agent letters in the table (claude=%v devin=%v)", claude, devin) + } +} + +// The filter keeps what is waiting on you, which is not the same as what has an +// agent: a window whose agent is merely working is not waiting. +func TestAgentsOnlyKeepsOnlyAttention(t *testing.T) { + m := New(agentSample(), Options{Tree: true, AgentsOnly: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + for _, r := range m.rows { + if r.kind == kindWindow && r.win.ID != "@1" { + t.Errorf("window %s survived the agents filter with agent %+v", r.win.ID, r.win.Agent) + } + } + if len(m.rows) == 0 { + t.Fatal("agents filter emptied a list that has a waiting agent") + } +} + +// A session header has to say "something in here wants you" without the user +// first opening the group. +func TestHeaderInheritsMostActionableAgent(t *testing.T) { + m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + for _, r := range m.rows { + if r.kind == kindHeader && r.group == "api" && r.agent.State != agent.Perm { + t.Errorf("api header agent = %+v, want the blocked one", r.agent) + } + } +} + +// Typing an agent name should narrow to its windows even though the name is +// never drawn as text. +func TestAgentNameIsSearchable(t *testing.T) { + m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + for _, r := range m.rows { + if r.kind == kindWindow && r.win.ID == "@2" && !strings.Contains(r.search, "devin") { + t.Errorf("window @2 search text %q lacks the agent name", r.search) + } + } +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 0e86861..48152d8 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -20,6 +20,7 @@ var ( colName = lipgloss.AdaptiveColor{Light: "#3B5BDB", Dark: "#7AA2F7"} colGroup = lipgloss.AdaptiveColor{Light: "#6F42C1", Dark: "#BB9AF7"} colPink = lipgloss.AdaptiveColor{Light: "#BF3989", Dark: "#F7768E"} + colRed = lipgloss.AdaptiveColor{Light: "#B42318", Dark: "#FF7A93"} colSelBg = lipgloss.AdaptiveColor{Light: "#DDE7F5", Dark: "#283457"} colBorder = lipgloss.AdaptiveColor{Light: "#B8BFC7", Dark: "#3B4261"} ) @@ -44,6 +45,17 @@ var ( cText = lipgloss.NewStyle().Foreground(colText) ) +// The agent column encodes the agent in a letter and its state in the colour, +// so one cell carries both. Amber and pink are the two "wants you now" states; +// blue is merely working and must stay quiet enough to ignore. +var ( + cAgentPerm = lipgloss.NewStyle().Foreground(colAmber).Bold(true) + cAgentAsk = lipgloss.NewStyle().Foreground(colPink).Bold(true) + cAgentErr = lipgloss.NewStyle().Foreground(colRed).Bold(true) + cAgentDone = lipgloss.NewStyle().Foreground(colGreen).Bold(true) + cAgentBusy = lipgloss.NewStyle().Foreground(colAccent) +) + // frame draws a rounded box with a title set into the top border. // // lipgloss v1.1.0 has no border-label API, and overlaying text onto a diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6267ec1..8f4ac1e 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -27,6 +27,7 @@ import ( "github.com/sahilm/fuzzy" "github.com/dsaad68/kaku-tab/internal/action" + "github.com/dsaad68/kaku-tab/internal/agent" "github.com/dsaad68/kaku-tab/internal/kaku" "github.com/dsaad68/kaku-tab/internal/model" "github.com/dsaad68/kaku-tab/internal/mru" @@ -61,6 +62,7 @@ type row struct { count int // header only status model.Status tabID string + agent agent.Record last bool // last child of its group -> └ } @@ -87,6 +89,7 @@ type State struct { Preview bool `json:"preview"` PaneMode bool `json:"pane_mode"` HideDetached bool `json:"hide_detached"` + AgentsOnly bool `json:"agents_only"` Collapse map[string]bool `json:"collapse"` } @@ -106,6 +109,12 @@ type Options struct { // HideDetached drops sessions with no terminal tab from the list. HideDetached bool + + // AgentsOnly narrows the list to windows holding an agent that wants you — + // blocked on permission, asking a question, finished, or failed. A window + // whose agent is merely working is not one of them: the point of the filter + // is "what is waiting on me", not "where are the agents". + AgentsOnly bool } type previewMsg struct { @@ -170,7 +179,8 @@ func (m *Model) State() State { return State{ Query: m.query, Cursor: m.cursor, Offset: m.offset, Preview: m.opt.Preview, PaneMode: m.opt.PaneMode, - HideDetached: m.opt.HideDetached, Collapse: m.collapse, + HideDetached: m.opt.HideDetached, AgentsOnly: m.opt.AgentsOnly, + Collapse: m.collapse, } } @@ -192,6 +202,18 @@ func (m *Model) build() { } } } + // Applied after HideDetached, not instead of it: a detached session is + // exactly where an agent is most likely to have finished unnoticed, so this + // filter must be able to surface one. + if m.opt.AgentsOnly { + kept := make([]model.Window, 0, len(windows)) + for _, w := range windows { + if w.Agent.Attention() { + kept = append(kept, w) + } + } + windows = kept + } groups := map[string][]model.Window{} var order []string @@ -260,6 +282,7 @@ func (m *Model) build() { // Header inherits the best status among its children, so a session // whose tab is showing something reads as attached at a glance. hstat, htab := model.Detached, "" + hagents := make([]agent.Record, 0, len(ws)) n := 0 for _, w := range ws { if m.opt.PaneMode { @@ -267,6 +290,7 @@ func (m *Model) build() { } else { n++ } + hagents = append(hagents, w.Agent) if w.Status > hstat { hstat, htab = w.Status, w.TabID } else if htab == "" && w.TabID != "" { @@ -276,7 +300,7 @@ func (m *Model) build() { if m.opt.Tree { m.rows = append(m.rows, row{ kind: kindHeader, group: g, search: g, count: n, - status: hstat, tabID: htab, + status: hstat, tabID: htab, agent: agent.Best(hagents), win: pickHeaderWindow(ws, m.opt.Sort == SortMRU), }) } @@ -285,8 +309,8 @@ func (m *Model) build() { for j, p := range w.Panes_ { m.rows = append(m.rows, row{ kind: kindPane, group: g, win: w, pane: p, - search: strings.Join([]string{w.Session, w.Index, p.Index, p.Cmd, p.Path}, " "), - status: w.Status, tabID: w.TabID, + search: strings.Join([]string{w.Session, w.Index, p.Index, p.Cmd, p.Path, p.Agent.Agent}, " "), + status: w.Status, tabID: w.TabID, agent: p.Agent, last: j == len(w.Panes_)-1, }) } @@ -294,8 +318,10 @@ func (m *Model) build() { } m.rows = append(m.rows, row{ kind: kindWindow, group: g, win: w, - search: strings.Join([]string{w.Session, w.Index, w.Name, w.Path}, " "), - status: w.Status, tabID: w.TabID, + // The agent name joins the search text so typing "claude" + // narrows to agent windows; it is never rendered as text. + search: strings.Join([]string{w.Session, w.Index, w.Name, w.Path, w.Agent.Agent}, " "), + status: w.Status, tabID: w.TabID, agent: w.Agent, last: i == len(ws)-1, }) } @@ -440,10 +466,14 @@ func (m *Model) helpPairs() [][2]string { if m.opt.HideDetached { detached = "show detached" } + agents := "waiting agents" + if m.opt.AgentsOnly { + agents = "all windows" + } pairs := [][2]string{ {"enter", "switch"}, {"^/", preview}, {"^t", "new tab"}, {"tab", "fold"}, - {"^p", "panes"}, {"^e", detached}, {"^r", "rename"}, {"^x", "kill"}, - {"^d", "detach"}, {"^u", "clear"}, + {"^p", "panes"}, {"^e", detached}, {"^a", agents}, {"^r", "rename"}, + {"^x", "kill"}, {"^d", "detach"}, {"^u", "clear"}, } if m.opt.Tree { pairs[3] = [2]string{"tab", "fold (S-tab all)"} @@ -694,6 +724,24 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.focusRow(keepID, keepHeader) return m, m.previewCmd() + case "ctrl+a": + keepID, keepHeader := "", false + if r, ok := m.current(); ok { + keepID, keepHeader = r.win.ID, r.kind == kindHeader + } + m.opt.AgentsOnly = !m.opt.AgentsOnly + m.build() + m.focusRow(keepID, keepHeader) + // Cleared on the way out too: left standing, the message would still be + // in the footer after the filter was toggled back off and the full list + // restored, which reads as a warning about the list you are looking at. + m.status = "" + if m.opt.AgentsOnly && len(m.view) == 0 { + // An empty list after a filter reads as a broken picker. Say why. + m.status = "no agent is waiting on you" + } + return m, m.previewCmd() + case "ctrl+x": if r, ok := m.current(); ok && r.kind != kindHeader { if err := action.Kill(r.win, m.opt.Suffix); err != nil { @@ -847,6 +895,29 @@ func (m *Model) badge(st model.Status, tab string, isHeader bool) string { } } +// agentCell renders the agent column: the agent's letter coloured by its state, +// or a blank. Exactly one display cell either way — the column is reserved on +// every row, so an indicator that appeared only on agent rows would shift every +// other column on those rows and nowhere else. +func agentCell(r agent.Record) string { + letter := r.Letter() + if letter == "" { + return " " + } + switch r.State { + case agent.Perm: + return cAgentPerm.Render(letter) + case agent.Ask: + return cAgentAsk.Render(letter) + case agent.Err: + return cAgentErr.Render(letter) + case agent.Done: + return cAgentDone.Render(letter) + default: + return cAgentBusy.Render(letter) + } +} + func glyph(st model.Status) string { switch st { case model.Visible: @@ -899,7 +970,8 @@ func (m *Model) renderRow(r row, selected bool) string { unit = strings.TrimSuffix(unit, "s") } line := cursor + cGroup.Render(arrow+" "+r.group) + " " + - cDim.Render(fmt.Sprintf("%d %s", r.count, unit)) + " " + m.badge(r.status, r.tabID, true) + cDim.Render(fmt.Sprintf("%d %s", r.count, unit)) + " " + + agentCell(r.agent) + " " + m.badge(r.status, r.tabID, true) return truncateANSI(line, lw) } @@ -926,9 +998,9 @@ func (m *Model) renderRow(r row, selected bool) string { badgeCol := strings.Repeat(" ", maxInt(0, m.badgeW-ansi.StringWidth(badge))) + badge fixed := cursorCells + ansi.StringWidth(indent) + m.badgeW + rightMargin if r.kind == kindPane { - fixed += 1 + 1 + 5 // glyph, active marker, five single spaces + fixed += 1 + 1 + 1 + 6 // glyph, active marker, agent, six single spaces } else { - fixed += 1 + 4 + 2 + 5 // glyph, "NNp ", flags, five single spaces + fixed += 1 + 1 + 4 + 2 + 6 // glyph, agent, "NNp ", flags, six single spaces } avail := lw - fixed if avail < 20 { @@ -960,6 +1032,7 @@ func (m *Model) renderRow(r row, selected bool) string { marker = cFlag.Render("*") } return truncateANSI(cursor+cDim.Render(indent)+glyph(r.status)+" "+marker+" "+ + agentCell(r.agent)+" "+ pad(label, labelW)+" "+cName.Render(pad(name, nameW))+" "+ cDim.Render(pad(padLeft(tilde(r.pane.Path), pathW), pathW))+" "+ badgeCol, lw) @@ -979,6 +1052,7 @@ func (m *Model) renderRow(r row, selected bool) string { } line := cursor + cDim.Render(indent) + glyph(r.status) + " " + + agentCell(r.agent) + " " + pad(label, labelW) + " " + cName.Render(pad(name, nameW)) + " " + fmt.Sprintf("%2dp ", r.win.Panes) + cFlag.Render(pad(flags, 2)) + " " + diff --git a/kaku-tab.tmux b/kaku-tab.tmux index 867b350..e6a4b13 100755 --- a/kaku-tab.tmux +++ b/kaku-tab.tmux @@ -62,6 +62,27 @@ if [ "$TITLES" = "on" ]; then "$BIN" titles >/dev/null 2>&1 & fi +# Agent counter on the right of the status bar. Opt-in: it appends to +# status-right, which most people compose by hand. +# +# The command prints nothing when no agent is running, so the status bar shows no +# stray icon or separator the rest of the time — which is why this is a plain +# #() emitting its own #[fg=...] styling rather than a themed status module. +# +# Refresh has two halves. status-interval is only the ceiling on staleness; the +# `hook` subcommand calls refresh-client -S on every attached client the moment +# an agent changes state, which is what makes the counter feel immediate. Set +# `status-interval` to 5 or so for the backstop. +# +# The guard makes a config reload idempotent: appending unconditionally would +# stack a second copy of the segment every time this file is sourced. +if [ "$(opt @kaku-tab-agents 'off')" = "on" ]; then + case "$(tmux show-option -gv status-right 2>/dev/null)" in + *"agents --format tmux"*) ;; + *) tmux set-option -ag status-right "#($BIN agents --format tmux)" ;; + esac +fi + # No hook is registered for pruning satellite sessions. `set-hook -ga` appends a # fresh copy on every config reload and tmux offers no way to remove one by # content, so it would accumulate duplicates. The picker prunes on every From 743bad4dd0bc8343422c4fbcf3204a2ec3929442 Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Wed, 19 Aug 2026 13:34:22 +0200 Subject: [PATCH 03/14] Draw the agent counter as two themed pills, and split the picker column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status segment was one unstyled count sitting next to a row of catppuccin modules, and it did not say what it was counting. Now it is two modules in catppuccin's own shape — rounded separator, icon on its own colour, value on the shared module background — reading how many agents are open and how many of them want you. The shape is built here rather than delegated to a catppuccin module because a real module always paints its icon and separators, including around an empty value, and this segment is empty most of the time. The palette is resolved from the live @thm_* options in a single display-message pass, so it sits flush against the modules beside it and still renders without catppuccin loaded. The second pill stays drawn at zero, greyed rather than hidden: a count that vanished would shift the first pill sideways every time an agent finished, which is exactly when you are looking at it. In the picker the column is now two cells — one glyph for which agent, one for what it wants — where before a single letter carried the agent and left the state to colour alone. Identity takes mauve and cyan, which no state uses, so the halves never read as one gradient, and busy is the only muted state: it is the one thing here you do not owe a response to. Counting and rendering move to internal/agent as pure functions over records and a plain-string theme. They were in cmd/kaku-tab, which .testcoverage.yml excludes as argument parsing — so the pill shape and the zero-state rule had no test. They do now, alongside a test pinning every glyph to one display cell: a double-width one would silently break the table's column budget. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 12 +++-- cmd/kaku-tab/agents.go | 76 ++++++++++++--------------- docs/agents.md | 64 ++++++++++++++++++----- docs/configuration.md | 10 ++-- internal/agent/agent.go | 15 ------ internal/agent/agent_test.go | 12 ----- internal/agent/status.go | 98 +++++++++++++++++++++++++++++++++++ internal/agent/status_test.go | 89 +++++++++++++++++++++++++++++++ internal/tmux/tmux.go | 27 ++++++++++ internal/ui/agentcol_test.go | 53 +++++++++++++------ internal/ui/theme.go | 26 ++++++++-- internal/ui/ui.go | 36 ++++++++----- 12 files changed, 393 insertions(+), 125 deletions(-) create mode 100644 internal/agent/status.go create mode 100644 internal/agent/status_test.go diff --git a/README.md b/README.md index fb14a58..1acc5a6 100644 --- a/README.md +++ b/README.md @@ -183,10 +183,14 @@ were, alt-tab style. ## Agents -Claude Code and Devin CLI sessions show up as a column in the picker — which -pane, which agent, and whether it is working, blocked on a permission prompt, -asking you something, finished, or failed — plus a counter on the right of the -tmux status bar. +Claude Code and Devin CLI sessions show up as a column in the picker — one glyph +for which agent, one for what it wants: working, blocked on a permission prompt, +asking you something, finished, or failed. The tmux status bar gets two counters +at the far right: how many agents are open, and how many of them want you. + +``` + 󰚩 3 󰂚 1 +``` ```sh kaku-tab install-hooks # one block in ~/.claude/settings.json, both CLIs diff --git a/cmd/kaku-tab/agents.go b/cmd/kaku-tab/agents.go index 244eccf..c93fb16 100644 --- a/cmd/kaku-tab/agents.go +++ b/cmd/kaku-tab/agents.go @@ -70,25 +70,15 @@ func hook() error { return nil } -// counts is the tally the status segment renders. -type counts struct { - waiting int // perm + ask: blocked on you right now - done int - failed int - working int -} - -func (c counts) empty() bool { return c.waiting+c.done+c.failed+c.working == 0 } - // sweep reads every pane's record, clearing any whose agent process is gone. // This is the one case pane-scoped storage cannot self-heal: an agent killed // outright never fires SessionEnd, and its pane outlives it. -func sweep() (counts, error) { +func sweep() (agent.Counts, error) { byPane, err := tmux.PaneAgents() if err != nil { - return counts{}, err + return agent.Counts{}, err } - var c counts + live := make([]agent.Record, 0, len(byPane)) for pane, r := range byPane { if r.Empty() { continue @@ -97,40 +87,42 @@ func sweep() (counts, error) { _ = tmux.UnsetPaneOption(pane, agent.PaneOption) continue } - switch r.State { - case agent.Perm, agent.Ask: - c.waiting++ - case agent.Done: - c.done++ - case agent.Err: - c.failed++ - default: - c.working++ - } + live = append(live, r) } - return c, nil + return agent.Tally(live), nil } -// segment renders the status-right counter. +// loadTheme resolves the status-bar palette from tmux in one pass. // -// It carries its own styling and returns "" when nothing is running, so the -// status bar shows no empty brackets or stray separator when there is no agent -// — which is most of the time, and the reason it is not a catppuccin module. -func segment(c counts) string { - if c.empty() { - return "" - } - var parts []string - add := func(n int, colour, icon string) { - if n > 0 { - parts = append(parts, fmt.Sprintf("#[fg=%s]%s %d#[default]", colour, icon, n)) +// Every value falls back twice: to the catppuccin palette when it is loaded, +// then to a plain terminal colour name when it is not — so the segment is +// themed without depending on a theme being installed. +func loadTheme() agent.Theme { + o := tmux.Options( + "@catppuccin_status_left_separator", "@thm_crust", "@thm_fg", + "@catppuccin_status_module_text_bg", "@thm_mauve", "@thm_peach", + "@thm_surface_2", "@kaku-tab-agent-color", "@kaku-tab-notify-color", + "@kaku-tab-agent-icon", "@kaku-tab-notify-icon", + ) + pick := func(def string, keys ...string) string { + for _, k := range keys { + if v := o[k]; v != "" { + return v + } } + return def + } + return agent.Theme{ + Sep: o["@catppuccin_status_left_separator"], + IconFG: pick("black", "@thm_crust"), + TextFG: pick("white", "@thm_fg"), + TextBG: pick("brightblack", "@catppuccin_status_module_text_bg"), + AgentBG: pick("magenta", "@kaku-tab-agent-color", "@thm_mauve"), + NotifyBG: pick("yellow", "@kaku-tab-notify-color", "@thm_peach"), + IdleBG: pick("brightblack", "@thm_surface_2"), + AgentIco: pick(agent.IconAgents, "@kaku-tab-agent-icon"), + NotifIco: pick(agent.IconNotify, "@kaku-tab-notify-icon"), } - add(c.waiting, "yellow", "") // bell: wants a decision from you - add(c.failed, "red", "") - add(c.done, "green", "") - add(c.working, "blue", "") - return strings.Join(parts, " ") } // refreshWindows writes the per-window rollup for use in tmux window formats. @@ -188,7 +180,7 @@ func agents(args []string) error { } } if format == "tmux" { - if s := segment(c); s != "" { + if s := loadTheme().Segment(c); s != "" { fmt.Println(s) } return nil diff --git a/docs/agents.md b/docs/agents.md index 7b919fd..460e7c9 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -103,21 +103,32 @@ either wrong would start silently vetoing your own tool calls. ## In the picker -An agent column sits between the status glyph and the window index, on window -rows, pane rows and session headers alike: - -- the **letter** is the agent — `C` claude, `D` devin -- the **colour** is the state — amber `perm`, pink `ask`, red `err`, - green `done`, blue `busy` +Two cells sit between the status glyph and the window index. The first says +*which* agent, the second says what it wants: + +| | Agent | | State | +|---|---|---|---| +| `` | Claude Code | `` | blocked asking permission | +| `󰚩` | Devin CLI | `` | blocked on a question | +| | | `` | finished a turn | +| | | `` | the turn failed | +| | | `󰔟` | working | + +Splitting identity from state means neither has to be inferred from the other's +colour. Identity takes mauve and cyan, which no state uses, so the two halves +never read as one gradient. `busy` is the only muted state, deliberately: it is +the one thing here you do not owe a response to, and a column that shouted on +every working agent is one you would learn to ignore. A window row shows the most actionable agent among its panes, and a session header the most actionable among its windows, so a pane blocked three windows deep is visible without unfolding anything. Switch to pane mode (^p) for the exact pane. -The column is one cell wide and reserved on every row, agent or not: an +The column is a fixed two cells and reserved on every row, agent or not: an indicator drawn only where there is an agent would shift every other column on -those rows and nowhere else. +those rows and nowhere else. Every glyph is pinned to one display cell by a +test — a double-width one would break the table's column budget. ^a filters to windows with an agent that wants you — `perm`, `ask`, `done` or `err`, but not `busy`. Typing an agent's name in the search box works @@ -125,18 +136,43 @@ too; the name is part of each row's match text without being drawn. ## The status-bar counter +Two pills at the far right of `status-right`: + +``` + 󰚩 3 󰂚 1 +``` + +- **󰚩 how many agents are open** — Claude Code and Devin CLI together +- **󰂚 how many of them want you** — waiting, finished, or failed + +The second pill stays drawn at zero, greyed rather than hidden. A count that +vanished would shift the first pill sideways every time an agent finished, which +is exactly when you are looking at it. The whole segment disappears when no +agent is running at all. + ```tmux set -g @kaku-tab-agents 'on' -set -g status-interval 5 +set -g status-interval 5 ``` Opt-in, and off by default, because it appends to `status-right` — which most people compose by hand. The plugin appends after whatever you have already set, -so it lands at the far right. - -It renders nothing at all when no agent is running, which is why it is a plain -`#()` emitting its own `#[fg=...]` styling rather than a themed status module: a -module would still draw its icon and separators around an empty value. +so it lands last. + +It draws catppuccin's own module shape — rounded separator, icon on its own +colour, value on the shared module background — resolved from the live `@thm_*` +palette, so it sits flush against the modules beside it. Without catppuccin +loaded it falls back to plain terminal colour names and still renders. It is +*not* a catppuccin module, because a real module always paints its icon and +separators, including around an empty value — which is what this is most of the +time. + +| Option | Default | Meaning | +|---|---|---| +| `@kaku-tab-agent-color` | `@thm_mauve` | pill colour for the open count | +| `@kaku-tab-notify-color` | `@thm_peach` | pill colour when something wants you | +| `@kaku-tab-agent-icon` | `󰚩` | icon for the open count | +| `@kaku-tab-notify-icon` | `󰂚` | icon for the notification count | Refresh has two halves. `status-interval` is only the ceiling on staleness — the hook calls `refresh-client -S` on every attached client the moment an agent diff --git a/docs/configuration.md b/docs/configuration.md index 1cf43ca..d3dbea8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,13 +109,17 @@ kaku-tab titles --dry-run ## Agents -A counter on the right of the status bar for the Claude Code and Devin CLI -sessions running in your panes, and an agent column in the picker. See -[agents.md](agents.md). +Two counters at the far right of the status bar — how many Claude Code and Devin +CLI sessions are open, and how many of them are waiting on you — plus an agent +column in the picker. See [agents.md](agents.md). | Option | Default | Meaning | |---|---|---| | `@kaku-tab-agents` | `off` | append the agent counter to `status-right` | +| `@kaku-tab-agent-color` | `@thm_mauve` | pill colour for the "agents open" count | +| `@kaku-tab-notify-color` | `@thm_peach` | pill colour when something wants you | +| `@kaku-tab-agent-icon` | 󰚩 | icon for the "agents open" count | +| `@kaku-tab-notify-icon` | 󰂚 | icon for the notification count | Off by default because it appends to `status-right`, which most people compose by hand. Turning it on is two lines, plus installing the hooks that feed it: diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 2e49812..7e7db99 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -99,21 +99,6 @@ func (r Record) Rank() int { } } -// Letter is the single display cell the picker draws for this agent: the agent -// is encoded in the letter and the state in the colour. A letter rather than a -// glyph so the column is unambiguously one cell wide with no nerd font, which -// the table's fixed column budget depends on. -func (r Record) Letter() string { - switch r.Agent { - case Devin: - return "D" - case Claude: - return "C" - default: - return "" - } -} - // Format renders a record for the pane option. // // Colon-separated, where the rest of this tool uses \x1f: every field here is diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 654128e..8b655a9 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -92,18 +92,6 @@ func TestBest(t *testing.T) { } } -// The picker's column budget reserves exactly one cell for this, so a -// two-character or multi-cell letter would shift every column on agent rows. -func TestLetterIsOneCellOrEmpty(t *testing.T) { - for _, tc := range []struct{ agent, want string }{ - {Claude, "C"}, {Devin, "D"}, {"", ""}, {"cursor", ""}, - } { - if got := (Record{Agent: tc.agent, State: Busy}).Letter(); got != tc.want { - t.Errorf("Letter(%q) = %q, want %q", tc.agent, got, tc.want) - } - } -} - func TestLiveRejectsUnknownAndDead(t *testing.T) { if Live(Record{}) { t.Error("empty record reported live") diff --git a/internal/agent/status.go b/internal/agent/status.go new file mode 100644 index 0000000..3d89069 --- /dev/null +++ b/internal/agent/status.go @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: MIT + +package agent + +import "strconv" + +// Counts is the tally the tmux status segment renders. +type Counts struct { + Waiting int // Perm + Ask: blocked on you right now + Done int + Failed int + Working int +} + +// Open is how many agent sessions are running at all. +func (c Counts) Open() int { return c.Waiting + c.Done + c.Failed + c.Working } + +// Attention is how many of them want something from you. Working is the only +// state that does not. +func (c Counts) Attention() int { return c.Waiting + c.Done + c.Failed } + +// Tally buckets a set of records. +func Tally(rs []Record) Counts { + var c Counts + for _, r := range rs { + switch r.State { + case Perm, Ask: + c.Waiting++ + case Done: + c.Done++ + case Err: + c.Failed++ + case Busy: + c.Working++ + } + } + return c +} + +// Default status-bar icons. Nerd font, matching the register the rest of a +// themed tmux status bar is already in. +const ( + IconAgents = "\U000f06a9" // nf-md-robot: how many agents are open + IconNotify = "\U000f009a" // nf-md-bell: how many of them want you +) + +// Theme is the catppuccin status-module shape, resolved from live tmux options +// by the caller. Kept as plain strings so the rendering below stays a pure +// function with no tmux in it. +type Theme struct { + Sep string // rounded separator opening the icon block + IconFG string + TextFG string + TextBG string + AgentBG string // icon block for the "agents open" pill + NotifyBG string // ... for the "wants you" pill, when non-zero + IdleBG string // ... and when zero + AgentIco string + NotifIco string +} + +// pill renders one status module: a rounded, coloured icon block followed by +// its value on the shared module background. +// +// Built here rather than delegated to a catppuccin module because a real module +// always paints its icon and separators — including around an empty value, +// which is what this segment is most of the time. +// +// The spaces are load-bearing and asymmetric on purpose. One pads the icon +// inside its coloured block; the next belongs to the value's block; the trailing +// one is catppuccin's right separator, the one-cell gap between neighbouring +// pills. Drop it and these fuse into each other and into the module beside them. +func (t Theme) pill(bg, icon, value string) string { + return "#[fg=" + bg + "]" + t.Sep + + "#[fg=" + t.IconFG + ",bg=" + bg + "] " + icon + " " + + "#[fg=" + t.TextFG + ",bg=" + t.TextBG + "] " + value + + "#[fg=" + t.TextBG + "] " +} + +// Segment renders the status-right counter: how many agents are open, and how +// many of them are waiting on you. +// +// Empty when nothing is running, so the status bar carries no icon, no zero and +// no stray separator the rest of the time. +func (t Theme) Segment(c Counts) string { + if c.Open() == 0 { + return "" + } + // The second pill is drawn even at zero, greyed rather than hidden. A count + // that vanished would shift the first pill sideways every time an agent + // finished — exactly when you are looking at it. + notify := t.IdleBG + if c.Attention() > 0 { + notify = t.NotifyBG + } + return t.pill(t.AgentBG, t.AgentIco, strconv.Itoa(c.Open())) + + t.pill(notify, t.NotifIco, strconv.Itoa(c.Attention())) +} diff --git a/internal/agent/status_test.go b/internal/agent/status_test.go new file mode 100644 index 0000000..bc188e9 --- /dev/null +++ b/internal/agent/status_test.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT + +package agent + +import ( + "strings" + "testing" +) + +func testTheme() Theme { + return Theme{ + Sep: "<", IconFG: "crust", TextFG: "fg", TextBG: "surf", + AgentBG: "mauve", NotifyBG: "peach", IdleBG: "grey", + AgentIco: "A", NotifIco: "N", + } +} + +func TestTally(t *testing.T) { + got := Tally([]Record{ + {State: Perm}, {State: Ask}, {State: Done}, {State: Err}, + {State: Busy}, {State: Busy}, {State: None}, + }) + want := Counts{Waiting: 2, Done: 1, Failed: 1, Working: 2} + if got != want { + t.Fatalf("Tally = %+v, want %+v", got, want) + } + // Open counts every agent; Attention counts only what you owe a reply to, + // which is the whole distinction the two pills exist to draw. + if got.Open() != 6 { + t.Errorf("Open = %d, want 6", got.Open()) + } + if got.Attention() != 4 { + t.Errorf("Attention = %d, want 4", got.Attention()) + } +} + +// Nothing running must render nothing at all — no icon, no zero, no separator. +// A status bar that always carries the segment is one you stop reading. +func TestSegmentEmptyWhenNoAgents(t *testing.T) { + if s := testTheme().Segment(Counts{}); s != "" { + t.Errorf("Segment of nothing = %q, want empty", s) + } +} + +func TestSegmentShowsBothCounts(t *testing.T) { + s := testTheme().Segment(Counts{Waiting: 1, Working: 2}) + if !strings.Contains(s, "] 3") { + t.Errorf("segment %q does not report 3 agents open", s) + } + if !strings.Contains(s, "] 1") { + t.Errorf("segment %q does not report 1 wanting attention", s) + } +} + +// The second pill stays drawn at zero rather than disappearing: a count that +// vanished would shift the first pill sideways every time an agent finished. +func TestSegmentKeepsNotifyPillAtZero(t *testing.T) { + s := testTheme().Segment(Counts{Working: 2}) + if !strings.Contains(s, "N") { + t.Fatalf("segment %q dropped the notify pill at zero", s) + } + if !strings.Contains(s, "grey") { + t.Errorf("segment %q should grey the notify pill at zero, got %q", s, s) + } + if strings.Contains(s, "peach") { + t.Errorf("segment %q highlighted the notify pill with nothing waiting", s) + } +} + +func TestSegmentHighlightsNotifyPillWhenWaiting(t *testing.T) { + for _, c := range []Counts{{Waiting: 1}, {Done: 1}, {Failed: 1}} { + s := testTheme().Segment(c) + if !strings.Contains(s, "peach") { + t.Errorf("Segment(%+v) = %q, want the notify pill highlighted", c, s) + } + } +} + +// Each pill is a rounded separator, an icon on its own background, then the +// value on the shared module background — catppuccin's shape, so these sit +// flush against the modules beside them. +func TestSegmentPillShape(t *testing.T) { + s := testTheme().Segment(Counts{Waiting: 1}) + want := "#[fg=mauve]<#[fg=crust,bg=mauve] A #[fg=fg,bg=surf] 1#[fg=surf] " + + "#[fg=peach]<#[fg=crust,bg=peach] N #[fg=fg,bg=surf] 1#[fg=surf] " + if s != want { + t.Errorf("segment shape\n got %q\nwant %q", s, want) + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 5c7493f..874a04c 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -285,6 +285,33 @@ func RefreshStatus() { } } +// Options reads several global options in one subprocess. +// +// Every tmux.Option is a fork, and the status segment needs a whole palette on +// a timer. display-message expands them all in a single pass, which keeps the +// status bar's per-tick cost at one process rather than one per colour. +func Options(names ...string) map[string]string { + if len(names) == 0 { + return nil + } + parts := make([]string, len(names)) + for i, n := range names { + parts[i] = "#{E:" + n + "}" + } + out, err := Run("display-message", "-p", "-F", f(parts...)) + if err != nil { + return nil + } + vals := strings.Split(out, FS) + m := make(map[string]string, len(names)) + for i, n := range names { + if i < len(vals) && vals[i] != "" { + m[n] = vals[i] + } + } + return m +} + // Option reads a global tmux option, returning def when unset. func Option(name, def string) string { out, err := Run("show-option", "-gqv", name) diff --git a/internal/ui/agentcol_test.go b/internal/ui/agentcol_test.go index 18e2633..121ce22 100644 --- a/internal/ui/agentcol_test.go +++ b/internal/ui/agentcol_test.go @@ -34,17 +34,32 @@ func agentSample() []model.Window { // The agent column is reserved on every row, agent or not. An indicator drawn // only where there is an agent would shift every other column on those rows and // nowhere else — the same class of bug the badge column was built to avoid. -func TestAgentCellIsAlwaysOneCell(t *testing.T) { +func TestAgentCellIsAlwaysAgentCellsWide(t *testing.T) { for _, r := range []agent.Record{ {}, - {Agent: agent.Claude, State: agent.Perm, PID: 1}, - {Agent: agent.Claude, State: agent.Ask, PID: 1}, - {Agent: agent.Claude, State: agent.Done, PID: 1}, - {Agent: agent.Claude, State: agent.Err, PID: 1}, - {Agent: agent.Devin, State: agent.Busy, PID: 1}, + {Agent: agent.Claude, State: agent.Perm, PID: 2}, + {Agent: agent.Claude, State: agent.Ask, PID: 2}, + {Agent: agent.Claude, State: agent.Done, PID: 2}, + {Agent: agent.Claude, State: agent.Err, PID: 2}, + {Agent: agent.Claude, State: agent.Busy, PID: 2}, + {Agent: agent.Devin, State: agent.Perm, PID: 2}, + {Agent: agent.Devin, State: agent.Busy, PID: 2}, } { - if w := ansi.StringWidth(agentCell(r)); w != 1 { - t.Errorf("agentCell(%+v) is %d cells, want 1", r, w) + if w := ansi.StringWidth(agentCell(r)); w != agentCells { + t.Errorf("agentCell(%+v) is %d cells, want %d", r, w, agentCells) + } + } +} + +// Every glyph in the column must be exactly one cell. A double-width one would +// still satisfy the total above by luck of pairing, and then break the moment +// it appeared next to a different partner. +func TestEveryAgentGlyphIsOneCell(t *testing.T) { + for _, g := range []string{ + glyphClaude, glyphDevin, glyphPerm, glyphAsk, glyphDone, glyphErr, glyphBusy, + } { + if w := ansi.StringWidth(g); w != 1 { + t.Errorf("glyph %q is %d cells, want 1", g, w) } } } @@ -74,20 +89,24 @@ func TestAgentColumnKeepsRowsInBudget(t *testing.T) { } } -func TestAgentLetterRendered(t *testing.T) { +// The two halves are independent: the shape says which agent, the shape beside +// it says what it wants. Neither may be inferable only from the other's colour. +func TestAgentAndStateAreBothShown(t *testing.T) { m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) m.width, m.height = 150, 30 - var claude, devin bool + var sawClaudePerm, sawDevinBusy bool for _, r := range m.rows { - switch ansi.Strip(agentCell(r.agent)) { - case "C": - claude = true - case "D": - devin = true + cell := ansi.Strip(agentCell(r.agent)) + if cell == glyphClaude+glyphPerm { + sawClaudePerm = true + } + if cell == glyphDevin+glyphBusy { + sawDevinBusy = true } } - if !claude || !devin { - t.Errorf("expected both agent letters in the table (claude=%v devin=%v)", claude, devin) + if !sawClaudePerm || !sawDevinBusy { + t.Errorf("expected both agent/state pairs (claude+perm=%v devin+busy=%v)", + sawClaudePerm, sawDevinBusy) } } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 48152d8..22a52bf 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -45,15 +45,33 @@ var ( cText = lipgloss.NewStyle().Foreground(colText) ) -// The agent column encodes the agent in a letter and its state in the colour, -// so one cell carries both. Amber and pink are the two "wants you now" states; -// blue is merely working and must stay quiet enough to ignore. +// The agent column is two cells: which agent, then what it wants. Splitting +// them means neither has to be inferred from the other's colour — the shape +// says claude or devin, and the shape beside it says blocked, asking, done or +// failed. +// +// Identity takes mauve and cyan, which no state uses, so the two halves never +// read as one gradient. `busy` is deliberately the only muted state: it is the +// one thing here you do not owe a response to, and a column that shouted on +// every working agent would train you to ignore it. +const ( + glyphClaude = "\uf069" // nf-fa-asterisk, for Claude Code's mark + glyphDevin = "\U000f06a9" // nf-md-robot + glyphPerm = "\uf0f3" // bell: blocked asking permission + glyphAsk = "\uf059" // question: blocked on a question + glyphDone = "\uf00c" // check: finished a turn + glyphErr = "\uf071" // warning: the turn failed + glyphBusy = "\U000f051f" // timer-sand: working, nothing owed +) + var ( + cIDClaude = lipgloss.NewStyle().Foreground(colGroup) + cIDDevin = lipgloss.NewStyle().Foreground(colAccent) cAgentPerm = lipgloss.NewStyle().Foreground(colAmber).Bold(true) cAgentAsk = lipgloss.NewStyle().Foreground(colPink).Bold(true) cAgentErr = lipgloss.NewStyle().Foreground(colRed).Bold(true) cAgentDone = lipgloss.NewStyle().Foreground(colGreen).Bold(true) - cAgentBusy = lipgloss.NewStyle().Foreground(colAccent) + cAgentBusy = lipgloss.NewStyle().Foreground(colMuted) ) // frame draws a rounded box with a title set into the top border. diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 8f4ac1e..de9087e 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -895,27 +895,35 @@ func (m *Model) badge(st model.Status, tab string, isHeader bool) string { } } -// agentCell renders the agent column: the agent's letter coloured by its state, -// or a blank. Exactly one display cell either way — the column is reserved on -// every row, so an indicator that appeared only on agent rows would shift every -// other column on those rows and nowhere else. +// agentCells is the width of the agent column: one cell naming the agent, one +// naming what it wants. Reserved on every row, agent or not — an indicator +// drawn only where there is an agent would shift every other column on those +// rows and nowhere else. +const agentCells = 2 + +// agentCell renders that column. Always exactly agentCells wide. func agentCell(r agent.Record) string { - letter := r.Letter() - if letter == "" { - return " " + if r.Empty() { + return strings.Repeat(" ", agentCells) + } + id := cIDClaude.Render(glyphClaude) + if r.Agent == agent.Devin { + id = cIDDevin.Render(glyphDevin) } + var state string switch r.State { case agent.Perm: - return cAgentPerm.Render(letter) + state = cAgentPerm.Render(glyphPerm) case agent.Ask: - return cAgentAsk.Render(letter) + state = cAgentAsk.Render(glyphAsk) case agent.Err: - return cAgentErr.Render(letter) + state = cAgentErr.Render(glyphErr) case agent.Done: - return cAgentDone.Render(letter) + state = cAgentDone.Render(glyphDone) default: - return cAgentBusy.Render(letter) + state = cAgentBusy.Render(glyphBusy) } + return id + state } func glyph(st model.Status) string { @@ -998,9 +1006,9 @@ func (m *Model) renderRow(r row, selected bool) string { badgeCol := strings.Repeat(" ", maxInt(0, m.badgeW-ansi.StringWidth(badge))) + badge fixed := cursorCells + ansi.StringWidth(indent) + m.badgeW + rightMargin if r.kind == kindPane { - fixed += 1 + 1 + 1 + 6 // glyph, active marker, agent, six single spaces + fixed += 1 + 1 + agentCells + 6 // glyph, active marker, agent, six spaces } else { - fixed += 1 + 1 + 4 + 2 + 6 // glyph, agent, "NNp ", flags, six single spaces + fixed += 1 + agentCells + 4 + 2 + 6 // glyph, agent, "NNp ", flags, six spaces } avail := lw - fixed if avail < 20 { From 8a1aef2733d22deaccea823a23212ef1955bf90d Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Wed, 19 Aug 2026 13:51:06 +0200 Subject: [PATCH 04/14] Document placing the status segment yourself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin appends its segment to the end of status-right, which is the far right of the bar. That is a fine default but not always the wanted spot: a bar that ends in a battery module wants the pills somewhere inside it, not past it. Placing the segment by hand already worked — the plugin's idempotency guard sees it and skips appending — but nothing said so. Document it, with the -F warning that goes with it: -agF expands formats at load time, which runs the #() once and freezes its output instead of leaving it for the status bar to re-run. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 4 ++++ docs/agents.md | 19 +++++++++++++++++++ docs/configuration.md | 5 +++++ 3 files changed, 28 insertions(+) diff --git a/README.md b/README.md index 1acc5a6..02b580d 100644 --- a/README.md +++ b/README.md @@ -201,6 +201,10 @@ set -g @kaku-tab-agents 'on' set -g status-interval 5 ``` +That appends the pills to the end of `status-right`, i.e. the far right of the +bar. To place them anywhere else, leave the option off and put +`#(kaku-tab agents --format tmux)` where you want it. + The agents report themselves: each CLI's lifecycle hooks run `kaku-tab hook`, which records the state on the pane it inherited via `$TMUX_PANE`. Nothing is guessed from the process table — `#{pane_current_command}` says `node` for diff --git a/docs/agents.md b/docs/agents.md index 460e7c9..ddc90a2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -159,6 +159,25 @@ Opt-in, and off by default, because it appends to `status-right` — which most people compose by hand. The plugin appends after whatever you have already set, so it lands last. +### Putting it somewhere else + +Appending is only the default, and the end of `status-right` is the far right of +the bar. To choose the position — to lead with the pills and keep a battery +module rightmost, say — place the segment yourself and leave `@kaku-tab-agents` +off, so the plugin does not append a second copy: + +```tmux +set -g status-right "#(kaku-tab agents --format tmux)" +set -ag status-right "#{E:@catppuccin_status_session}" +set -agF status-right "#{E:@catppuccin_status_battery}" + +set -g @kaku-tab-agents 'off' +``` + +Plain `-g`/`-ag`, never `-F`: `-F` expands formats at load time, which would run +the `#()` once and freeze its output instead of leaving it for the status bar to +re-run on each redraw. + It draws catppuccin's own module shape — rounded separator, icon on its own colour, value on the shared module background — resolved from the live `@thm_*` palette, so it sits flush against the modules beside it. Without catppuccin diff --git a/docs/configuration.md b/docs/configuration.md index d3dbea8..8edb90e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -137,6 +137,11 @@ kaku-tab install-hooks `refresh-client -S` the moment an agent changes state, so the count moves as it happens. +The plugin appends the segment to the end of `status-right`. To place it +somewhere else — before a battery module, say — set `@kaku-tab-agents 'off'` and +add `#(kaku-tab agents --format tmux)` where you want it. See +[agents.md](agents.md#putting-it-somewhere-else). + ## Full example ```tmux From 4de384acc328f21be53cf14345e5ae8bd5d25b75 Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Wed, 19 Aug 2026 13:52:25 +0200 Subject: [PATCH 05/14] Lead the status segment with the notification count The open-agent count was first and the notification count second. That is the wrong way round for the thing being scanned for: the notification count is the number that changes and the number you are looking for, and the open count is context for it. Swap them. The zero-state rule survives the swap unchanged, but its reason moves: the notification pill still stays drawn and greyed at zero, now so the pill *behind* it does not shift sideways every time an agent finishes. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +++--- docs/agents.md | 11 +++++++---- docs/configuration.md | 5 ++--- internal/agent/status.go | 12 +++++++----- internal/agent/status_test.go | 9 +++++++-- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 02b580d..9ba0970 100644 --- a/README.md +++ b/README.md @@ -185,11 +185,11 @@ were, alt-tab style. Claude Code and Devin CLI sessions show up as a column in the picker — one glyph for which agent, one for what it wants: working, blocked on a permission prompt, -asking you something, finished, or failed. The tmux status bar gets two counters -at the far right: how many agents are open, and how many of them want you. +asking you something, finished, or failed. The tmux status bar gets two counters: +how many agents want you, and how many are open. ``` - 󰚩 3 󰂚 1 + 󰂚 1 󰚩 3 ``` ```sh diff --git a/docs/agents.md b/docs/agents.md index ddc90a2..942327c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -139,14 +139,17 @@ too; the name is part of each row's match text without being drawn. Two pills at the far right of `status-right`: ``` - 󰚩 3 󰂚 1 + 󰂚 1 󰚩 3 ``` +- **󰂚 how many want you** — waiting, finished, or failed - **󰚩 how many agents are open** — Claude Code and Devin CLI together -- **󰂚 how many of them want you** — waiting, finished, or failed -The second pill stays drawn at zero, greyed rather than hidden. A count that -vanished would shift the first pill sideways every time an agent finished, which +Notifications lead: that is the number you scan for, and the one that changes. +The open count behind it is context for it. + +The notification pill stays drawn at zero, greyed rather than hidden. A count +that vanished would shift the pill beside it every time an agent finished, which is exactly when you are looking at it. The whole segment disappears when no agent is running at all. diff --git a/docs/configuration.md b/docs/configuration.md index 8edb90e..037b96e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,9 +109,8 @@ kaku-tab titles --dry-run ## Agents -Two counters at the far right of the status bar — how many Claude Code and Devin -CLI sessions are open, and how many of them are waiting on you — plus an agent -column in the picker. See [agents.md](agents.md). +Two counters in the status bar — how many Claude Code and Devin CLI sessions are +waiting on you, and how many are open — plus an agent column in the picker. See [agents.md](agents.md). | Option | Default | Meaning | |---|---|---| diff --git a/internal/agent/status.go b/internal/agent/status.go index 3d89069..4571929 100644 --- a/internal/agent/status.go +++ b/internal/agent/status.go @@ -86,13 +86,15 @@ func (t Theme) Segment(c Counts) string { if c.Open() == 0 { return "" } - // The second pill is drawn even at zero, greyed rather than hidden. A count - // that vanished would shift the first pill sideways every time an agent - // finished — exactly when you are looking at it. + // The notification pill is drawn even at zero, greyed rather than hidden. A + // count that vanished would shift the pill beside it sideways every time an + // agent finished — exactly when you are looking at it. notify := t.IdleBG if c.Attention() > 0 { notify = t.NotifyBG } - return t.pill(t.AgentBG, t.AgentIco, strconv.Itoa(c.Open())) + - t.pill(notify, t.NotifIco, strconv.Itoa(c.Attention())) + // Notifications lead: it is the number you are scanning for, and the one + // that changes. The open count behind it is context for it. + return t.pill(notify, t.NotifIco, strconv.Itoa(c.Attention())) + + t.pill(t.AgentBG, t.AgentIco, strconv.Itoa(c.Open())) } diff --git a/internal/agent/status_test.go b/internal/agent/status_test.go index bc188e9..66a1054 100644 --- a/internal/agent/status_test.go +++ b/internal/agent/status_test.go @@ -50,6 +50,10 @@ func TestSegmentShowsBothCounts(t *testing.T) { if !strings.Contains(s, "] 1") { t.Errorf("segment %q does not report 1 wanting attention", s) } + // Notifications lead — that is the number being scanned for. + if strings.Index(s, "N") > strings.Index(s, "A") { + t.Errorf("segment %q puts the open count before the notification count", s) + } } // The second pill stays drawn at zero rather than disappearing: a count that @@ -81,8 +85,9 @@ func TestSegmentHighlightsNotifyPillWhenWaiting(t *testing.T) { // flush against the modules beside them. func TestSegmentPillShape(t *testing.T) { s := testTheme().Segment(Counts{Waiting: 1}) - want := "#[fg=mauve]<#[fg=crust,bg=mauve] A #[fg=fg,bg=surf] 1#[fg=surf] " + - "#[fg=peach]<#[fg=crust,bg=peach] N #[fg=fg,bg=surf] 1#[fg=surf] " + // Notifications first, then the open count. + want := "#[fg=peach]<#[fg=crust,bg=peach] N #[fg=fg,bg=surf] 1#[fg=surf] " + + "#[fg=mauve]<#[fg=crust,bg=mauve] A #[fg=fg,bg=surf] 1#[fg=surf] " if s != want { t.Errorf("segment shape\n got %q\nwant %q", s, want) } From 7b39100d9ea46a0b64b3a44e2db5bfd85a7b5f0d Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Wed, 19 Aug 2026 14:34:08 +0200 Subject: [PATCH 06/14] Fit the picker to its contents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on a real two-session list at 150x40: 28 of 40 rows blank, and the columns budgeted 22 cells for a label holding "1", 34 for one holding "zsh" and 46 for a 22-character path. Seventy cells of padding on every row, pushing the badge — the one column this tool exists to show — an eyeful away from the name it belongs to. Four changes, all of them the same idea: Columns are sized to their contents, once for the whole table, instead of by fixed proportions of the frame. Caps still bind on outliers so one 400-character path cannot squeeze the rest out, but in the common case none of them apply and the row is exactly as wide as what is in it. Columns whose every cell reads the same are not drawn. The pane count is "1p" on every row of a table with no split windows and the flags column is blank when nothing is flagged; both now disappear and reappear with the table. A session with one window renders as one row. The header carried the same badge and the same agent state as the single child directly beneath it, there was nothing to fold, and half the rows in a list of one-window sessions were that duplicate. Behind @kaku-tab-merge-single, on by default. The popup opens at the size the list needs, with the configured sizes as maximums. tmux fixes a popup's geometry at creation and cannot resize it, so this has to be decided before the picker is drawn — hence ui.Measure, which builds the model against an unreachable width so no cap binds and every column reports its natural size. The footer is counted at the width we will actually open at, not the measuring width: the help bar wraps, and sizing against a one-line footer opened a popup with three lines of it eating the list. Two smaller ones while in here: the agent identity and state glyphs get a space between them, because flush against each other they read as one smudged symbol and that defeats the point of splitting them; and the footer now spells out the selected row's agent in words, since nothing on screen said what a glyph meant. Same list as above now renders 3 rows in an 80x12 popup instead of 6 in 90x28. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 + cmd/kaku-tab/main.go | 91 +++++++- docs/agents.md | 15 +- docs/configuration.md | 14 ++ internal/ui/agentcol_test.go | 4 +- internal/ui/layout_test.go | 226 +++++++++++++++++++ internal/ui/ui.go | 408 +++++++++++++++++++++++++++-------- 7 files changed, 666 insertions(+), 98 deletions(-) create mode 100644 internal/ui/layout_test.go diff --git a/README.md b/README.md index 9ba0970..76d4fe0 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,12 @@ and press Alt+L. Typing filters. A session header matches on behalf of its windows, so `api` shows the session *and* everything under it. +The popup opens at the size the list needs — the configured sizes are maximums — +and columns are sized to their contents, with constant ones (the pane count when +nothing is split, the flags when nothing is flagged) dropped entirely. A session +with a single window renders as one row rather than a header repeating its only +child; set `@kaku-tab-merge-single 'off'` to keep the header. + When there are more rows than fit, a scrollbar appears down the right edge — otherwise a list that continues below the frame looks exactly like one that ends there. Shift+Tab folds every session, and diff --git a/cmd/kaku-tab/main.go b/cmd/kaku-tab/main.go index 0882f1b..84672d0 100644 --- a/cmd/kaku-tab/main.go +++ b/cmd/kaku-tab/main.go @@ -185,7 +185,7 @@ func popup(selfTTY, selfSession string) error { preview := tmux.Option("@kaku-tab-preview", "off") == "on" for i := 0; i < 16; i++ { // bounded: a toggle loop should never run away - w, h := popupSize(preview) + w, h := popupSize(preview, selfSession) args := []string{"display-popup", "-E", "-B", "-w", w, "-h", h} if selfTTY != "" { args = append(args, "-c", selfTTY) @@ -214,16 +214,93 @@ func popup(selfTTY, selfSession string) error { // popupSize picks the geometry for the current preview setting: without a // preview pane the list alone needs far less width. -func popupSize(preview bool) (string, string) { +// +// The configured size is a *maximum*. A popup opened at a flat 60%x70% for a +// list of four rows is seven-tenths empty, and tmux cannot shrink it afterwards +// — display-popup fixes -w/-h at creation — so the fit has to be worked out +// here, before the picker is drawn. +func popupSize(preview bool, selfSession string) (string, string) { size := tmux.Option("@kaku-tab-popup-size", "90%,85%") if !preview { size = tmux.Option("@kaku-tab-popup-size-compact", "60%,70%") } w, h, ok := strings.Cut(size, ",") if !ok { - return "90%", "85%" + w, h = "90%", "85%" + } + w, h = strings.TrimSpace(w), strings.TrimSpace(h) + if tmux.Option("@kaku-tab-popup-fit", "on") != "on" { + return w, h + } + + // A preview needs the room the configured width buys it, so only the height + // is fitted in that mode. + cols, rows, ok := measure(selfSession, preview) + if !ok { + return w, h + } + if !preview { + w = fit(w, cols, "client_width") } - return strings.TrimSpace(w), strings.TrimSpace(h) + return w, fit(h, rows, "client_height") +} + +// measure asks the picker how big it would like to be. Failures are not fatal: +// the caller falls back to the configured size, which is what it used to always +// use. +func measure(selfSession string, preview bool) (cols, rows int, ok bool) { + ws, err := resolve.Resolve(liveSource{}, opts(selfSession, false)) + if err != nil || len(ws) == 0 { + return 0, 0, false + } + sortMode := sortOption() + c, r := ui.Measure(ws, ui.Options{ + Suffix: tmux.Option("@kaku-tab-satellite-suffix", model.DefaultSatelliteSuffix), + Tree: tmux.Option("@kaku-tab-tree", "on") == "on", + MergeSingle: tmux.Option("@kaku-tab-merge-single", "on") == "on", + Preview: preview, + HideDetached: tmux.Option("@kaku-tab-detached", "on") == "off", + Sort: sortMode, + MRU: mruList(sortMode), + }) + return c, r, true +} + +// fit reduces a configured dimension to what the content needs, never past a +// floor that keeps the picker usable, and never above the configured maximum. +// +// The maximum may be a percentage, which only tmux can resolve, so it is +// converted through the client's own size rather than guessed at. +func fit(configured string, need int, clientDim string) string { + var limit int + if pct, isPct := strings.CutSuffix(configured, "%"); isPct { + n, err := strconv.Atoi(pct) + if err != nil { + return configured + } + out, e := tmux.Run("display-message", "-p", "#{"+clientDim+"}") + if e != nil { + return configured + } + total, err := strconv.Atoi(strings.TrimSpace(out)) + if err != nil { + return configured + } + limit = total * n / 100 + } else if n, err := strconv.Atoi(configured); err == nil { + limit = n + } else { + return configured + } + + const floor = 12 // below this the frame and footer crowd out the list + if need < floor { + need = floor + } + if need > limit { + need = limit + } + return strconv.Itoa(need) } type persisted struct { @@ -301,6 +378,11 @@ func pick(selfTTY, selfSession string) error { // find every window gone. It only rides across a preview-toggle relaunch. agentsOnly := resumed && restore.AgentsOnly + mergeSingle := tmux.Option("@kaku-tab-merge-single", "on") == "on" + if resumed { + mergeSingle = restore.MergeSingle + } + self, _ := os.Executable() ctx := action.Ctx{SelfTTY: selfTTY, Suffix: suffix, AttachSh: self} @@ -320,6 +402,7 @@ func pick(selfTTY, selfSession string) error { HideDetached: hideDetached, AgentsOnly: agentsOnly, + MergeSingle: mergeSingle, }) // The picker owns the popup's terminal; Kaku's own alt-screen is untouched. diff --git a/docs/agents.md b/docs/agents.md index 942327c..211c4a7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -125,10 +125,17 @@ header the most actionable among its windows, so a pane blocked three windows deep is visible without unfolding anything. Switch to pane mode (^p) for the exact pane. -The column is a fixed two cells and reserved on every row, agent or not: an -indicator drawn only where there is an agent would shift every other column on -those rows and nowhere else. Every glyph is pinned to one display cell by a -test — a double-width one would break the table's column budget. +The two glyphs are separated by a space — flush against each other they read as +one smudged symbol, which defeats the point of splitting them. The column is +reserved on every row of a table that has any agent at all, and dropped entirely +from one that has none: an indicator drawn only where there is an agent would +shift every other column on those rows and nowhere else. Every glyph is pinned +to one display cell by a test; a double-width one would break the table's column +budget. + +Nothing on screen says what a glyph means, so the footer spells out the selected +row's agent in words — `claude · waiting for permission · 2m ago` — on whichever +row the cursor is on. ^a filters to windows with an agent that wants you — `perm`, `ask`, `done` or `err`, but not `busy`. Typing an agent's name in the search box works diff --git a/docs/configuration.md b/docs/configuration.md index 037b96e..5f21254 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,20 @@ set -g @plugin 'dsaad68/kaku-tab' | `@kaku-tab-popup-size-compact` | `60%,70%` | popup size with the preview hidden | | `@kaku-tab-preview` | `off` | start with the preview pane; Ctrl+/ toggles | | `@kaku-tab-tree` | `on` | group windows under their session; `off` gives a flat list | +| `@kaku-tab-merge-single` | `on` | a session with one window renders as one row instead of a header plus its only child | +| `@kaku-tab-popup-fit` | `on` | shrink the popup to what the list needs; the sizes above become maximums | + +### Sizing + +The popup sizes are a **maximum**, not a fixed geometry. A list of four rows +opened at a flat `60%,70%` is seven-tenths empty, and tmux cannot shrink a popup +afterwards — `display-popup` fixes `-w`/`-h` at creation — so the fit is worked +out before the picker is drawn. Set `@kaku-tab-popup-fit 'off'` for the old +behaviour. + +Columns are likewise sized to what is in them rather than to a proportion of the +frame, and a column whose every cell reads the same — the pane count when nothing +is split, the flags when nothing is flagged — is not drawn at all. ### Why `M-l` diff --git a/internal/ui/agentcol_test.go b/internal/ui/agentcol_test.go index 121ce22..49e7863 100644 --- a/internal/ui/agentcol_test.go +++ b/internal/ui/agentcol_test.go @@ -97,10 +97,10 @@ func TestAgentAndStateAreBothShown(t *testing.T) { var sawClaudePerm, sawDevinBusy bool for _, r := range m.rows { cell := ansi.Strip(agentCell(r.agent)) - if cell == glyphClaude+glyphPerm { + if cell == glyphClaude+" "+glyphPerm { sawClaudePerm = true } - if cell == glyphDevin+glyphBusy { + if cell == glyphDevin+" "+glyphBusy { sawDevinBusy = true } } diff --git a/internal/ui/layout_test.go b/internal/ui/layout_test.go new file mode 100644 index 0000000..9c7cd67 --- /dev/null +++ b/internal/ui/layout_test.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: MIT + +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/dsaad68/kaku-tab/internal/agent" + "github.com/dsaad68/kaku-tab/internal/model" +) + +// Two sessions of one window each, nothing multi-pane, nothing flagged — the +// shape that used to render six rows of which three were duplicates, in a table +// whose columns were mostly padding. +func flatSample() []model.Window { + return []model.Window{ + {RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1", Name: "zsh", Panes: 1, Path: "/home/u/api"}, + Status: model.Visible, TabID: "5", ClientSession: "api"}, + {RawWindow: model.RawWindow{Session: "kaku-tab", ID: "@2", Index: "1", Name: "claude", Panes: 1, Path: "/home/u/kaku-tab"}, + Status: model.Visible, TabID: "6", ClientSession: "kaku-tab"}, + } +} + +func modelAt(t *testing.T, ws []model.Window, opt Options, w, h int) *Model { + t.Helper() + m := New(ws, opt) + m.width, m.height = w, h + m.relayout() + return m +} + +// A session with one window needs no header: there is nothing to group and +// nothing to fold, and the header only repeated the row beneath it. +func TestMergeSingleDropsRedundantHeaders(t *testing.T) { + m := modelAt(t, flatSample(), Options{Tree: true, MergeSingle: true}, 150, 30) + if len(m.rows) != 2 { + t.Fatalf("got %d rows, want 2 (one per session)", len(m.rows)) + } + for _, r := range m.rows { + if r.kind == kindHeader { + t.Errorf("header survived for a one-window session: %+v", r.group) + } + if !r.merged { + t.Errorf("row %s not marked merged", r.group) + } + } + + // Off, the tree is unchanged: a header plus its child for each session. + m = modelAt(t, flatSample(), Options{Tree: true}, 150, 30) + if len(m.rows) != 4 { + t.Fatalf("with MergeSingle off got %d rows, want 4", len(m.rows)) + } +} + +// A merged row carries the session name, since it stands in for the header that +// would have shown it. +func TestMergedRowShowsSessionName(t *testing.T) { + m := modelAt(t, flatSample(), Options{Tree: true, MergeSingle: true}, 150, 30) + var got []string + for _, r := range m.rows { + got = append(got, m.rowLabel(r)) + } + want := "api kaku-tab" + if strings.Join(got, " ") != want { + t.Errorf("merged labels = %q, want %q", strings.Join(got, " "), want) + } +} + +// Merging only applies to a group of one. A session with two windows keeps its +// header, because there is now something to fold. +func TestMergeSingleLeavesRealGroupsAlone(t *testing.T) { + ws := append(flatSample(), model.Window{ + RawWindow: model.RawWindow{Session: "api", ID: "@3", Index: "2", Name: "vim", Panes: 1, Path: "/home/u/api"}, + Status: model.Visible, TabID: "5", ClientSession: "api"}) + m := modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 150, 30) + + headers, merged := 0, 0 + for _, r := range m.rows { + if r.kind == kindHeader { + headers++ + if r.group != "api" { + t.Errorf("unexpected header for %q", r.group) + } + } + if r.merged { + merged++ + } + } + if headers != 1 || merged != 1 { + t.Errorf("headers=%d merged=%d, want 1 and 1", headers, merged) + } +} + +// A column whose every cell reads the same carries no information. The pane +// count is "1p" on every row of a table with no split windows, and the flags +// column is blank when nothing is flagged. +func TestConstantColumnsAreDropped(t *testing.T) { + m := modelAt(t, flatSample(), Options{Tree: true, MergeSingle: true}, 150, 30) + if m.lay.panes != 0 { + t.Errorf("pane-count column drawn (%d) with no multi-pane window", m.lay.panes) + } + if m.lay.flags != 0 { + t.Errorf("flags column drawn (%d) with nothing flagged", m.lay.flags) + } + for _, r := range m.rows { + if strings.Contains(ansi.Strip(m.renderRow(r, false)), "1p") { + t.Errorf("row still renders a pane count: %q", ansi.Strip(m.renderRow(r, false))) + } + } + + // One split window is enough to bring the column back for the whole table. + ws := flatSample() + ws[0].Panes = 3 + ws[0].Activity = true + m = modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 150, 30) + if m.lay.panes != paneCountCells { + t.Errorf("pane-count column missing (%d) with a 3-pane window", m.lay.panes) + } + if m.lay.flags != flagCells { + t.Errorf("flags column missing (%d) with an activity flag", m.lay.flags) + } +} + +// Columns are sized to what is in them, not to a proportion of the frame. The +// old fixed split spent 22% of the width on a column holding "1". +func TestColumnsSizedToContent(t *testing.T) { + m := modelAt(t, flatSample(), Options{Tree: true, MergeSingle: true}, 150, 30) + if want := len("kaku-tab"); m.lay.label != want { + t.Errorf("label column = %d, want %d (its widest value)", m.lay.label, want) + } + if want := len("claude"); m.lay.name != want { + t.Errorf("name column = %d, want %d", m.lay.name, want) + } + // And the row is nowhere near the frame width it used to be padded out to. + for _, r := range m.rows { + if w := ansi.StringWidth(m.renderRow(r, false)); w > m.rowWidth()/2 { + t.Errorf("row is %d cells of a %d-cell frame; columns are not fitted", + w, m.rowWidth()) + } + } +} + +// An outlier must not squeeze the other columns out of existence. +func TestColumnCapsBindOnOutliers(t *testing.T) { + ws := flatSample() + ws[0].Name = strings.Repeat("x", 400) + ws[0].Path = "/" + strings.Repeat("y", 400) + m := modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 100, 30) + for _, r := range m.rows { + line := m.renderRow(r, false) + if w := ansi.StringWidth(line); w > m.rowWidth() { + t.Errorf("row %d cells > rowWidth %d", w, m.rowWidth()) + } + if !strings.Contains(ansi.Strip(line), "⟦") { + t.Errorf("badge truncated away by an outlier column: %q", ansi.Strip(line)) + } + } +} + +// Measure is what lets the popup open at the size it needs: tmux fixes a +// popup's geometry at creation and cannot resize it afterwards. +func TestMeasureFitsContent(t *testing.T) { + opt := Options{Tree: true, MergeSingle: true} + cols, rows := Measure(flatSample(), opt) + + if cols < minPopupCols { + t.Errorf("cols = %d, below the floor %d", cols, minPopupCols) + } + if cols > 200 { + t.Errorf("cols = %d for two short rows; not fitted", cols) + } + // Two rows plus frame, prompt, rule, blank and a footer that wraps at this + // width — comfortably under a screenful either way. + if rows < 8 || rows > 16 { + t.Errorf("rows = %d for a two-row list, want a snug fit", rows) + } + + // More windows must ask for more rows, or nothing is being measured. + big := flatSample() + for i := 0; i < 20; i++ { + big = append(big, model.Window{RawWindow: model.RawWindow{ + Session: "api", ID: "@x", Index: "9", Name: "w", Panes: 1, Path: "/home/u"}}) + } + _, bigRows := Measure(big, opt) + if bigRows <= rows { + t.Errorf("20 more windows asked for %d rows, no more than %d", bigRows, rows) + } +} + +// The glyphs are compact and nothing on screen says what they mean; the footer +// is where you find out, for whichever row the cursor is on. +func TestFooterSpellsOutTheSelectedAgent(t *testing.T) { + ws := flatSample() + ws[1].Agent = agent.Record{Agent: agent.Claude, State: agent.Perm, PID: 2, At: 1} + m := modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 150, 30) + + m.cursor = 0 + if got := strings.Join(m.footerLines(), " "); strings.Contains(got, "waiting for permission") { + t.Errorf("footer described an agent for a row that has none: %q", got) + } + m.cursor = 1 + got := ansi.Strip(strings.Join(m.footerLines(), " ")) + if !strings.Contains(got, "claude") || !strings.Contains(got, "waiting for permission") { + t.Errorf("footer = %q, want it to name the agent and its state", got) + } +} + +// The footer grows by a line when it describes an agent, so the list has to +// give one back — otherwise the bottom row is drawn over the frame. +func TestListHeightReservesTheFooter(t *testing.T) { + ws := flatSample() + ws[1].Agent = agent.Record{Agent: agent.Claude, State: agent.Perm, PID: 2, At: 1} + m := modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 150, 30) + + m.cursor = 0 + plain := m.listHeight() + m.cursor = 1 + withAgent := m.listHeight() + if withAgent >= plain { + t.Errorf("listHeight %d with the agent line, %d without; the line was not reserved", + withAgent, plain) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index de9087e..cff3fe4 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -19,6 +19,7 @@ import ( "fmt" "sort" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -64,6 +65,9 @@ type row struct { tabID string agent agent.Record last bool // last child of its group -> └ + // merged marks a row that stands in for its own group header, because that + // group has exactly one child and the header would only have repeated it. + merged bool } // Result is what the picker hands back to main. @@ -90,6 +94,7 @@ type State struct { PaneMode bool `json:"pane_mode"` HideDetached bool `json:"hide_detached"` AgentsOnly bool `json:"agents_only"` + MergeSingle bool `json:"merge_single"` Collapse map[string]bool `json:"collapse"` } @@ -110,6 +115,11 @@ type Options struct { // HideDetached drops sessions with no terminal tab from the list. HideDetached bool + // MergeSingle folds a group of one into a single row. A session with one + // window needs no header: there is nothing to group and nothing to fold, and + // the header only repeats the badge and agent state of the row below it. + MergeSingle bool + // AgentsOnly narrows the list to windows holding an agent that wants you — // blocked on permission, asking a question, finished, or failed. A window // whose agent is merely working is not one of them: the point of the filter @@ -117,6 +127,28 @@ type Options struct { AgentsOnly bool } +// layout is every column width in the table, computed once from the rows the +// table actually holds rather than from fixed proportions of the frame. +// +// Sizing to content is the point: a fixed 22%% of the width for a column that +// holds "1", and 34%% for one that holds "zsh", spent seventy cells on padding +// and pushed the badge — the one column this tool exists to show — an eyeful +// away from the name it belongs to. +// +// A width of 0 means the column is not drawn at all. Two of them earn that +// regularly: the pane count when nothing has a second pane, and the flags when +// nothing is flagged. A column whose every cell reads the same carries no +// information, and this table is mostly such rows. +type layout struct { + agent int // 0, or agentCells when any row has an agent + label int + name int + path int + panes int // 0, or paneCountCells when any window has more than one pane + flags int // 0, or flagCells when any window carries a flag + badge int +} + type previewMsg struct { target string content string @@ -138,7 +170,7 @@ type Model struct { collapse map[string]bool width int height int - badgeW int // widest badge across the table; shared by every row + lay layout // column widths, shared by every row preview map[string]string renaming bool rename string @@ -174,13 +206,46 @@ func New(ws []model.Window, opt Options) *Model { return m } +// Measure reports the size the picker would like: exactly wide enough for its +// widest row and tall enough for all of them, frame and footer included. +// +// The caller clamps this to whatever maximum it is willing to open. It exists +// because tmux cannot resize a popup once it is open — display-popup takes its +// -w/-h at creation — so the geometry has to be decided before the picker is +// ever drawn, and only the picker knows how wide its own table is. +func Measure(ws []model.Window, opt Options) (cols, rows int) { + m := New(ws, opt) + // Measure against a width no real terminal reaches, so the proportional + // caps in relayout never bind and every column reports its natural size. + m.width, m.height = 10000, 10000 + m.relayout() + + l := m.lay + content := m.fixedCells(l) + l.label + l.name + l.path + // scrollbar gutter + frame border + cols = maxInt(minPopupCols, content+scrollbarCells+2) + + // Count the footer at the width we will actually open at, not at the + // measuring width: the help bar wraps, and a popup sized against a + // one-line footer opens with three lines of it eating the list. + m.width = cols + // frame top+bottom, prompt, rule, blank, footer. + rows = len(m.rows) + 5 + len(m.footerLines()) + return cols, rows +} + +// minPopupCols is a floor on the fitted width. Below roughly this the help bar +// wraps into more lines than the list has rows, and a popup that is mostly +// footer is no better than one that is mostly blank. +const minPopupCols = 80 + // State captures what a relaunch needs to restore. func (m *Model) State() State { return State{ Query: m.query, Cursor: m.cursor, Offset: m.offset, Preview: m.opt.Preview, PaneMode: m.opt.PaneMode, HideDetached: m.opt.HideDetached, AgentsOnly: m.opt.AgentsOnly, - Collapse: m.collapse, + MergeSingle: m.opt.MergeSingle, Collapse: m.collapse, } } @@ -297,7 +362,12 @@ func (m *Model) build() { htab = w.TabID } } - if m.opt.Tree { + // A group of one becomes one row: the header would carry the same badge + // and the same agent state as the single child directly beneath it, and + // there is nothing to fold. Half the rows in a list of one-window + // sessions were that duplicate. + merge := m.opt.Tree && m.opt.MergeSingle && n == 1 + if m.opt.Tree && !merge { m.rows = append(m.rows, row{ kind: kindHeader, group: g, search: g, count: n, status: hstat, tabID: htab, agent: agent.Best(hagents), @@ -308,7 +378,7 @@ func (m *Model) build() { if m.opt.PaneMode { for j, p := range w.Panes_ { m.rows = append(m.rows, row{ - kind: kindPane, group: g, win: w, pane: p, + kind: kindPane, group: g, win: w, pane: p, merged: merge, search: strings.Join([]string{w.Session, w.Index, p.Index, p.Cmd, p.Path, p.Agent.Agent}, " "), status: w.Status, tabID: w.TabID, agent: p.Agent, last: j == len(w.Panes_)-1, @@ -317,7 +387,7 @@ func (m *Model) build() { continue } m.rows = append(m.rows, row{ - kind: kindWindow, group: g, win: w, + kind: kindWindow, group: g, win: w, merged: merge, // The agent name joins the search text so typing "claude" // narrows to agent windows; it is never rendered as text. search: strings.Join([]string{w.Session, w.Index, w.Name, w.Path, w.Agent.Agent}, " "), @@ -425,7 +495,10 @@ func (m *Model) refilter() { continue } } - if r.kind != kindHeader && m.collapse[r.group] { + // Never hide a merged row: it has no header, so nothing could unfold it + // again. A group collapsed before a reload merged it would otherwise + // vanish for good. + if r.kind != kindHeader && !r.merged && m.collapse[r.group] { continue } m.view = append(m.view, i) @@ -438,20 +511,90 @@ func (m *Model) refilter() { m.cursor = 0 } - // One badge column for the whole table, sized to the widest badge. Sizing - // it per row gave every row a different column budget, which is what made - // the table look ragged: "⟦kaku 7⟧ ← here" is nearly twice "⟦kaku 8⟧". - m.badgeW = 0 + m.relayout() + m.ensureVisible() +} + +// relayout sizes every column once for the whole table. +// +// Once for the whole table, never per row: deriving a width from each row's own +// content gives every row a different layout, which is what made this table look +// ragged before. Measured in display cells — ansi.StringWidth for anything that +// may carry styling, runewidth for plain text — because a byte or rune count +// treats escape sequences and nerd-font glyphs as one cell each and silently +// narrows whichever row happens to carry them. +func (m *Model) relayout() { + var l layout for _, r := range m.rows { + if !r.agent.Empty() { + l.agent = agentCells + } if r.kind == kindHeader { + // Headers are truncated rather than padded, so their badge does not + // set the column width. continue } - if w := ansi.StringWidth(m.badge(r.status, r.tabID, false)); w > m.badgeW { - m.badgeW = w + if w := ansi.StringWidth(m.badge(r.status, r.tabID, false)); w > l.badge { + l.badge = w + } + l.label = maxInt(l.label, runewidth.StringWidth(m.rowLabel(r))) + l.name = maxInt(l.name, runewidth.StringWidth(m.rowName(r))) + l.path = maxInt(l.path, runewidth.StringWidth(m.rowPath(r))) + if r.kind == kindWindow { + if r.win.Panes > 1 { + l.panes = paneCountCells + } + if rowFlags(r) != "" { + l.flags = flagCells + } } } - m.ensureVisible() + // Cap each flexible column so one outlier — a 90-character path, a session + // named after a git branch — cannot squeeze the others out. The caps are + // generous because they only ever bind on outliers; the common case is that + // none of them apply and the row is exactly as wide as its content. + avail := maxInt(20, m.rowWidth()-m.fixedCells(l)) + l.label = minInt(l.label, avail*30/100) + l.name = minInt(l.name, avail*40/100) + l.path = minInt(l.path, maxInt(8, avail-l.label-l.name)) + m.lay = l +} + +// fixedCells is everything in a row that is not one of the flexible columns: +// the cursor, the tree indent, the status glyph, the agent column, the pane +// count or active marker, the flags, the badge, the single space after each of +// them, and the right margin. +// +// This sum is the one piece of arithmetic here that has to be exact. Wrong by +// even a few cells and the row runs past the list width, where truncateANSI +// eats the badge — rightmost, and the point of the tool. +func (m *Model) fixedCells(l layout) int { + // status glyph and its space; a trailing space after each of label, name + // and path. + n := cursorCells + m.indentCells() + rightMargin + l.badge + 2 + 3 + if l.agent > 0 { + n += l.agent + 1 + } + if m.opt.PaneMode { + n += markerCells + 1 + } else { + if l.panes > 0 { + n += l.panes + } + if l.flags > 0 { + n += l.flags + 1 + } + } + return n +} + +// indentCells is the width of the tree connector on a child row. +func (m *Model) indentCells() int { + if m.opt.Tree { + return 3 // " ├ " + } + return 1 } // innerW is the drawable width inside the frame border. @@ -491,9 +634,27 @@ func (m *Model) helpLines() []string { return helpBarLines(m.helpPairs(), w) } +// footerLines is everything below the list: the selected row's agent, spelled +// out, and the help bar. One source of truth so listHeight reserves exactly the +// rows View is about to draw. +func (m *Model) footerLines() []string { + var out []string + if !m.renaming && m.status == "" { + if r, ok := m.current(); ok { + if words := agentWords(r.agent); words != "" { + out = append(out, agentCell(r.agent)+" "+cHead.Render(words)) + } + } + } + if m.status != "" { + return append(out, cFlag.Render(m.status)) + } + return append(out, m.helpLines()...) +} + func (m *Model) listHeight() int { - // frame top+bottom (2) + prompt + rule + blank + help lines - h := m.height - 5 - len(m.helpLines()) + // frame top+bottom (2) + prompt + rule + blank + footer + h := m.height - 5 - len(m.footerLines()) if !m.sideBySide() && m.opt.Preview { h = h/2 - 1 } @@ -560,6 +721,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height + // Column caps are a proportion of the available width, so a resize can + // change them even though the rows have not. + m.relayout() m.ensureVisible() return m, nil @@ -677,6 +841,9 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refilter() return m, m.previewCmd() } + if r.merged { + return m, nil // no header to fold into + } // On a child: fold the group it belongs to and land on its header, // rather than doing nothing. m.collapse[r.group] = true @@ -851,6 +1018,14 @@ func (m *Model) choose(mode action.Mode) (tea.Model, tea.Cmd) { // printable, which silently narrows the selected row's columns. const cursorCells = 3 +// Fixed column widths. paneCountCells covers "NNp " including its own trailing +// space; markerCells is the active-pane "*". +const ( + paneCountCells = 4 + flagCells = 2 + markerCells = 1 +) + // rightMargin keeps the badge column off the frame border. const rightMargin = 1 @@ -877,6 +1052,60 @@ func padLeft(s string, w int) string { return "…" + string(r) } +// rowLabel, rowName, rowPath and rowFlags are the single source of truth for +// what each flexible column holds. relayout measures exactly what renderRow +// draws — deriving the two separately is how a column ends up sized for text +// that is not in it. +func (m *Model) rowLabel(r row) string { + if r.merged { + // A merged row stands in for its own session header, so it carries the + // session name the header would have shown. + return r.group + } + if r.kind == kindPane { + if m.opt.Tree { + return r.win.Index + "." + r.pane.Index + } + return r.win.Session + ":" + r.win.Index + "." + r.pane.Index + } + if m.opt.Tree { + return r.win.Index + } + return r.win.Session + ":" + r.win.Index +} + +func (m *Model) rowName(r row) string { + if r.kind == kindPane { + return strings.TrimSpace(r.pane.Cmd) + } + return strings.TrimSpace(r.win.Name) +} + +func (m *Model) rowPath(r row) string { + if r.kind == kindPane { + return tilde(r.pane.Path) + } + return tilde(r.win.Path) +} + +func rowFlags(r row) string { + f := "" + if r.win.Activity { + f += "!" + } + if r.win.Zoomed { + f += "z" + } + return f +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + func (m *Model) badge(st model.Status, tab string, isHeader bool) string { switch st { case model.Visible: @@ -895,11 +1124,14 @@ func (m *Model) badge(st model.Status, tab string, isHeader bool) string { } } -// agentCells is the width of the agent column: one cell naming the agent, one -// naming what it wants. Reserved on every row, agent or not — an indicator -// drawn only where there is an agent would shift every other column on those -// rows and nowhere else. -const agentCells = 2 +// agentCells is the width of the agent column: one cell naming the agent, a +// space, one cell naming what it wants. The space is not decoration — flush +// against each other the two glyphs read as a single smudged symbol, which +// defeats the whole point of splitting identity from state. +// +// Reserved on every row of a table that has any agent at all, and dropped +// entirely from one that has none. +const agentCells = 3 // agentCell renders that column. Always exactly agentCells wide. func agentCell(r agent.Record) string { @@ -923,7 +1155,36 @@ func agentCell(r agent.Record) string { default: state = cAgentBusy.Render(glyphBusy) } - return id + state + return id + " " + state +} + +// agentWords spells out an agent record for the footer. The glyphs are compact +// but nothing on screen says what they mean; this is where you find out, on +// whichever row the cursor is on. +func agentWords(r agent.Record) string { + if r.Empty() { + return "" + } + var what string + switch r.State { + case agent.Perm: + what = "waiting for permission" + case agent.Ask: + what = "waiting for an answer" + case agent.Done: + what = "finished a turn" + case agent.Err: + what = "turn failed" + default: + what = "working" + } + out := r.Agent + " · " + what + if r.At > 0 { + if d := time.Since(time.Unix(r.At, 0)); d >= time.Second { + out += " · " + d.Round(time.Second).String() + " ago" + } + } + return out } func glyph(st model.Status) string { @@ -983,54 +1244,33 @@ func (m *Model) renderRow(r row, selected bool) string { return truncateANSI(line, lw) } - // Column budget. The badge is reserved first: it is the one column the - // whole tool exists to show, so it must never be what gets truncated. + // The badge is reserved first: it is the one column the whole tool exists to + // show, so it must never be what gets truncated. indent := " ├ " if r.last { indent = " └ " } - if !m.opt.Tree { - indent = " " + if !m.opt.Tree || r.merged { + // A merged row has no header above it, so a tree connector would point + // at nothing. + indent = strings.Repeat(" ", m.indentCells()) } - // Budget the columns exactly. `avail` is the space the three flexible - // columns share, so every fixed cell — cursor, indent, glyph, the single - // spaces between fields, the "NNp " counter, the flags, the badge column - // and the right margin — must be subtracted here. Getting this sum wrong - // by even a few cells pushes the row past lw, and the badge (rightmost, - // and the whole point of the tool) is what truncateANSI eats. - // - // badgeW is the table-wide maximum, never this row's own width: sizing it - // per row gives every row a different layout. + l := m.lay badge := m.badge(r.status, r.tabID, false) - badgeCol := strings.Repeat(" ", maxInt(0, m.badgeW-ansi.StringWidth(badge))) + badge - fixed := cursorCells + ansi.StringWidth(indent) + m.badgeW + rightMargin - if r.kind == kindPane { - fixed += 1 + 1 + agentCells + 6 // glyph, active marker, agent, six spaces - } else { - fixed += 1 + agentCells + 4 + 2 + 6 // glyph, agent, "NNp ", flags, six spaces - } - avail := lw - fixed - if avail < 20 { - avail = 20 - } - labelW := avail * 22 / 100 - nameW := avail * 34 / 100 - pathW := avail - labelW - nameW - if pathW < 8 { - pathW = 8 + badgeCol := strings.Repeat(" ", maxInt(0, l.badge-ansi.StringWidth(badge))) + badge + + label := pad(m.rowLabel(r), l.label) + if r.merged { + // Styled like the header it replaces, so a session still reads as a + // session rather than as a stray window index. + label = cGroup.Render(label) } + name := cName.Render(pad(m.rowName(r), l.name)) + path := cDim.Render(pad(padLeft(m.rowPath(r), l.path), l.path)) - var label, name string + var mid string if r.kind == kindPane { - // In the tree the session is already on the header, so a pane row shows - // only its own coordinates. - label = r.win.Index + "." + r.pane.Index - if !m.opt.Tree { - label = r.win.Session + ":" + label - } - name = strings.TrimSpace(r.pane.Cmd) - // The active-pane marker gets a column of its own, reserved on every // pane row. Rendered flush against the glyph it read as one smudged // symbol, and appearing only on the active row it shifted that row's @@ -1039,36 +1279,31 @@ func (m *Model) renderRow(r row, selected bool) string { if r.pane.Active { marker = cFlag.Render("*") } - return truncateANSI(cursor+cDim.Render(indent)+glyph(r.status)+" "+marker+" "+ - agentCell(r.agent)+" "+ - pad(label, labelW)+" "+cName.Render(pad(name, nameW))+" "+ - cDim.Render(pad(padLeft(tilde(r.pane.Path), pathW), pathW))+" "+ - badgeCol, lw) + mid = marker + " " } - label = r.win.Index - if !m.opt.Tree { - label = r.win.Session + ":" + r.win.Index + tail := "" + if l.panes > 0 { + tail += fmt.Sprintf("%2dp ", r.win.Panes) } - name = strings.TrimSpace(r.win.Name) - flags := "" - if r.win.Activity { - flags += "!" - } - if r.win.Zoomed { - flags += "z" + if l.flags > 0 { + tail += cFlag.Render(pad(rowFlags(r), l.flags)) + " " } - line := cursor + cDim.Render(indent) + glyph(r.status) + " " + - agentCell(r.agent) + " " + - pad(label, labelW) + " " + - cName.Render(pad(name, nameW)) + " " + - fmt.Sprintf("%2dp ", r.win.Panes) + cFlag.Render(pad(flags, 2)) + " " + - cDim.Render(pad(padLeft(tilde(r.win.Path), pathW), pathW)) + " " + - badgeCol + line := cursor + cDim.Render(indent) + glyph(r.status) + " " + mid + + m.agentCol(r) + label + " " + name + " " + tail + path + " " + badgeCol return truncateANSI(line, lw) } +// agentCol renders the agent column plus its trailing space, or nothing at all +// when no row in the table has an agent. +func (m *Model) agentCol(r row) string { + if m.lay.agent == 0 { + return "" + } + return agentCell(r.agent) + " " +} + func tilde(p string) string { if h := homeDir(); h != "" && strings.HasPrefix(p, h+"/") { return "~" + p[len(h):] @@ -1147,14 +1382,11 @@ func (m *Model) View() string { // Indent the footer to the same column as the prompt, and give it a blank // line of separation from the list so it reads as a footer rather than // another row. - hl := m.helpLines() - for i := range hl { - hl[i] = footerPad + hl[i] - } - help := strings.Join(hl, "\n") - if m.status != "" { - help = footerPad + cFlag.Render(truncateANSI(m.status, w-len(footerPad))) + fl := m.footerLines() + for i := range fl { + fl[i] = footerPad + truncateANSI(fl[i], w-len(footerPad)) } + help := strings.Join(fl, "\n") content := strings.Join([]string{prompt, rule(w), body, "", help}, "\n") return frame("tmux ⇄ kaku", content, w) From 9fa8998eb41a309052e62d6c4353df91c9e434fb Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Wed, 19 Aug 2026 16:10:57 +0200 Subject: [PATCH 07/14] Revert "Fit the picker to its contents" This reverts commit 7b39100d9ea46a0b64b3a44e2db5bfd85a7b5f0d. --- README.md | 6 - cmd/kaku-tab/main.go | 91 +------- docs/agents.md | 15 +- docs/configuration.md | 14 -- internal/ui/agentcol_test.go | 4 +- internal/ui/layout_test.go | 226 ------------------- internal/ui/ui.go | 408 ++++++++--------------------------- 7 files changed, 98 insertions(+), 666 deletions(-) delete mode 100644 internal/ui/layout_test.go diff --git a/README.md b/README.md index 76d4fe0..9ba0970 100644 --- a/README.md +++ b/README.md @@ -137,12 +137,6 @@ and press Alt+L. Typing filters. A session header matches on behalf of its windows, so `api` shows the session *and* everything under it. -The popup opens at the size the list needs — the configured sizes are maximums — -and columns are sized to their contents, with constant ones (the pane count when -nothing is split, the flags when nothing is flagged) dropped entirely. A session -with a single window renders as one row rather than a header repeating its only -child; set `@kaku-tab-merge-single 'off'` to keep the header. - When there are more rows than fit, a scrollbar appears down the right edge — otherwise a list that continues below the frame looks exactly like one that ends there. Shift+Tab folds every session, and diff --git a/cmd/kaku-tab/main.go b/cmd/kaku-tab/main.go index 84672d0..0882f1b 100644 --- a/cmd/kaku-tab/main.go +++ b/cmd/kaku-tab/main.go @@ -185,7 +185,7 @@ func popup(selfTTY, selfSession string) error { preview := tmux.Option("@kaku-tab-preview", "off") == "on" for i := 0; i < 16; i++ { // bounded: a toggle loop should never run away - w, h := popupSize(preview, selfSession) + w, h := popupSize(preview) args := []string{"display-popup", "-E", "-B", "-w", w, "-h", h} if selfTTY != "" { args = append(args, "-c", selfTTY) @@ -214,93 +214,16 @@ func popup(selfTTY, selfSession string) error { // popupSize picks the geometry for the current preview setting: without a // preview pane the list alone needs far less width. -// -// The configured size is a *maximum*. A popup opened at a flat 60%x70% for a -// list of four rows is seven-tenths empty, and tmux cannot shrink it afterwards -// — display-popup fixes -w/-h at creation — so the fit has to be worked out -// here, before the picker is drawn. -func popupSize(preview bool, selfSession string) (string, string) { +func popupSize(preview bool) (string, string) { size := tmux.Option("@kaku-tab-popup-size", "90%,85%") if !preview { size = tmux.Option("@kaku-tab-popup-size-compact", "60%,70%") } w, h, ok := strings.Cut(size, ",") if !ok { - w, h = "90%", "85%" - } - w, h = strings.TrimSpace(w), strings.TrimSpace(h) - if tmux.Option("@kaku-tab-popup-fit", "on") != "on" { - return w, h - } - - // A preview needs the room the configured width buys it, so only the height - // is fitted in that mode. - cols, rows, ok := measure(selfSession, preview) - if !ok { - return w, h - } - if !preview { - w = fit(w, cols, "client_width") + return "90%", "85%" } - return w, fit(h, rows, "client_height") -} - -// measure asks the picker how big it would like to be. Failures are not fatal: -// the caller falls back to the configured size, which is what it used to always -// use. -func measure(selfSession string, preview bool) (cols, rows int, ok bool) { - ws, err := resolve.Resolve(liveSource{}, opts(selfSession, false)) - if err != nil || len(ws) == 0 { - return 0, 0, false - } - sortMode := sortOption() - c, r := ui.Measure(ws, ui.Options{ - Suffix: tmux.Option("@kaku-tab-satellite-suffix", model.DefaultSatelliteSuffix), - Tree: tmux.Option("@kaku-tab-tree", "on") == "on", - MergeSingle: tmux.Option("@kaku-tab-merge-single", "on") == "on", - Preview: preview, - HideDetached: tmux.Option("@kaku-tab-detached", "on") == "off", - Sort: sortMode, - MRU: mruList(sortMode), - }) - return c, r, true -} - -// fit reduces a configured dimension to what the content needs, never past a -// floor that keeps the picker usable, and never above the configured maximum. -// -// The maximum may be a percentage, which only tmux can resolve, so it is -// converted through the client's own size rather than guessed at. -func fit(configured string, need int, clientDim string) string { - var limit int - if pct, isPct := strings.CutSuffix(configured, "%"); isPct { - n, err := strconv.Atoi(pct) - if err != nil { - return configured - } - out, e := tmux.Run("display-message", "-p", "#{"+clientDim+"}") - if e != nil { - return configured - } - total, err := strconv.Atoi(strings.TrimSpace(out)) - if err != nil { - return configured - } - limit = total * n / 100 - } else if n, err := strconv.Atoi(configured); err == nil { - limit = n - } else { - return configured - } - - const floor = 12 // below this the frame and footer crowd out the list - if need < floor { - need = floor - } - if need > limit { - need = limit - } - return strconv.Itoa(need) + return strings.TrimSpace(w), strings.TrimSpace(h) } type persisted struct { @@ -378,11 +301,6 @@ func pick(selfTTY, selfSession string) error { // find every window gone. It only rides across a preview-toggle relaunch. agentsOnly := resumed && restore.AgentsOnly - mergeSingle := tmux.Option("@kaku-tab-merge-single", "on") == "on" - if resumed { - mergeSingle = restore.MergeSingle - } - self, _ := os.Executable() ctx := action.Ctx{SelfTTY: selfTTY, Suffix: suffix, AttachSh: self} @@ -402,7 +320,6 @@ func pick(selfTTY, selfSession string) error { HideDetached: hideDetached, AgentsOnly: agentsOnly, - MergeSingle: mergeSingle, }) // The picker owns the popup's terminal; Kaku's own alt-screen is untouched. diff --git a/docs/agents.md b/docs/agents.md index 211c4a7..942327c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -125,17 +125,10 @@ header the most actionable among its windows, so a pane blocked three windows deep is visible without unfolding anything. Switch to pane mode (^p) for the exact pane. -The two glyphs are separated by a space — flush against each other they read as -one smudged symbol, which defeats the point of splitting them. The column is -reserved on every row of a table that has any agent at all, and dropped entirely -from one that has none: an indicator drawn only where there is an agent would -shift every other column on those rows and nowhere else. Every glyph is pinned -to one display cell by a test; a double-width one would break the table's column -budget. - -Nothing on screen says what a glyph means, so the footer spells out the selected -row's agent in words — `claude · waiting for permission · 2m ago` — on whichever -row the cursor is on. +The column is a fixed two cells and reserved on every row, agent or not: an +indicator drawn only where there is an agent would shift every other column on +those rows and nowhere else. Every glyph is pinned to one display cell by a +test — a double-width one would break the table's column budget. ^a filters to windows with an agent that wants you — `perm`, `ask`, `done` or `err`, but not `busy`. Typing an agent's name in the search box works diff --git a/docs/configuration.md b/docs/configuration.md index 5f21254..037b96e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,20 +17,6 @@ set -g @plugin 'dsaad68/kaku-tab' | `@kaku-tab-popup-size-compact` | `60%,70%` | popup size with the preview hidden | | `@kaku-tab-preview` | `off` | start with the preview pane; Ctrl+/ toggles | | `@kaku-tab-tree` | `on` | group windows under their session; `off` gives a flat list | -| `@kaku-tab-merge-single` | `on` | a session with one window renders as one row instead of a header plus its only child | -| `@kaku-tab-popup-fit` | `on` | shrink the popup to what the list needs; the sizes above become maximums | - -### Sizing - -The popup sizes are a **maximum**, not a fixed geometry. A list of four rows -opened at a flat `60%,70%` is seven-tenths empty, and tmux cannot shrink a popup -afterwards — `display-popup` fixes `-w`/`-h` at creation — so the fit is worked -out before the picker is drawn. Set `@kaku-tab-popup-fit 'off'` for the old -behaviour. - -Columns are likewise sized to what is in them rather than to a proportion of the -frame, and a column whose every cell reads the same — the pane count when nothing -is split, the flags when nothing is flagged — is not drawn at all. ### Why `M-l` diff --git a/internal/ui/agentcol_test.go b/internal/ui/agentcol_test.go index 49e7863..121ce22 100644 --- a/internal/ui/agentcol_test.go +++ b/internal/ui/agentcol_test.go @@ -97,10 +97,10 @@ func TestAgentAndStateAreBothShown(t *testing.T) { var sawClaudePerm, sawDevinBusy bool for _, r := range m.rows { cell := ansi.Strip(agentCell(r.agent)) - if cell == glyphClaude+" "+glyphPerm { + if cell == glyphClaude+glyphPerm { sawClaudePerm = true } - if cell == glyphDevin+" "+glyphBusy { + if cell == glyphDevin+glyphBusy { sawDevinBusy = true } } diff --git a/internal/ui/layout_test.go b/internal/ui/layout_test.go deleted file mode 100644 index 9c7cd67..0000000 --- a/internal/ui/layout_test.go +++ /dev/null @@ -1,226 +0,0 @@ -// SPDX-License-Identifier: MIT - -package ui - -import ( - "strings" - "testing" - - "github.com/charmbracelet/x/ansi" - - "github.com/dsaad68/kaku-tab/internal/agent" - "github.com/dsaad68/kaku-tab/internal/model" -) - -// Two sessions of one window each, nothing multi-pane, nothing flagged — the -// shape that used to render six rows of which three were duplicates, in a table -// whose columns were mostly padding. -func flatSample() []model.Window { - return []model.Window{ - {RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1", Name: "zsh", Panes: 1, Path: "/home/u/api"}, - Status: model.Visible, TabID: "5", ClientSession: "api"}, - {RawWindow: model.RawWindow{Session: "kaku-tab", ID: "@2", Index: "1", Name: "claude", Panes: 1, Path: "/home/u/kaku-tab"}, - Status: model.Visible, TabID: "6", ClientSession: "kaku-tab"}, - } -} - -func modelAt(t *testing.T, ws []model.Window, opt Options, w, h int) *Model { - t.Helper() - m := New(ws, opt) - m.width, m.height = w, h - m.relayout() - return m -} - -// A session with one window needs no header: there is nothing to group and -// nothing to fold, and the header only repeated the row beneath it. -func TestMergeSingleDropsRedundantHeaders(t *testing.T) { - m := modelAt(t, flatSample(), Options{Tree: true, MergeSingle: true}, 150, 30) - if len(m.rows) != 2 { - t.Fatalf("got %d rows, want 2 (one per session)", len(m.rows)) - } - for _, r := range m.rows { - if r.kind == kindHeader { - t.Errorf("header survived for a one-window session: %+v", r.group) - } - if !r.merged { - t.Errorf("row %s not marked merged", r.group) - } - } - - // Off, the tree is unchanged: a header plus its child for each session. - m = modelAt(t, flatSample(), Options{Tree: true}, 150, 30) - if len(m.rows) != 4 { - t.Fatalf("with MergeSingle off got %d rows, want 4", len(m.rows)) - } -} - -// A merged row carries the session name, since it stands in for the header that -// would have shown it. -func TestMergedRowShowsSessionName(t *testing.T) { - m := modelAt(t, flatSample(), Options{Tree: true, MergeSingle: true}, 150, 30) - var got []string - for _, r := range m.rows { - got = append(got, m.rowLabel(r)) - } - want := "api kaku-tab" - if strings.Join(got, " ") != want { - t.Errorf("merged labels = %q, want %q", strings.Join(got, " "), want) - } -} - -// Merging only applies to a group of one. A session with two windows keeps its -// header, because there is now something to fold. -func TestMergeSingleLeavesRealGroupsAlone(t *testing.T) { - ws := append(flatSample(), model.Window{ - RawWindow: model.RawWindow{Session: "api", ID: "@3", Index: "2", Name: "vim", Panes: 1, Path: "/home/u/api"}, - Status: model.Visible, TabID: "5", ClientSession: "api"}) - m := modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 150, 30) - - headers, merged := 0, 0 - for _, r := range m.rows { - if r.kind == kindHeader { - headers++ - if r.group != "api" { - t.Errorf("unexpected header for %q", r.group) - } - } - if r.merged { - merged++ - } - } - if headers != 1 || merged != 1 { - t.Errorf("headers=%d merged=%d, want 1 and 1", headers, merged) - } -} - -// A column whose every cell reads the same carries no information. The pane -// count is "1p" on every row of a table with no split windows, and the flags -// column is blank when nothing is flagged. -func TestConstantColumnsAreDropped(t *testing.T) { - m := modelAt(t, flatSample(), Options{Tree: true, MergeSingle: true}, 150, 30) - if m.lay.panes != 0 { - t.Errorf("pane-count column drawn (%d) with no multi-pane window", m.lay.panes) - } - if m.lay.flags != 0 { - t.Errorf("flags column drawn (%d) with nothing flagged", m.lay.flags) - } - for _, r := range m.rows { - if strings.Contains(ansi.Strip(m.renderRow(r, false)), "1p") { - t.Errorf("row still renders a pane count: %q", ansi.Strip(m.renderRow(r, false))) - } - } - - // One split window is enough to bring the column back for the whole table. - ws := flatSample() - ws[0].Panes = 3 - ws[0].Activity = true - m = modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 150, 30) - if m.lay.panes != paneCountCells { - t.Errorf("pane-count column missing (%d) with a 3-pane window", m.lay.panes) - } - if m.lay.flags != flagCells { - t.Errorf("flags column missing (%d) with an activity flag", m.lay.flags) - } -} - -// Columns are sized to what is in them, not to a proportion of the frame. The -// old fixed split spent 22% of the width on a column holding "1". -func TestColumnsSizedToContent(t *testing.T) { - m := modelAt(t, flatSample(), Options{Tree: true, MergeSingle: true}, 150, 30) - if want := len("kaku-tab"); m.lay.label != want { - t.Errorf("label column = %d, want %d (its widest value)", m.lay.label, want) - } - if want := len("claude"); m.lay.name != want { - t.Errorf("name column = %d, want %d", m.lay.name, want) - } - // And the row is nowhere near the frame width it used to be padded out to. - for _, r := range m.rows { - if w := ansi.StringWidth(m.renderRow(r, false)); w > m.rowWidth()/2 { - t.Errorf("row is %d cells of a %d-cell frame; columns are not fitted", - w, m.rowWidth()) - } - } -} - -// An outlier must not squeeze the other columns out of existence. -func TestColumnCapsBindOnOutliers(t *testing.T) { - ws := flatSample() - ws[0].Name = strings.Repeat("x", 400) - ws[0].Path = "/" + strings.Repeat("y", 400) - m := modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 100, 30) - for _, r := range m.rows { - line := m.renderRow(r, false) - if w := ansi.StringWidth(line); w > m.rowWidth() { - t.Errorf("row %d cells > rowWidth %d", w, m.rowWidth()) - } - if !strings.Contains(ansi.Strip(line), "⟦") { - t.Errorf("badge truncated away by an outlier column: %q", ansi.Strip(line)) - } - } -} - -// Measure is what lets the popup open at the size it needs: tmux fixes a -// popup's geometry at creation and cannot resize it afterwards. -func TestMeasureFitsContent(t *testing.T) { - opt := Options{Tree: true, MergeSingle: true} - cols, rows := Measure(flatSample(), opt) - - if cols < minPopupCols { - t.Errorf("cols = %d, below the floor %d", cols, minPopupCols) - } - if cols > 200 { - t.Errorf("cols = %d for two short rows; not fitted", cols) - } - // Two rows plus frame, prompt, rule, blank and a footer that wraps at this - // width — comfortably under a screenful either way. - if rows < 8 || rows > 16 { - t.Errorf("rows = %d for a two-row list, want a snug fit", rows) - } - - // More windows must ask for more rows, or nothing is being measured. - big := flatSample() - for i := 0; i < 20; i++ { - big = append(big, model.Window{RawWindow: model.RawWindow{ - Session: "api", ID: "@x", Index: "9", Name: "w", Panes: 1, Path: "/home/u"}}) - } - _, bigRows := Measure(big, opt) - if bigRows <= rows { - t.Errorf("20 more windows asked for %d rows, no more than %d", bigRows, rows) - } -} - -// The glyphs are compact and nothing on screen says what they mean; the footer -// is where you find out, for whichever row the cursor is on. -func TestFooterSpellsOutTheSelectedAgent(t *testing.T) { - ws := flatSample() - ws[1].Agent = agent.Record{Agent: agent.Claude, State: agent.Perm, PID: 2, At: 1} - m := modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 150, 30) - - m.cursor = 0 - if got := strings.Join(m.footerLines(), " "); strings.Contains(got, "waiting for permission") { - t.Errorf("footer described an agent for a row that has none: %q", got) - } - m.cursor = 1 - got := ansi.Strip(strings.Join(m.footerLines(), " ")) - if !strings.Contains(got, "claude") || !strings.Contains(got, "waiting for permission") { - t.Errorf("footer = %q, want it to name the agent and its state", got) - } -} - -// The footer grows by a line when it describes an agent, so the list has to -// give one back — otherwise the bottom row is drawn over the frame. -func TestListHeightReservesTheFooter(t *testing.T) { - ws := flatSample() - ws[1].Agent = agent.Record{Agent: agent.Claude, State: agent.Perm, PID: 2, At: 1} - m := modelAt(t, ws, Options{Tree: true, MergeSingle: true}, 150, 30) - - m.cursor = 0 - plain := m.listHeight() - m.cursor = 1 - withAgent := m.listHeight() - if withAgent >= plain { - t.Errorf("listHeight %d with the agent line, %d without; the line was not reserved", - withAgent, plain) - } -} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index cff3fe4..de9087e 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -19,7 +19,6 @@ import ( "fmt" "sort" "strings" - "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -65,9 +64,6 @@ type row struct { tabID string agent agent.Record last bool // last child of its group -> └ - // merged marks a row that stands in for its own group header, because that - // group has exactly one child and the header would only have repeated it. - merged bool } // Result is what the picker hands back to main. @@ -94,7 +90,6 @@ type State struct { PaneMode bool `json:"pane_mode"` HideDetached bool `json:"hide_detached"` AgentsOnly bool `json:"agents_only"` - MergeSingle bool `json:"merge_single"` Collapse map[string]bool `json:"collapse"` } @@ -115,11 +110,6 @@ type Options struct { // HideDetached drops sessions with no terminal tab from the list. HideDetached bool - // MergeSingle folds a group of one into a single row. A session with one - // window needs no header: there is nothing to group and nothing to fold, and - // the header only repeats the badge and agent state of the row below it. - MergeSingle bool - // AgentsOnly narrows the list to windows holding an agent that wants you — // blocked on permission, asking a question, finished, or failed. A window // whose agent is merely working is not one of them: the point of the filter @@ -127,28 +117,6 @@ type Options struct { AgentsOnly bool } -// layout is every column width in the table, computed once from the rows the -// table actually holds rather than from fixed proportions of the frame. -// -// Sizing to content is the point: a fixed 22%% of the width for a column that -// holds "1", and 34%% for one that holds "zsh", spent seventy cells on padding -// and pushed the badge — the one column this tool exists to show — an eyeful -// away from the name it belongs to. -// -// A width of 0 means the column is not drawn at all. Two of them earn that -// regularly: the pane count when nothing has a second pane, and the flags when -// nothing is flagged. A column whose every cell reads the same carries no -// information, and this table is mostly such rows. -type layout struct { - agent int // 0, or agentCells when any row has an agent - label int - name int - path int - panes int // 0, or paneCountCells when any window has more than one pane - flags int // 0, or flagCells when any window carries a flag - badge int -} - type previewMsg struct { target string content string @@ -170,7 +138,7 @@ type Model struct { collapse map[string]bool width int height int - lay layout // column widths, shared by every row + badgeW int // widest badge across the table; shared by every row preview map[string]string renaming bool rename string @@ -206,46 +174,13 @@ func New(ws []model.Window, opt Options) *Model { return m } -// Measure reports the size the picker would like: exactly wide enough for its -// widest row and tall enough for all of them, frame and footer included. -// -// The caller clamps this to whatever maximum it is willing to open. It exists -// because tmux cannot resize a popup once it is open — display-popup takes its -// -w/-h at creation — so the geometry has to be decided before the picker is -// ever drawn, and only the picker knows how wide its own table is. -func Measure(ws []model.Window, opt Options) (cols, rows int) { - m := New(ws, opt) - // Measure against a width no real terminal reaches, so the proportional - // caps in relayout never bind and every column reports its natural size. - m.width, m.height = 10000, 10000 - m.relayout() - - l := m.lay - content := m.fixedCells(l) + l.label + l.name + l.path - // scrollbar gutter + frame border - cols = maxInt(minPopupCols, content+scrollbarCells+2) - - // Count the footer at the width we will actually open at, not at the - // measuring width: the help bar wraps, and a popup sized against a - // one-line footer opens with three lines of it eating the list. - m.width = cols - // frame top+bottom, prompt, rule, blank, footer. - rows = len(m.rows) + 5 + len(m.footerLines()) - return cols, rows -} - -// minPopupCols is a floor on the fitted width. Below roughly this the help bar -// wraps into more lines than the list has rows, and a popup that is mostly -// footer is no better than one that is mostly blank. -const minPopupCols = 80 - // State captures what a relaunch needs to restore. func (m *Model) State() State { return State{ Query: m.query, Cursor: m.cursor, Offset: m.offset, Preview: m.opt.Preview, PaneMode: m.opt.PaneMode, HideDetached: m.opt.HideDetached, AgentsOnly: m.opt.AgentsOnly, - MergeSingle: m.opt.MergeSingle, Collapse: m.collapse, + Collapse: m.collapse, } } @@ -362,12 +297,7 @@ func (m *Model) build() { htab = w.TabID } } - // A group of one becomes one row: the header would carry the same badge - // and the same agent state as the single child directly beneath it, and - // there is nothing to fold. Half the rows in a list of one-window - // sessions were that duplicate. - merge := m.opt.Tree && m.opt.MergeSingle && n == 1 - if m.opt.Tree && !merge { + if m.opt.Tree { m.rows = append(m.rows, row{ kind: kindHeader, group: g, search: g, count: n, status: hstat, tabID: htab, agent: agent.Best(hagents), @@ -378,7 +308,7 @@ func (m *Model) build() { if m.opt.PaneMode { for j, p := range w.Panes_ { m.rows = append(m.rows, row{ - kind: kindPane, group: g, win: w, pane: p, merged: merge, + kind: kindPane, group: g, win: w, pane: p, search: strings.Join([]string{w.Session, w.Index, p.Index, p.Cmd, p.Path, p.Agent.Agent}, " "), status: w.Status, tabID: w.TabID, agent: p.Agent, last: j == len(w.Panes_)-1, @@ -387,7 +317,7 @@ func (m *Model) build() { continue } m.rows = append(m.rows, row{ - kind: kindWindow, group: g, win: w, merged: merge, + kind: kindWindow, group: g, win: w, // The agent name joins the search text so typing "claude" // narrows to agent windows; it is never rendered as text. search: strings.Join([]string{w.Session, w.Index, w.Name, w.Path, w.Agent.Agent}, " "), @@ -495,10 +425,7 @@ func (m *Model) refilter() { continue } } - // Never hide a merged row: it has no header, so nothing could unfold it - // again. A group collapsed before a reload merged it would otherwise - // vanish for good. - if r.kind != kindHeader && !r.merged && m.collapse[r.group] { + if r.kind != kindHeader && m.collapse[r.group] { continue } m.view = append(m.view, i) @@ -511,90 +438,20 @@ func (m *Model) refilter() { m.cursor = 0 } - m.relayout() - m.ensureVisible() -} - -// relayout sizes every column once for the whole table. -// -// Once for the whole table, never per row: deriving a width from each row's own -// content gives every row a different layout, which is what made this table look -// ragged before. Measured in display cells — ansi.StringWidth for anything that -// may carry styling, runewidth for plain text — because a byte or rune count -// treats escape sequences and nerd-font glyphs as one cell each and silently -// narrows whichever row happens to carry them. -func (m *Model) relayout() { - var l layout + // One badge column for the whole table, sized to the widest badge. Sizing + // it per row gave every row a different column budget, which is what made + // the table look ragged: "⟦kaku 7⟧ ← here" is nearly twice "⟦kaku 8⟧". + m.badgeW = 0 for _, r := range m.rows { - if !r.agent.Empty() { - l.agent = agentCells - } if r.kind == kindHeader { - // Headers are truncated rather than padded, so their badge does not - // set the column width. continue } - if w := ansi.StringWidth(m.badge(r.status, r.tabID, false)); w > l.badge { - l.badge = w - } - l.label = maxInt(l.label, runewidth.StringWidth(m.rowLabel(r))) - l.name = maxInt(l.name, runewidth.StringWidth(m.rowName(r))) - l.path = maxInt(l.path, runewidth.StringWidth(m.rowPath(r))) - if r.kind == kindWindow { - if r.win.Panes > 1 { - l.panes = paneCountCells - } - if rowFlags(r) != "" { - l.flags = flagCells - } - } - } - - // Cap each flexible column so one outlier — a 90-character path, a session - // named after a git branch — cannot squeeze the others out. The caps are - // generous because they only ever bind on outliers; the common case is that - // none of them apply and the row is exactly as wide as its content. - avail := maxInt(20, m.rowWidth()-m.fixedCells(l)) - l.label = minInt(l.label, avail*30/100) - l.name = minInt(l.name, avail*40/100) - l.path = minInt(l.path, maxInt(8, avail-l.label-l.name)) - m.lay = l -} - -// fixedCells is everything in a row that is not one of the flexible columns: -// the cursor, the tree indent, the status glyph, the agent column, the pane -// count or active marker, the flags, the badge, the single space after each of -// them, and the right margin. -// -// This sum is the one piece of arithmetic here that has to be exact. Wrong by -// even a few cells and the row runs past the list width, where truncateANSI -// eats the badge — rightmost, and the point of the tool. -func (m *Model) fixedCells(l layout) int { - // status glyph and its space; a trailing space after each of label, name - // and path. - n := cursorCells + m.indentCells() + rightMargin + l.badge + 2 + 3 - if l.agent > 0 { - n += l.agent + 1 - } - if m.opt.PaneMode { - n += markerCells + 1 - } else { - if l.panes > 0 { - n += l.panes - } - if l.flags > 0 { - n += l.flags + 1 + if w := ansi.StringWidth(m.badge(r.status, r.tabID, false)); w > m.badgeW { + m.badgeW = w } } - return n -} -// indentCells is the width of the tree connector on a child row. -func (m *Model) indentCells() int { - if m.opt.Tree { - return 3 // " ├ " - } - return 1 + m.ensureVisible() } // innerW is the drawable width inside the frame border. @@ -634,27 +491,9 @@ func (m *Model) helpLines() []string { return helpBarLines(m.helpPairs(), w) } -// footerLines is everything below the list: the selected row's agent, spelled -// out, and the help bar. One source of truth so listHeight reserves exactly the -// rows View is about to draw. -func (m *Model) footerLines() []string { - var out []string - if !m.renaming && m.status == "" { - if r, ok := m.current(); ok { - if words := agentWords(r.agent); words != "" { - out = append(out, agentCell(r.agent)+" "+cHead.Render(words)) - } - } - } - if m.status != "" { - return append(out, cFlag.Render(m.status)) - } - return append(out, m.helpLines()...) -} - func (m *Model) listHeight() int { - // frame top+bottom (2) + prompt + rule + blank + footer - h := m.height - 5 - len(m.footerLines()) + // frame top+bottom (2) + prompt + rule + blank + help lines + h := m.height - 5 - len(m.helpLines()) if !m.sideBySide() && m.opt.Preview { h = h/2 - 1 } @@ -721,9 +560,6 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height - // Column caps are a proportion of the available width, so a resize can - // change them even though the rows have not. - m.relayout() m.ensureVisible() return m, nil @@ -841,9 +677,6 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refilter() return m, m.previewCmd() } - if r.merged { - return m, nil // no header to fold into - } // On a child: fold the group it belongs to and land on its header, // rather than doing nothing. m.collapse[r.group] = true @@ -1018,14 +851,6 @@ func (m *Model) choose(mode action.Mode) (tea.Model, tea.Cmd) { // printable, which silently narrows the selected row's columns. const cursorCells = 3 -// Fixed column widths. paneCountCells covers "NNp " including its own trailing -// space; markerCells is the active-pane "*". -const ( - paneCountCells = 4 - flagCells = 2 - markerCells = 1 -) - // rightMargin keeps the badge column off the frame border. const rightMargin = 1 @@ -1052,60 +877,6 @@ func padLeft(s string, w int) string { return "…" + string(r) } -// rowLabel, rowName, rowPath and rowFlags are the single source of truth for -// what each flexible column holds. relayout measures exactly what renderRow -// draws — deriving the two separately is how a column ends up sized for text -// that is not in it. -func (m *Model) rowLabel(r row) string { - if r.merged { - // A merged row stands in for its own session header, so it carries the - // session name the header would have shown. - return r.group - } - if r.kind == kindPane { - if m.opt.Tree { - return r.win.Index + "." + r.pane.Index - } - return r.win.Session + ":" + r.win.Index + "." + r.pane.Index - } - if m.opt.Tree { - return r.win.Index - } - return r.win.Session + ":" + r.win.Index -} - -func (m *Model) rowName(r row) string { - if r.kind == kindPane { - return strings.TrimSpace(r.pane.Cmd) - } - return strings.TrimSpace(r.win.Name) -} - -func (m *Model) rowPath(r row) string { - if r.kind == kindPane { - return tilde(r.pane.Path) - } - return tilde(r.win.Path) -} - -func rowFlags(r row) string { - f := "" - if r.win.Activity { - f += "!" - } - if r.win.Zoomed { - f += "z" - } - return f -} - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - func (m *Model) badge(st model.Status, tab string, isHeader bool) string { switch st { case model.Visible: @@ -1124,14 +895,11 @@ func (m *Model) badge(st model.Status, tab string, isHeader bool) string { } } -// agentCells is the width of the agent column: one cell naming the agent, a -// space, one cell naming what it wants. The space is not decoration — flush -// against each other the two glyphs read as a single smudged symbol, which -// defeats the whole point of splitting identity from state. -// -// Reserved on every row of a table that has any agent at all, and dropped -// entirely from one that has none. -const agentCells = 3 +// agentCells is the width of the agent column: one cell naming the agent, one +// naming what it wants. Reserved on every row, agent or not — an indicator +// drawn only where there is an agent would shift every other column on those +// rows and nowhere else. +const agentCells = 2 // agentCell renders that column. Always exactly agentCells wide. func agentCell(r agent.Record) string { @@ -1155,36 +923,7 @@ func agentCell(r agent.Record) string { default: state = cAgentBusy.Render(glyphBusy) } - return id + " " + state -} - -// agentWords spells out an agent record for the footer. The glyphs are compact -// but nothing on screen says what they mean; this is where you find out, on -// whichever row the cursor is on. -func agentWords(r agent.Record) string { - if r.Empty() { - return "" - } - var what string - switch r.State { - case agent.Perm: - what = "waiting for permission" - case agent.Ask: - what = "waiting for an answer" - case agent.Done: - what = "finished a turn" - case agent.Err: - what = "turn failed" - default: - what = "working" - } - out := r.Agent + " · " + what - if r.At > 0 { - if d := time.Since(time.Unix(r.At, 0)); d >= time.Second { - out += " · " + d.Round(time.Second).String() + " ago" - } - } - return out + return id + state } func glyph(st model.Status) string { @@ -1244,33 +983,54 @@ func (m *Model) renderRow(r row, selected bool) string { return truncateANSI(line, lw) } - // The badge is reserved first: it is the one column the whole tool exists to - // show, so it must never be what gets truncated. + // Column budget. The badge is reserved first: it is the one column the + // whole tool exists to show, so it must never be what gets truncated. indent := " ├ " if r.last { indent = " └ " } - if !m.opt.Tree || r.merged { - // A merged row has no header above it, so a tree connector would point - // at nothing. - indent = strings.Repeat(" ", m.indentCells()) + if !m.opt.Tree { + indent = " " } - l := m.lay + // Budget the columns exactly. `avail` is the space the three flexible + // columns share, so every fixed cell — cursor, indent, glyph, the single + // spaces between fields, the "NNp " counter, the flags, the badge column + // and the right margin — must be subtracted here. Getting this sum wrong + // by even a few cells pushes the row past lw, and the badge (rightmost, + // and the whole point of the tool) is what truncateANSI eats. + // + // badgeW is the table-wide maximum, never this row's own width: sizing it + // per row gives every row a different layout. badge := m.badge(r.status, r.tabID, false) - badgeCol := strings.Repeat(" ", maxInt(0, l.badge-ansi.StringWidth(badge))) + badge - - label := pad(m.rowLabel(r), l.label) - if r.merged { - // Styled like the header it replaces, so a session still reads as a - // session rather than as a stray window index. - label = cGroup.Render(label) + badgeCol := strings.Repeat(" ", maxInt(0, m.badgeW-ansi.StringWidth(badge))) + badge + fixed := cursorCells + ansi.StringWidth(indent) + m.badgeW + rightMargin + if r.kind == kindPane { + fixed += 1 + 1 + agentCells + 6 // glyph, active marker, agent, six spaces + } else { + fixed += 1 + agentCells + 4 + 2 + 6 // glyph, agent, "NNp ", flags, six spaces + } + avail := lw - fixed + if avail < 20 { + avail = 20 + } + labelW := avail * 22 / 100 + nameW := avail * 34 / 100 + pathW := avail - labelW - nameW + if pathW < 8 { + pathW = 8 } - name := cName.Render(pad(m.rowName(r), l.name)) - path := cDim.Render(pad(padLeft(m.rowPath(r), l.path), l.path)) - var mid string + var label, name string if r.kind == kindPane { + // In the tree the session is already on the header, so a pane row shows + // only its own coordinates. + label = r.win.Index + "." + r.pane.Index + if !m.opt.Tree { + label = r.win.Session + ":" + label + } + name = strings.TrimSpace(r.pane.Cmd) + // The active-pane marker gets a column of its own, reserved on every // pane row. Rendered flush against the glyph it read as one smudged // symbol, and appearing only on the active row it shifted that row's @@ -1279,31 +1039,36 @@ func (m *Model) renderRow(r row, selected bool) string { if r.pane.Active { marker = cFlag.Render("*") } - mid = marker + " " + return truncateANSI(cursor+cDim.Render(indent)+glyph(r.status)+" "+marker+" "+ + agentCell(r.agent)+" "+ + pad(label, labelW)+" "+cName.Render(pad(name, nameW))+" "+ + cDim.Render(pad(padLeft(tilde(r.pane.Path), pathW), pathW))+" "+ + badgeCol, lw) } - tail := "" - if l.panes > 0 { - tail += fmt.Sprintf("%2dp ", r.win.Panes) + label = r.win.Index + if !m.opt.Tree { + label = r.win.Session + ":" + r.win.Index } - if l.flags > 0 { - tail += cFlag.Render(pad(rowFlags(r), l.flags)) + " " + name = strings.TrimSpace(r.win.Name) + flags := "" + if r.win.Activity { + flags += "!" + } + if r.win.Zoomed { + flags += "z" } - line := cursor + cDim.Render(indent) + glyph(r.status) + " " + mid + - m.agentCol(r) + label + " " + name + " " + tail + path + " " + badgeCol + line := cursor + cDim.Render(indent) + glyph(r.status) + " " + + agentCell(r.agent) + " " + + pad(label, labelW) + " " + + cName.Render(pad(name, nameW)) + " " + + fmt.Sprintf("%2dp ", r.win.Panes) + cFlag.Render(pad(flags, 2)) + " " + + cDim.Render(pad(padLeft(tilde(r.win.Path), pathW), pathW)) + " " + + badgeCol return truncateANSI(line, lw) } -// agentCol renders the agent column plus its trailing space, or nothing at all -// when no row in the table has an agent. -func (m *Model) agentCol(r row) string { - if m.lay.agent == 0 { - return "" - } - return agentCell(r.agent) + " " -} - func tilde(p string) string { if h := homeDir(); h != "" && strings.HasPrefix(p, h+"/") { return "~" + p[len(h):] @@ -1382,11 +1147,14 @@ func (m *Model) View() string { // Indent the footer to the same column as the prompt, and give it a blank // line of separation from the list so it reads as a footer rather than // another row. - fl := m.footerLines() - for i := range fl { - fl[i] = footerPad + truncateANSI(fl[i], w-len(footerPad)) + hl := m.helpLines() + for i := range hl { + hl[i] = footerPad + hl[i] + } + help := strings.Join(hl, "\n") + if m.status != "" { + help = footerPad + cFlag.Render(truncateANSI(m.status, w-len(footerPad))) } - help := strings.Join(fl, "\n") content := strings.Join([]string{prompt, rule(w), body, "", help}, "\n") return frame("tmux ⇄ kaku", content, w) From 0ad2b8001ea825d6fa4963ca2435f3cac16beb29 Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Sun, 23 Aug 2026 10:48:15 +0200 Subject: [PATCH 08/14] Separate the agent glyphs, and name the state in the footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes salvaged from the reverted layout rework, both local to the agent column and neither touching the table's geometry. The identity and state glyphs were rendered flush against each other, where they read as one smudged symbol — the same failure the active-pane marker already had its own column to avoid. A space between them costs one cell of a column budget that is written in terms of agentCells, so the constant is the only edit. And nothing on screen said what a glyph meant. The footer now spells out the selected row's agent in words, on whichever row the cursor is on. It is one extra line, so footerLines() becomes the single source of truth for what sits below the list: listHeight has to reserve exactly the rows View draws, or the bottom row lands on the frame. Co-Authored-By: Claude Opus 5 (1M context) --- docs/agents.md | 12 +++++- internal/ui/agentcol_test.go | 61 ++++++++++++++++++++++++++- internal/ui/ui.go | 81 +++++++++++++++++++++++++++++------- 3 files changed, 136 insertions(+), 18 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 942327c..cd5fe89 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -125,11 +125,21 @@ header the most actionable among its windows, so a pane blocked three windows deep is visible without unfolding anything. Switch to pane mode (^p) for the exact pane. -The column is a fixed two cells and reserved on every row, agent or not: an +The two glyphs are separated by a space. Flush against each other they read as +one smudged symbol, which defeats the point of splitting identity from state. + +The column is a fixed three cells and reserved on every row, agent or not: an indicator drawn only where there is an agent would shift every other column on those rows and nowhere else. Every glyph is pinned to one display cell by a test — a double-width one would break the table's column budget. +Nothing on screen says what a glyph means, so the footer spells out the selected +row's agent in words, on whichever row the cursor is on: + +``` + claude · waiting for permission · 2m ago +``` + ^a filters to windows with an agent that wants you — `perm`, `ask`, `done` or `err`, but not `busy`. Typing an agent's name in the search box works too; the name is part of each row's match text without being drawn. diff --git a/internal/ui/agentcol_test.go b/internal/ui/agentcol_test.go index 121ce22..f7d8703 100644 --- a/internal/ui/agentcol_test.go +++ b/internal/ui/agentcol_test.go @@ -97,10 +97,10 @@ func TestAgentAndStateAreBothShown(t *testing.T) { var sawClaudePerm, sawDevinBusy bool for _, r := range m.rows { cell := ansi.Strip(agentCell(r.agent)) - if cell == glyphClaude+glyphPerm { + if cell == glyphClaude+" "+glyphPerm { sawClaudePerm = true } - if cell == glyphDevin+glyphBusy { + if cell == glyphDevin+" "+glyphBusy { sawDevinBusy = true } } @@ -148,3 +148,60 @@ func TestAgentNameIsSearchable(t *testing.T) { } } } + +// The glyphs are compact and nothing on screen says what they mean; the footer +// is where you find out, for whichever row the cursor is on. +func TestFooterSpellsOutTheSelectedAgent(t *testing.T) { + m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + + // A row with no agent must not describe one. Counted rather than matched on + // wording: the help bar has a "waiting agents" key of its own, and a + // substring probe hits that instead. + for i, vi := range m.view { + if m.rows[vi].agent.Empty() { + m.cursor = i + if got, want := len(m.footerLines()), len(m.helpLines()); got != want { + t.Errorf("footer is %d lines on an agent-free row, want %d (help only)", got, want) + } + break + } + } + + // A row with one must name the agent and say what it wants, in words. + for i, vi := range m.view { + if m.rows[vi].agent.State == agent.Perm { + m.cursor = i + got := ansi.Strip(strings.Join(m.footerLines(), " ")) + if !strings.Contains(got, "claude") || !strings.Contains(got, "waiting for permission") { + t.Errorf("footer = %q, want the agent and its state named", got) + } + return + } + } + t.Fatal("no perm row in the sample to select") +} + +// The footer grows by a line when it describes an agent, so the list has to +// give one back — otherwise the bottom row is drawn over the frame. +func TestListHeightReservesTheAgentLine(t *testing.T) { + m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + + var plain, withAgent int + for i, vi := range m.view { + m.cursor = i + if m.rows[vi].agent.Empty() { + plain = m.listHeight() + } else { + withAgent = m.listHeight() + } + } + if plain == 0 || withAgent == 0 { + t.Fatal("sample lacks both an agent row and an agent-free one") + } + if withAgent >= plain { + t.Errorf("listHeight %d with the agent line, %d without; the line was not reserved", + withAgent, plain) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index de9087e..7a6fe6f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -19,6 +19,7 @@ import ( "fmt" "sort" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -491,9 +492,28 @@ func (m *Model) helpLines() []string { return helpBarLines(m.helpPairs(), w) } +// footerLines is everything below the list: the selected row's agent spelled +// out, then the help bar. One source of truth, so listHeight reserves exactly +// the rows View is about to draw — an extra line here without a matching one +// there draws the last list row over the frame. +func (m *Model) footerLines() []string { + if m.status != "" { + return []string{cFlag.Render(m.status)} + } + var out []string + if !m.renaming { + if r, ok := m.current(); ok { + if words := agentWords(r.agent); words != "" { + out = append(out, agentCell(r.agent)+" "+cHead.Render(words)) + } + } + } + return append(out, m.helpLines()...) +} + func (m *Model) listHeight() int { - // frame top+bottom (2) + prompt + rule + blank + help lines - h := m.height - 5 - len(m.helpLines()) + // frame top+bottom (2) + prompt + rule + blank + footer + h := m.height - 5 - len(m.footerLines()) if !m.sideBySide() && m.opt.Preview { h = h/2 - 1 } @@ -895,11 +915,16 @@ func (m *Model) badge(st model.Status, tab string, isHeader bool) string { } } -// agentCells is the width of the agent column: one cell naming the agent, one -// naming what it wants. Reserved on every row, agent or not — an indicator -// drawn only where there is an agent would shift every other column on those -// rows and nowhere else. -const agentCells = 2 +// agentCells is the width of the agent column: one cell naming the agent, a +// space, one cell naming what it wants. The space is not decoration — flush +// against each other the two glyphs read as a single smudged symbol, which +// defeats the whole point of splitting identity from state. +// +// Reserved on every row, agent or not — an indicator drawn only where there is +// an agent would shift every other column on those rows and nowhere else. The +// column budget in renderRow is written in terms of this constant, so widening +// it here is enough. +const agentCells = 3 // agentCell renders that column. Always exactly agentCells wide. func agentCell(r agent.Record) string { @@ -923,7 +948,36 @@ func agentCell(r agent.Record) string { default: state = cAgentBusy.Render(glyphBusy) } - return id + state + return id + " " + state +} + +// agentWords spells out an agent record for the footer. The glyphs are compact, +// but nothing on screen says what they mean; this is where you find out, for +// whichever row the cursor is on. +func agentWords(r agent.Record) string { + if r.Empty() { + return "" + } + var what string + switch r.State { + case agent.Perm: + what = "waiting for permission" + case agent.Ask: + what = "waiting for an answer" + case agent.Done: + what = "finished a turn" + case agent.Err: + what = "turn failed" + default: + what = "working" + } + out := r.Agent + " · " + what + if r.At > 0 { + if d := time.Since(time.Unix(r.At, 0)); d >= time.Second { + out += " · " + d.Round(time.Second).String() + " ago" + } + } + return out } func glyph(st model.Status) string { @@ -1147,14 +1201,11 @@ func (m *Model) View() string { // Indent the footer to the same column as the prompt, and give it a blank // line of separation from the list so it reads as a footer rather than // another row. - hl := m.helpLines() - for i := range hl { - hl[i] = footerPad + hl[i] - } - help := strings.Join(hl, "\n") - if m.status != "" { - help = footerPad + cFlag.Render(truncateANSI(m.status, w-len(footerPad))) + fl := m.footerLines() + for i := range fl { + fl[i] = footerPad + truncateANSI(fl[i], w-len(footerPad)) } + help := strings.Join(fl, "\n") content := strings.Join([]string{prompt, rule(w), body, "", help}, "\n") return frame("tmux ⇄ kaku", content, w) From b4854fe12401195114375fbbb5cb3db48cd52e63 Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Sun, 23 Aug 2026 12:32:23 +0200 Subject: [PATCH 09/14] Show what the agent is doing, in a box under the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer said "claude · waiting for permission" but not what for, which is the only part you actually need in order to decide anything. Moving onto a row with an agent now opens a box below the list; moving off it closes the box again, so a list with no agents in it looks exactly as it did before. Four of the five states can say something real, because the hook payload that set them carries text: UserPromptSubmit has the prompt, PermissionRequest has the tool and its argument, Stop has the reply that ended the turn, StopFailure has the error type. Notification carries a type and nothing else, so `ask` has no message and the box simply omits the line. The text lives in a second pane option rather than a field of @kt_agent. That record's format is only safe because every field comes from a fixed alphabet, and a message is free text. It is stored tagged with the state it describes and dropped on read when the two disagree. That is what stops a permission request from still being displayed after your approval has moved the pane back to busy — the option is still there, but it belongs to a state the pane has left. It also means events carrying no text can leave the option alone, so a prompt survives a whole turn of tool calls without every PostToolUse having to re-read and rewrite it. Control characters are stripped before storing: the value is read back through the \x1f-separated format string the rest of internal/tmux depends on, and a newline in an assistant reply would shift every later field. This is the one part of kaku-tab that stores what you typed, so it is opt-out via @kaku-tab-agent-message, which keeps the box and drops the message line. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 7 +-- README.md | 4 ++ cmd/kaku-tab/agents.go | 21 ++++++-- docs/agents.md | 44 ++++++++++++++-- docs/configuration.md | 1 + internal/agent/agent.go | 67 ++++++++++++++++++++++++ internal/agent/hook.go | 87 +++++++++++++++++++++++-------- internal/agent/hook_test.go | 36 ++++++++++++- internal/agent/status_test.go | 59 +++++++++++++++++++++ internal/tmux/tmux.go | 19 +++++-- internal/ui/agentcol_test.go | 97 +++++++++++++++++++++++++++++++++++ internal/ui/theme.go | 72 ++++++++++++++++++++++++++ internal/ui/ui.go | 48 +++++++++++++---- 13 files changed, 516 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7925006..d2b1861 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,9 +37,10 @@ the `list-panes` query resolve already makes. See [docs/agents.md](docs/agents.m ambiguous once a grouped session shares the window. - Never `set-hook -g` — it replaces the user's hooks. Use `-ga`. - Never key on `$WEZTERM_PANE`; it goes stale. Join on the tty. -- Agent state (`@kt_agent`) is set with `set-option -p` only. tmux pane options - inherit from window options, so one window-scoped write has every agent-free - pane in that window report an agent. The rollup is a separate option name. +- Agent state (`@kt_agent`, `@kt_agent_msg`) is set with `set-option -p` only. + tmux pane options inherit from window options, so one window-scoped write has + every agent-free pane in that window report an agent. The rollup is a separate + option name for the same reason. - `kaku-tab hook` must never print to stdout or exit non-zero. On `PermissionRequest` and `PreToolUse` both are decision channels, so a status reporter that got either wrong would silently veto the user's own tool calls. diff --git a/README.md b/README.md index 9ba0970..3a26a76 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,10 @@ That appends the pills to the end of `status-right`, i.e. the far right of the bar. To place them anywhere else, leave the option off and put `#(kaku-tab agents --format tmux)` where you want it. +Moving onto a row with an agent opens a box below the list saying what it is +doing — the prompt it is working on, the command it wants permission for, the +reply that ended its turn. + The agents report themselves: each CLI's lifecycle hooks run `kaku-tab hook`, which records the state on the pane it inherited via `$TMUX_PANE`. Nothing is guessed from the process table — `#{pane_current_command}` says `node` for diff --git a/cmd/kaku-tab/agents.go b/cmd/kaku-tab/agents.go index c93fb16..4e6479f 100644 --- a/cmd/kaku-tab/agents.go +++ b/cmd/kaku-tab/agents.go @@ -37,8 +37,8 @@ func hook() error { // rejects it and we exit quietly. The hook is never the thing that fails an // agent's turn. body, _ := io.ReadAll(io.LimitReader(os.Stdin, maxHookPayload)) - act, state := agent.Decide(body) - if act == agent.Ignore { + d := agent.Decide(body) + if d.Action == agent.Ignore { return nil } @@ -49,8 +49,9 @@ func hook() error { return nil } - if act == agent.Clear { + if d.Action == agent.Clear { _ = tmux.UnsetPaneOption(pane, agent.PaneOption) + _ = tmux.UnsetPaneOption(pane, agent.MsgOption) tmux.RefreshStatus() return nil } @@ -61,11 +62,23 @@ func hook() error { // immediately, and the record would read as dead the moment it was written. rec := agent.Record{ Agent: agent.Detect(os.Getenv), - State: state, + State: d.State, PID: os.Getppid(), At: time.Now().Unix(), } _ = tmux.SetPaneOption(pane, agent.PaneOption, agent.Format(rec)) + + // The message is only touched by events that carry one. Events that do not + // leave it alone, and the state tag on it decides whether it is still shown + // — so the prompt survives a whole turn of tool calls, while a permission + // request stops being displayed the moment the approval moves the state on. + // + // Opt-out, because this is the one thing here that stores what you typed: + // prompts and tool arguments land in a tmux option, readable by anything + // that can talk to the server. + if d.Msg != "" && tmux.Option("@kaku-tab-agent-message", "on") == "on" { + _ = tmux.SetPaneAgentMsg(pane, agent.FormatMsg(d.State, d.Msg)) + } tmux.RefreshStatus() return nil } diff --git a/docs/agents.md b/docs/agents.md index cd5fe89..cc8d288 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -38,7 +38,7 @@ The one case that does not self-heal is an agent killed outright — no is for: a record whose process is gone reads as absent, and `kaku-tab agents` clears it. -> **`@kt_agent` is only ever set with `-p`.** From `tmux(1)`: *"Pane options +> **`@kt_agent` and `@kt_agent_msg` are only ever set with `-p`.** From `tmux(1)`: *"Pane options > inherit from window options."* Set it at window scope even once and every > agent-free pane in that window reads back an agent that is not there. The > per-window rollup is deliberately a different option, `@kt_agent_win`. @@ -133,13 +133,49 @@ indicator drawn only where there is an agent would shift every other column on those rows and nowhere else. Every glyph is pinned to one display cell by a test — a double-width one would break the table's column budget. -Nothing on screen says what a glyph means, so the footer spells out the selected -row's agent in words, on whichever row the cursor is on: +### The agent box + +Nothing on screen says what a glyph means, so moving onto a row with an agent +opens a box below the list — and moving off it closes the box again. A list with +no agents in it looks exactly as it did before any of this existed. ``` - claude · waiting for permission · 2m ago +╭─ claude ───────────────────────────────────────────────╮ +│ waiting for permission · 2m ago │ +│ Bash: git push origin main --force-with-lease │ +╰──────────────────────────────────────────────────────────╯ ``` +The second line is what the agent is actually doing, taken from the hook payload +that set the state. Not every event carries one: + +| State | From | Message | +|---|---|---| +| `busy` | `UserPromptSubmit` | the prompt you gave it | +| `perm` | `PermissionRequest` | the tool and its argument — `Bash: git push` | +| `done` | `Stop` | the reply that ended the turn | +| `err` | `StopFailure` | the error type | +| `ask` | `Notification` | none — that payload carries a type and no text | + +Up to three wrapped lines, elided beyond that, and capped at 300 characters when +stored. + +The message is kept in a **second pane option**, `@kt_agent_msg`, rather than as +a field of `@kt_agent`: it is free text, and the record's format depends on +every field coming from a fixed alphabet. It is stored tagged with the state it +describes and dropped on read when the two disagree — which is what stops a +permission request from still being displayed after your approval has moved the +pane back to `busy`. Control characters are stripped before it is stored, since +it is read back through a `\x1f`-separated format string. + +Events that carry no text leave the option alone, so a prompt survives a whole +turn of tool calls. + +> This is the one part of kaku-tab that stores what you typed. Prompts and tool +> arguments land in a tmux option, readable by anything that can talk to the +> server. `set -g @kaku-tab-agent-message 'off'` keeps the box and drops the +> message line. + ^a filters to windows with an agent that wants you — `perm`, `ask`, `done` or `err`, but not `busy`. Typing an agent's name in the search box works too; the name is part of each row's match text without being drawn. diff --git a/docs/configuration.md b/docs/configuration.md index 037b96e..7ab57a9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -115,6 +115,7 @@ waiting on you, and how many are open — plus an agent column in the picker. Se | Option | Default | Meaning | |---|---|---| | `@kaku-tab-agents` | `off` | append the agent counter to `status-right` | +| `@kaku-tab-agent-message` | `on` | store what the agent is doing — the prompt, the tool awaiting permission, the reply — for the picker's agent box | | `@kaku-tab-agent-color` | `@thm_mauve` | pill colour for the "agents open" count | | `@kaku-tab-notify-color` | `@thm_peach` | pill colour when something wants you | | `@kaku-tab-agent-icon` | 󰚩 | icon for the "agents open" count | diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 7e7db99..8f10e56 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -27,6 +27,19 @@ import ( // rollup deliberately uses a different name, see WindowOption. const PaneOption = "@kt_agent" +// MsgOption carries the human-readable context for a pane's agent state: the +// prompt being worked on, the tool awaiting permission, the reply that ended the +// turn. +// +// A second option rather than a field in PaneOption, because a message is free +// text and PaneOption's format depends on every field being from a fixed +// alphabet. Both are pane-scoped, so both are written with -p only. +const MsgOption = "@kt_agent_msg" + +// MaxMsg caps what is stored. A whole assistant reply in a tmux option helps +// nobody, and the box shows a few lines at most. +const MaxMsg = 300 + // WindowOption is the per-window rollup, written only by `kaku-tab agents // --refresh` for use in tmux window formats. It is a distinct name precisely // because setting PaneOption at window scope would corrupt the per-pane read. @@ -62,6 +75,12 @@ type Record struct { State State PID int // the hook process's parent, i.e. the agent itself At int64 // unix seconds, for display only + + // Msg is context for State, and belongs to it: it is stored tagged with the + // state it was written for and dropped on read when the two disagree. That + // is what stops a permission request from still being displayed after the + // approval moved the pane back to Busy. + Msg string } // Empty reports whether there is no agent here. @@ -141,6 +160,54 @@ func Parse(s string) Record { return Record{Agent: name, State: st, PID: pid, At: at} } +// FormatMsg renders the message option, tagging it with the state it describes. +// Returns "" when there is nothing worth storing, which the caller writes as an +// unset rather than an empty option. +func FormatMsg(st State, msg string) string { + msg = sanitize(msg) + if st == None || msg == "" { + return "" + } + return string(st) + ":" + msg +} + +// ParseMsg reads the message option back, given the state the pane currently +// reports. A message tagged with a different state is stale — the pane has moved +// on since it was written — and is discarded. +func ParseMsg(now State, v string) string { + tag, msg, ok := strings.Cut(v, ":") + if !ok || State(tag) != now || now == None { + return "" + } + return strings.TrimSpace(msg) +} + +// sanitize makes a message safe to store in a tmux option and to read back +// through a \x1f-separated format string. Control characters are collapsed +// rather than escaped: this is a one-line summary, not a transcript. +func sanitize(s string) string { + var b strings.Builder + space := true // trim leading whitespace as we go + for _, r := range s { + if r < 0x20 || r == 0x7f { + r = ' ' + } + if r == ' ' { + if space { + continue + } + space = true + } else { + space = false + } + b.WriteRune(r) + if b.Len() >= MaxMsg { + break + } + } + return strings.TrimSpace(b.String()) +} + // Live reports whether the agent process is still running. This is the backstop // for the one case pane-scoped storage cannot handle on its own: an agent killed // outright, so no SessionEnd hook ever fires, inside a pane that survives it. diff --git a/internal/agent/hook.go b/internal/agent/hook.go index 2f66a07..05bb054 100644 --- a/internal/agent/hook.go +++ b/internal/agent/hook.go @@ -26,6 +26,25 @@ type payload struct { // payload carrying this is ignored outright — otherwise every Task call // would flash the pane to "done" mid-turn. AgentID string `json:"agent_id"` + + // The rest is text for the picker's agent box. Which of these is populated + // depends on the event, and several events carry none of them. + Prompt string `json:"prompt"` // UserPromptSubmit + LastReply string `json:"last_assistant_message"` // Stop + ToolName string `json:"tool_name"` // PermissionRequest + ToolInput map[string]any `json:"tool_input"` // PermissionRequest + ErrorType string `json:"error_type"` // StopFailure +} + +// Decision is what one hook event asks us to do to the pane's record. +type Decision struct { + Action Action + State State + // Msg is human-readable context for the state — the prompt being worked on, + // the tool awaiting permission, the reply that ended the turn. Empty for + // events that carry no text, which includes every Notification: that + // payload has a type and nothing to read. + Msg string } // Decide maps one hook payload to a pane-record action. @@ -33,55 +52,83 @@ type payload struct { // Unknown events return Ignore rather than an error: both CLIs are subscribed // through one shared hooks block, so each of them routinely delivers events the // other does not have, and that is normal traffic rather than a fault. -func Decide(b []byte) (Action, State) { +func Decide(b []byte) Decision { var p payload if json.Unmarshal(b, &p) != nil { - return Ignore, None + return Decision{} } if p.AgentID != "" { - return Ignore, None + return Decision{} } switch p.Event { case "SessionEnd": - return Clear, None + return Decision{Action: Clear} - case "SessionStart", "UserPromptSubmit", "PostToolUse", "PostToolBatch": + case "UserPromptSubmit": + // The prompt is the single most useful line the box can carry: it is + // what the agent is doing, in the user's own words. + return Decision{Action: Set, State: Busy, Msg: p.Prompt} + + case "SessionStart", "PostToolUse", "PostToolBatch": // The PostTool* events are not just a liveness heartbeat: they are what - // flips a pane out of Perm once you approve a call and the agent resumes. - // Without them an approved pane keeps counting as waiting until the turn - // ends. PreToolUse would do the same job but fires on the agent's hot - // path, so it is deliberately not subscribed. - return Set, Busy + // flips a pane out of Perm once you approve a call and the agent + // resumes. Without them an approved pane keeps counting as waiting + // until the turn ends. PreToolUse would do the same job but fires on + // the agent's hot path, so it is deliberately not subscribed. + // + // They carry no text of their own, which is exactly right: the message + // is tagged with the state it was written for, so the permission + // request stops being shown the moment the state leaves Perm. + return Decision{Action: Set, State: Busy} case "Stop": - return Set, Done + return Decision{Action: Set, State: Done, Msg: p.LastReply} case "StopFailure": - return Set, Err + return Decision{Action: Set, State: Err, Msg: p.ErrorType} // Devin's permission-decision event. Claude Code reports the same situation - // through Notification/permission_prompt below. + // through Notification/permission_prompt below, but only this one says what + // is actually being asked for. case "PermissionRequest": - return Set, Perm + return Decision{Action: Set, State: Perm, Msg: toolSummary(p.ToolName, p.ToolInput)} case "Notification": switch p.NotifyOn { case "permission_prompt": - return Set, Perm + return Decision{Action: Set, State: Perm} case "elicitation_dialog", "agent_needs_input": - return Set, Ask + return Decision{Action: Set, State: Ask} case "idle_prompt", "agent_completed": // "done and waiting for your next prompt" — the same meaning as // Stop, not a distinct question. - return Set, Done + return Decision{Action: Set, State: Done} case "elicitation_complete": // The form was answered or dismissed; the agent is working again. - return Set, Busy + return Decision{Action: Set, State: Busy} + } + return Decision{} + } + return Decision{} +} + +// toolArgKeys are the tool_input fields worth showing, most specific first. A +// permission prompt is only useful if it says what is being run, and the field +// that holds it differs per tool. +var toolArgKeys = []string{"command", "file_path", "path", "url", "pattern", "description"} + +// toolSummary renders a pending tool call as one line: "Bash: git push origin". +func toolSummary(name string, input map[string]any) string { + if name == "" { + return "" + } + for _, k := range toolArgKeys { + if v, ok := input[k].(string); ok && v != "" { + return name + ": " + v } - return Ignore, None } - return Ignore, None + return name } // Detect names the agent that invoked the hook. Both CLIs read hooks from diff --git a/internal/agent/hook_test.go b/internal/agent/hook_test.go index 30863a5..3691392 100644 --- a/internal/agent/hook_test.go +++ b/internal/agent/hook_test.go @@ -6,7 +6,8 @@ import "testing" func decide(t *testing.T, payload string) (Action, State) { t.Helper() - return Decide([]byte(payload)) + d := Decide([]byte(payload)) + return d.Action, d.State } func TestDecideClaudeEvents(t *testing.T) { @@ -101,3 +102,36 @@ func TestDetect(t *testing.T) { t.Errorf("Detect with nothing set = %q, want claude", got) } } + +// The box is only worth having if it says what the agent actually wants, so the +// events that carry text must surrender it. +func TestDecideExtractsMessages(t *testing.T) { + cases := []struct{ payload, want string }{ + {`{"hook_event_name":"UserPromptSubmit","prompt":"fix the flaky test"}`, "fix the flaky test"}, + {`{"hook_event_name":"Stop","last_assistant_message":"Done - two files changed."}`, "Done - two files changed."}, + {`{"hook_event_name":"StopFailure","error_type":"rate_limit"}`, "rate_limit"}, + {`{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"git push"}}`, "Bash: git push"}, + {`{"hook_event_name":"PermissionRequest","tool_name":"Edit","tool_input":{"file_path":"/tmp/x.go"}}`, "Edit: /tmp/x.go"}, + {`{"hook_event_name":"PermissionRequest","tool_name":"Weird","tool_input":{"n":3}}`, "Weird"}, + } + for _, tc := range cases { + if got := Decide([]byte(tc.payload)).Msg; got != tc.want { + t.Errorf("%s\n got %q\nwant %q", tc.payload, got, tc.want) + } + } +} + +// Events with no text must leave the message alone rather than blanking it, so +// a prompt survives a whole turn of tool calls. +func TestDecideLeavesMessageEmptyWhenNoneCarried(t *testing.T) { + for _, p := range []string{ + `{"hook_event_name":"PostToolUse","tool_name":"Bash"}`, + `{"hook_event_name":"PostToolBatch"}`, + `{"hook_event_name":"SessionStart"}`, + `{"hook_event_name":"Notification","notification_type":"permission_prompt"}`, + } { + if got := Decide([]byte(p)).Msg; got != "" { + t.Errorf("%s carried a message %q", p, got) + } + } +} diff --git a/internal/agent/status_test.go b/internal/agent/status_test.go index 66a1054..4acae7a 100644 --- a/internal/agent/status_test.go +++ b/internal/agent/status_test.go @@ -92,3 +92,62 @@ func TestSegmentPillShape(t *testing.T) { t.Errorf("segment shape\n got %q\nwant %q", s, want) } } + +// A message is stored tagged with the state it describes, and dropped on read +// when the pane has moved on — otherwise an approved permission request would +// still be displayed while the agent is back at work. +func TestMsgIsTiedToItsState(t *testing.T) { + v := FormatMsg(Perm, "Bash: git push") + if got := ParseMsg(Perm, v); got != "Bash: git push" { + t.Errorf("ParseMsg with the matching state = %q", got) + } + if got := ParseMsg(Busy, v); got != "" { + t.Errorf("ParseMsg with a moved-on state = %q, want empty", got) + } + if got := ParseMsg(None, v); got != "" { + t.Errorf("ParseMsg with no state = %q, want empty", got) + } +} + +// A colon in the message must not confuse the tag, which is split off once. +func TestMsgKeepsColons(t *testing.T) { + const msg = "Bash: cd /x && make test: run it" + if got := ParseMsg(Perm, FormatMsg(Perm, msg)); got != msg { + t.Errorf("round trip = %q, want %q", got, msg) + } +} + +func TestFormatMsgEmptyCases(t *testing.T) { + for _, tc := range []struct { + st State + msg string + }{ + {Perm, ""}, {Perm, " "}, {None, "something"}, {Perm, "\n\t "}, + } { + if got := FormatMsg(tc.st, tc.msg); got != "" { + t.Errorf("FormatMsg(%q, %q) = %q, want empty", tc.st, tc.msg, got) + } + } +} + +// The value is read back through a \x1f-separated format string and rendered on +// one line, so control characters have to be gone before it is ever stored. +func TestMsgSanitized(t *testing.T) { + got := ParseMsg(Done, FormatMsg(Done, "line one\nline\ttwo\x1fthree spaced")) + if strings.ContainsAny(got, "\n\t\x1f") { + t.Errorf("control characters survived: %q", got) + } + if got != "line one line two three spaced" { + t.Errorf("got %q", got) + } +} + +func TestMsgCapped(t *testing.T) { + got := ParseMsg(Done, FormatMsg(Done, strings.Repeat("word ", 400))) + if len(got) > MaxMsg { + t.Errorf("stored %d bytes, cap is %d", len(got), MaxMsg) + } + if got == "" { + t.Error("capping threw the whole message away") + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 874a04c..91b7b95 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -112,7 +112,7 @@ func Panes() (map[string][]model.Pane, error) { rows, err := query("list-panes", "-a", "-F", f( "#{window_id}", "#{pane_id}", "#{pane_index}", "#{pane_current_command}", "#{pane_current_path}", "#{pane_active}", - "#{"+agent.PaneOption+"}")) + "#{"+agent.PaneOption+"}", "#{"+agent.MsgOption+"}")) if err != nil { return nil, err } @@ -125,7 +125,7 @@ func Panes() (map[string][]model.Pane, error) { m[w] = append(m[w], model.Pane{ ID: at(r, 1), Index: at(r, 2), Cmd: at(r, 3), Path: at(r, 4), Active: boolAt(r, 5), - Agent: liveAgent(at(r, 6)), + Agent: liveAgent(at(r, 6), at(r, 7)), }) } return m, nil @@ -217,11 +217,15 @@ func CapturePane(target string, historyLines int) (string, error) { // killed outright inside a surviving pane leaves a record no SessionEnd hook // will ever clear, so the display must not believe it. `kaku-tab agents` is // what actually removes it from tmux; see PaneAgents. -func liveAgent(v string) agent.Record { +func liveAgent(v, msg string) agent.Record { r := agent.Parse(v) if !agent.Live(r) { return agent.Record{} } + // The message is tagged with the state it was written for; ParseMsg drops it + // when the pane has since moved on, so an approved permission request stops + // being shown the moment the agent resumes. + r.Msg = agent.ParseMsg(r.State, msg) return r } @@ -252,6 +256,15 @@ func SetPaneOption(pane, name, value string) error { return err } +// SetPaneAgentMsg writes a pane's agent message, clearing the option when there +// is nothing to say rather than leaving a stale line behind. +func SetPaneAgentMsg(pane, value string) error { + if value == "" { + return UnsetPaneOption(pane, agent.MsgOption) + } + return SetPaneOption(pane, agent.MsgOption, value) +} + // UnsetPaneOption clears a pane-scoped option. func UnsetPaneOption(pane, name string) error { _, err := Run("set-option", "-p", "-u", "-t", pane, name) diff --git a/internal/ui/agentcol_test.go b/internal/ui/agentcol_test.go index f7d8703..22e8252 100644 --- a/internal/ui/agentcol_test.go +++ b/internal/ui/agentcol_test.go @@ -205,3 +205,100 @@ func TestListHeightReservesTheAgentLine(t *testing.T) { withAgent, plain) } } + +// The box appears with the cursor and vanishes with it, so a list holding no +// agents looks exactly as it did before any of this existed. +func TestAgentBoxFollowsTheCursor(t *testing.T) { + ws := agentSample() + ws[0].Agent.Msg = "Bash: git push origin main" + m := New(ws, Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + + var withAgent, without int + for i, vi := range m.view { + m.cursor = i + if m.rows[vi].agent.Empty() { + without = len(m.agentBox()) + } else { + withAgent = len(m.agentBox()) + } + } + if without != 0 { + t.Errorf("box drawn (%d lines) for a row with no agent", without) + } + if withAgent == 0 { + t.Error("no box for a row with an agent") + } +} + +// It has to say what the agent wants, not just that it wants something. +func TestAgentBoxShowsStateAndMessage(t *testing.T) { + ws := agentSample() + ws[0].Agent.Msg = "Bash: git push origin main" + m := New(ws, Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + + for i, vi := range m.view { + if m.rows[vi].agent.State != agent.Perm { + continue + } + m.cursor = i + got := ansi.Strip(strings.Join(m.agentBox(), "\n")) + for _, want := range []string{"claude", "waiting for permission", "git push origin main"} { + if !strings.Contains(got, want) { + t.Errorf("box missing %q:\n%s", want, got) + } + } + return + } + t.Fatal("no perm row in the sample") +} + +// Every line of the box must be the same width, or the right border frays. +func TestAgentBoxLinesAreUniformWidth(t *testing.T) { + ws := agentSample() + ws[0].Agent.Msg = strings.Repeat("a long message that has to wrap ", 12) + for _, width := range []int{60, 90, 150, 220} { + m := New(ws, Options{Tree: true, SelfTab: "8"}) + m.width, m.height = width, 40 + for i, vi := range m.view { + if m.rows[vi].agent.State != agent.Perm { + continue + } + m.cursor = i + lines := m.agentBox() + if len(lines) == 0 { + t.Fatalf("width %d: no box", width) + } + first := ansi.StringWidth(lines[0]) + for j, l := range lines { + if w := ansi.StringWidth(l); w != first { + t.Errorf("width %d: box line %d is %d cells, line 0 is %d", width, j, w, first) + } + } + // And it must fit inside the frame. + if first > m.innerW() { + t.Errorf("width %d: box is %d cells, frame inner is %d", width, first, m.innerW()) + } + } + } +} + +// A long message is elided, never allowed to push the help bar off screen. +func TestAgentBoxCapsMessageLines(t *testing.T) { + ws := agentSample() + ws[0].Agent.Msg = strings.Repeat("word ", 500) + m := New(ws, Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 100, 30 + for i, vi := range m.view { + if m.rows[vi].agent.State != agent.Perm { + continue + } + m.cursor = i + // border top + state line + at most agentBoxLines + border bottom + if got, max := len(m.agentBox()), 3+agentBoxLines; got > max { + t.Errorf("box is %d lines, want at most %d", got, max) + } + return + } +} diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 22a52bf..dabdbdc 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" ) // Adaptive so the picker is legible on a light terminal too. Kaku runs at 0.7 @@ -108,6 +109,77 @@ func frame(title, content string, w int) string { return out.String() } +// smallBox draws a titled box around body lines, sized to w display cells. +// +// Deliberately not frame(): that one titles the whole picker and is measured +// against the popup width. This is an inline panel, and its lines are padded to +// the same width so a short message does not leave a ragged right border. +func smallBox(title string, body []string, w int) []string { + b := lipgloss.RoundedBorder() + if w < 8 { + w = 8 + } + inner := w - 2 + + tw := ansi.StringWidth(title) + fill := inner - tw - 3 // "─ " before the title, " " after + if fill < 0 { + fill = 0 + } + out := []string{ + cBorder.Render(b.TopLeft+b.Top+" ") + title + + cBorder.Render(" "+strings.Repeat(b.Top, fill)+b.TopRight), + } + for _, line := range body { + out = append(out, cBorder.Render(b.Left)+padToWidth(" "+line, inner)+cBorder.Render(b.Right)) + } + return append(out, cBorder.Render(b.BottomLeft+strings.Repeat(b.Bottom, inner)+b.BottomRight)) +} + +// wrapCells breaks plain text onto at most max lines of w display cells, +// marking the last one with an ellipsis when there was more. Measured with +// runewidth rather than ansi.StringWidth because this text carries no styling — +// it came out of a hook payload. +func wrapCells(text string, w, max int) []string { + if w < 4 || max < 1 { + return nil + } + var lines []string + cur, cut := "", false + + for _, word := range strings.Fields(text) { + // A word wider than the whole line can never fit; cut it rather than + // let it overflow the box. + if runewidth.StringWidth(word) > w { + word = runewidth.Truncate(word, w, "…") + } + cand := word + if cur != "" { + cand = cur + " " + word + } + if runewidth.StringWidth(cand) <= w { + cur = cand + continue + } + lines = append(lines, cur) + if len(lines) == max { + cut = true + cur = "" + break + } + cur = word + } + if cur != "" { + lines = append(lines, cur) + } + + if cut && len(lines) > 0 { + last := lines[len(lines)-1] + lines[len(lines)-1] = runewidth.Truncate(last, w-1, "") + "…" + } + return lines +} + // rule is a horizontal divider. func rule(w int) string { if w < 1 { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 7a6fe6f..e045bb6 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -500,17 +500,44 @@ func (m *Model) footerLines() []string { if m.status != "" { return []string{cFlag.Render(m.status)} } - var out []string - if !m.renaming { - if r, ok := m.current(); ok { - if words := agentWords(r.agent); words != "" { - out = append(out, agentCell(r.agent)+" "+cHead.Render(words)) - } - } + out := m.agentBox() + if len(out) > 0 { + out = append(out, "") } return append(out, m.helpLines()...) } +// agentBoxLines is the message budget: enough to read a permission request in +// full or the gist of a finished turn, without the box crowding out the list. +const agentBoxLines = 3 + +// agentBox describes the selected row's agent, or nothing when it has none. +// +// It sits in space the picker was wasting anyway, and it appears and disappears +// with the cursor — so a list with no agents in it looks exactly as it did +// before any of this existed. +func (m *Model) agentBox() []string { + if m.renaming { + return nil + } + r, ok := m.current() + if !ok || r.agent.Empty() { + return nil + } + + // Full width, indented to the same column as the help bar beneath it: a box + // stopping short of the frame reads as a rendering fault rather than a + // choice. + w := m.innerW() - 2*len(footerPad) + body := []string{cHead.Render(agentWords(r.agent))} + // The message is plain text from a hook payload, already stripped of + // control characters when it was stored. + if r.agent.Msg != "" { + body = append(body, wrapCells(r.agent.Msg, w-3, agentBoxLines)...) + } + return smallBox(agentCell(r.agent)+" "+cName.Render(r.agent.Agent), body, w) +} + func (m *Model) listHeight() int { // frame top+bottom (2) + prompt + rule + blank + footer h := m.height - 5 - len(m.footerLines()) @@ -951,9 +978,8 @@ func agentCell(r agent.Record) string { return id + " " + state } -// agentWords spells out an agent record for the footer. The glyphs are compact, -// but nothing on screen says what they mean; this is where you find out, for -// whichever row the cursor is on. +// agentWords spells out a record's state and age for the agent box. The agent's +// own name is not repeated here — the box title carries it. func agentWords(r agent.Record) string { if r.Empty() { return "" @@ -971,7 +997,7 @@ func agentWords(r agent.Record) string { default: what = "working" } - out := r.Agent + " · " + what + out := what if r.At > 0 { if d := time.Since(time.Unix(r.At, 0)); d >= time.Second { out += " · " + d.Round(time.Second).String() + " ago" From 3f48450dd6bcd70ebaef70f7fb99aff55d989f71 Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Sun, 23 Aug 2026 12:37:25 +0200 Subject: [PATCH 10/14] Keep only the first real line of an agent's message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box rendered its own first outing back at itself. A Stop payload carries last_assistant_message, which is a whole markdown document — paragraphs, a code fence, and in that case a rendered box — and flattening all of it onto one line produced nonsense. Worse, the box-drawing characters in it landed inside the picker's box and read as a broken border, so the bug looked like a layout fault rather than a content one. Take the first line that says something instead: skip blanks, code fences and pure line-art rules, shed a leading markdown marker so the text starts at a word, and drop any Box Drawing or Block Elements rune that survives into the kept line. One stray ╮ is enough to masquerade as a border. Bullets need their trailing space to qualify as markers, so "Bash: rm -rf ./build" and a leading flag both survive intact — a permission message is the one that most needs to be reproduced exactly. Co-Authored-By: Claude Opus 5 (1M context) --- docs/agents.md | 12 +++++- internal/agent/agent.go | 67 ++++++++++++++++++++++++++++++--- internal/agent/status_test.go | 70 ++++++++++++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index cc8d288..719f8c2 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -157,8 +157,16 @@ that set the state. Not every event carries one: | `err` | `StopFailure` | the error type | | `ask` | `Notification` | none — that payload carries a type and no text | -Up to three wrapped lines, elided beyond that, and capped at 300 characters when -stored. +Only the **first line that says something** is kept. An assistant reply is a +whole markdown document — headings, code blocks, sometimes a rendered box of its +own — and flattening the lot onto one line produced nonsense whose box-drawing +characters landed inside this box and read as a rendering fault. Blank lines, +code fences and pure line-art rules are skipped, a leading markdown marker is +shed so the text starts at a word, and any Box Drawing or Block Elements rune +that survives is dropped. + +What is left wraps to at most three lines, elided beyond that, and is capped at +300 characters when stored. The message is kept in a **second pane option**, `@kt_agent_msg`, rather than as a field of `@kt_agent`: it is free text, and the record's format depends on diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 8f10e56..904f73d 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -182,14 +182,23 @@ func ParseMsg(now State, v string) string { return strings.TrimSpace(msg) } -// sanitize makes a message safe to store in a tmux option and to read back -// through a \x1f-separated format string. Control characters are collapsed -// rather than escaped: this is a one-line summary, not a transcript. +// sanitize reduces a hook payload's text to one line worth showing. +// +// The text is whatever the agent produced — an assistant reply is a whole +// markdown document, headings and code blocks and all. Flattening the lot onto +// one line yields nonsense, and any box-drawing characters in it land inside +// the picker's own box and read as a rendering fault. So: take the first line +// that says something, drop the line art, collapse the whitespace, cap it. func sanitize(s string) string { + line := firstMeaningfulLine(s) + var b strings.Builder - space := true // trim leading whitespace as we go - for _, r := range s { - if r < 0x20 || r == 0x7f { + space := true // also trims the leading whitespace + for _, r := range line { + switch { + case isLineArt(r): + continue + case r < 0x20 || r == 0x7f: r = ' ' } if r == ' ' { @@ -208,6 +217,52 @@ func sanitize(s string) string { return strings.TrimSpace(b.String()) } +// firstMeaningfulLine picks the first line with prose in it, skipping the +// blanks, code fences and pure line-art rules that open so many replies, and +// shedding a leading markdown marker so the text starts at a word. +func firstMeaningfulLine(s string) string { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") { + continue + } + if onlyLineArt(line) { + continue + } + return stripMarker(line) + } + return "" +} + +// stripMarker removes an unambiguous leading markdown marker. Bullets need the +// trailing space to qualify, so a command line beginning with a flag is left +// alone. +func stripMarker(line string) string { + switch { + case strings.HasPrefix(line, "#"): + return strings.TrimSpace(strings.TrimLeft(line, "#")) + case strings.HasPrefix(line, ">"): + return strings.TrimSpace(strings.TrimLeft(line, ">")) + case strings.HasPrefix(line, "- "), strings.HasPrefix(line, "* "): + return strings.TrimSpace(line[2:]) + } + return line +} + +// isLineArt reports whether a rune is from the Box Drawing or Block Elements +// blocks. These are never prose, and inside the picker's box they masquerade as +// its borders. +func isLineArt(r rune) bool { return r >= 0x2500 && r <= 0x259f } + +func onlyLineArt(line string) bool { + for _, r := range line { + if r != ' ' && !isLineArt(r) { + return false + } + } + return true +} + // Live reports whether the agent process is still running. This is the backstop // for the one case pane-scoped storage cannot handle on its own: an agent killed // outright, so no SessionEnd hook ever fires, inside a pane that survives it. diff --git a/internal/agent/status_test.go b/internal/agent/status_test.go index 4acae7a..028d041 100644 --- a/internal/agent/status_test.go +++ b/internal/agent/status_test.go @@ -133,11 +133,77 @@ func TestFormatMsgEmptyCases(t *testing.T) { // The value is read back through a \x1f-separated format string and rendered on // one line, so control characters have to be gone before it is ever stored. func TestMsgSanitized(t *testing.T) { - got := ParseMsg(Done, FormatMsg(Done, "line one\nline\ttwo\x1fthree spaced")) + got := ParseMsg(Done, FormatMsg(Done, "one\ttwo\x1fthree spaced")) if strings.ContainsAny(got, "\n\t\x1f") { t.Errorf("control characters survived: %q", got) } - if got != "line one line two three spaced" { + if got != "one two three spaced" { + t.Errorf("got %q", got) + } +} + +// An assistant reply is a whole markdown document. Flattening the lot onto one +// line produced nonsense, and the box-drawing characters in it landed inside the +// picker's own box and read as a rendering fault. Only the first line that says +// something is kept. +func TestMsgTakesFirstMeaningfulLine(t *testing.T) { + reply := "Done. Move onto a row with an agent and you get:\n\n" + + "```\n" + + "╭─ claude ────────────╮\n" + + "│ waiting for permission │\n" + + "╰────────────────────────╯\n" + + "```\n\n" + + "Move off it and the box disappears." + got := ParseMsg(Done, FormatMsg(Done, reply)) + if got != "Done. Move onto a row with an agent and you get:" { + t.Errorf("got %q", got) + } +} + +// Line art anywhere in the kept line is dropped: one stray ╮ inside the box +// reads as its border. +func TestMsgDropsLineArt(t *testing.T) { + for _, in := range []string{ + "result ╭─────╮ here", + "▄▄▄ progress ▄▄▄ done", + "────────── heading", + } { + got := ParseMsg(Done, FormatMsg(Done, in)) + for _, r := range got { + if r >= 0x2500 && r <= 0x259f { + t.Errorf("line art %q survived in %q", r, got) + } + } + if got == "" { + t.Errorf("%q was reduced to nothing", in) + } + } +} + +// Leading markdown markers are shed so the text starts at a word — but a +// command that begins with a flag is not a bullet and must survive intact. +func TestMsgStripsMarkdownMarkers(t *testing.T) { + cases := map[string]string{ + "## Summary of changes": "Summary of changes", + "- fixed the resolver": "fixed the resolver", + "* fixed the resolver": "fixed the resolver", + "> quoted note": "quoted note", + "Bash: rm -rf ./build": "Bash: rm -rf ./build", + "Bash: ls -la": "Bash: ls -la", + "-flag-looking-thing": "-flag-looking-thing", + } + for in, want := range cases { + if got := ParseMsg(Perm, FormatMsg(Perm, in)); got != want { + t.Errorf("%q -> %q, want %q", in, got, want) + } + } +} + +// A reply that opens with a fence, a rule, or blank lines still has to yield +// its first real sentence. +func TestMsgSkipsOpeningNoise(t *testing.T) { + in := "\n\n```go\nfunc main() {}\n```\n──────\nHere is what changed." + if got := ParseMsg(Done, FormatMsg(Done, in)); got != "func main() {}" { t.Errorf("got %q", got) } } From 519f43bc6c2643fc6ebca5106a848d590cc2f38a Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Sun, 23 Aug 2026 13:07:21 +0200 Subject: [PATCH 11/14] Cover the satellite rules, key handling and scrollback search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three packages had gaps where a regression would have been silent. internal/model was at 0%. It holds the satellite naming rules, and a satellite is tied to its base by name alone — IsSatellite decides whether a grouped session's windows are listed once or twice, and BaseSession decides whether a client counts towards the session it belongs to. The two now have to agree: anything IsSatellite accepts, BaseSession must reduce to a shorter name that is not itself a satellite, or a rename leaves a tab pointing at a session that is not there. internal/ui/search.go was at 0% — a whole feature, untested. Case-insensitive substring matching, the 2000-hit cap that stops a one-letter query from building a slice of every line in every pane, the cursor clamp when a narrowing query shrinks the results out from under it, and highlight actually wrapping the match. That last one needs a colour profile forced: lipgloss renders plain with no terminal, so a styled string is otherwise indistinguishable from an unstyled one and the assertion passes vacuously. updateKey was at 7.6%, which is where regressions bite — it is the whole interaction surface. Navigation clamps, fold and unfold from both a header and a child, the detached and agent filters including the message that explains an empty result, and the state round trip a preview-toggle relaunch depends on. Those tests found a real bug, fixed here: both the query and the rename buffer backspaced by byte. One press over a multi-byte character left half a rune — "\xf3\xb0\x9a" from a nerd-font glyph — invalid UTF-8 that renders as mojibake and matches nothing. In a picker aimed at nerd-font and CJK window names that is the common case, not the edge, and it is the first invariant in CLAUDE.md. Thresholds ratcheted to just under where the tree now stands: total 40 -> 65, ui 40 -> 68, resolve 85 -> 88, agent 90 -> 95, and model added at 90. Co-Authored-By: Claude Opus 5 (1M context) --- .testcoverage.yml | 10 +- go.mod | 2 +- internal/action/titles_test.go | 25 +++ internal/model/model_test.go | 142 +++++++++++++++ internal/ui/keys_test.go | 322 +++++++++++++++++++++++++++++++++ internal/ui/search_test.go | 198 ++++++++++++++++++++ internal/ui/ui.go | 14 +- 7 files changed, 705 insertions(+), 8 deletions(-) create mode 100644 internal/action/titles_test.go create mode 100644 internal/model/model_test.go create mode 100644 internal/ui/keys_test.go create mode 100644 internal/ui/search_test.go diff --git a/.testcoverage.yml b/.testcoverage.yml index 4261393..a109bf5 100644 --- a/.testcoverage.yml +++ b/.testcoverage.yml @@ -21,18 +21,22 @@ exclude: threshold: file: 0 package: 0 - total: 40 + total: 65 override: # The join. Everything else is presentation or plumbing around the table it # produces, so this is the number that matters most. - path: ^internal/resolve$ - threshold: 85 + threshold: 88 - path: ^internal/ui$ - threshold: 40 + threshold: 68 - path: ^internal/mru$ threshold: 80 # The agent event mapping: pure functions over hook payloads, and the place a # wrong mapping would be invisible until an agent sat blocked with no badge. - path: ^internal/agent$ + threshold: 95 + # The satellite naming rules. A session is tied to its base by name alone, so + # a bug here orphans tabs or lists a grouped session's windows twice. + - path: ^internal/model$ threshold: 90 diff --git a/go.mod b/go.mod index a699c75..e376bf0 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.10.1 github.com/mattn/go-runewidth v0.0.27 + github.com/muesli/termenv v0.16.0 github.com/sahilm/fuzzy v0.1.3 ) @@ -22,7 +23,6 @@ require ( github.com/mattn/go-localereader v0.0.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.36.0 // indirect diff --git a/internal/action/titles_test.go b/internal/action/titles_test.go new file mode 100644 index 0000000..ad0c0a1 --- /dev/null +++ b/internal/action/titles_test.go @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT + +package action + +import "testing" + +// Nerd-font window namers pad their icons generously, and a terminal tab is +// narrow. A title arriving as "󰚩 claude" wastes half the tab on whitespace. +func TestSqueeze(t *testing.T) { + cases := map[string]string{ + " claude ": "claude", + "a b": "a b", + "\tclaude\tcode\t": "claude code", + "claude\ncode": "claude code", + "": "", + " ": "", + "single": "single", + " 󰚩 claude ": "󰚩 claude", + } + for in, want := range cases { + if got := squeeze(in); got != want { + t.Errorf("squeeze(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/model/model_test.go b/internal/model/model_test.go new file mode 100644 index 0000000..e1d81ca --- /dev/null +++ b/internal/model/model_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT + +package model + +import "testing" + +// A satellite is recognised purely by its name, so these rules decide whether a +// session's windows are listed, whether its client counts towards the base, and +// whether a rename carries it along. Getting IsSatellite wrong duplicates every +// row of a grouped session, or orphans a tab from the session it belongs to. +func TestIsSatellite(t *testing.T) { + const sfx = DefaultSatelliteSuffix + cases := map[string]bool{ + "api~kaku2": true, + "api~kaku64": true, + "~kaku2": true, // an empty base is still a satellite name + "api": false, + "api~kaku": false, // the suffix alone is not enough; an index must follow + "api~kakux": false, // ... and it has to be a digit + "api~kaku ": false, + "kaku2": false, // no suffix at all + "": false, + } + for name, want := range cases { + if got := IsSatellite(name, sfx); got != want { + t.Errorf("IsSatellite(%q) = %v, want %v", name, got, want) + } + } +} + +// An empty suffix disables the whole notion. Without this guard strings.Index +// returns 0 for every session and the picker would hide all of them. +func TestIsSatelliteEmptySuffixMatchesNothing(t *testing.T) { + for _, name := range []string{"api", "api~kaku2", ""} { + if IsSatellite(name, "") { + t.Errorf("IsSatellite(%q, \"\") reported a satellite", name) + } + } +} + +func TestBaseSession(t *testing.T) { + const sfx = DefaultSatelliteSuffix + cases := map[string]string{ + "api~kaku2": "api", + "api~kaku12": "api", + "~kaku2": "", + "api": "api", // not a satellite: returned unchanged + "api~kaku": "api~kaku", // ... and neither is this + "": "", + } + for name, want := range cases { + if got := BaseSession(name, sfx); got != want { + t.Errorf("BaseSession(%q) = %q, want %q", name, got, want) + } + } +} + +// A satellite is tied to its base by name alone, so the two functions have to +// agree: anything IsSatellite accepts, BaseSession must reduce to a shorter +// name, or a rename would leave the satellite pointing at a session that is not +// there. +func TestBaseSessionAgreesWithIsSatellite(t *testing.T) { + const sfx = DefaultSatelliteSuffix + for _, name := range []string{"api~kaku2", "web~kaku9", "a~kaku2"} { + base := BaseSession(name, sfx) + if base == name { + t.Errorf("%q is a satellite but BaseSession left it unchanged", name) + } + if IsSatellite(base, sfx) { + t.Errorf("BaseSession(%q) = %q, which is itself a satellite", name, base) + } + } +} + +// Indices start at 2: the base session is the first tab, so its first satellite +// is the second. +func TestNextSatelliteStartsAtTwo(t *testing.T) { + got := NextSatellite("api", DefaultSatelliteSuffix, func(string) bool { return false }) + if want := "api~kaku2"; got != want { + t.Errorf("NextSatellite = %q, want %q", got, want) + } +} + +func TestNextSatelliteSkipsTaken(t *testing.T) { + taken := map[string]bool{"api~kaku2": true, "api~kaku3": true, "api~kaku5": true} + got := NextSatellite("api", DefaultSatelliteSuffix, func(s string) bool { return taken[s] }) + if want := "api~kaku4"; got != want { + t.Errorf("NextSatellite = %q, want %q", got, want) + } +} + +// The search is bounded, so an exhausted range has to yield something rather +// than loop or return an empty name that would collide with the base session. +func TestNextSatelliteExhausted(t *testing.T) { + got := NextSatellite("api", DefaultSatelliteSuffix, func(string) bool { return true }) + if want := "api~kakux"; got != want { + t.Errorf("NextSatellite when everything is taken = %q, want %q", got, want) + } + if got == "api" { + t.Error("fell back to the base session name") + } +} + +// Whatever NextSatellite hands out must read back as a satellite, or the picker +// will list the new session's windows a second time. +func TestNextSatelliteProducesRecognisableNames(t *testing.T) { + const sfx = DefaultSatelliteSuffix + n := 0 + name := NextSatellite("api", sfx, func(string) bool { + n++ + return n < 40 // force it deep into the range + }) + if !IsSatellite(name, sfx) { + t.Errorf("NextSatellite produced %q, which IsSatellite rejects", name) + } + if BaseSession(name, sfx) != "api" { + t.Errorf("BaseSession(%q) = %q, want api", name, BaseSession(name, sfx)) + } +} + +// These strings are the machine-readable output of `kaku-tab resolve`. +func TestStatusString(t *testing.T) { + cases := map[Status]string{ + Visible: "VISIBLE", + AttachedHidden: "ATTACHED_HIDDEN", + Detached: "DETACHED", + Status(99): "DETACHED", // an unknown status must not print blank + } + for st, want := range cases { + if got := st.String(); got != want { + t.Errorf("Status(%d).String() = %q, want %q", int(st), got, want) + } + } +} + +// Status is ordered, and both the resolver and the tree header rollup rely on +// it: "the best status among these windows" is a numeric comparison. +func TestStatusOrdering(t *testing.T) { + if Detached >= AttachedHidden || AttachedHidden >= Visible { + t.Error("Status constants are not ordered least- to most-present") + } +} diff --git a/internal/ui/keys_test.go b/internal/ui/keys_test.go new file mode 100644 index 0000000..54de01b --- /dev/null +++ b/internal/ui/keys_test.go @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: MIT + +package ui + +import ( + "strings" + "testing" + "unicode/utf8" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/dsaad68/kaku-tab/internal/model" +) + +func key(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} } + +func typeText(m *Model, s string) { + for _, r := range s { + m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + } +} + +func TestCursorMovementStaysInRange(t *testing.T) { + m := newTestModel(t) + n := len(m.view) + if n < 2 { + t.Fatalf("sample gives %d visible rows, need at least 2", n) + } + + for i := 0; i < n+5; i++ { + m.Update(key(tea.KeyDown)) + } + if m.cursor != n-1 { + t.Errorf("cursor ran to %d past the end of %d rows", m.cursor, n) + } + for i := 0; i < n+5; i++ { + m.Update(key(tea.KeyUp)) + } + if m.cursor != 0 { + t.Errorf("cursor ran to %d past the start", m.cursor) + } + + m.Update(key(tea.KeyEnd)) + if m.cursor != n-1 { + t.Errorf("end put the cursor at %d, want %d", m.cursor, n-1) + } + m.Update(key(tea.KeyHome)) + if m.cursor != 0 { + t.Errorf("home put the cursor at %d, want 0", m.cursor) + } +} + +// Esc and ctrl+c leave without choosing anything: the caller must not act on a +// cancelled picker. +func TestEscapeCancelsWithoutChoosing(t *testing.T) { + for _, k := range []tea.KeyType{tea.KeyEsc, tea.KeyCtrlC} { + m := newTestModel(t) + m.Update(key(k)) + if !m.quitting { + t.Errorf("%v did not quit", k) + } + if m.Result().Chosen { + t.Errorf("%v reported a choice", k) + } + } +} + +func TestTypingFiltersAndCtrlUClears(t *testing.T) { + m := newTestModel(t) + all := len(m.view) + + typeText(m, "termdown") + if len(m.view) == 0 { + t.Fatal("query matched nothing at all") + } + if len(m.view) >= all { + t.Errorf("query did not narrow the list: %d of %d rows", len(m.view), all) + } + + m.Update(key(tea.KeyCtrlU)) + if m.query != "" { + t.Errorf("ctrl+u left query %q", m.query) + } + if len(m.view) != all { + t.Errorf("clearing restored %d rows, want %d", len(m.view), all) + } +} + +// Regression: the query was sliced by byte. A single backspace over a +// multi-byte character left half a rune behind — and this picker is aimed at +// nerd-font and CJK window names, where that is the common case, not the edge. +func TestBackspaceDeletesWholeRunes(t *testing.T) { + for _, s := range []string{"é", "日本", "󰚩", "aé"} { + m := newTestModel(t) + typeText(m, s) + if m.query != s { + t.Fatalf("typing %q produced %q", s, m.query) + } + m.Update(key(tea.KeyBackspace)) + + if !utf8.ValidString(m.query) { + t.Errorf("backspacing %q left invalid UTF-8: %q", s, m.query) + } + want := string([]rune(s)[:len([]rune(s))-1]) + if m.query != want { + t.Errorf("backspacing %q gave %q, want %q", s, m.query, want) + } + } +} + +func TestBackspaceOnEmptyQueryIsSafe(t *testing.T) { + m := newTestModel(t) + m.Update(key(tea.KeyBackspace)) + if m.query != "" { + t.Errorf("query became %q", m.query) + } +} + +// Same slicing bug, same fix, in the rename buffer — which is prefilled with a +// window name, exactly the place a nerd-font glyph shows up. +func TestRenameBackspaceDeletesWholeRunes(t *testing.T) { + m := newTestModel(t) + m.renaming, m.rename = true, "󰚩 claude" + m.Update(key(tea.KeyBackspace)) + if !utf8.ValidString(m.rename) { + t.Errorf("rename buffer is invalid UTF-8: %q", m.rename) + } + if want := "󰚩 claud"; m.rename != want { + t.Errorf("rename = %q, want %q", m.rename, want) + } + + m.rename = "󰚩" + m.Update(key(tea.KeyBackspace)) + if m.rename != "" { + t.Errorf("deleting the only rune left %q", m.rename) + } +} + +// The rename field opens prefilled, so clearing it has to be cheap. +func TestRenameEditingKeys(t *testing.T) { + m := newTestModel(t) + m.renaming, m.rename = true, "api server" + + m.Update(key(tea.KeyCtrlW)) + if want := "api"; m.rename != want { + t.Errorf("ctrl+w gave %q, want %q", m.rename, want) + } + m.Update(key(tea.KeyCtrlU)) + if m.rename != "" { + t.Errorf("ctrl+u gave %q", m.rename) + } + + // Escape abandons the rename rather than applying an empty name. + m.rename = "half-typed" + m.Update(key(tea.KeyEsc)) + if m.renaming || m.rename != "" { + t.Errorf("esc left renaming=%v rename=%q", m.renaming, m.rename) + } +} + +// While renaming, ordinary keys edit the buffer instead of driving the list. +func TestRenameSwallowsNavigation(t *testing.T) { + m := newTestModel(t) + before := m.cursor + m.renaming, m.rename = true, "" + m.Update(key(tea.KeyDown)) + if m.cursor != before { + t.Errorf("down moved the cursor to %d during a rename", m.cursor) + } +} + +func TestTabFoldsAndUnfoldsASession(t *testing.T) { + m := newTestModel(t) + all := len(m.view) + + // Land on a header, then fold it. + for i, vi := range m.view { + if m.rows[vi].kind == kindHeader { + m.cursor = i + break + } + } + r, _ := m.current() + group := r.group + + m.Update(key(tea.KeyTab)) + if !m.collapse[group] { + t.Fatalf("tab did not fold %q", group) + } + if len(m.view) >= all { + t.Errorf("folding %q left %d rows of %d", group, len(m.view), all) + } + + m.Update(key(tea.KeyTab)) + if m.collapse[group] { + t.Errorf("tab did not unfold %q", group) + } + if len(m.view) != all { + t.Errorf("unfolding restored %d rows, want %d", len(m.view), all) + } +} + +// Tab on a child folds the group it belongs to and lands on its header, rather +// than doing nothing. +func TestTabOnAChildFoldsItsGroup(t *testing.T) { + m := newTestModel(t) + for i, vi := range m.view { + if m.rows[vi].kind != kindHeader { + m.cursor = i + break + } + } + child, _ := m.current() + m.Update(key(tea.KeyTab)) + + if !m.collapse[child.group] { + t.Fatalf("group %q not folded", child.group) + } + now, ok := m.current() + if !ok || now.kind != kindHeader || now.group != child.group { + t.Errorf("cursor landed on %+v, want the header for %q", now, child.group) + } +} + +func TestShiftTabFoldsThenUnfoldsEverything(t *testing.T) { + m := newTestModel(t) + m.Update(key(tea.KeyShiftTab)) + for _, r := range m.rows { + if r.kind == kindHeader && !m.collapse[r.group] { + t.Fatalf("%q left open", r.group) + } + } + m.Update(key(tea.KeyShiftTab)) + for _, r := range m.rows { + if r.kind == kindHeader && m.collapse[r.group] { + t.Errorf("%q left folded", r.group) + } + } +} + +// A detached session has no terminal tab, so hiding them leaves exactly what +// you can switch to right now. +func TestCtrlEHidesDetachedSessions(t *testing.T) { + m := newTestModel(t) + m.Update(key(tea.KeyCtrlE)) + if !m.opt.HideDetached { + t.Fatal("ctrl+e did not set HideDetached") + } + for _, r := range m.rows { + if r.kind != kindHeader && r.win.Status == model.Detached { + t.Errorf("detached window %s survived", r.win.ID) + } + } + + m.Update(key(tea.KeyCtrlE)) + if m.opt.HideDetached { + t.Error("ctrl+e did not toggle back") + } + var sawDetached bool + for _, r := range m.rows { + if r.kind != kindHeader && r.win.Status == model.Detached { + sawDetached = true + } + } + if !sawDetached { + t.Error("detached windows did not come back") + } +} + +// The filter can empty the list, which reads as a broken picker unless it says +// why. +func TestCtrlAExplainsAnEmptyResult(t *testing.T) { + m := newTestModel(t) + m.Update(key(tea.KeyCtrlA)) + if !m.opt.AgentsOnly { + t.Fatal("ctrl+a did not set AgentsOnly") + } + if len(m.view) != 0 { + t.Fatalf("sample has no agents, expected an empty list, got %d rows", len(m.view)) + } + if !strings.Contains(m.status, "no agent") { + t.Errorf("status = %q, want an explanation", m.status) + } + + // Toggling back must clear the message, or it stands over the full list. + m.Update(key(tea.KeyCtrlA)) + if m.status != "" { + t.Errorf("status %q survived the toggle back", m.status) + } + if len(m.view) == 0 { + t.Error("list did not come back") + } +} + +// State has to survive a preview-toggle relaunch, which closes and reopens the +// popup: tmux cannot resize one in place. +func TestStateRoundTripsThroughARelaunch(t *testing.T) { + m := newTestModel(t) + typeText(m, "api") + m.Update(key(tea.KeyCtrlE)) + for i, vi := range m.view { + if m.rows[vi].kind == kindHeader { + m.cursor = i + m.Update(key(tea.KeyTab)) + break + } + } + + st := m.State() + restored := New(sample(), Options{Tree: true, SelfTab: "8", Restore: st}) + if restored.query != st.Query { + t.Errorf("query %q, want %q", restored.query, st.Query) + } + if restored.cursor != st.Cursor { + t.Errorf("cursor %d, want %d", restored.cursor, st.Cursor) + } + for g, want := range st.Collapse { + if restored.collapse[g] != want { + t.Errorf("fold state for %q = %v, want %v", g, restored.collapse[g], want) + } + } +} diff --git a/internal/ui/search_test.go b/internal/ui/search_test.go new file mode 100644 index 0000000..6999ce5 --- /dev/null +++ b/internal/ui/search_test.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT + +package ui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/muesli/termenv" + + "github.com/dsaad68/kaku-tab/internal/model" +) + +// withColor forces a colour profile for the duration of one test. lipgloss +// renders plain when it cannot see a terminal, so without this every styled +// string in a test is indistinguishable from an unstyled one — and "did this +// get highlighted" is exactly what needs asserting. +func withColor(t *testing.T) { + t.Helper() + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) +} + +// searchWith builds a model around a fixed set of scrollback lines, standing in +// for what index() would have captured from the panes. +func searchWith(lines ...string) *SearchModel { + w := model.Window{ + RawWindow: model.RawWindow{Session: "api", ID: "@1", Index: "1", Name: "zsh"}, + Status: model.Visible, TabID: "5", + } + p := model.Pane{ID: "%1", Index: "1"} + m := NewSearch([]model.Window{w}, Options{Tree: true}, "") + for i, l := range lines { + m.hits = append(m.hits, hit{win: w, pane: p, line: i + 1, text: l, low: strings.ToLower(l)}) + } + m.indexing = false + return m +} + +// An empty query shows nothing rather than everything: the whole scrollback of +// every pane is not a useful default screenful. +func TestSearchEmptyQueryMatchesNothing(t *testing.T) { + m := searchWith("alpha", "beta") + m.refilter() + if len(m.view) != 0 { + t.Errorf("empty query matched %d lines, want 0", len(m.view)) + } +} + +func TestSearchIsCaseInsensitiveSubstring(t *testing.T) { + m := searchWith("Error: connection refused", "all good", "MINOR ERROR") + for _, q := range []string{"error", "ERROR", "ErRoR"} { + m.query = q + m.refilter() + if len(m.view) != 2 { + t.Errorf("query %q matched %d lines, want 2", q, len(m.view)) + } + } + + m.query = "refused" + m.refilter() + if len(m.view) != 1 || m.hits[m.view[0]].text != "Error: connection refused" { + t.Errorf("substring query matched %v", m.view) + } +} + +func TestSearchQueryIsTrimmed(t *testing.T) { + m := searchWith("needle in here") + m.query = " needle " + m.refilter() + if len(m.view) != 1 { + t.Errorf("padded query matched %d lines, want 1", len(m.view)) + } +} + +// The result set is capped: a one-letter query against a full scrollback would +// otherwise build a slice of every line in every pane. +func TestSearchCapsResults(t *testing.T) { + lines := make([]string, 3000) + for i := range lines { + lines[i] = "match" + } + m := searchWith(lines...) + m.query = "match" + m.refilter() + if len(m.view) != 2000 { + t.Errorf("matched %d lines, want the 2000 cap", len(m.view)) + } +} + +// Narrowing the query must not leave the cursor pointing past the end of the +// results — the row it names is what Enter acts on. +func TestSearchCursorClampsWhenResultsShrink(t *testing.T) { + m := searchWith("aaa one", "aaa two", "aaa three", "bbb") + m.query = "aaa" + m.refilter() + m.cursor = len(m.view) - 1 + + m.query = "aaa one" + m.refilter() + if m.cursor >= len(m.view) { + t.Errorf("cursor %d past %d results", m.cursor, len(m.view)) + } + if m.cursor < 0 { + t.Errorf("cursor went negative: %d", m.cursor) + } + + m.query = "no such thing" + m.refilter() + if m.cursor != 0 { + t.Errorf("cursor %d with no results, want 0", m.cursor) + } +} + +// A hit is one line out of thousands; without the match marked you still have +// to hunt for it. +func TestHighlightMarksTheMatch(t *testing.T) { + withColor(t) + got := highlight("connection refused here", "refused") + if ansi.Strip(got) != "connection refused here" { + t.Errorf("highlight altered the text: %q", ansi.Strip(got)) + } + if got == "connection refused here" { + t.Error("nothing was styled") + } + // The styling must wrap the match, not the whole line. + if !strings.HasPrefix(got, "connection ") { + t.Errorf("text before the match was styled: %q", got) + } +} + +func TestHighlightIsCaseInsensitive(t *testing.T) { + withColor(t) + got := highlight("Connection REFUSED", "refused") + if ansi.Strip(got) != "Connection REFUSED" { + t.Errorf("highlight altered the text: %q", ansi.Strip(got)) + } + if got == "Connection REFUSED" { + t.Error("a differently-cased match was not styled") + } +} + +// A query that does not appear, or is empty, leaves the line exactly as it was. +func TestHighlightLeavesNonMatchesAlone(t *testing.T) { + withColor(t) + for _, q := range []string{"", " ", "absent"} { + if got := highlight("a plain line", q); got != "a plain line" { + t.Errorf("query %q altered the line: %q", q, got) + } + } +} + +// The scrollback view scrolls, so the cursor has to stay on screen or Enter +// acts on a row you cannot see. +func TestSearchEnsureVisibleFollowsTheCursor(t *testing.T) { + lines := make([]string, 500) + for i := range lines { + lines[i] = "match" + } + m := searchWith(lines...) + m.width, m.height = 100, 20 + m.query = "match" + m.refilter() + + h := m.listHeight() + m.cursor = 300 + m.ensureVisible() + if m.cursor < m.offset || m.cursor >= m.offset+h { + t.Errorf("cursor %d outside the window [%d,%d)", m.cursor, m.offset, m.offset+h) + } + + m.cursor = 0 + m.ensureVisible() + if m.offset != 0 { + t.Errorf("offset %d after returning to the top", m.offset) + } +} + +func TestSearchBadgeNamesTheDestination(t *testing.T) { + m := searchWith("x") + cases := []struct { + st model.Status + want string + }{ + {model.Visible, "kaku 5"}, + {model.AttachedHidden, "hidden"}, + {model.Detached, "new tab"}, + } + for _, tc := range cases { + w := model.Window{Status: tc.st, TabID: "5"} + if got := ansi.Strip(m.badge(w)); !strings.Contains(got, tc.want) { + t.Errorf("badge for %v = %q, want it to mention %q", tc.st, got, tc.want) + } + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index e045bb6..2ae30a3 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -673,8 +673,11 @@ func (m *Model) updateRename(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.rename = "" return m, m.reloadCmd() case tea.KeyBackspace: - if n := len(m.rename); n > 0 { - m.rename = m.rename[:n-1] + // By rune, not byte — same reason as the query's backspace. This field + // opens prefilled with a window name, which is exactly where the + // glyphs are. + if r := []rune(m.rename); len(r) > 0 { + m.rename = string(r[:len(r)-1]) } case tea.KeyRunes, tea.KeySpace: m.rename += string(msg.Runes) @@ -830,8 +833,11 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Quit case "backspace": - if n := len(m.query); n > 0 { - m.query = m.query[:n-1] + // By rune, never by byte. A window name here is routinely a nerd-font + // glyph or CJK, and shaving one byte off the end of one leaves half a + // rune — invalid UTF-8 that renders as mojibake and matches nothing. + if r := []rune(m.query); len(r) > 0 { + m.query = string(r[:len(r)-1]) m.refilter() return m, m.previewCmd() } From 3fe52589442e4adc9a6de8e65437bf646127a43e Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Sun, 23 Aug 2026 16:20:44 +0200 Subject: [PATCH 12/14] Reach a waiting agent in one key, and say more about it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six additions, all of them about the gap between "an agent wants you" and "you are looking at it". kaku-tab go-agent jumps straight to whatever most wants you, and pressing the key again walks to the next one. Ranked the way the picker ranks: blocked before failed before finished, oldest first within a rank, since the one that has been waiting longest is the one being kept waiting. Before this, reaching a blocked agent was four keys through the picker. The per-window rollup is finished. @kt_agent_win was written only by a manual `agents --refresh` that nothing called and nothing read — a half-built feature. It now refreshes whenever a pane changes state, in one query, writing only the windows whose value actually changed, and the docs carry a window-status-format snippet that consumes it. That query reads @kt_agent_win in *pane* scope on purpose. Pane options inherit from window options and no pane sets this one, so every pane reports its window's value — the same inheritance that forces @kt_agent to be pane-only, used deliberately here rather than tripped over. A desktop notification fires on the transition into a waiting state and never on the repeats, so a turn of tool calls does not re-notify you about a permission you already granted. Off by default. The body is an agent's own output, so it goes to osascript as an argument rather than spliced into the script: a quote or backslash in a reply would otherwise close the AppleScript string literal and let the remainder run as script. Knowing the previous state is what makes all three of those cheap. The hook now reads what it is about to overwrite, and notifying, refreshing the rollup and redrawing the counter all happen only on a real change — which steady-state PostToolUse traffic is not. `ask` stops being the one state with no message: the Elicitation event carries the question an MCP server is asking. The reference does not pin down which field holds it, so every plausible name is read rather than one guessed at. %a puts the state in the terminal tab title, so a blocked agent is visible with tmux not even on screen. And a busy record untouched for thirty minutes now reads as "no activity" in amber — every hook event refreshes the timestamp, so one that old has not made a tool call in half an hour. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +++ cmd/kaku-tab/agents.go | 84 ++++++++++++++++++++++++++++----- cmd/kaku-tab/goagent.go | 88 +++++++++++++++++++++++++++++++++++ cmd/kaku-tab/installhooks.go | 4 +- cmd/kaku-tab/main.go | 4 ++ docs/agents.md | 48 ++++++++++++++++++- docs/configuration.md | 4 +- internal/action/titles.go | 16 +++++-- internal/agent/agent.go | 62 ++++++++++++++++++++++-- internal/agent/hook.go | 26 +++++++++++ internal/agent/hook_test.go | 20 ++++++++ internal/agent/status_test.go | 52 +++++++++++++++++++++ internal/tmux/tmux.go | 42 +++++++++++++++++ internal/ui/theme.go | 3 ++ internal/ui/ui.go | 29 ++++++------ kaku-tab.tmux | 10 ++++ 16 files changed, 462 insertions(+), 36 deletions(-) create mode 100644 cmd/kaku-tab/goagent.go diff --git a/README.md b/README.md index 3a26a76..9866323 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,11 @@ how many agents want you, and how many are open. kaku-tab install-hooks # one block in ~/.claude/settings.json, both CLIs ``` +```tmux +set -g @kaku-tab-agent-key 'M-a' # jump to whatever wants you; again for the next +set -g @kaku-tab-agent-notify 'on' # notify on the transition into waiting +``` + ```tmux set -g @kaku-tab-agents 'on' set -g status-interval 5 @@ -235,6 +240,7 @@ kaku-tab restore [--windows] # open a tab per detached session kaku-tab prune # reap orphaned satellite sessions kaku-tab titles [--dry-run] # retitle tabs after their tmux window kaku-tab agents # which pane each Claude Code / Devin session is in +kaku-tab go-agent # jump to the agent that wants you kaku-tab install-hooks # register the agent hooks with both CLIs ``` diff --git a/cmd/kaku-tab/agents.go b/cmd/kaku-tab/agents.go index 4e6479f..f9c7567 100644 --- a/cmd/kaku-tab/agents.go +++ b/cmd/kaku-tab/agents.go @@ -6,7 +6,9 @@ import ( "fmt" "io" "os" + "os/exec" "regexp" + "runtime" "sort" "strings" "time" @@ -49,10 +51,15 @@ func hook() error { return nil } + // What this pane said a moment ago. A state that has not changed needs no + // notification, no window rollup and no status redraw — and steady-state + // PostToolUse traffic is almost all of the events we see. + prev := tmux.PaneAgentAt(pane) + if d.Action == agent.Clear { _ = tmux.UnsetPaneOption(pane, agent.PaneOption) _ = tmux.UnsetPaneOption(pane, agent.MsgOption) - tmux.RefreshStatus() + settled(prev.State, agent.None, agent.Record{}) return nil } @@ -78,11 +85,60 @@ func hook() error { // that can talk to the server. if d.Msg != "" && tmux.Option("@kaku-tab-agent-message", "on") == "on" { _ = tmux.SetPaneAgentMsg(pane, agent.FormatMsg(d.State, d.Msg)) + rec.Msg = d.Msg } - tmux.RefreshStatus() + settled(prev.State, d.State, rec) return nil } +// settled does the work that only matters when a pane actually changed state: +// tell the user, redraw the counter, and refresh the per-window rollup. +func settled(from, to agent.State, rec agent.Record) { + if from == to { + return + } + notify(from, to, rec) + _ = refreshWindows() + tmux.RefreshStatus() +} + +// notify raises a desktop notification when a pane starts wanting something +// from you. Only on the transition into that state, never on the repeats, and +// never for Busy — which is the state you are not being asked to do anything +// about. +// +// Off by default: a tmux plugin has no business popping system notifications +// until it is asked to. +func notify(from, to agent.State, rec agent.Record) { + if !agent.Attention(to) || from == to { + return + } + if tmux.Option("@kaku-tab-agent-notify", "off") != "on" { + return + } + body := rec.Agent + " · " + agent.Words(to) + if rec.Msg != "" { + body += " — " + rec.Msg + } + // Detached and best-effort: a notifier that is missing, slow or broken must + // never be the reason an agent's hook hangs. + // + // The body is passed as an argument, never spliced into the script. It is + // an agent's own output — a prompt, a reply, a command awaiting approval — + // and a quote or backslash in it would otherwise close the AppleScript + // string literal and let the rest run as script. + switch runtime.GOOS { + case "darwin": + _ = exec.Command("osascript", + "-e", "on run argv", + "-e", `display notification (item 1 of argv) with title "kaku-tab"`, + "-e", "end run", + "--", body).Start() + default: + _ = exec.Command("notify-send", "--", "kaku-tab", body).Start() + } +} + // sweep reads every pane's record, clearing any whose agent process is gone. // This is the one case pane-scoped storage cannot self-heal: an agent killed // outright never fires SessionEnd, and its pane outlives it. @@ -138,26 +194,30 @@ func loadTheme() agent.Theme { } } -// refreshWindows writes the per-window rollup for use in tmux window formats. +// refreshWindows writes the per-window rollup, for use in tmux window formats. // // Deliberately a different option name from the pane record: tmux pane options // inherit from window options, so reusing @kt_agent here would have every // agent-free pane in the window read back an agent that is not there. +// +// One query in, and only the windows whose rollup actually changed are written +// — this runs from a hook, and a set-option per window per event would be the +// most expensive thing in the whole path. func refreshWindows() error { - ws, err := resolve.Resolve(liveSource{}, resolve.Options{ - Suffix: tmux.Option("@kaku-tab-satellite-suffix", model.DefaultSatelliteSuffix), - Scope: "all", - WithAgents: true, - }) + targets, best, current, err := tmux.WindowAgents() if err != nil { return err } - for _, w := range ws { - if w.Agent.Empty() { - _ = tmux.UnsetWindowOption(w.Session, w.ID, agent.WindowOption) + for win, session := range targets { + want := string(best[win].State) + if want == string(current[win].State) { + continue + } + if want == "" { + _ = tmux.UnsetWindowOption(session, win, agent.WindowOption) continue } - _ = tmux.SetWindowOption(w.Session, w.ID, agent.WindowOption, string(w.Agent.State)) + _ = tmux.SetWindowOption(session, win, agent.WindowOption, want) } return nil } diff --git a/cmd/kaku-tab/goagent.go b/cmd/kaku-tab/goagent.go new file mode 100644 index 0000000..554c664 --- /dev/null +++ b/cmd/kaku-tab/goagent.go @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT + +package main + +import ( + "fmt" + "os" + "sort" + + "github.com/dsaad68/kaku-tab/internal/action" + "github.com/dsaad68/kaku-tab/internal/agent" + "github.com/dsaad68/kaku-tab/internal/model" + "github.com/dsaad68/kaku-tab/internal/resolve" + "github.com/dsaad68/kaku-tab/internal/tmux" +) + +// cursorOption remembers which agent pane was last jumped to, so pressing the +// key again advances instead of landing on the same one. +// +// Server state rather than a file: it is meaningless once the panes are gone, +// and the panes die with the server. +const cursorOption = "@kaku-tab-agent-cursor" + +// target is one pane with an agent that wants something. +type target struct { + win model.Window + pane model.Pane +} + +// goAgent jumps straight to the agent that most wants you, without going +// through the picker. Pressing the key again moves to the next one. +// +// The ordering is agent.Rank — blocked before failed before finished — so the +// first press always lands on whatever is costing you the most. +func goAgent(selfTTY string) error { + suffix := tmux.Option("@kaku-tab-satellite-suffix", model.DefaultSatelliteSuffix) + ws, err := resolve.Resolve(liveSource{}, opts("", true)) + if err != nil { + return err + } + + var ts []target + for _, w := range ws { + for _, p := range w.Panes_ { + if p.Agent.Attention() { + ts = append(ts, target{win: w, pane: p}) + } + } + } + if len(ts) == 0 { + _, _ = tmux.Run("display-message", "no agent is waiting on you") + return nil + } + + sort.SliceStable(ts, func(i, j int) bool { + if ri, rj := ts[i].pane.Agent.Rank(), ts[j].pane.Agent.Rank(); ri != rj { + return ri < rj + } + // Oldest first within a rank: the one that has been waiting longest is + // the one being kept waiting. + return ts[i].pane.Agent.At < ts[j].pane.Agent.At + }) + + pick := ts[0] + if len(ts) > 1 { + // Advance past whatever the last press landed on. A cursor naming a pane + // that is gone, or not in this list, simply falls through to the front. + last := tmux.Option(cursorOption, "") + for i, t := range ts { + if t.pane.ID == last { + pick = ts[(i+1)%len(ts)] + break + } + } + } + _ = tmux.SetOption(cursorOption, pick.pane.ID) + + self, _ := os.Executable() + ctx := action.Ctx{SelfTTY: selfTTY, Suffix: suffix, AttachSh: self} + if err := action.Go(pick.win, pick.pane.ID, action.Reuse, ctx); err != nil { + return err + } + _, _ = tmux.Run("display-message", + fmt.Sprintf("%s · %s — %s:%s.%s", pick.pane.Agent.Agent, + agent.Words(pick.pane.Agent.State), + pick.win.Session, pick.win.Index, pick.pane.Index)) + return nil +} diff --git a/cmd/kaku-tab/installhooks.go b/cmd/kaku-tab/installhooks.go index dede160..876f787 100644 --- a/cmd/kaku-tab/installhooks.go +++ b/cmd/kaku-tab/installhooks.go @@ -15,7 +15,7 @@ import ( // written. The two CLIs are subscribed through one shared block and each // ignores the events it does not have, so this is the union of both: // -// Claude Code only Notification, PostToolBatch, StopFailure +// Claude Code only Notification, PostToolBatch, StopFailure, Elicitation // Devin CLI only PermissionRequest // // PreToolUse is deliberately absent. It fires on every single tool call, on the @@ -27,6 +27,8 @@ var hookEvents = []string{ "PostToolUse", "PostToolBatch", "PermissionRequest", + "Elicitation", + "ElicitationResult", "Notification", "Stop", "StopFailure", diff --git a/cmd/kaku-tab/main.go b/cmd/kaku-tab/main.go index 0882f1b..839ce28 100644 --- a/cmd/kaku-tab/main.go +++ b/cmd/kaku-tab/main.go @@ -11,6 +11,7 @@ // a Kaku tab per detached session // kaku-tab hook agent lifecycle hook (reads stdin) // kaku-tab agents [--format tmux] agent panes, or the status counter +// kaku-tab go-agent [tty] jump to whatever is waiting on you // kaku-tab install-hooks register the hooks with both CLIs package main @@ -125,6 +126,8 @@ func main() { err = hook() case "agents": err = agents(os.Args[2:]) + case "go-agent": + err = goAgent(arg(2)) case "install-hooks": err = installHooks(os.Args[2:]) case "version", "--version", "-v": @@ -153,6 +156,7 @@ const usage = `kaku-tab — tmux window ⇄ Kaku tab picker hook publish agent state for the current pane (reads stdin) agents [--format tmux] [--refresh] list agent panes, or render the status-bar counter + go-agent [tty] jump to the agent that wants you; again for the next install-hooks [--dry-run] add the agent hooks to ~/.claude/settings.json version diff --git a/docs/agents.md b/docs/agents.md index 719f8c2..97874d3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -155,7 +155,8 @@ that set the state. Not every event carries one: | `perm` | `PermissionRequest` | the tool and its argument — `Bash: git push` | | `done` | `Stop` | the reply that ended the turn | | `err` | `StopFailure` | the error type | -| `ask` | `Notification` | none — that payload carries a type and no text | +| `busy` | going quiet | a `busy` record untouched for 30 minutes reads as `no activity` — every hook event refreshes the timestamp, so one this old has not made a tool call in half an hour | +| `ask` | `Elicitation` | the question an MCP server is asking | Only the **first line that says something** is kept. An assistant reply is a whole markdown document — headings, code blocks, sometimes a rendered box of its @@ -188,6 +189,51 @@ turn of tool calls. `done` or `err`, but not `busy`. Typing an agent's name in the search box works too; the name is part of each row's match text without being drawn. +## Jumping to what wants you + +```tmux +set -g @kaku-tab-agent-key 'M-a' +``` + +Jumps straight to the agent that most wants you, no picker in the way. Press it +again to move to the next one, and again to wrap — so a row of blocked sessions +is walked with one key rather than four. + +The order is the same ranking the picker uses: blocked before failed before +finished, and oldest first within a rank, since the one that has been waiting +longest is the one being kept waiting. With nothing waiting it says so and does +nothing. + +## Desktop notification + +```tmux +set -g @kaku-tab-agent-notify 'on' +``` + +Fires on the **transition** into a state that wants you, and never on the +repeats — a turn of tool calls does not re-notify you about the permission you +already granted. Off by default: a tmux plugin has no business raising system +notifications until it is asked to. + +The body carries the agent's own text, so it is passed to `osascript` as an +argument rather than spliced into the script. A quote or backslash in a reply +would otherwise close the AppleScript string literal and let the remainder run +as script. + +## A badge in the window list + +`@kt_agent_win` carries the most actionable agent in each window, refreshed +whenever a pane changes state. Use it in your window format: + +```tmux +set -g window-status-format "#{?@kt_agent_win,#{@kt_agent_win} ,}#I:#W" +set -g window-status-current-format "#{?@kt_agent_win,#{@kt_agent_win} ,}#I:#W" +``` + +Only the windows whose rollup actually changed are written, because this runs +from a hook and a `set-option` per window per event would be the most expensive +thing in the path. + ## The status-bar counter Two pills at the far right of `status-right`: diff --git a/docs/configuration.md b/docs/configuration.md index 7ab57a9..0704e94 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -89,7 +89,7 @@ driving yourself. | Option | Default | Meaning | |---|---|---| | `@kaku-tab-titles` | `off` | retitle tabs after the tmux window their client shows | -| `@kaku-tab-title-format` | `%g` | `%s` session · `%g` base session · `%w` window name · `%i` window index | +| `@kaku-tab-title-format` | `%g` | `%s` session · `%g` base session · `%w` window name · `%i` window index · `%a` agent state (`!` waiting, `✓` finished, `✗` failed, `·` working) | `%w` is deliberately absent from the default. With tmux `automatic-rename` on, every window is named after its foreground process, so `%g · %w` produces a tab @@ -115,6 +115,8 @@ waiting on you, and how many are open — plus an agent column in the picker. Se | Option | Default | Meaning | |---|---|---| | `@kaku-tab-agents` | `off` | append the agent counter to `status-right` | +| `@kaku-tab-agent-key` | *(unset)* | key that jumps to the agent that wants you; press again for the next | +| `@kaku-tab-agent-notify` | `off` | desktop notification on the transition into a waiting state | | `@kaku-tab-agent-message` | `on` | store what the agent is doing — the prompt, the tool awaiting permission, the reply — for the picker's agent box | | `@kaku-tab-agent-color` | `@thm_mauve` | pill colour for the "agents open" count | | `@kaku-tab-notify-color` | `@thm_peach` | pill colour when something wants you | diff --git a/internal/action/titles.go b/internal/action/titles.go index 9472eab..6d259f1 100644 --- a/internal/action/titles.go +++ b/internal/action/titles.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "github.com/dsaad68/kaku-tab/internal/agent" "github.com/dsaad68/kaku-tab/internal/kaku" "github.com/dsaad68/kaku-tab/internal/model" "github.com/dsaad68/kaku-tab/internal/tmux" @@ -51,17 +52,25 @@ func SyncTitles(format, suffix string, dry bool) (map[string]string, error) { if err != nil { return nil, err } + // The agent rollup rides along in this query: a client format resolves + // against the window that client is showing, which is exactly the window + // being titled. rows, err := tmux.Run("list-clients", "-F", - "#{client_tty}"+tmux.FS+"#{window_name}"+tmux.FS+"#{window_index}") + "#{client_tty}"+tmux.FS+"#{window_name}"+tmux.FS+"#{window_index}"+ + tmux.FS+"#{"+agent.WindowOption+"}") if err != nil { return nil, err } - meta := map[string][2]string{} // tty -> {window name, index} + meta := map[string][3]string{} // tty -> {window name, index, agent state} for _, line := range strings.Split(rows, "\n") { f := strings.Split(line, tmux.FS) if len(f) >= 3 { - meta[f[0]] = [2]string{f[1], f[2]} + state := "" + if len(f) >= 4 { + state = f[3] + } + meta[f[0]] = [3]string{f[1], f[2], state} } } @@ -79,6 +88,7 @@ func SyncTitles(format, suffix string, dry bool) (map[string]string, error) { "%g", model.BaseSession(c.Session, suffix), "%w", squeeze(m[0]), "%i", m[1], + "%a", agent.Mark(agent.State(m[2])), ) title := strings.TrimSpace(r.Replace(format)) out[tab] = title diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 904f73d..ed65737 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" "syscall" + "time" ) // PaneOption is the tmux pane option the state is published in. @@ -86,10 +87,10 @@ type Record struct { // Empty reports whether there is no agent here. func (r Record) Empty() bool { return r.State == None } -// Attention reports whether this state is one you owe a response to. Busy is -// the only state that is not: everything else is the agent waiting on you. -func (r Record) Attention() bool { - switch r.State { +// Attention reports whether a state is one you owe a response to. Busy is the +// only state that is not: everything else is the agent waiting on you. +func Attention(st State) bool { + switch st { case Perm, Ask, Done, Err: return true default: @@ -97,6 +98,59 @@ func (r Record) Attention() bool { } } +// Attention reports whether this record wants something from you. +func (r Record) Attention() bool { return Attention(r.State) } + +// StuckAfter is how long a Busy record may go without an update before it is +// treated as probably wedged. Every hook event refreshes the timestamp, so a +// genuinely working agent is never more than a tool call old. +const StuckAfter = 30 * time.Minute + +// Stuck reports whether a working agent has gone quiet for long enough to be +// suspicious. Only Busy qualifies: the other states are *meant* to sit still. +func (r Record) Stuck(now time.Time, after time.Duration) bool { + if r.State != Busy || r.At <= 0 { + return false + } + return now.Sub(time.Unix(r.At, 0)) > after +} + +// Mark is a one-cell state marker for a terminal tab title, where there is no +// colour to lean on and very little room. +func Mark(st State) string { + switch st { + case Perm, Ask: + return "!" + case Done: + return "✓" + case Err: + return "✗" + case Busy: + return "·" + default: + return "" + } +} + +// Words names a state in plain language. Kept here rather than in the picker +// because the notifier says the same thing, and the two must not drift. +func Words(st State) string { + switch st { + case Perm: + return "waiting for permission" + case Ask: + return "waiting for an answer" + case Done: + return "finished a turn" + case Err: + return "turn failed" + case Busy: + return "working" + default: + return "" + } +} + // Rank orders records by how much they want you, lowest first. Perm and Ask // outrank Err because a blocked agent is burning wall-clock right now, where a // failed turn has already stopped. Used to roll panes up to a window and diff --git a/internal/agent/hook.go b/internal/agent/hook.go index 05bb054..cc6c51b 100644 --- a/internal/agent/hook.go +++ b/internal/agent/hook.go @@ -34,6 +34,12 @@ type payload struct { ToolName string `json:"tool_name"` // PermissionRequest ToolInput map[string]any `json:"tool_input"` // PermissionRequest ErrorType string `json:"error_type"` // StopFailure + // Elicitation, whose field name the reference does not pin down the way it + // does the others. Read defensively and take whichever is populated, rather + // than guess one and silently show nothing. + Message string `json:"message"` + Question string `json:"question"` + Description string `json:"description"` } // Decision is what one hook event asks us to do to the pane's record. @@ -94,6 +100,17 @@ func Decide(b []byte) Decision { case "PermissionRequest": return Decision{Action: Set, State: Perm, Msg: toolSummary(p.ToolName, p.ToolInput)} + // An MCP server asking the user something. This is the only event that can + // say what the question actually is — Notification/elicitation_dialog + // reports that one is open and nothing more. + case "Elicitation": + return Decision{Action: Set, State: Ask, + Msg: firstNonEmpty(p.Message, p.Question, p.Description, p.Prompt)} + + case "ElicitationResult": + // Answered or declined; the agent is working again. + return Decision{Action: Set, State: Busy} + case "Notification": switch p.NotifyOn { case "permission_prompt": @@ -113,6 +130,15 @@ func Decide(b []byte) Decision { return Decision{} } +func firstNonEmpty(vs ...string) string { + for _, v := range vs { + if v != "" { + return v + } + } + return "" +} + // toolArgKeys are the tool_input fields worth showing, most specific first. A // permission prompt is only useful if it says what is being run, and the field // that holds it differs per tool. diff --git a/internal/agent/hook_test.go b/internal/agent/hook_test.go index 3691392..18d2171 100644 --- a/internal/agent/hook_test.go +++ b/internal/agent/hook_test.go @@ -135,3 +135,23 @@ func TestDecideLeavesMessageEmptyWhenNoneCarried(t *testing.T) { } } } + +// An MCP elicitation is the only event that can say what the question actually +// is; Notification/elicitation_dialog reports only that one is open. The field +// name is not pinned down by the reference, so every plausible one is read. +func TestDecideElicitation(t *testing.T) { + for _, field := range []string{"message", "question", "description", "prompt"} { + payload := `{"hook_event_name":"Elicitation","` + field + `":"Which branch?"}` + d := Decide([]byte(payload)) + if d.Action != Set || d.State != Ask { + t.Errorf("%s -> (%v,%q), want (Set,ask)", payload, d.Action, d.State) + } + if d.Msg != "Which branch?" { + t.Errorf("%s -> msg %q", payload, d.Msg) + } + } + // Answered or declined, so the agent is working again. + if d := Decide([]byte(`{"hook_event_name":"ElicitationResult"}`)); d.Action != Set || d.State != Busy { + t.Errorf("ElicitationResult -> (%v,%q), want (Set,busy)", d.Action, d.State) + } +} diff --git a/internal/agent/status_test.go b/internal/agent/status_test.go index 028d041..4927d5a 100644 --- a/internal/agent/status_test.go +++ b/internal/agent/status_test.go @@ -5,6 +5,7 @@ package agent import ( "strings" "testing" + "time" ) func testTheme() Theme { @@ -217,3 +218,54 @@ func TestMsgCapped(t *testing.T) { t.Error("capping threw the whole message away") } } + +// Only a working agent can be stuck; the other states are meant to sit still. +func TestStuckOnlyAppliesToBusy(t *testing.T) { + now := time.Unix(1_000_000, 0) + old := now.Add(-time.Hour).Unix() + for _, st := range []State{Perm, Ask, Done, Err} { + if (Record{State: st, At: old}).Stuck(now, StuckAfter) { + t.Errorf("%q reported stuck", st) + } + } + if !(Record{State: Busy, At: old}).Stuck(now, StuckAfter) { + t.Error("an hour-old busy record is not stuck") + } + if (Record{State: Busy, At: now.Add(-time.Minute).Unix()}).Stuck(now, StuckAfter) { + t.Error("a minute-old busy record reported stuck") + } + // A record with no timestamp cannot be judged, so it is not accused. + if (Record{State: Busy}).Stuck(now, StuckAfter) { + t.Error("a record with no timestamp reported stuck") + } +} + +// Words and Mark cover every state, and neither may go blank on one — a state +// with no wording renders as an empty notification or a title marker that +// silently disappears. +func TestWordsAndMarkCoverEveryState(t *testing.T) { + for _, st := range []State{Busy, Perm, Ask, Done, Err} { + if Words(st) == "" { + t.Errorf("Words(%q) is empty", st) + } + if Mark(st) == "" { + t.Errorf("Mark(%q) is empty", st) + } + } + if Words(None) != "" || Mark(None) != "" { + t.Error("the empty state should describe itself as nothing") + } +} + +// Attention is what the notification and the counter both key on, so the two +// forms must not drift apart. +func TestAttentionMatchesTheRecordMethod(t *testing.T) { + for _, st := range []State{None, Busy, Perm, Ask, Done, Err} { + if got, want := (Record{State: st}).Attention(), Attention(st); got != want { + t.Errorf("Record.Attention(%q)=%v, Attention(%q)=%v", st, got, st, want) + } + } + if Attention(Busy) { + t.Error("working is not something you owe a reply to") + } +} diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index 91b7b95..78f9093 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -256,6 +256,48 @@ func SetPaneOption(pane, name, value string) error { return err } +// PaneAgentAt reads one pane's record. Used by the hook to learn what it is +// about to overwrite: a state that has not changed needs no notification, no +// window rollup and no status redraw, which is most of the traffic. +func PaneAgentAt(pane string) agent.Record { + out, err := Run("display-message", "-p", "-t", pane, "#{"+agent.PaneOption+"}") + if err != nil { + return agent.Record{} + } + return agent.Parse(strings.TrimSpace(out)) +} + +// WindowAgents returns the most actionable agent per window, plus the rollup +// each window currently advertises, in one query. +// +// Reading #{@kt_agent_win} in *pane* scope is deliberate: pane options inherit +// from window options, and no pane ever sets this one, so every pane reports +// its window's value. That is the same inheritance that makes @kt_agent +// pane-only, used here on purpose rather than tripped over. +func WindowAgents() (targets map[string]string, best, current map[string]agent.Record, err error) { + rows, err := query("list-panes", "-a", "-F", f( + "#{session_name}", "#{window_id}", + "#{"+agent.PaneOption+"}", "#{"+agent.WindowOption+"}")) + if err != nil { + return nil, nil, nil, err + } + targets = map[string]string{} + best = map[string]agent.Record{} + current = map[string]agent.Record{} + for _, r := range rows { + win := at(r, 1) + if win == "" { + continue + } + targets[win] = at(r, 0) + current[win] = agent.Record{State: agent.State(at(r, 3))} + if rec := liveAgent(at(r, 2), ""); !rec.Empty() { + best[win] = agent.Best([]agent.Record{best[win], rec}) + } + } + return targets, best, current, nil +} + // SetPaneAgentMsg writes a pane's agent message, clearing the option when there // is nothing to say rather than leaving a stale line behind. func SetPaneAgentMsg(pane, value string) error { diff --git a/internal/ui/theme.go b/internal/ui/theme.go index dabdbdc..b3d5588 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -73,6 +73,9 @@ var ( cAgentErr = lipgloss.NewStyle().Foreground(colRed).Bold(true) cAgentDone = lipgloss.NewStyle().Foreground(colGreen).Bold(true) cAgentBusy = lipgloss.NewStyle().Foreground(colMuted) + // Working, but not for a long time. Loud enough to notice, not as loud as a + // state that is genuinely blocked on you. + cAgentStuck = lipgloss.NewStyle().Foreground(colAmber) ) // frame draws a rounded box with a title set into the top border. diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 2ae30a3..5349615 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -979,29 +979,30 @@ func agentCell(r agent.Record) string { case agent.Done: state = cAgentDone.Render(glyphDone) default: - state = cAgentBusy.Render(glyphBusy) + // A stalled agent still reads as working at a glance, which is the one + // case where "working" is misleading. + style := cAgentBusy + if r.Stuck(time.Now(), agent.StuckAfter) { + style = cAgentStuck + } + state = style.Render(glyphBusy) } return id + " " + state } // agentWords spells out a record's state and age for the agent box. The agent's -// own name is not repeated here — the box title carries it. +// own name is not repeated here — the box title carries it. The wording itself +// lives in internal/agent, so the desktop notification says the same thing. func agentWords(r agent.Record) string { if r.Empty() { return "" } - var what string - switch r.State { - case agent.Perm: - what = "waiting for permission" - case agent.Ask: - what = "waiting for an answer" - case agent.Done: - what = "finished a turn" - case agent.Err: - what = "turn failed" - default: - what = "working" + what := agent.Words(r.State) + if r.Stuck(time.Now(), agent.StuckAfter) { + // Every hook event refreshes the timestamp, so a working agent this old + // has not made a tool call in half an hour. Say so rather than keep + // reporting it as busy. + what += " · no activity" } out := what if r.At > 0 { diff --git a/kaku-tab.tmux b/kaku-tab.tmux index e6a4b13..1054bc5 100755 --- a/kaku-tab.tmux +++ b/kaku-tab.tmux @@ -16,6 +16,7 @@ opt() { local v; v="$(tmux show-option -gqv "$1")"; [ -n "$v" ] && printf '%s' " KEY="$(opt @kaku-tab-key 'M-l')" SEARCH_KEY="$(opt @kaku-tab-search-key '')" +AGENT_KEY="$(opt @kaku-tab-agent-key '')" TITLES="$(opt @kaku-tab-titles 'off')" SIZE="$(opt @kaku-tab-popup-size '90%,85%')" W="${SIZE%%,*}"; H="${SIZE##*,}" @@ -62,6 +63,15 @@ if [ "$TITLES" = "on" ]; then "$BIN" titles >/dev/null 2>&1 & fi +# Jump straight to the agent that wants you, no picker in the way. Pressing it +# again advances to the next one, so a row of blocked sessions is walked with one +# key rather than four. +# +# Opt-in: unbound unless you name a key, like the search binding above. +if [ -n "$AGENT_KEY" ]; then + tmux bind-key -n "$AGENT_KEY" run-shell -b "$BIN go-agent '#{client_tty}'" +fi + # Agent counter on the right of the status bar. Opt-in: it appends to # status-right, which most people compose by hand. # From 20a8800b7de8cedb2522e48f5534714f4b744e77 Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Sun, 23 Aug 2026 16:28:05 +0200 Subject: [PATCH 13/14] Take the agent glyphs off session headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A header inherits its agent from its children, and the child carrying it is the very next line. With a list of one-window sessions that meant the same pair drawn twice, one row apart, on every session — three glyphs of ornament before each window index and two more above it. The record stays on the header row, so folding a session and resting on it still opens the agent box. Only the drawing goes. Co-Authored-By: Claude Opus 5 (1M context) --- docs/agents.md | 13 ++++++++---- internal/ui/agentcol_test.go | 40 ++++++++++++++++++++++++++++++++++++ internal/ui/ui.go | 7 ++++++- 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 97874d3..22ad4f0 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -120,10 +120,15 @@ never read as one gradient. `busy` is the only muted state, deliberately: it is the one thing here you do not owe a response to, and a column that shouted on every working agent is one you would learn to ignore. -A window row shows the most actionable agent among its panes, and a session -header the most actionable among its windows, so a pane blocked three windows -deep is visible without unfolding anything. Switch to pane mode (^p) -for the exact pane. +A window row shows the most actionable agent among its panes, so a pane blocked +two panes deep is visible without switching to pane mode (^p) to find +which one. + +Session headers do **not** draw the glyphs. A header inherits them from its +children and the child carrying them is the very next line, so for a one-window +session the pair was drawn twice, one row apart. The header still holds the +record — fold a session, rest on it, and the agent box tells you what is going +on inside without unfolding anything. The two glyphs are separated by a space. Flush against each other they read as one smudged symbol, which defeats the point of splitting identity from state. diff --git a/internal/ui/agentcol_test.go b/internal/ui/agentcol_test.go index 22e8252..32300b8 100644 --- a/internal/ui/agentcol_test.go +++ b/internal/ui/agentcol_test.go @@ -302,3 +302,43 @@ func TestAgentBoxCapsMessageLines(t *testing.T) { return } } + +// A header inherits its children's agent, and the child carrying it is the very +// next line — drawn on both, a one-window session showed the pair twice, one row +// apart. The record stays on the row so the box still opens; only the glyphs go. +func TestHeaderDoesNotDrawTheAgentGlyphs(t *testing.T) { + m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 150, 30 + + var checkedHeader, checkedChild bool + for _, r := range m.rows { + line := ansi.Strip(m.renderRow(r, false)) + switch r.kind { + case kindHeader: + if r.agent.Empty() { + continue + } + checkedHeader = true + for _, g := range []string{glyphClaude, glyphDevin, glyphPerm, glyphBusy} { + if strings.Contains(line, g) { + t.Errorf("header %q still draws %q: %s", r.group, g, line) + } + } + // ... but it still knows, so the box opens when the cursor rests here. + if agentWords(r.agent) == "" { + t.Errorf("header %q lost its agent record", r.group) + } + default: + if r.agent.Empty() { + continue + } + checkedChild = true + if !strings.Contains(line, glyphClaude) && !strings.Contains(line, glyphDevin) { + t.Errorf("child row lost its agent glyph: %s", line) + } + } + } + if !checkedHeader || !checkedChild { + t.Fatalf("sample exercised header=%v child=%v, need both", checkedHeader, checkedChild) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 5349615..571ba39 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1064,9 +1064,14 @@ func (m *Model) renderRow(r row, selected bool) string { if r.count == 1 { unit = strings.TrimSuffix(unit, "s") } + // No agent glyphs on a header. It inherits them from its children, and + // the child carrying them is the very next line — for a one-window + // session the pair was drawn twice, one row apart. The record is still + // kept on the row, so folding a session and resting on it still opens + // the agent box. line := cursor + cGroup.Render(arrow+" "+r.group) + " " + cDim.Render(fmt.Sprintf("%d %s", r.count, unit)) + " " + - agentCell(r.agent) + " " + m.badge(r.status, r.tabID, true) + m.badge(r.status, r.tabID, true) return truncateANSI(line, lw) } From fda5d97b04ad99f868b62e1c00884069c648b275 Mon Sep 17 00:00:00 2001 From: Daniel Saad Date: Sun, 23 Aug 2026 16:50:13 +0200 Subject: [PATCH 14/14] Fix everything the review of #3 turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings, all reproduced by the reviewer and all verified fixed against a real tmux server. The agent box could push the frame off a short popup. footerLines grew to eleven rows while listHeight's floor still assumed three, so View emitted more lines than the popup had and the title and prompt scrolled away — at exactly the documented compact default, 60x17. The footer is now budgeted: the list keeps a minimum, the box gets what is left after the help bar, and the message shrinks to fit before the box is dropped, because "waiting for permission" in three rows is most of the value. That budget is also held constant as the cursor moves. Sized to the box currently on screen, it resized the viewport under your hands: arrowing onto an agent row scrolled the list several rows in one keypress. Room is reserved only when the table has an agent at all, so an agent-free list is untouched. go-agent found nothing whenever @kaku-tab-scope was not "all" — it passed an empty SelfSession, and the session and group scopes compare against it, so every window fell out of scope while an agent sat blocked. It now takes the client's session like the picker does. printResolve, which had the same latent gap, is pinned to "all" instead: it is the debugging view of the join and a scoped one would hide the rows you opened it to see. A stale message could be redisplayed. The state tag distinguishes states, not turns, so a second Done reached without any text — an agent_completed notification, or a Stop with an empty reply — showed the *previous* turn's reply as if it were this one's. Entering a state with nothing to say now clears the message; staying in one still leaves it, which is what lets a prompt survive a turn of tool calls. The sweeper cleared @kt_agent but left @kt_agent_msg, which turned that into a cross-session leak: the next agent to occupy the pane and reach the same state without text of its own displayed the dead one's reply. firstMeaningfulLine skipped fence markers but not fence contents, so a reply opening with a diff or a snippet put a stray line of code in the box. Fenced blocks are now skipped entire, with the first line inside one kept as a fallback for a reply that is nothing else. And the 1 MiB payload cap could strand a pane. Truncation is not graceful here: a cut payload fails to parse and the event is dropped, and PostToolUse is precisely the event that clears a pane out of "waiting for permission" once you approve a call. Losing one to a large Read left the pane advertising a request you had already granted, with go-agent sending you back to it. Also noted by the review: m.status replaces the whole footer and was only ever cleared by ctrl+a, so one message left standing hid the help bar and the agent box until something overwrote it. Any keypress now dismisses it. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/kaku-tab/agents.go | 27 ++++++-- cmd/kaku-tab/goagent.go | 7 +- cmd/kaku-tab/main.go | 14 ++-- internal/agent/agent.go | 24 +++++-- internal/agent/status_test.go | 28 ++++++-- internal/ui/agentcol_test.go | 101 +++++++++++++++++++++-------- internal/ui/ui.go | 116 +++++++++++++++++++++++++++++----- kaku-tab.tmux | 2 +- 8 files changed, 255 insertions(+), 64 deletions(-) diff --git a/cmd/kaku-tab/agents.go b/cmd/kaku-tab/agents.go index f9c7567..577cade 100644 --- a/cmd/kaku-tab/agents.go +++ b/cmd/kaku-tab/agents.go @@ -21,10 +21,15 @@ import ( var paneIDRe = regexp.MustCompile(`^%[0-9]+$`) -// maxHookPayload caps what we read from a hook's stdin. The fields we want are -// in the first few hundred bytes, but a PostToolUse payload can carry a whole -// file's contents, and a hook must not be the thing that stalls the agent. -const maxHookPayload = 1 << 20 +// maxHookPayload caps what we read from a hook's stdin, so a runaway payload +// cannot exhaust memory. +// +// Generous on purpose. Truncation is not a graceful degradation here: a cut +// payload fails to parse, the event is dropped, and PostToolUse is precisely +// the event that clears a pane out of "waiting for permission" once you approve +// a call. Losing one to a large Read strands the pane — and go-agent keeps +// sending you back to it — until some later, smaller event lands. +const maxHookPayload = 32 << 20 // hook is the publisher: it runs as a child of Claude Code or Devin CLI, learns // the pane from the $TMUX_PANE it inherited, and records what the agent is doing @@ -83,9 +88,17 @@ func hook() error { // Opt-out, because this is the one thing here that stores what you typed: // prompts and tool arguments land in a tmux option, readable by anything // that can talk to the server. - if d.Msg != "" && tmux.Option("@kaku-tab-agent-message", "on") == "on" { + switch { + case d.Msg != "" && tmux.Option("@kaku-tab-agent-message", "on") == "on": _ = tmux.SetPaneAgentMsg(pane, agent.FormatMsg(d.State, d.Msg)) rec.Msg = d.Msg + case prev.State != d.State: + // Entering a state with nothing to say clears whatever was there. The + // state tag alone is not enough: it distinguishes states, not turns, so + // a second Done reached without any text — an agent_completed + // notification, or a Stop with an empty reply — would otherwise + // redisplay the *previous* turn's reply as if it were this one's. + _ = tmux.UnsetPaneOption(pane, agent.MsgOption) } settled(prev.State, d.State, rec) return nil @@ -154,6 +167,10 @@ func sweep() (agent.Counts, error) { } if !agent.Live(r) { _ = tmux.UnsetPaneOption(pane, agent.PaneOption) + // The message goes with it. Left behind, the next agent to occupy + // this pane and reach the same state without text of its own would + // display the dead one's reply. + _ = tmux.UnsetPaneOption(pane, agent.MsgOption) continue } live = append(live, r) diff --git a/cmd/kaku-tab/goagent.go b/cmd/kaku-tab/goagent.go index 554c664..b34001b 100644 --- a/cmd/kaku-tab/goagent.go +++ b/cmd/kaku-tab/goagent.go @@ -32,9 +32,12 @@ type target struct { // // The ordering is agent.Rank — blocked before failed before finished — so the // first press always lands on whatever is costing you the most. -func goAgent(selfTTY string) error { +func goAgent(selfTTY, selfSession string) error { suffix := tmux.Option("@kaku-tab-satellite-suffix", model.DefaultSatelliteSuffix) - ws, err := resolve.Resolve(liveSource{}, opts("", true)) + // The client's own session, not "": the session and group scopes resolve + // against it, and passing an empty one made every window fall out of scope — + // go-agent then reported nothing waiting while an agent sat blocked. + ws, err := resolve.Resolve(liveSource{}, opts(selfSession, true)) if err != nil { return err } diff --git a/cmd/kaku-tab/main.go b/cmd/kaku-tab/main.go index 839ce28..b827413 100644 --- a/cmd/kaku-tab/main.go +++ b/cmd/kaku-tab/main.go @@ -11,7 +11,7 @@ // a Kaku tab per detached session // kaku-tab hook agent lifecycle hook (reads stdin) // kaku-tab agents [--format tmux] agent panes, or the status counter -// kaku-tab go-agent [tty] jump to whatever is waiting on you +// kaku-tab go-agent [tty] [session] jump to whatever is waiting on you // kaku-tab install-hooks register the hooks with both CLIs package main @@ -127,7 +127,7 @@ func main() { case "agents": err = agents(os.Args[2:]) case "go-agent": - err = goAgent(arg(2)) + err = goAgent(arg(2), arg(3)) case "install-hooks": err = installHooks(os.Args[2:]) case "version", "--version", "-v": @@ -156,7 +156,8 @@ const usage = `kaku-tab — tmux window ⇄ Kaku tab picker hook publish agent state for the current pane (reads stdin) agents [--format tmux] [--refresh] list agent panes, or render the status-bar counter - go-agent [tty] jump to the agent that wants you; again for the next + go-agent [tty] [session] + jump to the agent that wants you; again for the next install-hooks [--dry-run] add the agent hooks to ~/.claude/settings.json version @@ -422,7 +423,12 @@ func titles(dry bool) error { } func printResolve() error { - ws, err := resolve.Resolve(liveSource{}, opts("", false)) + // Explicitly every window, whatever @kaku-tab-scope says. This is the + // debugging view of the join, and a scoped one would hide exactly the rows + // you opened it to look at. + o := opts("", false) + o.Scope = "all" + ws, err := resolve.Resolve(liveSource{}, o) if err != nil { return err } diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ed65737..f0ab071 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -271,21 +271,33 @@ func sanitize(s string) string { return strings.TrimSpace(b.String()) } -// firstMeaningfulLine picks the first line with prose in it, skipping the -// blanks, code fences and pure line-art rules that open so many replies, and -// shedding a leading markdown marker so the text starts at a word. +// firstMeaningfulLine picks the first line with prose in it, skipping blank +// lines, pure line-art rules, and fenced blocks entire — not merely the fence +// markers. A reply that opens with a diff or a code block is common, and +// returning its first line of code put a stray fragment in the box. +// +// A reply that is nothing but a code block still has to say something, so the +// first line inside one is the fallback rather than nothing at all. func firstMeaningfulLine(s string) string { + fenced, fallback := false, "" for _, line := range strings.Split(s, "\n") { line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") { + if strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") { + fenced = !fenced + continue + } + if line == "" || onlyLineArt(line) { continue } - if onlyLineArt(line) { + if fenced { + if fallback == "" { + fallback = stripMarker(line) + } continue } return stripMarker(line) } - return "" + return fallback } // stripMarker removes an unambiguous leading markdown marker. Bullets need the diff --git a/internal/agent/status_test.go b/internal/agent/status_test.go index 4927d5a..e65f7ac 100644 --- a/internal/agent/status_test.go +++ b/internal/agent/status_test.go @@ -200,11 +200,31 @@ func TestMsgStripsMarkdownMarkers(t *testing.T) { } } -// A reply that opens with a fence, a rule, or blank lines still has to yield -// its first real sentence. -func TestMsgSkipsOpeningNoise(t *testing.T) { +// A reply that opens with a fence, a rule, or blank lines still has to yield its +// first real sentence — the prose *after* the block, not the first line of code +// inside it. Replies that open with a diff or a snippet are common. +func TestMsgSkipsFencedBlocksEntire(t *testing.T) { in := "\n\n```go\nfunc main() {}\n```\n──────\nHere is what changed." - if got := ParseMsg(Done, FormatMsg(Done, in)); got != "func main() {}" { + if got := ParseMsg(Done, FormatMsg(Done, in)); got != "Here is what changed." { + t.Errorf("got %q, want the prose after the block", got) + } +} + +// ... but a reply that is nothing but a code block still has to say something. +func TestMsgFallsBackInsideAFence(t *testing.T) { + in := "```sh\ngit push --force\n```" + if got := ParseMsg(Done, FormatMsg(Done, in)); got != "git push --force" { + t.Errorf("got %q, want the fenced line as a fallback", got) + } + if got := ParseMsg(Done, FormatMsg(Done, "```\n```")); got != "" { + t.Errorf("an empty block yielded %q", got) + } +} + +// Prose before a block wins over anything inside it. +func TestMsgPrefersProseBeforeAFence(t *testing.T) { + in := "Here is the fix:\n```go\nfunc main() {}\n```" + if got := ParseMsg(Done, FormatMsg(Done, in)); got != "Here is the fix:" { t.Errorf("got %q", got) } } diff --git a/internal/ui/agentcol_test.go b/internal/ui/agentcol_test.go index 32300b8..55ce21a 100644 --- a/internal/ui/agentcol_test.go +++ b/internal/ui/agentcol_test.go @@ -155,14 +155,16 @@ func TestFooterSpellsOutTheSelectedAgent(t *testing.T) { m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) m.width, m.height = 150, 30 - // A row with no agent must not describe one. Counted rather than matched on - // wording: the help bar has a "waiting agents" key of its own, and a - // substring probe hits that instead. + // A row with no agent must not describe one. Probed by the box's own border + // rather than by wording: the help bar has a "waiting agents" key of its + // own, and a substring probe hits that instead. The line *count* is no + // probe either — the footer is padded to a constant height so the list does + // not move as the cursor does. for i, vi := range m.view { if m.rows[vi].agent.Empty() { m.cursor = i - if got, want := len(m.footerLines()), len(m.helpLines()); got != want { - t.Errorf("footer is %d lines on an agent-free row, want %d (help only)", got, want) + if got := ansi.Strip(strings.Join(m.footerLines(), "\n")); strings.Contains(got, "╭") { + t.Errorf("a box was drawn for an agent-free row:\n%s", got) } break } @@ -182,27 +184,76 @@ func TestFooterSpellsOutTheSelectedAgent(t *testing.T) { t.Fatal("no perm row in the sample to select") } -// The footer grows by a line when it describes an agent, so the list has to -// give one back — otherwise the bottom row is drawn over the frame. -func TestListHeightReservesTheAgentLine(t *testing.T) { - m := New(agentSample(), Options{Tree: true, SelfTab: "8"}) +// Regression: the agent box is up to six lines and appears only under the +// cursor, so on a short popup View emitted more lines than the frame had — the +// title and the prompt scrolled off the top. tmux fixes a popup's height at +// creation, so there is no growing out of it. +// +// 60x17 is the documented compact default (60%,70%) on a 24-row terminal, which +// is where this was reproduced. +func TestViewNeverOverflowsTheFrame(t *testing.T) { + ws := agentSample() + ws[0].Agent.Msg = strings.Repeat("a message long enough to wrap several times ", 6) + for _, size := range [][2]int{{60, 17}, {80, 12}, {100, 10}, {150, 40}, {60, 9}} { + m := New(ws, Options{Tree: true, SelfTab: "8"}) + m.width, m.height = size[0], size[1] + for i := range m.view { + m.cursor = i + m.ensureVisible() + if got := strings.Count(m.View(), "\n") + 1; got > m.height { + t.Errorf("%dx%d row %d: View is %d lines, frame is %d", + size[0], size[1], i, got, m.height) + } + } + } +} + +// The footer is held at a constant height as the cursor moves. Sized to the box +// currently on screen it resized the viewport under your hands, so arrowing onto +// an agent row scrolled the list several rows in one keypress. +func TestListHeightDoesNotMoveWithTheCursor(t *testing.T) { + ws := agentSample() + ws[0].Agent.Msg = "Bash: git push origin main" + m := New(ws, Options{Tree: true, SelfTab: "8"}) m.width, m.height = 150, 30 - var plain, withAgent int - for i, vi := range m.view { + first := m.listHeight() + for i := range m.view { m.cursor = i - if m.rows[vi].agent.Empty() { - plain = m.listHeight() - } else { - withAgent = m.listHeight() + if got := m.listHeight(); got != first { + t.Fatalf("listHeight is %d on row %d but %d on row 0", got, i, first) } } - if plain == 0 || withAgent == 0 { - t.Fatal("sample lacks both an agent row and an agent-free one") +} + +// Room is held for the box only when there is an agent to put in it, so a list +// with none is unchanged. +func TestNoAgentMeansNoReservedBox(t *testing.T) { + plain := New(sample(), Options{Tree: true, SelfTab: "8"}) + plain.width, plain.height = 150, 30 + withAgents := New(agentSample(), Options{Tree: true, SelfTab: "8"}) + withAgents.width, withAgents.height = 150, 30 + + if plain.footerReserve() != len(plain.helpLines()) { + t.Errorf("agent-free list reserved %d rows, want just the help bar (%d)", + plain.footerReserve(), len(plain.helpLines())) + } + if withAgents.footerReserve() <= plain.footerReserve() { + t.Errorf("a list with agents reserved %d rows, no more than the %d of one without", + withAgents.footerReserve(), plain.footerReserve()) } - if withAgent >= plain { - t.Errorf("listHeight %d with the agent line, %d without; the line was not reserved", - withAgent, plain) +} + +// However tall the footer wants to be, the list keeps some rows. +func TestListKeepsAMinimumHeight(t *testing.T) { + ws := agentSample() + ws[0].Agent.Msg = strings.Repeat("long ", 100) + for _, h := range []int{6, 9, 12, 20} { + m := New(ws, Options{Tree: true, SelfTab: "8"}) + m.width, m.height = 60, h + if got := m.listHeight(); got < minListRows { + t.Errorf("height %d: listHeight %d, want at least %d", h, got, minListRows) + } } } @@ -218,9 +269,9 @@ func TestAgentBoxFollowsTheCursor(t *testing.T) { for i, vi := range m.view { m.cursor = i if m.rows[vi].agent.Empty() { - without = len(m.agentBox()) + without = len(m.agentBox(maxAgentBox)) } else { - withAgent = len(m.agentBox()) + withAgent = len(m.agentBox(maxAgentBox)) } } if without != 0 { @@ -243,7 +294,7 @@ func TestAgentBoxShowsStateAndMessage(t *testing.T) { continue } m.cursor = i - got := ansi.Strip(strings.Join(m.agentBox(), "\n")) + got := ansi.Strip(strings.Join(m.agentBox(maxAgentBox), "\n")) for _, want := range []string{"claude", "waiting for permission", "git push origin main"} { if !strings.Contains(got, want) { t.Errorf("box missing %q:\n%s", want, got) @@ -266,7 +317,7 @@ func TestAgentBoxLinesAreUniformWidth(t *testing.T) { continue } m.cursor = i - lines := m.agentBox() + lines := m.agentBox(maxAgentBox) if len(lines) == 0 { t.Fatalf("width %d: no box", width) } @@ -296,7 +347,7 @@ func TestAgentBoxCapsMessageLines(t *testing.T) { } m.cursor = i // border top + state line + at most agentBoxLines + border bottom - if got, max := len(m.agentBox()), 3+agentBoxLines; got > max { + if got, max := len(m.agentBox(maxAgentBox)), maxAgentBox; got > max { t.Errorf("box is %d lines, want at most %d", got, max) } return diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 571ba39..7d3367a 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -492,19 +492,91 @@ func (m *Model) helpLines() []string { return helpBarLines(m.helpPairs(), w) } -// footerLines is everything below the list: the selected row's agent spelled -// out, then the help bar. One source of truth, so listHeight reserves exactly -// the rows View is about to draw — an extra line here without a matching one -// there draws the last list row over the frame. +// minListRows is what the list keeps no matter how tall the footer gets. Below +// this the picker stops being a picker. +const minListRows = 3 + +// maxAgentBox is the tallest the agent box can be: two borders, the state line, +// and the message budget. +const maxAgentBox = 3 + agentBoxLines + +// boxAllowance is how many rows the agent box may occupy, given what the frame +// has left after the help bar. +// +// Shrinking it beats dropping it: on a short popup the message is the first +// thing that can go, but "claude · waiting for permission" still fits in three +// rows and is most of the value. Below three there is no box worth drawing — +// two of them would be borders. +func (m *Model) boxAllowance() int { + if m.status != "" || !m.anyAgent() { + return 0 + } + n := minInt(maxAgentBox, m.frameBudget()-len(m.helpLines())-1) + if n < 3 { + return 0 + } + return n +} + +// frameBudget is every row below the list that the frame can spare. +func (m *Model) frameBudget() int { + // frame top+bottom (2) + prompt + rule + blank, and the list keeps its own. + return maxInt(0, m.height-5-minListRows) +} + +// footerReserve is how many rows are held below the list. +// +// Constant as the cursor moves, not sized to the box currently on screen. The +// box appears and disappears with the cursor, and a footer that grew with it +// would resize the viewport under your hands — arrowing onto an agent row would +// scroll the list several rows in one keypress. +// +// Room is held for the box only when the table has an agent in it at all, so a +// list with none looks exactly as it did before any of this existed. +func (m *Model) footerReserve() int { + if m.status != "" { + return 1 + } + n := len(m.helpLines()) + if box := m.boxAllowance(); box > 0 { + n += box + 1 // the box, and the blank line under it + } + // tmux fixes a popup's height at creation, so there is no growing out of + // it: whatever the footer wants, the list keeps minListRows. + return minInt(n, m.frameBudget()) +} + +func (m *Model) anyAgent() bool { + for _, r := range m.rows { + if !r.agent.Empty() { + return true + } + } + return false +} + +// footerLines is everything below the list: the selected row's agent box and +// the help bar, padded to exactly footerReserve rows so the help stays pinned to +// the bottom and the list above never moves. func (m *Model) footerLines() []string { + reserve := m.footerReserve() + if reserve == 0 { + return nil + } if m.status != "" { return []string{cFlag.Render(m.status)} } - out := m.agentBox() + + out := m.agentBox(m.boxAllowance()) if len(out) > 0 { out = append(out, "") } - return append(out, m.helpLines()...) + out = append(out, m.helpLines()...) + if len(out) > reserve { + out = out[len(out)-reserve:] + } + // Pad at the top, so the help bar sits at the bottom either way. + return append(make([]string, reserve-len(out)), out...) } // agentBoxLines is the message budget: enough to read a permission request in @@ -516,8 +588,8 @@ const agentBoxLines = 3 // It sits in space the picker was wasting anyway, and it appears and disappears // with the cursor — so a list with no agents in it looks exactly as it did // before any of this existed. -func (m *Model) agentBox() []string { - if m.renaming { +func (m *Model) agentBox(allowance int) []string { + if m.renaming || allowance < 3 { return nil } r, ok := m.current() @@ -532,20 +604,22 @@ func (m *Model) agentBox() []string { body := []string{cHead.Render(agentWords(r.agent))} // The message is plain text from a hook payload, already stripped of // control characters when it was stored. - if r.agent.Msg != "" { - body = append(body, wrapCells(r.agent.Msg, w-3, agentBoxLines)...) + // Whatever is left of the allowance after the two borders and the state + // line goes to the message. + if lines := minInt(agentBoxLines, allowance-3); r.agent.Msg != "" && lines > 0 { + body = append(body, wrapCells(r.agent.Msg, w-3, lines)...) } return smallBox(agentCell(r.agent)+" "+cName.Render(r.agent.Agent), body, w) } func (m *Model) listHeight() int { // frame top+bottom (2) + prompt + rule + blank + footer - h := m.height - 5 - len(m.footerLines()) + h := m.height - 5 - m.footerReserve() if !m.sideBySide() && m.opt.Preview { h = h/2 - 1 } - if h < 3 { - h = 3 + if h < minListRows { + h = minListRows } return h } @@ -686,6 +760,11 @@ func (m *Model) updateRename(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Any keypress dismisses the last message. It replaces the whole footer — + // help bar and agent box both — so one left standing from an earlier kill or + // filter would blank them until something happened to overwrite it. + m.status = "" + switch msg.String() { case "ctrl+c", "esc": m.quitting = true @@ -782,10 +861,6 @@ func (m *Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.opt.AgentsOnly = !m.opt.AgentsOnly m.build() m.focusRow(keepID, keepHeader) - // Cleared on the way out too: left standing, the message would still be - // in the footer after the filter was toggled back off and the full list - // restored, which reads as a warning about the list you are looking at. - m.status = "" if m.opt.AgentsOnly && len(m.view) == 0 { // An empty list after a filter reads as a broken picker. Say why. m.status = "no agent is waiting on you" @@ -1298,6 +1373,13 @@ func countSelectable(rows []row) int { return n } +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + func maxInt(a, b int) int { if a > b { return a diff --git a/kaku-tab.tmux b/kaku-tab.tmux index 1054bc5..83ecb44 100755 --- a/kaku-tab.tmux +++ b/kaku-tab.tmux @@ -69,7 +69,7 @@ fi # # Opt-in: unbound unless you name a key, like the search binding above. if [ -n "$AGENT_KEY" ]; then - tmux bind-key -n "$AGENT_KEY" run-shell -b "$BIN go-agent '#{client_tty}'" + tmux bind-key -n "$AGENT_KEY" run-shell -b "$BIN go-agent '#{client_tty}' '#{session_name}'" fi # Agent counter on the right of the status bar. Opt-in: it appends to