14 Commits

Author SHA1 Message Date
f312b6d345 Add stackable toast notifications
Replaces the single-slot status-line flash with a top-right toast
stack over the focused pane. flashError, flashTransient, and
notifyAttention all push onto the same stack (cap 5, FIFO drop).
Ctrl-N dismisses the most recent toast; empty stack falls through to
the focused PTY so readline / nano / emacs / opencode bindings keep
working. A new "Clear notifications" palette item empties the stack.
2026-05-15 20:26:35 +01:00
e6f5a94fae Trim actioned perf-audit items; add palette polish TODO
Removes the 2026-05-15 perf audit findings that have either shipped
(see CHANGELOG) or are tracked elsewhere, and replaces them with the
remaining palette-refinement notes: generic labels for focused
actions ("Close current agent") and a higher-level concern that the
palette has grown cluttered as features were added.
2026-05-15 19:53:51 +01:00
c1ecba0624 Use mise to install zig + go in release CI; cut 0.0.4
All checks were successful
release / build-linux-amd64 (push) Successful in 13m7s
`mlugg/setup-zig` was chasing mirrors for ~4 minutes on every run
(see v0.0.1 / v0.0.2 logs) and `actions/setup-go` was spending
another ~4 minutes downloading Go before patterm started building.
mise already manages the project's zig pin; adding `go = "1.26.3"`
to `.mise.toml` (matching go.mod) lets `jdx/mise-action@v2` install
both with one cached step. Subsequent runs reuse the mise cache
instead of re-resolving mirror URLs and re-downloading toolchains.

Also adds an `actions/cache@v4` step for `~/.cache/go-build` and
`~/go/pkg/mod` keyed on `go.sum` so `go build` itself doesn't
re-pull modules every tag push.
2026-05-15 19:38:13 +01:00
878e9370bc Fix error flashes replacing focused pane 2026-05-15 19:27:42 +01:00
fd9c19e5c2 Fix release CI: upgrade mlugg/setup-zig to v2 and cut 0.0.3
Some checks failed
release / build-linux-amd64 (push) Has been cancelled
`mlugg/setup-zig@v1` is deprecated and only knows the pre-0.14
tarball name (`zig-linux-x86_64-<ver>.tar.xz`), so every mirror —
and the official ziglang.org/builds — returned 404 for Zig 0.15.2
on both the v0.0.1 and v0.0.2 release runs. v2 uses the new
`zig-x86_64-linux-<ver>.tar.xz` layout that Zig switched to in
0.14+.

Also rolls the existing CHANGELOG `[Unreleased]` work into a
dated `[0.0.3]` section and adds the CI fix to its Fixed list.
2026-05-15 19:14:21 +01:00
6d90cd7185 Match Solo summary cadence options 2026-05-15 19:13:54 +01:00
d648d5b775 Add auto-summary settings 2026-05-15 19:09:21 +01:00
1bf51bb784 Merge pull request 'Overhaul command palette UX' (#4) from feat/palette-ux-overhaul into main
Reviewed-on: #4
2026-05-15 18:25:38 +01:00
81bc77366f Overhaul command palette UX
Six-phase sweep: section headers (Focused / Open / Spawn / Quit) with
header-skip cursor; chip strip mirroring sw/sp/k macros, driven by
Tab; unified Spawn verbs across agent / process / terminal / custom;
dropped duplicate global Close list in favor of Ctrl-X inline close
on a Switch row plus the [Close] chip; scored matching (prefix >
word-boundary > substring > fuzzy) with matched-char highlighting;
title bar surfaces focus subject; rename forms split long subject
onto its own row; new Alt-1..9 quick-pick, Home/End, ? help overlay,
and Ctrl-R relaunch toggle inside the spawn-process form. Scroll
indicator and cursor/total counter round out the footer.
2026-05-15 16:41:44 +01:00
0c960fa859 Clarify sub-agent reply routing in MCP tool descriptions
A sub-agent's reply to send_message lands in the caller's own pane
tagged [sub-agent:<name>], not in the sub-agent's output. The
descriptions for wait_for_pattern, send_message, both
timer_fire_when_idle_*, and the server-instructions preamble now
spell this out, along with the canonical send_message →
timer_fire_when_idle_any → read-own-pane pattern. help('readiness')
and help('coordination') updated to match. Previously agents reached
for wait_for_pattern on the sub-agent and deadlocked until timeout
because the reply had already been delivered to their own pane.
2026-05-15 16:08:07 +01:00
b05065a601 Sync TODO.md perf-audit review pass
Removed low/marginal items from the original sweep; remaining items
have measured or workflow evidence to justify action.
2026-05-15 16:07:58 +01:00
08187aed77 Don't steal focus when an agent spawns a child via MCP 2026-05-15 15:53:50 +01:00
24c8183832 Auto-snap child viewport to bottom when typing into scrollback
Typing into a focused child while its emulator viewport was
scrolled up left the keystroke heading to the PTY but the input
box invisible below the visible region — it looked like typing
did nothing. processStdin's flushForward now sets
pendingViewportBottom whenever bytes are actually injected, so
the existing post-loop handler snaps the viewport and repaints.

Wheel events and Ctrl-B paths are untouched: both are intercepted
before reaching forward, so wheel still scrolls into history and
Ctrl-B is still the explicit escape hatch. Only bytes that would
actually reach the child PTY trigger the snap.
2026-05-15 15:34:00 +01:00
b5dfaf39c4 Marquee long sidebar names; truncate with ellipsis otherwise
Sidebar rows that overflow the rail width used to spill characters
into the main viewport. They now truncate with a trailing "…"
when unfocused (or when the focused name still fits). The focused
row whose name overflows runs a pause-scroll-pause marquee: 1 s
hold on the head, ~150 ms per cell scroll, 1 s hold on the tail,
snap back. The row's geometry never moves while it animates, so
nothing below shifts.

A dedicated 150 ms goroutine flips sidebarDirty only while a row
is actively animating; the chrome ticker does the actual repaint.
Idle is a single cheap wakeup. focus / spawn / exit / restart all
reset the marquee state so the new focused row starts from frame
zero. When the row's budget is tight, the trailing timer
indicator drops before the name ellipses since the name is the
only identifier the row carries.

clampVisible() is a defensive net inside write(): even if a row's
decoration size were mis-computed, it will not spill past the
sidebar band into the PTY area.
2026-05-15 15:33:39 +01:00
28 changed files with 4023 additions and 556 deletions

View File

@@ -11,14 +11,19 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: jdx/mise-action@v2
with: with:
go-version-file: go.mod
cache: true cache: true
- uses: mlugg/setup-zig@v1 - name: Cache Go modules
uses: actions/cache@v4
with: with:
version: 0.15.2 path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Build libghostty-vt - name: Build libghostty-vt
run: make deps run: make deps

View File

@@ -3,6 +3,8 @@
# libghostty-vt is built from a pinned upstream Ghostty commit; that # libghostty-vt is built from a pinned upstream Ghostty commit; that
# commit's build.zig.zon pins minimum_zig_version = 0.15.2. We match # commit's build.zig.zon pins minimum_zig_version = 0.15.2. We match
# it here so contributors don't have to puzzle out the version from # it here so contributors don't have to puzzle out the version from
# a deep upstream file. # a deep upstream file. The go pin matches go.mod so CI and local
# builds use the same toolchain.
[tools] [tools]
zig = "0.15.2" zig = "0.15.2"
go = "1.26.3"

View File

@@ -6,13 +6,150 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Changed
- Replaced the single-slot status-line "flash" with a stackable toast
surface anchored at the top-right of the focused pane. `flashError`,
`flashTransient`, and MCP `request_human_attention` now push onto
the toast stack (cap 5, oldest drops). Toasts persist until
dismissed with `Ctrl-N`, or cleared via the new
"Clear notifications" palette command. The status line no longer
shows the `[!]` prefix.
- `Ctrl-N` is consumed by the host only when there is a toast to
dismiss; an empty stack lets `Ctrl-N` pass through to the focused
child so readline / nano / emacs / opencode keep their bindings.
## [0.0.4] - 2026-05-15
### Changed
- Release workflow (`.gitea/workflows/release.yml`) now provisions
Zig and Go through `jdx/mise-action@v2`, reading the versions from
`.mise.toml` (zig 0.15.2, go 1.26.3). Both toolchains were
previously installed via `mlugg/setup-zig` and `actions/setup-go`,
whose mirror chase / GitHub fetch combined for ~8 minutes per run
before any patterm code compiled. mise pulls each tool once and
caches the install dir, so subsequent runs hit the cache instead of
re-downloading. `make deps` still resolves zig via `mise which zig`
with a PATH fallback; `go.mod` already pinned `go 1.26.3`, so the
new `go` entry in `.mise.toml` just keeps CI and local builds on
the same toolchain.
- A Go module/build cache step (`actions/cache@v4`, keyed on
`go.sum`) was added so `go build` doesn't re-download dependencies
on every tag push.
## [0.0.3] - 2026-05-15
### Added
- Auto-summarization for top-level agent tabs. patterm now loads
`$XDG_CONFIG_HOME/patterm/settings.json`, enables Codex-based
summaries by default (`gpt-5.4-mini`; OpenCode defaults to
`opencode-go/minimax-m2.7`), and can run Codex, OpenCode, or opt-in
Claude summarizers with configurable model names. Summary
attempts are armed by meaningful human input, wait for recent output
to go quiet, and respect a minimum cadence so unchanged tabs are not
summarized on a timer. The active thread summary appears under the
top tab title and in the sidebar below the Agent Tree section.
- Settings overlay reachable from the command palette via
`Open Settings`. The searchable Settings picker opens
`Agents / Auto-summarization`, where users can enable/disable
summaries, choose provider, edit provider model names, cycle cadence,
test the selected summarizer (`patterm okay`), summarize the current
top-level agent immediately, and explicitly save or cancel draft
settings changes. Cadence choices match Solo: `15s`, `30s`, and
`1m`; the value is a minimum quiet/activity gap before another
summary attempt for the same top-level agent, not a background
periodic timer.
### Changed
- Command palette UX overhaul. The single flat list grew section
bands (`── Focused ──`, `── Open ──`, `── Spawn ──`, `── Quit ──`)
so the rows are scannable at a glance; cursor navigation skips
the dim header rows transparently. A chip strip — `[All] Open
Spawn Close` — sits below the query line and tracks the active
macro filter; `Tab` / `Shift-Tab` cycle through the chips, and
the typed-prefix macros (`sw `, `sp `, `k `) still work and now
collapse the whole prefix on a single backspace instead of
leaving a stray `sw` behind. The title bar surfaces the current
focus subject (`on: <child>` / `pad: <name>`) so the user knows
which Focused row is targeting what. The duplicate global Close
list is gone — close is reachable via the Focused-section action,
the `k ` macro / `[Close]` chip, or the new `Ctrl-X` inline close
on a Switch row. The "(current)" marker on the focused Switch row
became a leading `▶`. The empty-state hint now reads `no matches
· ⌫ to widen` instead of bare `no matches`. The middle divider
shows a `▼ N more` / `▲ N above` scroll indicator when the list
overflows, and the footer carries a `cursor/total` counter.
- Spawn verbs are unified on **Spawn**: `Run process: …`
`Spawn process: …`, `New Terminal``Spawn terminal`, and the
freeform-form row is now `Spawn process… (custom)` so the
trailing ellipsis still signals it opens a form.
- Filtering switched from binary fuzzy-include to scored ranking.
Prefix matches beat word-boundary matches beat substring matches
beat scattered-fuzzy matches; ties fall back to section order so
a Focused-section hit always outranks an equally tight Spawn
hit. The matched characters in the rendered label render in
accent+bold so the user can see why a row matched.
- Rename forms split the long subject (`scratchpad:
some-really-long-name.md`) onto its own dim row above the input
so the title bar no longer truncates with an ellipsis when the
subject name is wide.
- New palette accelerators: `Alt-1` … `Alt-9` quick-pick the Nth
visible row, `Home` / `End` jump to first / last selectable row,
`?` (with empty query) opens an inline keybinding cheat-sheet
which any further keystroke dismisses, and `Ctrl-R` inside the
Spawn-process form toggles "Relaunch on exit" without leaving
the command field.
### Fixed ### Fixed
- Error/status flashes now restore the currently focused pane instead
of drawing the empty-state hint over a running agent or process.
- Release workflow (`.gitea/workflows/release.yml`) now uses
`mlugg/setup-zig@v2` instead of the deprecated `@v1`. v1 hard-coded
the pre-0.14 tarball name (`zig-linux-x86_64-<ver>.tar.xz`), so
every mirror and the official `ziglang.org/builds` returned 404 for
Zig 0.15.2 and the v0.0.1 / v0.0.2 tag pushes never produced a
release asset. v2 uses the post-0.14 `zig-x86_64-linux-<ver>.tar.xz`
layout, so the runner can fetch Zig and build patterm.
- Typing into a focused child while its emulator viewport is
scrolled up into scrollback history now auto-snaps the viewport
back to the live area. Previously the keystroke reached the
child PTY but the input box was off-screen below the visible
region, so it looked like typing did nothing. Wheel scrolling
and Ctrl-B are unchanged; only forwarded keystrokes snap.
- Top tab bar now keeps the top-level agent's tab highlighted - Top tab bar now keeps the top-level agent's tab highlighted
when focus is on one of its sub-agents (or on a Processes pane when focus is on one of its sub-agents (or on a Processes pane
entry, matching the existing agent-tree behavior). Previously entry, matching the existing agent-tree behavior). Previously
the tab would lose its highlight as soon as you stepped into a the tab would lose its highlight as soon as you stepped into a
child agent, even though you were still within that thread. child agent, even though you were still within that thread.
### Changed
- MCP tool descriptions and `help('coordination')` /
`help('readiness')` now spell out that a sub-agent's reply to
`send_message` lands in the caller's own pane (tagged
`[sub-agent:<name>]`), not in the sub-agent's output. The canonical
wait-for-reply pattern — `send_message` → `timer_fire_when_idle_any`
on the sub-agent → read your own pane — is now called out on
`send_message`, `wait_for_pattern`, both `timer_fire_when_idle_*`,
the help topics, and the server-instructions preamble every agent
reads at startup. Previously `wait_for_pattern` was the obvious
blocking primitive in the catalog, and agents routinely called it
against the sub-agent for a reply that had already arrived in their
own pane, deadlocking until the wait timed out. No behaviour
changes; descriptions only.
- Agent-initiated `spawn_agent` and `spawn_process` MCP calls no
longer steal viewport focus from the currently active tab. The
new child still appears in the sidebar and tab bar; switch to it
explicitly via the palette or `select_process`. Palette-initiated
spawns and persistence restores are unchanged — they still auto-
focus the new pane.
- Sidebar rows (Processes, Agent Tree, Scratchpads) now truncate
overflowing names with a trailing `` instead of spilling into
the main viewport. The focused row marquees its name when it
overflows — 1 s hold on the head, ~150 ms per cell scroll until
the tail is visible, 1 s hold on the tail, snap back. Row
position never moves while the marquee animates. When budget is
tight, the trailing timer indicator drops before the name
ellipses, since the name is the only identifier the row carries.
## [0.0.2] - 2026-05-15 ## [0.0.2] - 2026-05-15
### Added ### Added

187
TODO.md
View File

@@ -1,184 +1,3 @@
# Perf Audit (auto-generated 2026-05-15) The close action in the command palette should just be "Close current agent" rather than "Close codex"
Findings from a codebase sweep — not user-reported, needs review before Same with the other "focused" parts. It seems a bit clunky right now. "Close current agent"
action. Each item names the anchor and a sketched fix. In general I think while the feature set has grown, the actual refinement of it isn't great, it feels a bit cluttered.
Baseline benchmark numbers (`go test -bench=. ./internal/app/`, AMD
Ryzen 7 7800X3D, libghostty-vt **ReleaseFast** after the Makefile
fix landed):
```
# Renderer alone
ViewportRenderer_PlainASCII 229 MB/s 1.3 KB/op 6 allocs/op
ViewportRenderer_StyledLines 89 MB/s 91 KB/op 4325 allocs/op
ViewportRenderer_RatatuiBurst 40 MB/s 365 KB/op 17306 allocs/op
RendererThroughput_ReuseInstance 90 MB/s 316 KB/op 17380 allocs/op
ContainsOSC_NoOSC 3050 MB/s 0 B/op 0 allocs/op
# ASCII-video stream (renderer only — 3 sec at the target fps)
ASCIIVideo_Stream_8Color_120fps 260 µs/frame 3845 fps_ceiling 3.1% budget
ASCIIVideo_Stream_TrueColor_120fps 576 µs/frame 1735 fps_ceiling 6.9% budget
# Full pipeline (em.Write + renderer + io.Discard write)
Pipeline_ASCIIVideo_8Color_120fps 493 µs/frame 2030 fps_ceiling 5.9% budget
Pipeline_ASCIIVideo_TrueColor_120fps 1075 µs/frame 931 fps_ceiling 12.9% budget
# Emulator alone (libghostty-vt CSI/SGR parser)
Emulator_Write_Stream_8Color_120fps 257 µs/frame 3890 fps_ceiling
Emulator_Write_Stream_TrueColor_120fps 488 µs/frame 2051 fps_ceiling
```
Result of the fix below: 27-32× pipeline speedup, 60× emulator
speedup. Pipeline hits 930-2030 fps end-to-end — 7-16× headroom
over the 120 fps target on the heaviest workload (truecolor
full-screen redraws).
- [ ] **viewport renderer allocates ~1 alloc per 4 input bytes on SGR/CSI-heavy chunks.** [MEDIUM]
- `internal/app/viewport_renderer.go` — the styled-lines and
ratatui benchmarks show 4-17k allocs per chunk. The hot
contributors are likely (a) `string(vr.buf)` / `string(params)`
conversions in `emitCSI` for every escape sequence, (b) the
`pending strings.Builder` resizing as fragments arrive, and (c)
`vr.shifter.Shift(vr.buf)` returning a fresh slice per CSI.
- Fix direction: switch CSI param parsing to byte-slice
comparison (no string conversion); reuse `vr.buf` and
`vr.pending` backing arrays across `Render` calls by
pre-growing in `newViewportRenderer`; have `cursorShifter.Shift`
return into a caller-owned buffer instead of allocating.
Profile-guided: run the styled-lines bench, point pprof at the
allocs profile, fix the top three call sites.
- [ ] **viewport renderer throughput (~90 MB/s styled) limits codex steady-state.** [MEDIUM]
- The styled-lines and ratatui benchmarks come in at 89 MB/s and
40 MB/s respectively. A 100 KB/s codex burst is far under that
limit, but a session-resume dump of a 5 MiB chat history takes
50-130 ms of pure renderer time at those rates — enough to be
user-visible at the start of a long resume.
- Fix direction: same as the alloc fix above; once the per-call
allocation cost drops, the throughput ceiling rises with it.
Worth re-running the benches after fixing the allocs and only
investing further if the styled-lines bench is still under
~300 MB/s.
- [ ] **Session.Children() allocates a fresh slice on every call.** [MEDIUM]
- `internal/app/session.go:530-541` walks `s.order` under `s.mu` and
builds a new `[]*Child` slice every time. Callers on hot paths:
`drawSidebar` calls it twice per frame
(`internal/app/sidebar.go:139` and `:171`); `drawTabBar` calls it
once per frame (`internal/app/tabbar.go:37`); the classifier
iterates it every 250 ms (`internal/app/classifier.go:38`); and
palette/navigation hit it on every Ctrl-A/D/W/S keystroke.
- Fix direction: store the snapshot in an `atomic.Pointer[[]*Child]`
on `Session`, refresh it under `s.mu` only when `Spawn` / `delete`
mutates the map. Readers get O(1) `Load()` with zero allocation —
same pattern already used for `listeners` (session.go:118-123).
- [ ] **wait_for_pattern re-scans the entire stream/grid every iteration.** [MEDIUM]
- `internal/app/host.go:476-493` (the `check` closure). On `scope =
"scrollback"` it calls `c.StreamRead(0)` followed by
`stripANSIBytes(nil, b)` over the entire ring on every wake — a
full O(ring size) walk per chunk arrival. On `grid` it goes
through PlainText (one CGO call) plus a regex match against the
full grid string. For an agent waiting on a marker in a chatty
pane, every PTY chunk fires `check()`.
- Fix direction: for `scrollback`, track the offset of the last
check and run the regex only over the new tail, reusing a
per-call scratch buffer for ANSI stripping. For `grid`, dedupe
on `ScreenVersion()` — skip when version hasn't changed.
- [ ] **search_output compiles regex + strips ANSI on every call.** [MEDIUM]
- `internal/app/host.go:428` compiles a fresh `regexp.Regexp` per
invocation; `:434` strips ANSI over the entire ring buffer when
`kind="rendered"`. Agents that poll `search_output` with the same
pattern (the typical "watch for marker" loop) repay both costs on
every call.
- Fix direction: small LRU of compiled regexes keyed by pattern
string (cap maybe 32) on `toolHost`. Cache the stripped-ANSI
buffer keyed by `c.ScreenVersion()` so consecutive searches over
an unchanged ring reuse the strip.
- [ ] **GetProcessOutput grid mode acquires the emulator twice.** [MEDIUM]
- `internal/app/host.go:375-391` does `em := c.Emulator()` for
ActiveScreen / Cursor / Size, then at line 387 re-fetches
`em := c.Emulator()` for PlainText. Each `Emulator()` call goes
through `ptyMu` and inspects the live PTY pointer. Under a
chatty agent polling `get_process_output` every 100 ms this is
a redundant lock and pointer chase per call.
- Fix direction: hold the emulator reference from the first
lookup; reuse it for PlainText. The check `if em == nil` still
runs cleanly because the variable is captured.
- [ ] **FindChildByIdentity is O(N) under the session lock.** [LOW]
- `internal/app/session.go:553-565` scans the children map looking
for a matching `Identity` token on every new mcp-stdio
connection. Not a steady-state hot path — only fires once per
child spawn — but with many short-lived sub-agents it adds up
and contends with everyone else taking `s.mu`.
- Fix direction: maintain an `identityIndex map[string]string`
(identity → child id) updated alongside spawn / exit, give the
lookup an O(1) read.
- [ ] **Per-promoter regex matches in the idle classifier.** [LOW]
- `internal/app/idle.go:175-182` (`matchAny`) walks each compiled
pattern and runs the DFA over the same 4 KiB tail. A preset with
five permission patterns + five error patterns is ten DFA
invocations per child per 250 ms tick.
- Fix direction: at preset load time, compile each `_patterns`
list into a single alternation regex (`(?:p1)|(?:p2)|…`). The
classifier then makes one Match call per category per tick.
- [ ] **Port-detection dedup is O(N²) over c.ports.** [LOW]
- `internal/app/child.go:461-467`: for each fresh URL match the
code linearly scans the existing port list. The list rarely
grows past a handful, but a dev server that lists "all open
ports" in one log line interacts badly: M new matches × N
existing entries.
- Fix direction: keep a `seenPorts map[int]struct{}` next to
`c.ports`, rebuilt on prune (none today). O(1) per match.
- [ ] **Port-sighting string allocations happen before the dedup check.** [LOW]
- `internal/app/child.go:455-456` allocates `urlForm` and `portStr`
before line 461's `seen` walk. Both strings are wasted when the
port is already in `c.ports`. Inside `c.portsMu` for the whole
loop body too, blocking the `Ports()` reader path.
- Fix direction: bind the port int first (cheap parse from
`m[1]`), do the seen check, only then allocate the URL string
for the surviving sighting.
- [ ] **classifier `time.Now()` syscall per child per tick.** [LOW]
- `internal/app/classifier.go:54` (and the `IdleMS` /
`TitleIdleMS` helpers it transitively calls in
`internal/app/child.go:343-374`) each call `time.Now()`.
Reading time on Linux is fast (vDSO) but with N children × 4
`time.Now()` per tick × 4 ticks/sec it's wasted work that can
be batched.
- Fix direction: capture `now := time.Now().UnixNano()` once at
the top of `classifyAll` and thread it into `classifyOne` and
the helpers as a parameter.
- [ ] **wait_for_pattern subscribes a listener for every call.** [LOW]
- `internal/app/host.go:472-474`: each invocation calls
`Session.Subscribe(wake)` which clones the listener slice and
swaps the atomic pointer; the `defer Unsubscribe` does the same
on exit. Two allocations per `wait_for_pattern`. The agent
pattern of looping on `wait_for_pattern` after every tool call
pays this churn on the steady-state path.
- Fix direction: a per-child `chunkBroadcaster` registered once
at child spawn that hands out lightweight subscriber tokens,
rather than going through the full session listener machinery.
# On Hold
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD]
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
most likely the *host* terminal's font fallback for opencode's
Nerd Font private-use codepoints, not a patterm substitution.
Need a concrete reproduction (which codepoint, which host
terminal/font) before changing rendering.
- [ ] After codex rips for like 15 minutes, the terminal becomes quite slow. [ON HOLD / VERIFYING]
- 2026-05-14: Perf plan P1-P11 landed (see CHANGELOG). Needs a real
long-running codex session to confirm whether the steady-state
slowdown is gone or some hotspot remains. Capture a pprof if it
still feels slow after ≥15 minutes — the structural drivers the
audit named are all addressed, so a remaining symptom is a new
one and probably wants fresh profiling.

View File

@@ -55,6 +55,10 @@ func Run(ctx context.Context, opts Options) error {
if err != nil { if err != nil {
return fmt.Errorf("app: load presets: %w", err) return fmt.Errorf("app: load presets: %w", err)
} }
appSettings, settingsPath, err := loadSettings()
if err != nil {
logf("settings load: %v", err)
}
// Ensure the per-project scratchpad dir exists so MCP and the UI // Ensure the per-project scratchpad dir exists so MCP and the UI
// can read/write into it. SPEC §3. // can read/write into it. SPEC §3.
@@ -169,7 +173,24 @@ func Run(ctx context.Context, opts Options) error {
hostRows: rows, hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
metrics: metrics, metrics: metrics,
settings: appSettings,
settingsPath: settingsPath,
ctx: ctx,
} }
st.summaries = newSummaryManager(sess, opts.ProjectDir, presets, func() autoSummarySettings {
st.settingsMu.Lock()
defer st.settingsMu.Unlock()
return st.settings.AutoSummary.clone()
}, func() {
st.markChromeDirty()
st.markSidebarDirty()
}, func(_ string, result summaryState) {
if result.Error != "" {
st.flashError(fmt.Sprintf("summary: %v", result.Error))
return
}
st.flashTransient("summary updated")
})
sess.SetMetrics(metrics) sess.SetMetrics(metrics)
host.attention = st host.attention = st
host.focus = st host.focus = st
@@ -177,6 +198,7 @@ func Run(ctx context.Context, opts Options) error {
host.scratch = st host.scratch = st
st.lastExit.Store(-1) st.lastExit.Store(-1)
sess.Subscribe(st) sess.Subscribe(st)
go st.summaries.run(ctx)
st.enterScreen() st.enterScreen()
st.renderEmptyState() st.renderEmptyState()
@@ -306,6 +328,28 @@ func Run(ctx context.Context, opts Options) error {
} }
}() }()
// Marquee ticker: while a focused sidebar row's name overflows the
// rail width, advance the pause-scroll-pause animation by marking
// the sidebar dirty every marqueeStep. The chrome ticker above does
// the actual repaint. When no row is animating, this is a single
// cheap wakeup with no work.
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(marqueeStep)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if st.marquee.active() {
st.markSidebarDirty()
}
}
}()
// External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit). // External termination: SPEC §2 step 4 (SIGTERM/SIGHUP → graceful exit).
wg.Add(1) wg.Add(1)
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@@ -376,7 +420,6 @@ type uiState struct {
// switch resets the offset cleanly. // switch resets the offset cleanly.
padOffsetName string padOffsetName string
// activeAgentID tracks which top-level agent tab "owns" the agent // activeAgentID tracks which top-level agent tab "owns" the agent
// tree section of the sidebar. It only updates when focus lands on // tree section of the sidebar. It only updates when focus lands on
// an agent (or one of its sub-agents), so the agent tree stays // an agent (or one of its sub-agents), so the agent tree stays
@@ -389,10 +432,11 @@ type uiState struct {
repaintNextPTY string repaintNextPTY string
repaintNextPTYBudget int repaintNextPTYBudget int
// attention is the latest request_human_attention surfaced via MCP; // toasts is the stackable notification surface. flashError,
// rendered in the status line until cleared. // flashTransient, and notifyAttention all push onto it; the user
attentionText string // dismisses entries with Ctrl-N or the "Clear notifications"
attentionAt string // palette command.
toasts toastStack
// pendingTrust is the most recent trust prompt — surfaced in the // pendingTrust is the most recent trust prompt — surfaced in the
// status line until the user resolves it with Ctrl-K. v1 keeps the // status line until the user resolves it with Ctrl-K. v1 keeps the
@@ -410,6 +454,12 @@ type uiState struct {
// check on the disabled path. // check on the disabled path.
metrics *metricsTracker metrics *metricsTracker
settingsMu sync.Mutex
settings settings
settingsPath string
ctx context.Context
summaries *summaryManager
// chromeCacheMu guards the last-rendered byte cache for each chrome // chromeCacheMu guards the last-rendered byte cache for each chrome
// element. The tab bar, sidebar, and status line all repaint on // element. The tab bar, sidebar, and status line all repaint on
// many state changes and on every PTY chunk, but their content // many state changes and on every PTY chunk, but their content
@@ -436,6 +486,11 @@ type uiState struct {
sidebarDirty atomic.Bool sidebarDirty atomic.Bool
chromeWake chan struct{} chromeWake chan struct{}
// marquee animates the focused sidebar row's name when it overflows
// the rail width. The dedicated 150ms ticker below flips
// sidebarDirty while a row is animating; idle case is free.
marquee marqueeState
// padsCacheMu guards the cached scratchpad listing. The sidebar // padsCacheMu guards the cached scratchpad listing. The sidebar
// and palette/sidebar nav helpers read it on every chunk-driven // and palette/sidebar nav helpers read it on every chunk-driven
// repaint; the cache invalidates in scratchpadsChanged() which is // repaint; the cache invalidates in scratchpadsChanged() which is
@@ -451,6 +506,33 @@ func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...) logf(format, args...)
} }
func (st *uiState) activeSummaryText(width int) string {
if width <= 0 || st.summaries == nil {
return ""
}
st.settingsMu.Lock()
enabled := st.settings.AutoSummary.Enabled
st.settingsMu.Unlock()
if !enabled {
return ""
}
st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
return ""
}
sum := st.summaries.Summary(active)
text := strings.TrimSpace(sum.Text)
if text == "" {
return ""
}
if visibleLen(text) > width {
text = clipRunes(text, width-1) + "…"
}
return text
}
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried // trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
// to spawn / start / restart against an untrusted command preset and // to spawn / start / restart against an untrusted command preset and
// the host wants user confirmation before the next attempt succeeds. // the host wants user confirmation before the next attempt succeeds.
@@ -476,6 +558,7 @@ func (st *uiState) focusProcess(processID string) {
if c == nil { if c == nil {
return return
} }
st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
@@ -543,6 +626,7 @@ func (st *uiState) focusScratchpad(name string) {
if name == "" { if name == "" {
return return
} }
st.marquee.reset()
st.mu.Lock() st.mu.Lock()
if st.padOffsetName != name { if st.padOffsetName != name {
st.padOffset = 0 st.padOffset = 0
@@ -586,6 +670,7 @@ func (st *uiState) restartFocusedCommand(processID string) {
if c == nil || c.Kind != KindCommand { if c == nil || c.Kind != KindCommand {
return return
} }
st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
renderer := newViewportRenderer(layout) renderer := newViewportRenderer(layout)
st.mu.Lock() st.mu.Lock()
@@ -638,20 +723,15 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
} }
// notifyAttention is the request_human_attention sink (SPEC §7). We // notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most // push a toast onto the stack; the focused-pane render path picks it
// recent ask so the status line keeps showing it. The sidebar-blink is // up. The sidebar-blink is deferred until the §4 chrome lands.
// deferred until the §4 chrome lands.
func (st *uiState) notifyAttention(childID, reason string) { func (st *uiState) notifyAttention(childID, reason string) {
c := st.sess.FindChild(childID) c := st.sess.FindChild(childID)
name := childID name := childID
if c != nil { if c != nil {
name = c.DisplayName() name = c.DisplayName()
} }
st.mu.Lock() st.notifyToast(toastAttention, fmt.Sprintf("%s — %s", name, reason))
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
st.attentionAt = childID
st.mu.Unlock()
st.drawStatusLine()
} }
func (st *uiState) scratchpadsChanged() { func (st *uiState) scratchpadsChanged() {
@@ -670,8 +750,30 @@ func (st *uiState) scratchpadsChanged() {
} }
} }
// OnChildSpawned auto-focuses the new child. // OnChildSpawned auto-focuses the new child when the spawn came from
// the user (palette, persistence restore, or an external MCP client with
// no resolved identity). When ParentID is set — meaning a patterm-managed
// agent spawned this child via spawn_agent/spawn_process — focus stays
// on whatever the user was watching; the new child is still surfaced in
// the sidebar/tab bar so it's reachable via the palette or select_process.
func (st *uiState) OnChildSpawned(c *Child) { func (st *uiState) OnChildSpawned(c *Child) {
if st.summaries != nil {
st.summaries.RegisterChild(c)
}
if c.ParentID != "" {
st.mu.Lock()
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
st.drawTabBar()
st.drawSidebar()
return
}
st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
onAlt := childIsOnAlt(c) onAlt := childIsOnAlt(c)
st.mu.Lock() st.mu.Lock()
@@ -732,7 +834,11 @@ func (st *uiState) OnChildStateChanged(string, IdleState) {
// OnChildExited drops focus and shows the empty state if it was the // OnChildExited drops focus and shows the empty state if it was the
// focused child. // focused child.
func (st *uiState) OnChildExited(c *Child) { func (st *uiState) OnChildExited(c *Child) {
if st.summaries != nil {
st.summaries.UnregisterChild(c.ID)
}
st.lastExit.Store(int32(c.ExitCode())) st.lastExit.Store(int32(c.ExitCode()))
st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
renderEmpty := false renderEmpty := false
st.mu.Lock() st.mu.Lock()
@@ -818,6 +924,9 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
if st.metrics != nil { if st.metrics != nil {
entry = time.Now() entry = time.Now()
} }
if st.summaries != nil {
st.summaries.ObserveOutput(childID)
}
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.mu.Lock() st.mu.Lock()
focus := st.focusedID focus := st.focusedID
@@ -1054,8 +1163,6 @@ func (st *uiState) drawStatusLine() {
palOpen := st.palette != nil palOpen := st.palette != nil
focusID := st.focusedID focusID := st.focusedID
focusName := st.focusedName focusName := st.focusedName
attention := st.attentionText
attentionAt := st.attentionAt
var trustMsg string var trustMsg string
if st.pendingTrust != nil { if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName) trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
@@ -1095,13 +1202,6 @@ func (st *uiState) drawStatusLine() {
left = owner left = owner
} }
} }
if attention != "" && attentionAt == focusID {
left = "[!] " + attention
}
if attention != "" && attentionAt == "" {
// Sticky attention/flash from somewhere outside the focused pane.
left = "[!] " + attention
}
if trustMsg != "" { if trustMsg != "" {
left = "[trust] " + trustMsg left = "[trust] " + trustMsg
} }
@@ -1157,8 +1257,6 @@ func (st *uiState) drawStatusLine() {
// child is focused. // child is focused.
func (st *uiState) renderEmptyState() { func (st *uiState) renderEmptyState() {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.outMu.Lock()
defer st.outMu.Unlock()
line := "Press Ctrl-K to spawn an agent or process" line := "Press Ctrl-K to spawn an agent or process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2) row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2) col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
@@ -1168,7 +1266,10 @@ func (st *uiState) renderEmptyState() {
if col < int(layout.mainLeft) { if col < int(layout.mainLeft) {
col = int(layout.mainLeft) col = int(layout.mainLeft)
} }
st.outMu.Lock()
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line) fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
st.outMu.Unlock()
st.renderToasts()
} }
func (st *uiState) hostSizeSnapshot() (uint16, uint16) { func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
@@ -1291,6 +1392,16 @@ func (st *uiState) processStdin(chunk []byte) {
} }
forward := make([]byte, 0, len(chunk)) forward := make([]byte, 0, len(chunk))
var pendingAction *paletteAction
var pendingNav navEntry
var pendingRestartID string
var pendingViewportDelta int
var pendingViewportBottom bool
var pendingPadStep int
var pendingPadExit bool
var pendingDismissToast bool
flushForward := func() { flushForward := func() {
if len(forward) == 0 { if len(forward) == 0 {
return return
@@ -1302,22 +1413,22 @@ func (st *uiState) processStdin(chunk []byte) {
// writes so claude / codex / opencode don't treat a // writes so claude / codex / opencode don't treat a
// "text\r" batch as a paste. // "text\r" batch as a paste.
_ = c.InjectAsUser(forward) _ = c.InjectAsUser(forward)
if st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward)
}
if prev != OwnerUser { if prev != OwnerUser {
go st.drawStatusLine() go st.drawStatusLine()
} }
// Auto-snap the emulator viewport to the live area
// on any forwarded keystroke. Without this, typing
// while scrolled into history leaves the cursor /
// echoed bytes off-screen below the visible region.
pendingViewportBottom = true
} }
} }
forward = forward[:0] forward = forward[:0]
} }
var pendingAction *paletteAction
var pendingNav navEntry
var pendingRestartID string
var pendingViewportDelta int
var pendingViewportBottom bool
var pendingPadStep int
var pendingPadExit bool
// childOnPrimary captures whether the focused child is on its primary // childOnPrimary captures whether the focused child is on its primary
// screen at the start of this chunk. Wheel events on the primary // screen at the start of this chunk. Wheel events on the primary
// screen scroll the emulator viewport (inline scrollback); on the // screen scroll the emulator viewport (inline scrollback); on the
@@ -1476,6 +1587,11 @@ func (st *uiState) processStdin(chunk []byte) {
} else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 's'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 's'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'n'); hit {
// Ctrl-N is the toast dismiss key. In pad view we
// allow it through the chord block so the handler
// below can fire even though pads otherwise swallow
// bytes.
} else { } else {
i++ i++
continue continue
@@ -1574,6 +1690,22 @@ func (st *uiState) processStdin(chunk []byte) {
break break
} }
} }
// Ctrl-N dismisses the most recent toast. We only consume the
// chord when there's actually a toast to dismiss; otherwise the
// bytes fall through to the focused PTY so readline /
// nano / emacs / opencode keep working in shells and editors.
if hit, adv := matchCtrlChar(chunk, i, 'n'); hit {
if st.toasts.length() > 0 {
flushForward()
pendingDismissToast = true
i += adv
continue
}
forward = append(forward, chunk[i:i+adv]...)
i += adv
continue
}
// Ctrl-B snaps the focused child's emulator viewport back to the // Ctrl-B snaps the focused child's emulator viewport back to the
// active area. Use this as the escape hatch from a scrolled-up // active area. Use this as the escape hatch from a scrolled-up
// state — wheel scrolls move the viewport into the libghostty // state — wheel scrolls move the viewport into the libghostty
@@ -1655,6 +1787,11 @@ func (st *uiState) processStdin(chunk []byte) {
if pendingPadExit { if pendingPadExit {
st.exitPadView() st.exitPadView()
} }
if pendingDismissToast {
if st.toasts.dismissTop() {
st.refreshToastSurface()
}
}
} }
// scrollFocusedViewport scrolls the focused child's emulator viewport by // scrollFocusedViewport scrolls the focused child's emulator viewport by
@@ -1707,7 +1844,10 @@ func (st *uiState) scrollFocusedViewportToBottom() {
} }
func (st *uiState) openPaletteLocked() { func (st *uiState) openPaletteLocked() {
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets) st.settingsMu.Lock()
appSettings := st.settings.clone()
st.settingsMu.Unlock()
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings)
// Push a "no kitty flags" entry onto the host terminal's keyboard // Push a "no kitty flags" entry onto the host terminal's keyboard
// stack so palette input arrives in plain legacy form regardless of // stack so palette input arrives in plain legacy form regardless of
// what the focused child pushed. Codex/ratatui enables kitty mode // what the focused child pushed. Codex/ratatui enables kitty mode
@@ -1860,6 +2000,11 @@ func (st *uiState) closePalette(action paletteAction) {
case "quit": case "quit":
st.requestExit() st.requestExit()
case "toasts-clear":
if st.toasts.clear() {
st.refreshToastSurface()
}
case "pad-delete": case "pad-delete":
st.handlePadDelete(action.padName) st.handlePadDelete(action.padName)
@@ -1880,9 +2025,85 @@ func (st *uiState) closePalette(action paletteAction) {
case "proc-restart": case "proc-restart":
st.handleProcRestart(action.childID) st.handleProcRestart(action.childID)
case "settings-close":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "settings-test":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
go st.testSummarizer()
case "settings-run-now":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
st.runSummaryNow()
} }
} }
func (st *uiState) applySettingsAction(action paletteAction) {
if action.settings == nil {
return
}
next := action.settings.clone()
st.settingsMu.Lock()
path := st.settingsPath
st.settingsMu.Unlock()
if err := saveSettings(path, next); err != nil {
st.flashError(fmt.Sprintf("save settings: %v", err))
return
}
st.settingsMu.Lock()
st.settings = next
st.settingsMu.Unlock()
}
func (st *uiState) testSummarizer() {
if st.summaries == nil {
return
}
base := st.ctx
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithTimeout(base, summaryTimeout)
defer cancel()
if err := st.summaries.Test(ctx); err != nil {
st.flashError(fmt.Sprintf("summarizer test: %v", err))
return
}
st.flashTransient("summarizer test passed")
}
func (st *uiState) runSummaryNow() {
if st.summaries == nil {
return
}
st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
st.flashError("no active top-level agent to summarize")
return
}
ctx := st.ctx
if ctx == nil {
ctx = context.Background()
}
st.summaries.RunNow(ctx, active)
st.flashTransient("summary requested")
}
func (st *uiState) handlePadDelete(name string) { func (st *uiState) handlePadDelete(name string) {
if name == "" || st.pads == nil { if name == "" || st.pads == nil {
st.repaintFocused() st.repaintFocused()
@@ -2060,28 +2281,18 @@ func (st *uiState) handleProcRestart(childID string) {
st.drawStatusLine() st.drawStatusLine()
} }
// flashError surfaces a spawn/etc. failure in the status line until the // flashError surfaces a spawn/etc. failure as an error toast over the
// next attention update overwrites it. stderr is hidden under the alt // focused pane. stderr is hidden under the alt screen so we can't rely
// screen so we can't rely on Fprintln(os.Stderr). // on Fprintln(os.Stderr).
func (st *uiState) flashError(msg string) { func (st *uiState) flashError(msg string) {
st.mu.Lock() st.notifyToast(toastError, msg)
st.attentionText = msg
st.attentionAt = "" // shows on every focus until cleared
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
} }
// flashTransient is the softer cousin of flashError used for // flashTransient is the softer cousin of flashError used for
// trust-prompt resolutions. Same status-line surface; the prefix differs. // trust-prompt resolutions and other ack-style notices. Same
// stackable surface, info styling.
func (st *uiState) flashTransient(msg string) { func (st *uiState) flashTransient(msg string) {
st.mu.Lock() st.notifyToast(toastInfo, msg)
st.attentionText = msg
st.attentionAt = ""
st.mu.Unlock()
st.drawStatusLine()
} }
// repaintFocused redraws the current focused child's screen snapshot. // repaintFocused redraws the current focused child's screen snapshot.
@@ -2125,8 +2336,9 @@ func (st *uiState) repaintFocused() {
} }
st.mu.Unlock() st.mu.Unlock()
st.outMu.Lock() st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out) _, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
} }
// repaintFocusedPad paints the focused scratchpad's content into the // repaintFocusedPad paints the focused scratchpad's content into the
@@ -2150,8 +2362,9 @@ func (st *uiState) repaintFocusedPad() {
return return
} }
st.outMu.Lock() st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out) _, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
} }
// renderPadView builds the bytes that paint a scratchpad's content // renderPadView builds the bytes that paint a scratchpad's content

View File

@@ -1135,8 +1135,9 @@ func helpFor(topic string) mcp.HelpResponse {
case "coordination": case "coordination":
return mcp.HelpResponse{ return mcp.HelpResponse{
Topic: "coordination", Topic: "coordination",
Content: "send_message tags the message with the caller's role (parent → [orchestrator], child → [sub-agent:<name>]). Siblings must route through their parent. request_human_attention raises a UI notification when you can't safely decide.", Content: "send_message tags the message with the caller's role (parent → [orchestrator], child → [sub-agent:<name>]). Siblings must route through their parent. request_human_attention raises a UI notification when you can't safely decide.\n\n" +
RelatedTools: []string{"send_message", "request_human_attention"}, "Reply routing: a sub-agent's reply to your send_message lands in YOUR pane tagged `[sub-agent:<name>]`, not in the sub-agent's output. Anti-pattern: `wait_for_pattern(sub_agent, …)` to wait for a reply — the sub-agent is already idle, its output won't change, and the call spins to timeout. Pattern: send_message → timer_fire_when_idle_any([sub_agent_id], body=\"[system] sub-agent finished\") → when the timer fires, the reply is already queued as your next user turn (or visible via get_process_output on your own pane).",
RelatedTools: []string{"send_message", "request_human_attention", "timer_fire_when_idle_any", "timer_fire_when_idle_all"},
} }
case "scratchpads": case "scratchpads":
return mcp.HelpResponse{ return mcp.HelpResponse{
@@ -1162,8 +1163,13 @@ func helpFor(topic string) mcp.HelpResponse {
case "readiness": case "readiness":
return mcp.HelpResponse{ return mcp.HelpResponse{
Topic: "readiness", Topic: "readiness",
Content: "A pane is 'idle' once nothing has been written to its PTY for ~1s (SPEC §11). Treat idle as a signal to read, not a guarantee of completion. wait_for_pattern lets you wait on a known terminal marker for stronger evidence.", Content: "A pane is 'idle' once nothing has been written to its PTY for ~1s (SPEC §11). Treat idle as a signal to read, not a guarantee of completion.\n\n" +
RelatedTools: []string{"wait_for_pattern", "get_process_status"}, "Waiting for a sub-agent's reply (canonical pattern):\n" +
" 1. send_message(sub_agent_id, request)\n" +
" 2. timer_fire_when_idle_any(watched=[sub_agent_id], body=\"[system] sub-agent done\")\n" +
" 3. When the timer fires you re-enter as a fresh user turn; the sub-agent's reply is already in your own pane tagged `[sub-agent:<name>]` (read via get_process_output on yourself if you need it explicitly).\n\n" +
"wait_for_pattern is for waiting on text a process emits in its OWN output (a shell prompt, a build's \"tests passed\" line). It does NOT see send_message replies, because those land in the caller's pane, not the target's — calling wait_for_pattern on a sub-agent to wait for its reply deadlocks until timeout.",
RelatedTools: []string{"wait_for_pattern", "get_process_status", "timer_fire_when_idle_any", "send_message"},
} }
case "permissions": case "permissions":
return mcp.HelpResponse{ return mcp.HelpResponse{

View File

@@ -14,10 +14,10 @@ func TestTerminalLayoutWideUsesMainViewport(t *testing.T) {
if l.childCols() != 91 { if l.childCols() != 91 {
t.Fatalf("child cols: got %d want 91", l.childCols()) t.Fatalf("child cols: got %d want 91", l.childCols())
} }
if l.childRows() != 37 { if l.childRows() != 36 {
t.Fatalf("child rows: got %d want 37", l.childRows()) t.Fatalf("child rows: got %d want 36", l.childRows())
} }
if l.mainTop != 3 || l.statusRow != 40 { if l.mainTop != 4 || l.statusRow != 40 {
t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow) t.Fatalf("unexpected vertical chrome: mainTop=%d statusRow=%d", l.mainTop, l.statusRow)
} }
} }
@@ -30,8 +30,8 @@ func TestTerminalLayoutNarrowHidesSidebar(t *testing.T) {
if l.childCols() != 38 { if l.childCols() != 38 {
t.Fatalf("child cols: got %d want 38", l.childCols()) t.Fatalf("child cols: got %d want 38", l.childCols())
} }
if l.childRows() != 9 { if l.childRows() != 8 {
t.Fatalf("child rows: got %d want 9", l.childRows()) t.Fatalf("child rows: got %d want 8", l.childRows())
} }
} }
@@ -46,13 +46,13 @@ func TestSpawnSizingUsesViewportDimensions(t *testing.T) {
l := newTerminalLayout(120, 40) l := newTerminalLayout(120, 40)
launcher := NewLauncher(nil, "", l.childCols(), l.childRows()) launcher := NewLauncher(nil, "", l.childCols(), l.childRows())
cols, rows := launcher.size() cols, rows := launcher.size()
if cols != 91 || rows != 37 { if cols != 91 || rows != 36 {
t.Fatalf("launcher size: got %dx%d want 91x37", cols, rows) t.Fatalf("launcher size: got %dx%d want 91x36", cols, rows)
} }
host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows()) host := newToolHost(nil, nil, nil, preset.Set{}, nil, l.childCols(), l.childRows())
cols, rows = host.size() cols, rows = host.size()
if cols != 91 || rows != 37 { if cols != 91 || rows != 36 {
t.Fatalf("tool host size: got %dx%d want 91x37", cols, rows) t.Fatalf("tool host size: got %dx%d want 91x36", cols, rows)
} }
} }

123
internal/app/marquee.go Normal file
View File

@@ -0,0 +1,123 @@
package app
import (
"sync"
"time"
)
// Phase ordering of the marquee state machine: hold the head, scroll
// one cell per marqueeStep until the tail is visible, hold the tail,
// snap back to the head.
const (
phaseHoldStart = iota
phaseScroll
phaseHoldEnd
)
const (
marqueeHoldStart = time.Second
marqueeStep = 150 * time.Millisecond
marqueeHoldEnd = time.Second
)
// marqueeState drives the focused sidebar row's pause-scroll-pause
// animation. State is wall-clock anchored (since), not tick-count
// anchored, so a missed tick yields a slightly later frame rather
// than a skipped one.
type marqueeState struct {
mu sync.Mutex
id string
nameLen int
budget int
state int
offset int
since time.Time
}
// step advances the state machine for the row identified by id with
// the given visible name length (in runes) and column budget. It
// returns the current scroll offset, whether the row is animating
// (i.e. nameLen > budget), and how long until the next visual change.
//
// When id changes, or nameLen <= budget, the state machine resets to
// phaseHoldStart with offset 0 anchored at now.
func (m *marqueeState) step(id string, nameLen, budget int, now time.Time) (offset int, animating bool, nextWake time.Duration) {
m.mu.Lock()
defer m.mu.Unlock()
if id != m.id || nameLen != m.nameLen || budget != m.budget {
m.id = id
m.nameLen = nameLen
m.budget = budget
m.state = phaseHoldStart
m.offset = 0
m.since = now
}
if nameLen <= budget || budget <= 0 {
return 0, false, 0
}
maxOffset := nameLen - budget
for {
elapsed := now.Sub(m.since)
switch m.state {
case phaseHoldStart:
if elapsed < marqueeHoldStart {
return 0, true, marqueeHoldStart - elapsed
}
m.state = phaseScroll
m.since = m.since.Add(marqueeHoldStart)
continue
case phaseScroll:
steps := int(elapsed / marqueeStep)
if steps >= maxOffset {
m.offset = maxOffset
m.state = phaseHoldEnd
m.since = m.since.Add(time.Duration(maxOffset) * marqueeStep)
continue
}
m.offset = steps
rem := marqueeStep - (elapsed % marqueeStep)
return m.offset, true, rem
case phaseHoldEnd:
if elapsed < marqueeHoldEnd {
return maxOffset, true, marqueeHoldEnd - elapsed
}
m.state = phaseHoldStart
m.offset = 0
m.since = m.since.Add(marqueeHoldEnd)
continue
default:
m.state = phaseHoldStart
m.offset = 0
m.since = now
return 0, true, marqueeHoldStart
}
}
}
// active reports whether the marquee currently has an overflowing row
// to animate. The marquee ticker goroutine uses this to gate dirty
// flag flips so an idle sidebar costs nothing.
func (m *marqueeState) active() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.id != "" && m.nameLen > m.budget && m.budget > 0
}
// reset clears all state, forcing the next step() call to start a
// fresh phaseHoldStart. Call this when focus changes so the newly
// focused row begins with a full head-hold instead of inheriting
// whatever phase the previous focus was in.
func (m *marqueeState) reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.id = ""
m.nameLen = 0
m.budget = 0
m.state = phaseHoldStart
m.offset = 0
m.since = time.Time{}
}

View File

@@ -0,0 +1,161 @@
package app
import (
"testing"
"time"
)
func TestMarqueeStepFits(t *testing.T) {
var m marqueeState
now := time.Unix(0, 0)
off, animating, _ := m.step("a", 5, 10, now)
if animating {
t.Fatalf("expected no animation when name fits in budget")
}
if off != 0 {
t.Fatalf("expected offset 0, got %d", off)
}
}
func TestMarqueePhaseProgression(t *testing.T) {
var m marqueeState
// name 10 runes, budget 5 → maxOffset = 5.
const nameLen, budget = 10, 5
t0 := time.Unix(0, 0)
// At t0: phaseHoldStart, offset 0, animating.
off, anim, wake := m.step("row", nameLen, budget, t0)
if off != 0 || !anim || wake != marqueeHoldStart {
t.Fatalf("t0: off=%d anim=%v wake=%v", off, anim, wake)
}
// Just before hold expires: still offset 0.
off, anim, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart-time.Millisecond))
if off != 0 || !anim {
t.Fatalf("pre-expiry hold: off=%d anim=%v", off, anim)
}
// At hold expiry + 1 step: should have transitioned to scroll, offset 1.
off, anim, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+marqueeStep))
if !anim || off != 1 {
t.Fatalf("first scroll step: off=%d anim=%v", off, anim)
}
// Mid-scroll: offset == 3.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+3*marqueeStep))
if off != 3 {
t.Fatalf("mid scroll: off=%d", off)
}
// Tail reached: offset == maxOffset == 5.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+time.Millisecond))
if off != 5 {
t.Fatalf("tail: off=%d", off)
}
// Hold-end window still pegged at maxOffset.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+marqueeHoldEnd/2))
if off != 5 {
t.Fatalf("hold-end mid: off=%d", off)
}
// After hold-end: snap back to offset 0.
off, _, _ = m.step("row", nameLen, budget, t0.Add(marqueeHoldStart+5*marqueeStep+marqueeHoldEnd+time.Millisecond))
if off != 0 {
t.Fatalf("snap back: off=%d", off)
}
}
func TestMarqueeIDChangeResets(t *testing.T) {
var m marqueeState
t0 := time.Unix(0, 0)
_, _, _ = m.step("a", 10, 5, t0)
// Advance well into scroll for row "a".
_, _, _ = m.step("a", 10, 5, t0.Add(marqueeHoldStart+3*marqueeStep))
// Now focus moves to "b": offset must reset to 0 and phase to hold-start.
off, anim, wake := m.step("b", 10, 5, t0.Add(marqueeHoldStart+3*marqueeStep))
if off != 0 || !anim || wake != marqueeHoldStart {
t.Fatalf("id reset: off=%d anim=%v wake=%v", off, anim, wake)
}
}
func TestMarqueeActive(t *testing.T) {
var m marqueeState
if m.active() {
t.Fatalf("fresh marquee should not be active")
}
_, _, _ = m.step("row", 10, 5, time.Unix(0, 0))
if !m.active() {
t.Fatalf("expected active after overflow step")
}
_, _, _ = m.step("row", 4, 5, time.Unix(0, 0))
if m.active() {
t.Fatalf("should not be active when name fits")
}
}
func TestMarqueeReset(t *testing.T) {
var m marqueeState
_, _, _ = m.step("row", 10, 5, time.Unix(0, 0))
m.reset()
if m.active() {
t.Fatalf("expected inactive after reset")
}
// After reset, stepping the same id starts fresh.
off, _, wake := m.step("row", 10, 5, time.Unix(5, 0))
if off != 0 || wake != marqueeHoldStart {
t.Fatalf("post-reset start: off=%d wake=%v", off, wake)
}
}
func TestFitName(t *testing.T) {
cases := []struct {
name, in string
budget int
want string
}{
{"fits", "abc", 5, "abc"},
{"exact", "abcde", 5, "abcde"},
{"truncate", "abcdef", 5, "abcd…"},
{"budget1", "abcdef", 1, "…"},
{"budget0", "abc", 0, ""},
{"unicode", "αβγδεζη", 4, "αβγ…"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := fitName(c.in, c.budget)
if got != c.want {
t.Fatalf("fitName(%q, %d) = %q want %q", c.in, c.budget, got, c.want)
}
})
}
}
func TestMarqueeWindow(t *testing.T) {
got := marqueeWindow("abcdefgh", 4, 2)
if got != "cdef" {
t.Fatalf("window = %q", got)
}
// Clamp end-of-string overflow.
got = marqueeWindow("abcdef", 4, 10)
if got != "cdef" {
t.Fatalf("clamped window = %q", got)
}
}
func TestClampVisible(t *testing.T) {
// Plain string longer than width.
if got := clampVisible("abcdef", 3); visibleLen(got) != 3 {
t.Fatalf("plain clamp visible = %d (%q)", visibleLen(got), got)
}
// Already-fitting string is unchanged.
if got := clampVisible("abc", 5); got != "abc" {
t.Fatalf("unchanged = %q", got)
}
// SGR-wrapped string: visible portion must be <= width.
in := "\x1b[1mhello\x1b[0m world"
got := clampVisible(in, 5)
if visibleLen(got) != 5 {
t.Fatalf("sgr clamp visible = %d (%q)", visibleLen(got), got)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -31,8 +31,10 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
func TestContextItemsScratchpad(t *testing.T) { func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{}) p := newPalette(nil, "", "notes.md", preset.Set{})
if i, _ := findItem(p, "pad-delete"); i != 0 { // pad-delete is the first selectable row; the Focused section header
t.Fatalf("pad-delete at %d; want top", i) // (a non-selectable row) sits above it.
if i, _ := findItem(p, "pad-delete"); i != 1 {
t.Fatalf("pad-delete at %d; want 1 (after Focused header)", i)
} }
if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" { if _, it := findItem(p, "pad-rename-form"); it == nil || it.action.padName != "notes.md" {
t.Fatalf("pad-rename-form missing or wrong padName: %+v", it) t.Fatalf("pad-rename-form missing or wrong padName: %+v", it)

View File

@@ -47,36 +47,50 @@ func TestPaletteBareEscCancels(t *testing.T) {
} }
} }
// firstSelectable returns the lowest item index whose action is
// selectable (not a section header), or -1 if the palette has no
// selectable rows.
func firstSelectable(p *paletteState) int {
for i, it := range p.items {
if it.action.kind != "header" {
return i
}
}
return -1
}
func TestPaletteKittyArrowsNavigate(t *testing.T) { func TestPaletteKittyArrowsNavigate(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}} pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr}) p := newPalette(nil, "", "", preset.Set{Agents: pr})
if p.cursor != 0 { first := firstSelectable(p)
t.Fatalf("initial cursor %d", p.cursor) if first < 0 || p.cursor != first {
t.Fatalf("initial cursor %d, want first selectable %d", p.cursor, first)
} }
// Kitty functional Down arrow. // Kitty functional Down arrow.
_, _, adv := p.handleInput([]byte("\x1b[57353u"), 0) _, _, adv := p.handleInput([]byte("\x1b[57353u"), 0)
if adv != 8 { if adv != 8 {
t.Fatalf("advance %d", adv) t.Fatalf("advance %d", adv)
} }
if p.cursor != 1 { if p.cursor != first+1 {
t.Fatalf("cursor %d after Down, want 1", p.cursor) t.Fatalf("cursor %d after Down, want %d", p.cursor, first+1)
} }
// Kitty functional Up arrow. // Kitty functional Up arrow.
_, _, _ = p.handleInput([]byte("\x1b[57352u"), 0) _, _, _ = p.handleInput([]byte("\x1b[57352u"), 0)
if p.cursor != 0 { if p.cursor != first {
t.Fatalf("cursor %d after Up, want 0", p.cursor) t.Fatalf("cursor %d after Up, want %d", p.cursor, first)
} }
} }
func TestPaletteLegacyArrowsStillWork(t *testing.T) { func TestPaletteLegacyArrowsStillWork(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr}) p := newPalette(nil, "", "", preset.Set{Agents: pr})
first := firstSelectable(p)
_, _, adv := p.handleInput([]byte("\x1b[B"), 0) _, _, adv := p.handleInput([]byte("\x1b[B"), 0)
if adv != 3 { if adv != 3 {
t.Fatalf("advance %d", adv) t.Fatalf("advance %d", adv)
} }
if p.cursor != 1 { if p.cursor != first+1 {
t.Fatalf("cursor %d, want 1", p.cursor) t.Fatalf("cursor %d, want %d", p.cursor, first+1)
} }
} }

View File

@@ -0,0 +1,385 @@
package app
import (
"strings"
"testing"
"github.com/hjbdev/patterm/internal/preset"
)
// -- Phase 1: naming & dropped global Close list ---------------------
func TestPaletteVerbsAreUnified(t *testing.T) {
procs := []*preset.Preset{{Name: "dev"}}
agents := []*preset.Preset{{Name: "claude"}}
p := newPalette(nil, "", "", preset.Set{Agents: agents, Processes: procs})
gotLabels := make([]string, 0, len(p.items))
for _, it := range p.items {
if it.action.kind == "header" {
continue
}
gotLabels = append(gotLabels, it.label)
}
joined := strings.Join(gotLabels, "\n")
mustContain := []string{
"Spawn agent: claude",
"Spawn process: dev",
"Spawn terminal",
"Spawn process… (custom)",
}
for _, want := range mustContain {
if !strings.Contains(joined, want) {
t.Errorf("missing unified-verb label %q in:\n%s", want, joined)
}
}
// The pre-overhaul verb forms must not appear anywhere.
mustNotContain := []string{"Run process:", "New Terminal", "Spawn process… (custom)"}
for _, bad := range mustNotContain {
if strings.Contains(joined, bad) {
t.Errorf("leftover legacy verb %q present in:\n%s", bad, joined)
}
}
}
func TestPaletteDropsGlobalCloseList(t *testing.T) {
c1 := makeFakeChild("a", "claude", KindAgent)
c2 := makeFakeChild("b", "dev", KindCommand)
p := newPalette([]*Child{c1, c2}, "", "", preset.Set{})
// No focus → no Focused context, so no "kill" / "agent-close" /
// "proc-stop" rows should exist at all.
for _, kind := range []string{"kill", "agent-close", "proc-stop", "proc-delete"} {
if i, _ := findItem(p, kind); i != -1 {
t.Fatalf("kind %q present at %d; global Close list should be gone", kind, i)
}
}
}
// -- Phase 2: section headers and cursor skip ------------------------
func TestPaletteSectionHeadersPresent(t *testing.T) {
c := makeFakeChild("a", "claude", KindAgent)
p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
wantSections := []string{"Focused", "Open", "Spawn", "Quit"}
for _, w := range wantSections {
found := false
for _, it := range p.items {
if it.action.kind == "header" && strings.Contains(it.label, w) {
found = true
break
}
}
if !found {
t.Errorf("section header %q missing from items", w)
}
}
}
func TestPaletteCursorSkipsHeaders(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// Initial cursor must land on a selectable row, never a header.
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("initial cursor sits on a header: %+v", p.items[p.cursor])
}
// Walk to the end with cursorDown; every stop must be selectable.
for i := 0; i < len(p.items)*2; i++ {
p.cursorDown()
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("cursorDown landed on a header at index %d", p.cursor)
}
}
// Walk back to top.
for i := 0; i < len(p.items)*2; i++ {
p.cursorUp()
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("cursorUp landed on a header at index %d", p.cursor)
}
}
}
func TestPaletteEnterOnHeaderIsNoOp(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// Force the cursor onto a header.
for i, it := range p.items {
if it.action.kind == "header" {
p.cursor = i
break
}
}
_, done, _ := p.handleInput([]byte("\r"), 0)
if done {
t.Fatalf("Enter on header closed palette; expected no-op")
}
}
// -- Phase 3: filter chips & macro coexistence -----------------------
func TestPaletteTabCyclesChip(t *testing.T) {
p := newTestPalette()
// All → Open
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "sw " {
t.Fatalf("Tab #1: query %q, want %q", string(p.query), "sw ")
}
// Open → Spawn
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "sp " {
t.Fatalf("Tab #2: query %q, want %q", string(p.query), "sp ")
}
// Spawn → Close
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "k " {
t.Fatalf("Tab #3: query %q, want %q", string(p.query), "k ")
}
// Close → All (wraps)
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "" {
t.Fatalf("Tab #4 wrap: query %q, want empty", string(p.query))
}
}
func TestPaletteShiftTabCyclesBackwards(t *testing.T) {
p := newTestPalette()
// Shift-Tab via legacy CSI Z: All → Close
_, _, _ = p.handleInput([]byte("\x1b[Z"), 0)
if string(p.query) != "k " {
t.Fatalf("Shift-Tab: query %q, want %q", string(p.query), "k ")
}
}
func TestPaletteBackspaceThroughTrailingMacro(t *testing.T) {
p := newTestPalette()
p.query = []rune("sw ")
p.rebuild()
p.backspace()
if string(p.query) != "" {
t.Fatalf("backspace through 'sw ' left %q; want empty", string(p.query))
}
}
func TestPaletteMacroPreservesQueryCase(t *testing.T) {
// Tab cycling shouldn't downcase the user-typed search text.
p := newTestPalette()
p.query = []rune("Foo")
p.rebuild()
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if string(p.query) != "sw Foo" {
t.Fatalf("query after Tab over 'Foo' = %q; want 'sw Foo'", string(p.query))
}
}
// -- Phase 4: scored matching ----------------------------------------
func TestFuzzyScorePrefixBeatsBoundaryBeatsSubstring(t *testing.T) {
prefix, _ := fuzzyScore("spawn agent: foo", "", "spa")
boundary, _ := fuzzyScore("hello spam", "", "spa")
substring, _ := fuzzyScore("escapade", "", "spa")
if !(prefix > boundary && boundary > substring) {
t.Fatalf("score ordering wrong: prefix=%d boundary=%d substring=%d", prefix, boundary, substring)
}
}
func TestFuzzyScoreReturnsMatchPositions(t *testing.T) {
_, pos := fuzzyScore("spawn process: dev", "", "dev")
want := []int{15, 16, 17}
if len(pos) != len(want) {
t.Fatalf("positions = %v, want %v", pos, want)
}
for i, p := range pos {
if p != want[i] {
t.Fatalf("pos[%d] = %d, want %d (full %v)", i, p, want[i], pos)
}
}
}
func TestPaletteScoredResultsDropHeaders(t *testing.T) {
pr := []*preset.Preset{{Name: "claude"}, {Name: "codex"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// Type a needle that matches both.
p.query = []rune("c")
p.rebuild()
for _, it := range p.items {
if it.action.kind == "header" {
t.Fatalf("scored mode should not emit header rows; got %+v", it)
}
}
}
func TestPaletteScoringFloatsPrefixMatchToTop(t *testing.T) {
// "x" is a prefix of "xtest" preset; it's a scattered-fuzzy match
// against many other rows. Scoring should land the prefix match at
// the top regardless of group order.
pr := []*preset.Preset{
{Name: "alpha"},
{Name: "xtest"},
{Name: "beta"},
}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
p.query = []rune("xt")
p.rebuild()
if len(p.items) == 0 {
t.Fatalf("no scored items for needle 'xt'")
}
if !strings.Contains(p.items[0].label, "xtest") {
t.Fatalf("expected xtest at top of scored list, got %q", p.items[0].label)
}
}
// -- Phase 5: power-user accelerators --------------------------------
func TestPaletteCtrlXOnSwitchKills(t *testing.T) {
c := makeFakeChild("a", "claude", KindAgent)
p := newPalette([]*Child{c}, "", "", preset.Set{})
// Cursor should already be on the switch row (it's the first
// selectable item with no Focused section).
idx, _ := findItem(p, "switch")
if idx < 0 {
t.Fatalf("no switch item in palette")
}
p.cursor = idx
action, done, _ := p.handleInput([]byte{0x18}, 0)
if !done {
t.Fatalf("Ctrl-X on switch row didn't close palette: action=%+v", action)
}
if action.kind != "kill" || action.childID != "a" {
t.Fatalf("Ctrl-X action = %+v, want kill of 'a'", action)
}
}
func TestPaletteCtrlXOnNonSwitchIsNoOp(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{})
// Cursor parks on Quit or Spawn entries — neither is a switch row.
_, done, _ := p.handleInput([]byte{0x18}, 0)
if done {
t.Fatalf("Ctrl-X on non-switch closed palette")
}
}
func TestPaletteHelpToggle(t *testing.T) {
p := newTestPalette()
// `?` with empty query opens help.
_, done, _ := p.handleInput([]byte("?"), 0)
if done {
t.Fatalf("? closed palette")
}
if !p.showHelp {
t.Fatalf("? didn't open help")
}
// Next keystroke dismisses.
_, _, _ = p.handleInput([]byte("a"), 0)
if p.showHelp {
t.Fatalf("help still showing after dismissing keystroke")
}
}
func TestPaletteHelpDoesNotInterceptInQuery(t *testing.T) {
p := newTestPalette()
p.query = []rune("dev")
p.rebuild()
_, _, _ = p.handleInput([]byte("?"), 0)
if p.showHelp {
t.Fatalf("? with non-empty query incorrectly opened help")
}
if string(p.query) != "dev?" {
t.Fatalf("? with non-empty query failed to append: %q", string(p.query))
}
}
func TestPaletteHomeEndJumpsOverHeaders(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// End jumps to last selectable.
p.cursorEnd()
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("End landed on header: %+v", p.items[p.cursor])
}
if p.items[p.cursor].action.kind != "quit" {
t.Fatalf("End on simple palette should park on Quit; got %+v", p.items[p.cursor])
}
// Home returns to first selectable.
p.cursorHome()
if p.items[p.cursor].action.kind == "header" {
t.Fatalf("Home landed on header: %+v", p.items[p.cursor])
}
}
func TestPaletteAltDigitQuickPick(t *testing.T) {
pr := []*preset.Preset{{Name: "first"}, {Name: "second"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
// Alt-1 picks the first selectable item (Spawn agent: first).
action, done, adv := p.handleInput([]byte("\x1b1"), 0)
if adv != 2 {
t.Fatalf("Alt-1 advance %d, want 2", adv)
}
if !done {
t.Fatalf("Alt-1 didn't close palette")
}
if action.kind != "spawn-agent" || action.preset == nil || action.preset.Name != "first" {
t.Fatalf("Alt-1 action = %+v, want spawn-agent first", action)
}
}
func TestAutoSummaryCadenceCyclesSoloValues(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeAutoSummary
for i, row := range autoSummaryRows() {
if row.key == "cadence" {
p.cursor = i
break
}
}
if p.settings.AutoSummary.Cadence != "1m" {
t.Fatalf("initial cadence = %q", p.settings.AutoSummary.Cadence)
}
p.activateAutoSummaryRow()
if p.settings.AutoSummary.Cadence != "15s" {
t.Fatalf("first cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
p.activateAutoSummaryRow()
if p.settings.AutoSummary.Cadence != "30s" {
t.Fatalf("second cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
p.activateAutoSummaryRow()
if p.settings.AutoSummary.Cadence != "1m" {
t.Fatalf("third cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
}
func TestPaletteFormCtrlRTogglesRelaunchFromCommandField(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
// Type without leaving the command field, then Ctrl-R.
for _, b := range []byte("xyz") {
_, _, _ = p.handleInput([]byte{b}, 0)
}
if p.form.field != 0 {
t.Fatalf("field jumped to %d", p.form.field)
}
_, _, _ = p.handleInput([]byte{0x12}, 0)
if !p.form.relaunch {
t.Fatalf("Ctrl-R didn't toggle relaunch from command field")
}
// Second press toggles back.
_, _, _ = p.handleInput([]byte{0x12}, 0)
if p.form.relaunch {
t.Fatalf("second Ctrl-R didn't toggle off")
}
}
// -- Phase 6: counter / scroll indicator -----------------------------
func TestPaletteFooterCounter(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
total := p.visibleSelectableCount()
if total < 4 { // 3 spawn-agents + terminal + custom + quit
t.Fatalf("expected ≥4 selectables; got %d", total)
}
idx := p.selectableIndex()
if idx <= 0 {
t.Fatalf("selectable index = %d on freshly-built palette; want ≥1", idx)
}
}

150
internal/app/settings.go Normal file
View File

@@ -0,0 +1,150 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/hjbdev/patterm/internal/preset"
)
const (
defaultSummaryProvider = "codex"
defaultCodexModel = "gpt-5.4-mini"
defaultOpenCodeModel = "opencode-go/minimax-m2.7"
defaultClaudeModel = "claude-haiku-4-5"
)
type settings struct {
AutoSummary autoSummarySettings `json:"auto_summary"`
}
type autoSummarySettings struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Models map[string]string `json:"models"`
Cadence string `json:"cadence"`
QuietWindowMS int `json:"quiet_window_ms"`
MinInputChars int `json:"min_input_chars"`
MaxHistoryChars int `json:"max_history_chars"`
}
func defaultSettings() settings {
return settings{
AutoSummary: autoSummarySettings{
Enabled: true,
Provider: defaultSummaryProvider,
Models: defaultSummaryModels(),
Cadence: "1m",
QuietWindowMS: 3000,
MinInputChars: 4,
MaxHistoryChars: 12000,
},
}
}
func defaultSummaryModels() map[string]string {
return map[string]string{
"codex": defaultCodexModel,
"opencode": defaultOpenCodeModel,
"claude": defaultClaudeModel,
}
}
func loadSettings() (settings, string, error) {
base, err := preset.ConfigDir()
if err != nil {
return settings{}, "", err
}
path := filepath.Join(base, "settings.json")
st := defaultSettings()
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return st, path, nil
}
return st, path, fmt.Errorf("settings: read %s: %w", path, err)
}
if err := json.Unmarshal(b, &st); err != nil {
return defaultSettings(), path, fmt.Errorf("settings: parse %s: %w", path, err)
}
st.normalize()
return st, path, nil
}
func saveSettings(path string, st settings) error {
if path == "" {
return fmt.Errorf("settings: empty path")
}
st.normalize()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(st, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
return os.WriteFile(path, b, 0o600)
}
func (st *settings) normalize() {
def := defaultSettings()
if st.AutoSummary.Provider == "" {
st.AutoSummary.Provider = def.AutoSummary.Provider
}
switch st.AutoSummary.Provider {
case "codex", "opencode", "claude":
default:
st.AutoSummary.Provider = def.AutoSummary.Provider
}
if st.AutoSummary.Models == nil {
st.AutoSummary.Models = defaultSummaryModels()
} else {
for k, v := range defaultSummaryModels() {
if st.AutoSummary.Models[k] == "" {
st.AutoSummary.Models[k] = v
}
}
}
if st.AutoSummary.Cadence == "" {
st.AutoSummary.Cadence = def.AutoSummary.Cadence
}
if st.AutoSummary.QuietWindowMS <= 0 {
st.AutoSummary.QuietWindowMS = def.AutoSummary.QuietWindowMS
}
if st.AutoSummary.MinInputChars <= 0 {
st.AutoSummary.MinInputChars = def.AutoSummary.MinInputChars
}
if st.AutoSummary.MaxHistoryChars <= 0 {
st.AutoSummary.MaxHistoryChars = def.AutoSummary.MaxHistoryChars
}
}
func (st settings) clone() settings {
st.normalize()
if st.AutoSummary.Models != nil {
models := make(map[string]string, len(st.AutoSummary.Models))
for k, v := range st.AutoSummary.Models {
models[k] = v
}
st.AutoSummary.Models = models
}
return st
}
func (a autoSummarySettings) clone() autoSummarySettings {
st := settings{AutoSummary: a}.clone()
return st.AutoSummary
}
func (a autoSummarySettings) modelFor(provider string) string {
if a.Models == nil {
return defaultSummaryModels()[provider]
}
if m := a.Models[provider]; m != "" {
return m
}
return defaultSummaryModels()[provider]
}

View File

@@ -0,0 +1,72 @@
package app
import (
"os"
"path/filepath"
"testing"
)
func TestLoadSettingsDefaults(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
st, path, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings: %v", err)
}
if filepath.Base(path) != "settings.json" {
t.Fatalf("settings path = %q", path)
}
if !st.AutoSummary.Enabled {
t.Fatal("auto-summary should default enabled")
}
if st.AutoSummary.Provider != "codex" {
t.Fatalf("provider = %q want codex", st.AutoSummary.Provider)
}
if st.AutoSummary.Cadence != "1m" {
t.Fatalf("cadence = %q want 1m", st.AutoSummary.Cadence)
}
if got := st.AutoSummary.modelFor("codex"); got != "gpt-5.4-mini" {
t.Fatalf("codex model = %q", got)
}
if got := st.AutoSummary.modelFor("opencode"); got != "opencode-go/minimax-m2.7" {
t.Fatalf("opencode model = %q", got)
}
}
func TestSettingsCloneDoesNotShareModelMap(t *testing.T) {
st := defaultSettings()
cp := st.clone()
cp.AutoSummary.Models["codex"] = "changed"
if st.AutoSummary.Models["codex"] == "changed" {
t.Fatal("clone shared Models map with original")
}
a := st.AutoSummary.clone()
a.Models["opencode"] = "changed"
if st.AutoSummary.Models["opencode"] == "changed" {
t.Fatal("autoSummarySettings clone shared Models map with original")
}
}
func TestSaveAndLoadSettings(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", dir)
st := defaultSettings()
st.AutoSummary.Provider = "opencode"
st.AutoSummary.Models["opencode"] = "minimax/test"
path := filepath.Join(dir, "patterm", "settings.json")
if err := saveSettings(path, st); err != nil {
t.Fatalf("saveSettings: %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("settings file missing: %v", err)
}
got, _, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings: %v", err)
}
if got.AutoSummary.Provider != "opencode" {
t.Fatalf("provider = %q", got.AutoSummary.Provider)
}
if got.AutoSummary.modelFor("opencode") != "minimax/test" {
t.Fatalf("opencode model = %q", got.AutoSummary.modelFor("opencode"))
}
}

View File

@@ -12,6 +12,128 @@ const (
statusRows = 1 statusRows = 1
) )
// fitName returns name truncated to fit budget visible cells, with a
// trailing "…" when it overflows. Operates on RAW (unstyled) input;
// the caller wraps the result in SGR. Returns "" when budget <= 0.
func fitName(name string, budget int) string {
if budget <= 0 {
return ""
}
runes := []rune(name)
if len(runes) <= budget {
return name
}
if budget == 1 {
return "…"
}
return string(runes[:budget-1]) + "…"
}
// marqueeWindow returns the window of name starting at offset, exactly
// budget cells wide. Pre: caller has decided the name overflows budget
// and offset is in [0, len([]rune(name))-budget]. Operates on RAW
// (unstyled) input.
func marqueeWindow(name string, budget, offset int) string {
if budget <= 0 {
return ""
}
runes := []rune(name)
if len(runes) <= budget {
return name
}
if offset < 0 {
offset = 0
}
end := offset + budget
if end > len(runes) {
end = len(runes)
offset = end - budget
if offset < 0 {
offset = 0
}
}
return string(runes[offset:end])
}
// clampVisible truncates s so that its visible (non-SGR) length is at
// most width cells, preserving any active style by appending a reset.
// Used as a defensive net by write() so a row whose decoration was
// mis-sized still cannot spill past the sidebar band into the PTY area.
func clampVisible(s string, width int) string {
if width <= 0 {
return ""
}
if visibleLen(s) <= width {
return s
}
var b strings.Builder
b.Grow(len(s))
visible := 0
inEsc := false
for _, r := range s {
if inEsc {
b.WriteRune(r)
if r == 'm' || r == 'H' {
inEsc = false
}
continue
}
if r == 0x1b {
inEsc = true
b.WriteRune(r)
continue
}
if visible >= width {
break
}
b.WriteRune(r)
visible++
}
b.WriteString(styleReset)
return b.String()
}
// chooseSidebarSuffix decides whether to keep or drop the trailing
// timer indicator from a sidebar row's suffix. When the row's name
// would have to ellipsise with the timer present, but the budget
// freed by dropping the timer still leaves at least 6 cells for the
// name, the timer is dropped. The name is the only identifier the
// user has for that row; the timer is recoverable from the status
// line and palette.
func chooseSidebarSuffix(nameRuneLen, width int, prefix, suffix, timer string) (string, int) {
prefixCost := visibleLen(prefix)
budget := width - prefixCost - visibleLen(suffix)
if nameRuneLen <= budget || timer == "" {
return suffix, budget
}
slim := strings.TrimSuffix(suffix, timer)
if slim == suffix {
return suffix, budget
}
slimBudget := width - prefixCost - visibleLen(slim)
if slimBudget >= 6 {
return slim, slimBudget
}
return suffix, budget
}
// rowNameSlot returns the unstyled name cell for a sidebar row.
// Unfocused (or focused-and-fitting) rows get fitName with a trailing
// "…" on overflow. The focused row, when its name overflows the
// budget, gets the current marquee window — exactly budget cells
// wide so the surrounding row geometry stays put while it animates.
func (st *uiState) rowNameSlot(id, rawName string, budget int, focused bool) string {
if budget <= 0 {
return ""
}
runes := []rune(rawName)
if !focused || len(runes) <= budget {
return fitName(rawName, budget)
}
off, _, _ := st.marquee.step(id, len(runes), budget, time.Now())
return marqueeWindow(rawName, budget, off)
}
// formatShortDuration renders a duration as a short, sidebar-friendly // formatShortDuration renders a duration as a short, sidebar-friendly
// suffix: ms under 1s, "12s" under 60s, "3m" otherwise. // suffix: ms under 1s, "12s" under 60s, "3m" otherwise.
func formatShortDuration(d time.Duration) string { func formatShortDuration(d time.Duration) string {
@@ -73,6 +195,9 @@ func (st *uiState) drawSidebar() {
if row > maxRow { if row > maxRow {
return return
} }
if visibleLen(content) > width {
content = clampVisible(content, width)
}
pad := width - visibleLen(content) pad := width - visibleLen(content)
if pad < 0 { if pad < 0 {
pad = 0 pad = 0
@@ -154,14 +279,19 @@ func (st *uiState) drawSidebar() {
if c.AutoRestart() { if c.AutoRestart() {
marker = " " + styleDim + "⟳" + styleReset marker = " " + styleDim + "⟳" + styleReset
} }
var line string timer := timerIndicator(c)
var prefix, openStyle string
if focused { if focused {
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " + prefix = " " + styleAccent + "▎" + styleReset + " " + glyph + " "
styleBold + c.DisplayName() + styleReset + marker + timerIndicator(c) openStyle = styleBold
} else { } else {
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker + timerIndicator(c) prefix = " " + glyph + " "
openStyle = styleHint
} }
write(line) raw := c.DisplayName()
suffix, budget := chooseSidebarSuffix(len([]rune(raw)), width, prefix, marker+timer, timer)
nameCell := st.rowNameSlot(c.ID, raw, budget, focused)
write(prefix + openStyle + nameCell + styleReset + suffix)
} }
// Agent Tree section — formerly "Session tree". Shows the active // Agent Tree section — formerly "Session tree". Shows the active
@@ -186,14 +316,29 @@ func (st *uiState) drawSidebar() {
} }
focused := c.ID == focus focused := c.ID == focus
glyph := statusGlyph(c, focused) glyph := statusGlyph(c, focused)
var line string timer := timerIndicator(c)
var prefix, openStyle string
if focused { if focused {
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " + prefix = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " "
styleBold + c.DisplayName() + styleReset + timerIndicator(c) openStyle = styleBold
} else { } else {
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset + timerIndicator(c) prefix = " " + indent + glyph + " "
openStyle = styleHint
}
raw := c.DisplayName()
suffix, budget := chooseSidebarSuffix(len([]rune(raw)), width, prefix, timer, timer)
nameCell := st.rowNameSlot(c.ID, raw, budget, focused)
write(prefix + openStyle + nameCell + styleReset + suffix)
}
if summary := st.activeSummaryText(width - 4); summary != "" && row+2 <= maxRow {
write("")
for _, line := range wrapSidebarSummary(summary, width-4) {
if row > maxRow {
break
}
write(" " + styleDim + line + styleReset)
} }
write(line)
} }
// Scratchpads list — names only. The preview pane used to live // Scratchpads list — names only. The preview pane used to live
@@ -212,14 +357,18 @@ func (st *uiState) drawSidebar() {
if row > maxRow { if row > maxRow {
break break
} }
var line string focused := e.Name == focusPad
if e.Name == focusPad { var prefix, openStyle string
line = " " + styleAccent + "▎" + styleReset + " " + if focused {
styleBold + e.Name + styleReset prefix = " " + styleAccent + "▎" + styleReset + " "
openStyle = styleBold
} else { } else {
line = " " + styleHint + e.Name + styleReset prefix = " "
openStyle = styleHint
} }
write(line) budget := width - visibleLen(prefix)
nameCell := st.rowNameSlot("pad:"+e.Name, e.Name, budget, focused)
write(prefix + openStyle + nameCell + styleReset)
} }
} }
} }
@@ -251,3 +400,42 @@ func (st *uiState) drawSidebar() {
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame) fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
st.outMu.Unlock() st.outMu.Unlock()
} }
func wrapSidebarSummary(s string, width int) []string {
if width < 1 {
width = 1
}
words := strings.Fields(s)
if len(words) == 0 {
return nil
}
var out []string
var cur string
for _, word := range words {
if visibleLen(word) > width {
if cur != "" {
out = append(out, cur)
cur = ""
}
out = append(out, clipRunes(word, width-1)+"…")
continue
}
if cur == "" {
cur = word
continue
}
if visibleLen(cur)+1+visibleLen(word) <= width {
cur += " " + word
continue
}
out = append(out, cur)
cur = word
}
if cur != "" {
out = append(out, cur)
}
if len(out) > 3 {
out = out[:3]
}
return out
}

View File

@@ -0,0 +1,46 @@
package app
import (
"testing"
)
// TestOnChildSpawnedAgentChildKeepsFocus verifies that when a child is
// spawned with a ParentID set (i.e. a patterm-managed agent caused the
// spawn over MCP), OnChildSpawned does NOT steal viewport focus from
// the currently focused child.
func TestOnChildSpawnedAgentChildKeepsFocus(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
st := &uiState{sess: sess}
parent := newChildEntry("p_parent", "parent", KindAgent, nil, nil, "", "", "")
st.focusedID = parent.ID
st.focusedName = parent.Name
subAgent := newChildEntry("p_sub", "sub", KindAgent, nil, nil, parent.ID, "", "")
st.OnChildSpawned(subAgent)
if got := st.focusedID; got != parent.ID {
t.Fatalf("agent-initiated spawn should not change focusedID: want %q, got %q", parent.ID, got)
}
if got := st.focusedName; got != parent.Name {
t.Fatalf("focusedName changed: want %q, got %q", parent.Name, got)
}
}
// TestOnChildSpawnedPaletteChildTakesFocus verifies the legacy path is
// preserved: spawns with an empty ParentID (palette, restore, external
// MCP caller) still auto-focus the new child.
func TestOnChildSpawnedPaletteChildTakesFocus(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
st := &uiState{sess: sess}
st.lastExit.Store(-1)
c := newChildEntry("p_new", "newchild", KindAgent, nil, nil, "", "", "")
st.OnChildSpawned(c)
if got := st.focusedID; got != c.ID {
t.Fatalf("palette-initiated spawn should auto-focus: want %q, got %q", c.ID, got)
}
}

463
internal/app/summarizer.go Normal file
View File

@@ -0,0 +1,463 @@
package app
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
"time"
"unicode"
"github.com/hjbdev/patterm/internal/preset"
)
const (
summaryTickInterval = time.Second
summaryTimeout = 90 * time.Second
summaryMaxLineCells = 240
)
type summaryState struct {
Text string
State IdleState
UpdatedAt time.Time
Error string
}
type summaryManager struct {
sess *Session
projectDir string
presets preset.Set
settings func() autoSummarySettings
onUpdate func()
onResult func(string, summaryState)
mu sync.Mutex
tracked map[string]bool
entries map[string]*summaryEntry
}
type summaryEntry struct {
armed bool
dirty bool
running bool
lastInputAt time.Time
lastOutputAt time.Time
lastAttemptAt time.Time
lastSummarized int64
state summaryState
}
type summarizerResponse struct {
Summary string `json:"summary"`
State string `json:"state"`
}
func newSummaryManager(sess *Session, projectDir string, presets preset.Set, settingsFn func() autoSummarySettings, onUpdate func(), onResult func(string, summaryState)) *summaryManager {
return &summaryManager{
sess: sess,
projectDir: projectDir,
presets: presets,
settings: settingsFn,
onUpdate: onUpdate,
onResult: onResult,
tracked: make(map[string]bool),
entries: make(map[string]*summaryEntry),
}
}
func (m *summaryManager) run(ctx context.Context) {
ticker := time.NewTicker(summaryTickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
m.maybeStart(ctx, time.Now())
}
}
}
func (m *summaryManager) ObserveHumanInput(childID string, b []byte) {
if m == nil || !m.isTracked(childID) {
return
}
cfg := m.settings()
if len(strings.TrimSpace(string(b))) < cfg.MinInputChars {
return
}
m.mu.Lock()
e := m.entryLocked(childID)
e.armed = true
e.lastInputAt = time.Now()
m.mu.Unlock()
}
func (m *summaryManager) ObserveOutput(childID string) {
if m == nil || !m.isTracked(childID) {
return
}
m.mu.Lock()
e := m.entryLocked(childID)
if e.armed {
e.dirty = true
e.lastOutputAt = time.Now()
}
m.mu.Unlock()
}
func (m *summaryManager) RegisterChild(c *Child) {
if m == nil || c == nil {
return
}
m.mu.Lock()
defer m.mu.Unlock()
if isTopLevelSummarizedAgent(c) {
m.tracked[c.ID] = true
} else {
delete(m.tracked, c.ID)
}
}
func (m *summaryManager) UnregisterChild(id string) {
if m == nil || id == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
delete(m.tracked, id)
}
func (m *summaryManager) isTracked(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.tracked[id]
}
func (m *summaryManager) Summary(childID string) summaryState {
if m == nil || childID == "" {
return summaryState{}
}
m.mu.Lock()
defer m.mu.Unlock()
if e := m.entries[childID]; e != nil {
return e.state
}
return summaryState{}
}
func (m *summaryManager) RunNow(ctx context.Context, childID string) {
if m == nil || childID == "" {
return
}
c := m.sess.FindChild(childID)
if !isTopLevelSummarizedAgent(c) {
return
}
m.mu.Lock()
e := m.entryLocked(c.ID)
if e.running {
m.mu.Unlock()
return
}
e.running = true
e.lastAttemptAt = time.Now()
m.mu.Unlock()
go m.runOne(ctx, c.ID, true)
}
func (m *summaryManager) Test(ctx context.Context) error {
cfg := m.settings()
return runSummarizerHealth(ctx, cfg, m.projectDir)
}
func (m *summaryManager) entryLocked(id string) *summaryEntry {
e := m.entries[id]
if e == nil {
e = &summaryEntry{}
m.entries[id] = e
}
return e
}
func (m *summaryManager) maybeStart(ctx context.Context, now time.Time) {
cfg := m.settings()
if !cfg.Enabled {
return
}
cadence, err := time.ParseDuration(cfg.Cadence)
if err != nil || cadence <= 0 {
cadence = time.Minute
}
quiet := time.Duration(cfg.QuietWindowMS) * time.Millisecond
var startID string
for _, c := range m.sess.Children() {
if !isTopLevelSummarizedAgent(c) {
continue
}
m.mu.Lock()
e := m.entryLocked(c.ID)
eligible := e.armed && e.dirty && !e.running &&
!e.lastOutputAt.IsZero() && now.Sub(e.lastOutputAt) >= quiet &&
(e.lastAttemptAt.IsZero() || now.Sub(e.lastAttemptAt) >= cadence) &&
c.ScreenVersion() != e.lastSummarized
if eligible {
e.running = true
e.lastAttemptAt = now
startID = c.ID
}
m.mu.Unlock()
if startID != "" {
go m.runOne(ctx, startID, false)
return
}
}
}
func (m *summaryManager) runOne(ctx context.Context, childID string, manual bool) {
c := m.sess.FindChild(childID)
if c == nil {
m.finish(childID, summaryState{Error: "process disappeared"}, 0)
return
}
cfg := m.settings()
snapshot := buildSummarySnapshot(c, cfg.MaxHistoryChars, m.chromeHintsFor(c.PresetRef))
if strings.TrimSpace(snapshot) == "" {
m.finish(childID, summaryState{Error: "empty snapshot"}, c.ScreenVersion())
return
}
runCtx, cancel := context.WithTimeout(ctx, summaryTimeout)
defer cancel()
resp, err := runSummarizer(runCtx, cfg, m.projectDir, snapshot)
st := summaryState{UpdatedAt: time.Now()}
if err != nil {
st.Error = err.Error()
m.finish(childID, st, c.ScreenVersion())
return
}
st.Text = strings.TrimSpace(resp.Summary)
st.State = summaryIdleState(resp.State)
if st.Text == "" {
st.Error = "empty summary"
}
if manual && st.Text != "" && st.State == StateUnknown {
st.State = c.IdleState()
}
m.finish(childID, st, c.ScreenVersion())
}
func (m *summaryManager) finish(childID string, st summaryState, version int64) {
m.mu.Lock()
e := m.entryLocked(childID)
e.running = false
if st.Text != "" || st.Error != "" {
if st.Text == "" && e.state.Text != "" {
st.Text = e.state.Text
st.State = e.state.State
st.UpdatedAt = e.state.UpdatedAt
}
e.state = st
}
if st.Text != "" {
e.armed = false
e.dirty = false
e.lastSummarized = version
}
m.mu.Unlock()
if m.onUpdate != nil {
m.onUpdate()
}
if m.onResult != nil && (st.Text != "" || st.Error != "") {
m.onResult(childID, st)
}
}
func isTopLevelSummarizedAgent(c *Child) bool {
return c != nil && c.Kind == KindAgent && c.ParentID == "" && c.Status() == StatusRunning
}
func (m *summaryManager) chromeHintsFor(presetName string) []string {
if presetName == "" {
return nil
}
for _, p := range m.presets.Agents {
if p.Name == presetName {
return p.ChromeTrimHints
}
}
return nil
}
func buildSummarySnapshot(c *Child, maxChars int, chromeHints []string) string {
if maxChars <= 0 {
maxChars = 12000
}
grid := ""
if em := c.Emulator(); em != nil {
if txt, err := em.PlainText(); err == nil {
grid = compactSummaryText(applyChromeTrim(txt, chromeHints))
}
}
tailBytes := max(maxChars*4, maxChars)
b := c.tailBytes(tailBytes)
history := compactSummaryText(applyChromeTrim(string(stripANSIBytes(nil, b)), chromeHints))
history = tailString(history, maxChars)
var out strings.Builder
if history != "" {
out.WriteString("Recent rendered history:\n")
out.WriteString(history)
out.WriteString("\n\n")
}
if grid != "" && !strings.Contains(history, grid) {
out.WriteString("Current visible grid:\n")
out.WriteString(grid)
}
return tailString(out.String(), maxChars)
}
func compactSummaryText(in string) string {
in = string(stripANSIBytes(nil, []byte(in)))
in = strings.ReplaceAll(in, "\r\n", "\n")
in = strings.ReplaceAll(in, "\r", "\n")
lines := strings.Split(in, "\n")
out := make([]string, 0, len(lines))
blank := false
for _, line := range lines {
line = strings.TrimRightFunc(line, unicode.IsSpace)
line = strings.Map(func(r rune) rune {
if r == '\t' || r == '\n' {
return r
}
if r < 0x20 || r == 0x7f {
return -1
}
return r
}, line)
line = truncateSummaryLine(line, summaryMaxLineCells)
if strings.TrimSpace(line) == "" {
if blank {
continue
}
blank = true
out = append(out, "")
continue
}
blank = false
out = append(out, line)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
func truncateSummaryLine(s string, max int) string {
if max <= 0 || visibleLen(s) <= max {
return s
}
return clipRunes(s, max-1) + "…"
}
func tailString(s string, max int) string {
rs := []rune(s)
if len(rs) <= max {
return s
}
return string(rs[len(rs)-max:])
}
func runSummarizer(ctx context.Context, cfg autoSummarySettings, projectDir, snapshot string) (summarizerResponse, error) {
prompt := summaryPrompt(snapshot)
out, err := runSummarizerCommand(ctx, cfg, projectDir, prompt)
if err != nil {
return summarizerResponse{}, err
}
resp, err := parseSummarizerResponse(out)
if err != nil {
return summarizerResponse{}, err
}
if summaryIdleState(resp.State) == StateUnknown {
return summarizerResponse{}, fmt.Errorf("invalid summary state %q", resp.State)
}
return resp, nil
}
func runSummarizerHealth(ctx context.Context, cfg autoSummarySettings, projectDir string) error {
out, err := runSummarizerCommand(ctx, cfg, projectDir, "Reply with exactly: patterm okay")
if err != nil {
return err
}
if strings.TrimSpace(out) != "patterm okay" {
return fmt.Errorf("health check did not return patterm okay")
}
return nil
}
func runSummarizerCommand(ctx context.Context, cfg autoSummarySettings, projectDir, prompt string) (string, error) {
provider := cfg.Provider
model := cfg.modelFor(provider)
var cmd *exec.Cmd
switch provider {
case "opencode":
cmd = exec.CommandContext(ctx, "opencode", "run", "--model", model, "--dir", projectDir, prompt)
case "claude":
cmd = exec.CommandContext(ctx, "claude", "--print", "--model", model, prompt)
default:
cmd = exec.CommandContext(ctx, "codex", "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--ask-for-approval", "never", "--model", model, "-")
cmd.Stdin = strings.NewReader(prompt)
}
cmd.Dir = projectDir
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("%s summarizer: %s", provider, msg)
}
return string(out), nil
}
func summaryPrompt(snapshot string) string {
return "Summarize this terminal/agent snapshot for a compact UI catch-up aid.\n" +
"Return only JSON with keys summary and state. State must be one of IDLE, PERMISSION, THINKING, WORKING, ERROR.\n" +
"Keep summary under 180 characters, concrete, and avoid mentioning that you are summarizing.\n\n" +
snapshot
}
func parseSummarizerResponse(out string) (summarizerResponse, error) {
var resp summarizerResponse
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &resp); err == nil {
return resp, nil
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "{") || !strings.HasSuffix(line, "}") {
continue
}
if err := json.Unmarshal([]byte(line), &resp); err == nil {
return resp, nil
}
}
return resp, fmt.Errorf("summary output was not JSON")
}
func summaryIdleState(s string) IdleState {
switch strings.ToUpper(strings.TrimSpace(s)) {
case "IDLE":
return StateIdle
case "PERMISSION":
return StatePermission
case "THINKING":
return StateThinking
case "WORKING":
return StateWorking
case "ERROR":
return StateError
default:
return StateUnknown
}
}

View File

@@ -0,0 +1,85 @@
package app
import (
"strings"
"testing"
"github.com/hjbdev/patterm/internal/preset"
)
func TestParseSummarizerResponseAllowsWrappedJSON(t *testing.T) {
resp, err := parseSummarizerResponse("log\n{\"summary\":\"Waiting for tests\",\"state\":\"WORKING\"}\n")
if err != nil {
t.Fatalf("parseSummarizerResponse: %v", err)
}
if resp.Summary != "Waiting for tests" || summaryIdleState(resp.State) != StateWorking {
t.Fatalf("response = %+v", resp)
}
}
func TestCompactSummaryTextDropsControlAndRedundantWhitespace(t *testing.T) {
got := compactSummaryText("hello\x00 world \n\n\n\x1b[31mred\x1b[0m\n")
if strings.ContainsRune(got, '\x00') {
t.Fatalf("control byte survived: %q", got)
}
if strings.Contains(got, "\n\n\n") {
t.Fatalf("redundant blanks survived: %q", got)
}
if strings.Contains(got, "\x1b") {
t.Fatalf("ansi survived: %q", got)
}
}
func TestWrapSidebarSummaryKeepsWordBoundaries(t *testing.T) {
got := wrapSidebarSummary("alpha beta gamma delta", 12)
want := []string{"alpha beta", "gamma delta"}
if len(got) != len(want) {
t.Fatalf("lines = %#v", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("line %d = %q want %q", i, got[i], want[i])
}
}
long := wrapSidebarSummary("supercalifragilistic short", 8)
if len(long) == 0 || !strings.HasSuffix(long[0], "…") {
t.Fatalf("long word should clip with ellipsis: %#v", long)
}
}
func TestSummaryManagerArmsOnlyTrackedTopLevelAgents(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
c := newChildEntry("a1", "agent", KindAgent, []string{"fake"}, nil, "", "", "")
running := StatusRunning
c.status.Store(&running)
sess.children[c.ID] = c
sess.order = append(sess.order, c.ID)
cfg := defaultSettings().AutoSummary
m := newSummaryManager(sess, t.TempDir(), preset.Set{}, func() autoSummarySettings {
return cfg.clone()
}, nil, nil)
m.ObserveHumanInput(c.ID, []byte("please summarize"))
if got := m.Summary(c.ID); got.Text != "" {
t.Fatalf("untracked agent should not update summary state: %+v", got)
}
m.RegisterChild(c)
m.ObserveHumanInput(c.ID, []byte("please summarize"))
m.ObserveOutput(c.ID)
m.mu.Lock()
e := m.entries[c.ID]
m.mu.Unlock()
if e == nil || !e.armed || !e.dirty {
t.Fatalf("tracked top-level agent not armed/dirty: %+v", e)
}
sub := newChildEntry("a2", "sub", KindAgent, []string{"fake"}, nil, c.ID, "", "")
sub.status.Store(&running)
m.RegisterChild(sub)
m.ObserveHumanInput(sub.ID, []byte("please summarize"))
m.mu.Lock()
_, ok := m.entries[sub.ID]
m.mu.Unlock()
if ok {
t.Fatal("sub-agent should not get a summary entry")
}
}

View File

@@ -8,9 +8,9 @@ import (
"unicode/utf8" "unicode/utf8"
) )
// Two-row tab bar: labels row, underline row. The PTY viewport's top // Three-row tab bar: labels row, active-thread summary row, underline row. The PTY viewport's top
// row is therefore mainTop == tabBarRows + 1. // row is therefore mainTop == tabBarRows + 1.
const tabBarRows = 2 const tabBarRows = 3
// drawTabBar renders the top tab strip across the full host width. // drawTabBar renders the top tab strip across the full host width.
// Tabs share the available width with a flex layout — each visible // Tabs share the available width with a flex layout — each visible
@@ -139,7 +139,8 @@ func (st *uiState) drawTabBar() {
} }
var b strings.Builder var b strings.Builder
// Clear both rows so a stale label from the previous frame can't // Clear all tab-bar rows so stale labels or summaries from the
// previous frame can't
// bleed through. Use ECH clamped to `width` (= childCols) instead of // bleed through. Use ECH clamped to `width` (= childCols) instead of
// `\x1b[2K`: 2K wipes the entire line including the sidebar columns, // `\x1b[2K`: 2K wipes the entire line including the sidebar columns,
// and if drawSidebar's chrome cache is fresh it won't repaint to // and if drawSidebar's chrome cache is fresh it won't repaint to
@@ -147,6 +148,7 @@ func (st *uiState) drawTabBar() {
// and content should be. // and content should be.
fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX", width) fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX", width)
fmt.Fprintf(&b, "\x1b[2;1H\x1b[%dX", width) fmt.Fprintf(&b, "\x1b[2;1H\x1b[%dX", width)
fmt.Fprintf(&b, "\x1b[3;1H\x1b[%dX", width)
for _, t := range tabs { for _, t := range tabs {
// Row 1: centre-ish label inside the tab cell. // Row 1: centre-ish label inside the tab cell.
@@ -170,9 +172,9 @@ func (st *uiState) drawTabBar() {
b.WriteString(strings.Repeat(" ", rightPad)) b.WriteString(strings.Repeat(" ", rightPad))
b.WriteString(styleReset) b.WriteString(styleReset)
// Row 2: underline. Thick accent for the active tab, faint // Row 3: underline. Thick accent for the active tab, faint
// border for the rest. // border for the rest.
fmt.Fprintf(&b, "\x1b[2;%dH", t.startCol) fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol)
if t.active { if t.active {
b.WriteString(styleAccent) b.WriteString(styleAccent)
b.WriteString(strings.Repeat("━", t.width)) b.WriteString(strings.Repeat("━", t.width))
@@ -189,10 +191,14 @@ func (st *uiState) drawTabBar() {
fmt.Fprintf(&b, "\x1b[1;%dH %s%s%s ", hintCol, styleDim, newHint, styleReset) fmt.Fprintf(&b, "\x1b[1;%dH %s%s%s ", hintCol, styleDim, newHint, styleReset)
// Underline continues faintly under the hint so the strip // Underline continues faintly under the hint so the strip
// reads as one bar. // reads as one bar.
fmt.Fprintf(&b, "\x1b[2;%dH%s%s%s", fmt.Fprintf(&b, "\x1b[3;%dH%s%s%s",
hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset) hintCol, styleBorder, strings.Repeat("─", newHintW), styleReset)
} }
if summary := st.activeSummaryText(width - 2); summary != "" {
fmt.Fprintf(&b, "\x1b[2;1H %s%s%s", styleDim, summary, styleReset)
}
frame := b.String() frame := b.String()
st.chromeCacheMu.Lock() st.chromeCacheMu.Lock()
if frame == st.tabBarCache { if frame == st.tabBarCache {

288
internal/app/toast.go Normal file
View File

@@ -0,0 +1,288 @@
package app
import (
"fmt"
"os"
"strings"
"sync"
)
// toastKind classifies a toast for styling and for migrating the
// pre-existing flashError / flashTransient / notifyAttention call
// sites onto the new stack.
type toastKind int
const (
toastInfo toastKind = iota
toastError
toastAttention
)
// toast is one entry in the host-level notification stack. Toasts
// persist until the user dismisses them with Ctrl-N or the
// "Clear notifications" palette command — there's no auto-expiry.
type toast struct {
id uint64
kind toastKind
text string
}
// toastStackCap caps how many toasts can be visible at once.
// Older entries drop off the bottom when a new push would exceed it.
const toastStackCap = 5
// toastBoxMaxWidth bounds the rendered box width so a wide pane
// doesn't produce huge toasts. Boxes shrink below this when the pane
// is narrow.
const toastBoxMaxWidth = 50
// toastBoxMinWidth is the floor below which we refuse to render —
// any narrower and there's not enough room for borders + content.
const toastBoxMinWidth = 20
// toastStack owns the ordered list of live toasts. Oldest at
// index 0, newest (visually topmost) at the end. The stack's own
// mutex is intentionally separate from uiState.mu so push / dismiss
// can be called from any goroutine without participating in the
// host's bigger lock-ordering rules.
type toastStack struct {
mu sync.Mutex
items []toast
next uint64
}
func (s *toastStack) push(kind toastKind, text string) {
s.mu.Lock()
defer s.mu.Unlock()
s.next++
s.items = append(s.items, toast{id: s.next, kind: kind, text: text})
if len(s.items) > toastStackCap {
s.items = s.items[len(s.items)-toastStackCap:]
}
}
// dismissTop pops the most recent toast (the one rendered at the
// top of the stack). Returns true if something was removed so
// callers can decide whether to repaint.
func (s *toastStack) dismissTop() bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
return false
}
s.items = s.items[:len(s.items)-1]
return true
}
func (s *toastStack) clear() bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
return false
}
s.items = s.items[:0]
return true
}
func (s *toastStack) snapshot() []toast {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.items) == 0 {
return nil
}
out := make([]toast, len(s.items))
copy(out, s.items)
return out
}
func (s *toastStack) length() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.items)
}
// notifyToast is the single entry point that the former flash
// helpers now delegate to. It pushes onto the stack and triggers a
// repaint of the focused surface so the new toast appears
// immediately; the repaint path also re-renders the stack on top.
func (st *uiState) notifyToast(kind toastKind, text string) {
st.toasts.push(kind, text)
st.refreshToastSurface()
}
// refreshToastSurface re-renders whatever surface the toasts are
// drawn over (focused child, focused pad, or the empty-state
// canvas). Each of those paths calls renderToasts at the end, so
// the toast layer is always reapplied on top of a freshly-drawn
// pane. Centralised so push / dismiss / clear share one code path.
func (st *uiState) refreshToastSurface() {
st.mu.Lock()
focusedPad := st.focusedPad
focusedID := st.focusedID
palOpen := st.palette != nil
st.mu.Unlock()
if palOpen {
// Palette owns the whole screen while it's open; toasts will
// repaint via closePalette's restore path.
return
}
switch {
case focusedPad != "":
st.repaintFocusedPad()
case focusedID != "":
st.repaintFocused()
default:
st.renderEmptyState()
}
}
// renderToasts draws the toast stack over the top-right of the
// focused pane. Called from repaintFocused / repaintFocusedPad /
// renderEmptyState after they finish so toasts always sit on top of
// freshly-redrawn pane content. Safe to call when the stack is
// empty (no-op).
func (st *uiState) renderToasts() {
items := st.toasts.snapshot()
if len(items) == 0 {
return
}
st.mu.Lock()
palOpen := st.palette != nil
st.mu.Unlock()
if palOpen {
return
}
layout := st.layoutSnapshot()
paneCols := int(layout.childCols())
paneRows := int(layout.childRows())
if paneCols < toastBoxMinWidth+2 || paneRows < 3 {
return
}
boxWidth := toastBoxMaxWidth
if max := paneCols - 4; max < boxWidth {
boxWidth = max
}
if boxWidth < toastBoxMinWidth {
return
}
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
var b strings.Builder
b.WriteString("\x1b7\x1b[?25l")
row := int(layout.mainTop) + 1
col := int(layout.mainLeft) + paneCols - boxWidth - 1
if col < int(layout.mainLeft) {
col = int(layout.mainLeft)
}
// Render newest first (visually on top), iterating items in
// reverse so the most recent push lands at the smallest row.
for idx := len(items) - 1; idx >= 0; idx-- {
t := items[idx]
isTopmost := idx == len(items)-1
hintLine := ""
if isTopmost && len(items) > 1 {
hintLine = fmt.Sprintf("Ctrl-N · %d more", len(items)-1)
}
height := 3
if hintLine != "" {
height++
}
// Stop if we'd run off the bottom of the pane.
if row+height > int(layout.mainTop)+paneRows {
break
}
border := toastBorderStyle(t.kind)
// Top border.
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("╭")
b.WriteString(strings.Repeat("─", boxWidth-2))
b.WriteString("╮")
b.WriteString(styleReset)
row++
// Content row.
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
b.WriteString(" ")
b.WriteString(toastIcon(t.kind))
body := t.text
bodyRoom := contentWidth - 2 // icon + space
if visibleLen(body) > bodyRoom {
body = clipRunes(body, bodyRoom-1) + "…"
}
b.WriteString(body)
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(body))))
b.WriteString(" ")
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
row++
// Hint row (topmost only, when stack has more than one).
if hintLine != "" {
if visibleLen(hintLine) > contentWidth {
hintLine = clipRunes(hintLine, contentWidth-1) + "…"
}
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
b.WriteString(" ")
b.WriteString(styleHint)
b.WriteString(hintLine)
b.WriteString(styleReset)
b.WriteString(strings.Repeat(" ", max(0, contentWidth-visibleLen(hintLine))))
b.WriteString(" ")
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
row++
}
// Bottom border.
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("╰")
b.WriteString(strings.Repeat("─", boxWidth-2))
b.WriteString("╯")
b.WriteString(styleReset)
row++
// 1-row gap between stacked toasts.
row++
}
b.WriteString("\x1b[?25h\x1b8")
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.WriteString(b.String())
}
func toastBorderStyle(kind toastKind) string {
switch kind {
case toastError:
return styleError
case toastAttention:
return styleAccent
default:
return styleBorder
}
}
func toastIcon(kind toastKind) string {
switch kind {
case toastError:
return styleError + "✗ " + styleReset
case toastAttention:
return styleAccent + "! " + styleReset
default:
return styleHint + "• " + styleReset
}
}

100
internal/app/toast_test.go Normal file
View File

@@ -0,0 +1,100 @@
package app
import "testing"
func TestToastStackPushAndOrder(t *testing.T) {
var s toastStack
s.push(toastInfo, "one")
s.push(toastError, "two")
s.push(toastAttention, "three")
snap := s.snapshot()
if len(snap) != 3 {
t.Fatalf("snapshot len = %d, want 3", len(snap))
}
if snap[0].text != "one" || snap[1].text != "two" || snap[2].text != "three" {
t.Fatalf("snapshot order wrong: %#v", snap)
}
if snap[0].kind != toastInfo || snap[1].kind != toastError || snap[2].kind != toastAttention {
t.Fatalf("snapshot kinds wrong: %#v", snap)
}
// IDs strictly increase.
if !(snap[0].id < snap[1].id && snap[1].id < snap[2].id) {
t.Fatalf("ids not increasing: %#v", snap)
}
}
func TestToastStackCapDropsOldest(t *testing.T) {
var s toastStack
for i := 0; i < toastStackCap+3; i++ {
s.push(toastInfo, "msg")
}
snap := s.snapshot()
if len(snap) != toastStackCap {
t.Fatalf("len = %d, want %d", len(snap), toastStackCap)
}
// The earliest IDs should have been dropped, leaving the highest
// toastStackCap IDs.
for i := 1; i < len(snap); i++ {
if snap[i].id <= snap[i-1].id {
t.Fatalf("ordering broken after cap: %#v", snap)
}
}
// First retained id should be 4 (1,2,3 dropped; cap=5 leaves 4..8).
want := uint64(toastStackCap + 3 - toastStackCap + 1)
if snap[0].id != want {
t.Fatalf("first retained id = %d, want %d", snap[0].id, want)
}
}
func TestToastStackDismissTop(t *testing.T) {
var s toastStack
if s.dismissTop() {
t.Fatalf("dismissTop on empty stack returned true")
}
s.push(toastInfo, "a")
s.push(toastError, "b")
if !s.dismissTop() {
t.Fatalf("dismissTop returned false with items present")
}
snap := s.snapshot()
if len(snap) != 1 || snap[0].text != "a" {
t.Fatalf("after dismissTop: %#v", snap)
}
if !s.dismissTop() {
t.Fatalf("dismissTop on last item returned false")
}
if s.length() != 0 {
t.Fatalf("length after final dismiss = %d, want 0", s.length())
}
}
func TestToastStackClear(t *testing.T) {
var s toastStack
if s.clear() {
t.Fatalf("clear on empty returned true")
}
s.push(toastInfo, "a")
s.push(toastError, "b")
s.push(toastAttention, "c")
if !s.clear() {
t.Fatalf("clear returned false with items present")
}
if s.length() != 0 {
t.Fatalf("length after clear = %d, want 0", s.length())
}
if snap := s.snapshot(); snap != nil {
t.Fatalf("snapshot after clear = %#v, want nil", snap)
}
}
func TestToastStackSnapshotIsCopy(t *testing.T) {
var s toastStack
s.push(toastInfo, "a")
snap := s.snapshot()
snap[0].text = "mutated"
again := s.snapshot()
if again[0].text != "a" {
t.Fatalf("snapshot is not an independent copy: %#v", again)
}
}

View File

@@ -16,7 +16,7 @@ func bytesRepeat(b byte, n int) []byte {
func TestViewportRendererShiftsCursor(t *testing.T) { func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40)) vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H"))) got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[3;1H" { if got != "\x1b[4;1H" {
t.Fatalf("CUP home: got %q", got) t.Fatalf("CUP home: got %q", got)
} }
} }
@@ -66,7 +66,7 @@ func TestViewportRendererSwallowsOriginModeToggles(t *testing.T) {
if !strings.Contains(got, "a") || !strings.Contains(got, "b") || !strings.Contains(got, "c") { if !strings.Contains(got, "a") || !strings.Contains(got, "b") || !strings.Contains(got, "c") {
t.Fatalf("origin-mode toggles should not drop surrounding text: got %q", got) t.Fatalf("origin-mode toggles should not drop surrounding text: got %q", got)
} }
if strings.Count(got, "\x1b[3;1H") != 2 { if strings.Count(got, "\x1b[4;1H") != 2 {
t.Fatalf("origin-mode set/reset should home inside the viewport twice: got %q", got) t.Fatalf("origin-mode set/reset should home inside the viewport twice: got %q", got)
} }
} }
@@ -88,23 +88,23 @@ func TestViewportRendererOriginModeCUPUsesScrollTop(t *testing.T) {
if strings.Contains(got, "\x1b[?6h") { if strings.Contains(got, "\x1b[?6h") {
t.Fatalf("origin-mode set leaked to host: %q", got) t.Fatalf("origin-mode set leaked to host: %q", got)
} }
if !strings.Contains(got, "\x1b[7;1H") { if !strings.Contains(got, "\x1b[8;1H") {
t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 7: got %q", got) t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 8: got %q", got)
} }
} }
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) { func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
// hostRows=7 leaves four viewport rows after the 2-row tab bar and // hostRows=7 leaves three viewport rows after the 3-row tab bar and
// 1-row status reservation. // 1-row status reservation.
vr := newViewportRenderer(newTerminalLayout(20, 7)) vr := newViewportRenderer(newTerminalLayout(20, 7))
got := string(vr.Render([]byte("\x1b[2J"))) got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") { if strings.Contains(got, "\x1b[2J") {
t.Fatalf("host clear-screen leaked through: %q", got) t.Fatalf("host clear-screen leaked through: %q", got)
} }
if strings.Count(got, "\x1b[20X") != 4 { if strings.Count(got, "\x1b[20X") != 3 {
t.Fatalf("clear rows: got %q", got) t.Fatalf("clear rows: got %q", got)
} }
if !strings.Contains(got, "\x1b[3;1H") || !strings.Contains(got, "\x1b[6;1H") { if !strings.Contains(got, "\x1b[4;1H") || !strings.Contains(got, "\x1b[6;1H") {
t.Fatalf("clear did not target viewport rows: %q", got) t.Fatalf("clear did not target viewport rows: %q", got)
} }
} }
@@ -140,13 +140,12 @@ func TestViewportRendererClearToEndIsViewportOnly(t *testing.T) {
t.Fatalf("host clear-to-end leaked through: %q", got) t.Fatalf("host clear-to-end leaked through: %q", got)
} }
// childCols == 19 (40 cols - 28 sidebar - 1 gap - 0-index fudge). // childCols == 19 (40 cols - 28 sidebar - 1 gap - 0-index fudge).
// Each of the 4 viewport rows should get a 19-cell erase.
// childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved). // childCols == 11 with hostCols=40 (28 sidebar + 1 gap reserved).
// 4 viewport rows, but the cursor row uses ECH at cursor (col 1), // 3 viewport rows, but the cursor row uses ECH at cursor (col 1),
// so we expect 4 erases of 11 cells each. // so we expect 3 erases of 11 cells each.
count := strings.Count(got, "\x1b[11X") count := strings.Count(got, "\x1b[11X")
if count != 4 { if count != 3 {
t.Fatalf("expected 4 ECH-11 sequences, got %d in %q", count, got) t.Fatalf("expected 3 ECH-11 sequences, got %d in %q", count, got)
} }
} }
@@ -182,7 +181,7 @@ func TestViewportRendererClampsCUPColumn(t *testing.T) {
// column so the host cursor never lands in the sidebar. // column so the host cursor never lands in the sidebar.
vr := newViewportRenderer(newTerminalLayout(120, 40)) vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95H"))) got := string(vr.Render([]byte("\x1b[5;95H")))
if !strings.Contains(got, "\x1b[7;91H") { if !strings.Contains(got, "\x1b[8;91H") {
t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got) t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got)
} }
} }
@@ -277,7 +276,7 @@ func TestViewportRendererFlagsScrollVerbs(t *testing.T) {
func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) { func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40)) vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[37;1H\n")) _ = vr.Render([]byte("\x1b[36;1H\n"))
if !vr.TookScrollAction() { if !vr.TookScrollAction() {
t.Fatalf("LF at viewport bottom should flag scroll") t.Fatalf("LF at viewport bottom should flag scroll")
} }
@@ -285,7 +284,7 @@ func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T)
func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) { func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40)) vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[36;1H\n")) _ = vr.Render([]byte("\x1b[35;1H\n"))
if vr.TookScrollAction() { if vr.TookScrollAction() {
t.Fatalf("LF before viewport bottom should not flag scroll") t.Fatalf("LF before viewport bottom should not flag scroll")
} }
@@ -312,7 +311,7 @@ func TestViewportRendererClampsCUUAtViewportTop(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40)) vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to viewport row 1 then CUU by 50. // CUP to viewport row 1 then CUU by 50.
got := string(vr.Render([]byte("\x1b[1;1H\x1b[50ACLOBBER"))) got := string(vr.Render([]byte("\x1b[1;1H\x1b[50ACLOBBER")))
if !strings.Contains(got, "\x1b[3;1H") { if !strings.Contains(got, "\x1b[4;1H") {
t.Fatalf("expected CUP shifted to mainTop: got %q", got) t.Fatalf("expected CUP shifted to mainTop: got %q", got)
} }
// The CUU should have been swallowed (n clamped to 0 from row 1). // The CUU should have been swallowed (n clamped to 0 from row 1).
@@ -339,10 +338,10 @@ func TestViewportRendererClampsCUUPartial(t *testing.T) {
} }
func TestViewportRendererClampsCUDAtViewportBottom(t *testing.T) { func TestViewportRendererClampsCUDAtViewportBottom(t *testing.T) {
// childRows=37 for layout(120, 40). Park cursor at row 37, ask for // childRows=36 for layout(120, 40). Park cursor at row 36, ask for
// 10 down → safe step is 0. // 10 down → safe step is 0.
vr := newViewportRenderer(newTerminalLayout(120, 40)) vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[37;1H\x1b[10B"))) got := string(vr.Render([]byte("\x1b[36;1H\x1b[10B")))
if strings.Contains(got, "\x1b[10B") { if strings.Contains(got, "\x1b[10B") {
t.Fatalf("CUD past viewport bottom should be dropped: got %q", got) t.Fatalf("CUD past viewport bottom should be dropped: got %q", got)
} }
@@ -363,10 +362,10 @@ func TestViewportRendererClampsCPLAndHomesColumn(t *testing.T) {
func TestViewportRendererClampsCNL(t *testing.T) { func TestViewportRendererClampsCNL(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40)) vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to row 35 then CNL by 50 → safe step is 2 (childRows-35). // CUP to row 34 then CNL by 50 → safe step is 2 (childRows-34).
got := string(vr.Render([]byte("\x1b[35;10H\x1b[50E"))) got := string(vr.Render([]byte("\x1b[34;10H\x1b[50E")))
if !strings.Contains(got, "\x1b[2E") { if !strings.Contains(got, "\x1b[2E") {
t.Fatalf("CNL 50 from row 35 should clamp to 2: got %q", got) t.Fatalf("CNL 50 from row 34 should clamp to 2: got %q", got)
} }
} }

View File

@@ -0,0 +1,37 @@
{
"name": "error_flash_preserves_focused_pane",
"presets": {
"processes": [
{
"name": "steady",
"argv": ["sh", "-lc", "printf 'STEADY READY\\n'; sleep 5"]
}
]
},
"trust": ["steady"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "steady", "name": "steady"},
"save_as": "proc"
},
{ "type": "wait_text", "contains": "STEADY READY", "timeout_ms": 5000 },
{ "type": "send_chord", "chord": "ctrl-k" },
{ "type": "send_text", "text": "Open Settings" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "no active top-level agent to summarize", "timeout_ms": 5000 },
{ "type": "wait_text", "contains": "STEADY READY", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "STEADY READY" },
{ "type": "assert_not_contains", "contains": "Press Ctrl-K to spawn an agent or process" }
]
}

View File

@@ -16,7 +16,7 @@
{ "type": "send_chord", "chord": "ctrl-k" }, { "type": "send_chord", "chord": "ctrl-k" },
{ "type": "send_text", "text": "Rename process" }, { "type": "send_text", "text": "Rename process" },
{ "type": "send_chord", "chord": "enter" }, { "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "Rename process", "timeout_ms": 3000 }, { "type": "wait_text", "contains": "process: original", "timeout_ms": 3000 },
{ "type": "send_chord", "chord": "ctrl-u" }, { "type": "send_chord", "chord": "ctrl-u" },
{ "type": "send_text", "text": "renamed-pane" }, { "type": "send_text", "text": "renamed-pane" },
{ "type": "send_chord", "chord": "enter" }, { "type": "send_chord", "chord": "enter" },

View File

@@ -5,7 +5,7 @@
"scripts": [ "scripts": [
{ {
"name": "linefeed-scroll", "name": "linefeed-scroll",
"body": "#!/bin/sh\n# Plain LF at the bottom of the child viewport scrolls the host's\n# DECSTBM region. Because that region spans every column, enough LFs\n# drag the sidebar border and section labels out of the visible region\n# unless patterm invalidates and repaints the sidebar cache.\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'warmup %02d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'LINEFEED READY\\n'\nIFS= read -r _\nprintf '\\033[1;37r'\nprintf '\\033[37;1H'\ni=0\nwhile [ $i -lt 45 ]; do\n printf 'scroll line %02d\\n' \"$i\"\n i=$((i + 1))\ndone\nprintf 'LINEFEED DONE\\n'\nsleep 5\n" "body": "#!/bin/sh\n# Plain LF at the bottom of the child viewport scrolls the host's\n# DECSTBM region. Because that region spans every column, enough LFs\n# drag the sidebar border and section labels out of the visible region\n# unless patterm invalidates and repaints the sidebar cache.\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'warmup %02d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'LINEFEED READY\\n'\nIFS= read -r _\nprintf '\\033[1;36r'\nprintf '\\033[36;1H'\ni=0\nwhile [ $i -lt 45 ]; do\n printf 'scroll line %02d\\n' \"$i\"\n i=$((i + 1))\ndone\nprintf 'LINEFEED DONE\\n'\nsleep 5\n"
} }
], ],
"steps": [ "steps": [
@@ -19,13 +19,13 @@
{ "type": "mark_raw", "save_as": "before_scroll" }, { "type": "mark_raw", "save_as": "before_scroll" },
{ "type": "send_chord", "chord": "enter" }, { "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "LINEFEED DONE", "timeout_ms": 5000 }, { "type": "wait_text", "contains": "LINEFEED DONE", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ {
"type": "assert_raw_since_regex", "type": "assert_raw_since_regex",
"from": "before_scroll", "from": "before_scroll",
"regex": "Agent Tree", "regex": "LINEFEED DONE",
"timeout_ms": 2000 "timeout_ms": 2000
}, },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" }, { "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "Agent Tree" }, { "type": "assert_contains", "contains": "Agent Tree" },
{ "type": "assert_contains", "contains": "Scratchpads" }, { "type": "assert_contains", "contains": "Scratchpads" },

View File

@@ -0,0 +1,32 @@
{
"name": "toast_dismiss",
"presets": {
"processes": [
{
"name": "steady",
"argv": ["sh", "-lc", "printf 'STEADY READY\\n'; sleep 30"]
}
]
},
"trust": ["steady"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "steady", "name": "steady"},
"save_as": "proc"
},
{ "type": "wait_text", "contains": "STEADY READY", "timeout_ms": 5000 },
{
"type": "mcp_call",
"method": "request_human_attention",
"params": {"process_id": "{{proc.process_id}}", "reason": "needs eyes on the deploy"}
},
{ "type": "wait_text", "contains": "needs eyes on the deploy", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "STEADY READY" },
{ "type": "send_chord", "chord": "ctrl-n" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "STEADY READY" },
{ "type": "assert_not_contains", "contains": "needs eyes on the deploy" }
]
}

View File

@@ -43,7 +43,7 @@ var serverInfo = map[string]any{
// up as sub-agents and won't be tied into the patterm lifecycle. // up as sub-agents and won't be tied into the patterm lifecycle.
// //
// Keep this short — clients vary in how much they surface to the LLM. // Keep this short — clients vary in how much they surface to the LLM.
const serverInstructions = "You are already running INSIDE patterm; the `patterm` MCP server is connected over the same stdio MCP transport you use for any other MCP server. Use the MCP tools you see in tools/list — do NOT (a) try to launch `patterm` or `patterm mcp-stdio` yourself, (b) poke the Unix socket through perl / nc / socat / curl, or (c) shell out to `claude` / `codex` / `opencode` to start a peer. Any of those bypasses caller-identity and the new agent will land as a stray top-level tab instead of a child under you. Start with `whoami` for your role and the full tool list, then `help('topics')` for orientation. `spawn_agent` is the only correct way to start a sub-agent; `spawn_process` is for non-LLM commands; `list_processes` / `get_process_output` inspect them; `send_input` / `send_message` drive them. Whatever you spawn is yours to `close_process` when done." const serverInstructions = "You are already running INSIDE patterm; the `patterm` MCP server is connected over the same stdio MCP transport you use for any other MCP server. Use the MCP tools you see in tools/list — do NOT (a) try to launch `patterm` or `patterm mcp-stdio` yourself, (b) poke the Unix socket through perl / nc / socat / curl, or (c) shell out to `claude` / `codex` / `opencode` to start a peer. Any of those bypasses caller-identity and the new agent will land as a stray top-level tab instead of a child under you. Start with `whoami` for your role and the full tool list, then `help('topics')` for orientation. `spawn_agent` is the only correct way to start a sub-agent; `spawn_process` is for non-LLM commands; `list_processes` / `get_process_output` inspect them; `send_input` / `send_message` drive them. Whatever you spawn is yours to `close_process` when done. When you `send_message` a sub-agent, its reply comes back into YOUR pane as `[sub-agent:<name>] …`, not into the sub-agent's output — to wait for it, use `timer_fire_when_idle_any([sub_agent])` and then read your own pane; do NOT `wait_for_pattern` on the sub-agent, that will deadlock until timeout."
// toolDescriptor is the shape returned by `tools/list`. inputSchema is // toolDescriptor is the shape returned by `tools/list`. inputSchema is
// a JSON Schema object — we provide a minimal `{type: "object"}` schema // a JSON Schema object — we provide a minimal `{type: "object"}` schema
@@ -219,7 +219,7 @@ func toolCatalog() []toolDescriptor {
}, },
{ {
Name: "wait_for_pattern", Name: "wait_for_pattern",
Description: "Block until pattern appears in process output or timeout elapses.", Description: "Block until pattern appears in the TARGET process's own output, or timeout elapses. Use this for waiting on text the target itself will emit (a shell prompt, a build's \"tests passed\" line, etc.). Anti-pattern: do NOT use this to wait for a sub-agent's reply to send_message — replies are routed into the CALLER's pane tagged `[sub-agent:<name>]`, not into the sub-agent's output, so this call will spin to timeout. For sub-agent coordination use `timer_fire_when_idle_any` and then read your own pane.",
InputSchema: objectSchema(map[string]any{ InputSchema: objectSchema(map[string]any{
"process_id": stringProp("Target process id."), "process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."), "pattern": stringProp("Regex pattern."),
@@ -249,7 +249,7 @@ func toolCatalog() []toolDescriptor {
}, },
{ {
Name: "send_message", Name: "send_message",
Description: "Deliver a text message to another process as orchestrator-owned input.", Description: "Deliver a text message to another process as orchestrator-owned input. Fire-and-forget: returns immediately, without waiting for the recipient to read or act. If the recipient replies via send_message, that reply arrives in YOUR pane tagged `[sub-agent:<name>]` (child→parent) or `[orchestrator]` (parent→child) — NOT in the recipient's output. To wait for a sub-agent's reply, schedule `timer_fire_when_idle_any([sub_agent_id], body=…)` and then read your own pane when the timer fires. Do not `wait_for_pattern` on the recipient for a reply; it will deadlock.",
InputSchema: objectSchema(map[string]any{ InputSchema: objectSchema(map[string]any{
"target_process_id": stringProp("Recipient process id."), "target_process_id": stringProp("Recipient process id."),
"message": stringProp("Message body."), "message": stringProp("Message body."),
@@ -283,7 +283,7 @@ func toolCatalog() []toolDescriptor {
}, },
{ {
Name: "timer_fire_when_idle_any", Name: "timer_fire_when_idle_any",
Description: "Schedule a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.", Description: "Canonical way to wait for a sub-agent to finish working: send_message the sub-agent, then schedule this with watched=[sub_agent_id]; when it fires, the reply is already sitting in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
InputSchema: objectSchema(map[string]any{ InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."), "watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."), "body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
@@ -294,7 +294,7 @@ func toolCatalog() []toolDescriptor {
}, },
{ {
Name: "timer_fire_when_idle_all", Name: "timer_fire_when_idle_all",
Description: "Schedule a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.", Description: "Canonical way to wait for several sub-agents to finish working in parallel: send_message each one, then schedule this with watched=[…ids]; when it fires, each reply is in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
InputSchema: objectSchema(map[string]any{ InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."), "watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."), "body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),