15 Commits

Author SHA1 Message Date
ef9b8e71c6 Release v0.0.5
All checks were successful
release / build-linux-amd64 (push) Successful in 11m51s
2026-05-15 20:56:38 +01:00
e64060e40f Calm down the focused-section labels in the command palette
Focused-section rows are now bare verbs (Rename, Close, Stop, Restart,
Delete, Edit) instead of repeating the focused name. The title bar
already carries the subject, and the row hint preserves fuzzy-search
matches like "close codex". Section banners are replaced by a single
blank spacer row so the verbs themselves carry the visual weight,
and the Open section no longer lists "Switch to <current>" for the
pane that's already focused.
2026-05-15 20:30:31 +01:00
e4ab8c2136 Merge pull request 'Add stackable toast notifications' (#5) from worktree-toast-notifications into main 2026-05-15 20:28:10 +01:00
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
26 changed files with 3561 additions and 538 deletions

View File

@@ -11,14 +11,19 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: jdx/mise-action@v2
with:
go-version-file: go.mod
cache: true
- uses: mlugg/setup-zig@v1
- name: Cache Go modules
uses: actions/cache@v4
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
run: make deps

View File

@@ -3,6 +3,8 @@
# 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
# 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]
zig = "0.15.2"
go = "1.26.3"

View File

@@ -6,7 +6,123 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.0.5] - 2026-05-15
### 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.
- Command palette is calmer when something is focused. Focused-section
rows now read as bare verbs (`Rename`, `Close`, `Stop`, `Restart`,
`Delete`, `Edit`) instead of repeating the focused name (`Close
agent: codex`); the title bar's `on: codex` / `pad: notes.md`
carries the subject. Fuzzy queries still match the dropped context
through the row hint (e.g. typing `close codex` still finds the
Close row).
- Dashed `── Focused ──` / `── Open ──` / `── Spawn ──` section
banners are gone. Sections are separated by a single blank spacer
row, so the action labels themselves carry the visual weight.
- The Open section no longer lists a `Switch to <current>` row for
the pane you're already focused on.
## [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
- 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
@@ -20,6 +136,25 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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

184
TODO.md
View File

@@ -1,184 +0,0 @@
# Perf Audit (auto-generated 2026-05-15)
Findings from a codebase sweep — not user-reported, needs review before
action. Each item names the anchor and a sketched fix.
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 {
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
// can read/write into it. SPEC §3.
@@ -169,7 +173,24 @@ func Run(ctx context.Context, opts Options) error {
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
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)
host.attention = st
host.focus = st
@@ -177,6 +198,7 @@ func Run(ctx context.Context, opts Options) error {
host.scratch = st
st.lastExit.Store(-1)
sess.Subscribe(st)
go st.summaries.run(ctx)
st.enterScreen()
st.renderEmptyState()
@@ -398,7 +420,6 @@ type uiState struct {
// switch resets the offset cleanly.
padOffsetName string
// activeAgentID tracks which top-level agent tab "owns" the agent
// 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
@@ -411,10 +432,11 @@ type uiState struct {
repaintNextPTY string
repaintNextPTYBudget int
// attention is the latest request_human_attention surfaced via MCP;
// rendered in the status line until cleared.
attentionText string
attentionAt string
// toasts is the stackable notification surface. flashError,
// flashTransient, and notifyAttention all push onto it; the user
// dismisses entries with Ctrl-N or the "Clear notifications"
// palette command.
toasts toastStack
// pendingTrust is the most recent trust prompt — surfaced in the
// status line until the user resolves it with Ctrl-K. v1 keeps the
@@ -432,6 +454,12 @@ type uiState struct {
// check on the disabled path.
metrics *metricsTracker
settingsMu sync.Mutex
settings settings
settingsPath string
ctx context.Context
summaries *summaryManager
// chromeCacheMu guards the last-rendered byte cache for each chrome
// element. The tab bar, sidebar, and status line all repaint on
// many state changes and on every PTY chunk, but their content
@@ -478,6 +506,33 @@ func (st *uiState) dbgf(format string, args ...any) {
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
// to spawn / start / restart against an untrusted command preset and
// the host wants user confirmation before the next attempt succeeds.
@@ -668,20 +723,15 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
}
// notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most
// recent ask so the status line keeps showing it. The sidebar-blink is
// deferred until the §4 chrome lands.
// push a toast onto the stack; the focused-pane render path picks it
// up. The sidebar-blink is deferred until the §4 chrome lands.
func (st *uiState) notifyAttention(childID, reason string) {
c := st.sess.FindChild(childID)
name := childID
if c != nil {
name = c.DisplayName()
}
st.mu.Lock()
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
st.attentionAt = childID
st.mu.Unlock()
st.drawStatusLine()
st.notifyToast(toastAttention, fmt.Sprintf("%s — %s", name, reason))
}
func (st *uiState) scratchpadsChanged() {
@@ -700,8 +750,29 @@ 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) {
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()
onAlt := childIsOnAlt(c)
@@ -763,6 +834,9 @@ func (st *uiState) OnChildStateChanged(string, IdleState) {
// OnChildExited drops focus and shows the empty state if it was the
// focused child.
func (st *uiState) OnChildExited(c *Child) {
if st.summaries != nil {
st.summaries.UnregisterChild(c.ID)
}
st.lastExit.Store(int32(c.ExitCode()))
st.marquee.reset()
layout := st.layoutSnapshot()
@@ -850,6 +924,9 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
if st.metrics != nil {
entry = time.Now()
}
if st.summaries != nil {
st.summaries.ObserveOutput(childID)
}
layout := st.layoutSnapshot()
st.mu.Lock()
focus := st.focusedID
@@ -1086,8 +1163,6 @@ func (st *uiState) drawStatusLine() {
palOpen := st.palette != nil
focusID := st.focusedID
focusName := st.focusedName
attention := st.attentionText
attentionAt := st.attentionAt
var trustMsg string
if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
@@ -1127,13 +1202,6 @@ func (st *uiState) drawStatusLine() {
left = owner
}
}
if attention != "" && attentionAt == focusID {
left = "[!] " + attention
}
if attention != "" && attentionAt == "" {
// Sticky attention/flash from somewhere outside the focused pane.
left = "[!] " + attention
}
if trustMsg != "" {
left = "[trust] " + trustMsg
}
@@ -1189,8 +1257,6 @@ func (st *uiState) drawStatusLine() {
// child is focused.
func (st *uiState) renderEmptyState() {
layout := st.layoutSnapshot()
st.outMu.Lock()
defer st.outMu.Unlock()
line := "Press Ctrl-K to spawn an agent or process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
@@ -1200,7 +1266,10 @@ func (st *uiState) renderEmptyState() {
if 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)
st.outMu.Unlock()
st.renderToasts()
}
func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
@@ -1331,6 +1400,7 @@ func (st *uiState) processStdin(chunk []byte) {
var pendingViewportBottom bool
var pendingPadStep int
var pendingPadExit bool
var pendingDismissToast bool
flushForward := func() {
if len(forward) == 0 {
@@ -1343,6 +1413,9 @@ func (st *uiState) processStdin(chunk []byte) {
// writes so claude / codex / opencode don't treat a
// "text\r" batch as a paste.
_ = c.InjectAsUser(forward)
if st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward)
}
if prev != OwnerUser {
go st.drawStatusLine()
}
@@ -1514,6 +1587,11 @@ func (st *uiState) processStdin(chunk []byte) {
} else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'w'); 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 {
i++
continue
@@ -1612,6 +1690,22 @@ func (st *uiState) processStdin(chunk []byte) {
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
// active area. Use this as the escape hatch from a scrolled-up
// state — wheel scrolls move the viewport into the libghostty
@@ -1693,6 +1787,11 @@ func (st *uiState) processStdin(chunk []byte) {
if pendingPadExit {
st.exitPadView()
}
if pendingDismissToast {
if st.toasts.dismissTop() {
st.refreshToastSurface()
}
}
}
// scrollFocusedViewport scrolls the focused child's emulator viewport by
@@ -1745,7 +1844,10 @@ func (st *uiState) scrollFocusedViewportToBottom() {
}
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
// stack so palette input arrives in plain legacy form regardless of
// what the focused child pushed. Codex/ratatui enables kitty mode
@@ -1898,6 +2000,11 @@ func (st *uiState) closePalette(action paletteAction) {
case "quit":
st.requestExit()
case "toasts-clear":
if st.toasts.clear() {
st.refreshToastSurface()
}
case "pad-delete":
st.handlePadDelete(action.padName)
@@ -1918,9 +2025,85 @@ func (st *uiState) closePalette(action paletteAction) {
case "proc-restart":
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) {
if name == "" || st.pads == nil {
st.repaintFocused()
@@ -2098,28 +2281,18 @@ func (st *uiState) handleProcRestart(childID string) {
st.drawStatusLine()
}
// flashError surfaces a spawn/etc. failure in the status line until the
// next attention update overwrites it. stderr is hidden under the alt
// screen so we can't rely on Fprintln(os.Stderr).
// flashError surfaces a spawn/etc. failure as an error toast over the
// focused pane. stderr is hidden under the alt screen so we can't rely
// on Fprintln(os.Stderr).
func (st *uiState) flashError(msg string) {
st.mu.Lock()
st.attentionText = msg
st.attentionAt = "" // shows on every focus until cleared
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
st.notifyToast(toastError, msg)
}
// 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) {
st.mu.Lock()
st.attentionText = msg
st.attentionAt = ""
st.mu.Unlock()
st.drawStatusLine()
st.notifyToast(toastInfo, msg)
}
// repaintFocused redraws the current focused child's screen snapshot.
@@ -2163,8 +2336,9 @@ func (st *uiState) repaintFocused() {
}
st.mu.Unlock()
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
}
// repaintFocusedPad paints the focused scratchpad's content into the
@@ -2188,8 +2362,9 @@ func (st *uiState) repaintFocusedPad() {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
}
// renderPadView builds the bytes that paint a scratchpad's content

View File

@@ -1135,8 +1135,9 @@ func helpFor(topic string) mcp.HelpResponse {
case "coordination":
return mcp.HelpResponse{
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.",
RelatedTools: []string{"send_message", "request_human_attention"},
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" +
"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":
return mcp.HelpResponse{
@@ -1162,8 +1163,13 @@ func helpFor(topic string) mcp.HelpResponse {
case "readiness":
return mcp.HelpResponse{
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.",
RelatedTools: []string{"wait_for_pattern", "get_process_status"},
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" +
"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":
return mcp.HelpResponse{

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -31,14 +31,17 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
if i, _ := findItem(p, "pad-delete"); i != 0 {
t.Fatalf("pad-delete at %d; want top", i)
// With the dashed section header gone, pad-edit is the first row;
// pad-rename-form follows, with destructive pad-delete last in the
// Focused section.
if i, _ := findItem(p, "pad-edit"); i != 0 {
t.Fatalf("pad-edit at %d; want 0", i)
}
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)
}
if _, it := findItem(p, "pad-edit"); it == nil {
t.Fatalf("pad-edit missing")
if i, _ := findItem(p, "pad-delete"); i < 0 {
t.Fatalf("pad-delete missing")
}
// No focused child → no agent/proc context items.
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
@@ -81,8 +84,11 @@ func TestContextItemsProcess(t *testing.T) {
}
func TestContextItemsAppearAboveSwitch(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand)
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
// Two children so there's still a non-focused switch entry to compare
// against (the focused child is suppressed from the Open section).
focused := makeFakeChild("pid", "devserver", KindCommand)
other := makeFakeChild("oid", "worker", KindCommand)
p := newPalette([]*Child{focused, other}, "pid", "", preset.Set{})
procIdx, _ := findItem(p, "proc-rename-form")
switchIdx, _ := findItem(p, "switch")
if procIdx < 0 || switchIdx < 0 {

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) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
if p.cursor != 0 {
t.Fatalf("initial cursor %d", p.cursor)
first := firstSelectable(p)
if first < 0 || p.cursor != first {
t.Fatalf("initial cursor %d, want first selectable %d", p.cursor, first)
}
// Kitty functional Down arrow.
_, _, adv := p.handleInput([]byte("\x1b[57353u"), 0)
if adv != 8 {
t.Fatalf("advance %d", adv)
}
if p.cursor != 1 {
t.Fatalf("cursor %d after Down, want 1", p.cursor)
if p.cursor != first+1 {
t.Fatalf("cursor %d after Down, want %d", p.cursor, first+1)
}
// Kitty functional Up arrow.
_, _, _ = p.handleInput([]byte("\x1b[57352u"), 0)
if p.cursor != 0 {
t.Fatalf("cursor %d after Up, want 0", p.cursor)
if p.cursor != first {
t.Fatalf("cursor %d after Up, want %d", p.cursor, first)
}
}
func TestPaletteLegacyArrowsStillWork(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", "", preset.Set{Agents: pr})
first := firstSelectable(p)
_, _, adv := p.handleInput([]byte("\x1b[B"), 0)
if adv != 3 {
t.Fatalf("advance %d", adv)
}
if p.cursor != 1 {
t.Fatalf("cursor %d, want 1", p.cursor)
if p.cursor != first+1 {
t.Fatalf("cursor %d, want %d", p.cursor, first+1)
}
}

View File

@@ -0,0 +1,408 @@
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 TestPaletteSectionsSeparatedBySpacers(t *testing.T) {
// Section-named dashed headers are gone; groups are visually
// separated by a single non-selectable blank row. Verify that the
// build emits one such spacer between every pair of adjacent groups
// and never a leading spacer.
c := makeFakeChild("a", "claude", KindAgent)
other := makeFakeChild("b", "worker", KindCommand)
p := newPalette([]*Child{c, other}, "a", "",
preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
if len(p.items) == 0 {
t.Fatalf("palette built no items")
}
if p.items[0].action.kind == "header" {
t.Fatalf("first row is a spacer; should be a selectable item")
}
transitions := 0
prevGroup := p.items[0].group
for i := 1; i < len(p.items); i++ {
it := p.items[i]
if it.group != prevGroup {
if it.action.kind != "header" || it.label != "" {
t.Fatalf("group transition at %d not a blank spacer: %+v", i, it)
}
transitions++
// The row immediately after the spacer must be selectable.
if i+1 >= len(p.items) || p.items[i+1].action.kind == "header" {
t.Fatalf("spacer at %d not followed by selectable row", i)
}
prevGroup = p.items[i+1].group
}
// No dashed banners anywhere.
if it.action.kind == "header" && strings.Contains(it.label, "──") {
t.Errorf("dashed section header still present at %d: %q", i, it.label)
}
}
if transitions == 0 {
t.Fatalf("no section transitions found in palette items")
}
}
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

@@ -331,6 +331,16 @@ func (st *uiState) drawSidebar() {
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)
}
}
// Scratchpads list — names only. The preview pane used to live
// here and clobbered the main viewport when content overflowed the
// rail. Focus moves to a pad via Ctrl+W/S; the content renders in
@@ -390,3 +400,42 @@ func (st *uiState) drawSidebar() {
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
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"
)
// 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.
const tabBarRows = 2
const tabBarRows = 3
// drawTabBar renders the top tab strip across the full host width.
// Tabs share the available width with a flex layout — each visible
@@ -139,7 +139,8 @@ func (st *uiState) drawTabBar() {
}
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
// `\x1b[2K`: 2K wipes the entire line including the sidebar columns,
// 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.
fmt.Fprintf(&b, "\x1b[1;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 {
// Row 1: centre-ish label inside the tab cell.
@@ -170,9 +172,9 @@ func (st *uiState) drawTabBar() {
b.WriteString(strings.Repeat(" ", rightPad))
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.
fmt.Fprintf(&b, "\x1b[2;%dH", t.startCol)
fmt.Fprintf(&b, "\x1b[3;%dH", t.startCol)
if t.active {
b.WriteString(styleAccent)
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)
// Underline continues faintly under the hint so the strip
// 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)
}
if summary := st.activeSummaryText(width - 2); summary != "" {
fmt.Fprintf(&b, "\x1b[2;1H %s%s%s", styleDim, summary, styleReset)
}
frame := b.String()
st.chromeCacheMu.Lock()
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) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
if got != "\x1b[3;1H" {
if got != "\x1b[4;1H" {
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") {
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)
}
}
@@ -88,23 +88,23 @@ func TestViewportRendererOriginModeCUPUsesScrollTop(t *testing.T) {
if strings.Contains(got, "\x1b[?6h") {
t.Fatalf("origin-mode set leaked to host: %q", got)
}
if !strings.Contains(got, "\x1b[7;1H") {
t.Fatalf("CUP row 1 in origin mode should land at scrollTop row 5 shifted to host row 7: got %q", got)
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 8: got %q", got)
}
}
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.
vr := newViewportRenderer(newTerminalLayout(20, 7))
got := string(vr.Render([]byte("\x1b[2J")))
if strings.Contains(got, "\x1b[2J") {
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)
}
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)
}
}
@@ -140,13 +140,12 @@ func TestViewportRendererClearToEndIsViewportOnly(t *testing.T) {
t.Fatalf("host clear-to-end leaked through: %q", got)
}
// 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).
// 4 viewport rows, but the cursor row uses ECH at cursor (col 1),
// so we expect 4 erases of 11 cells each.
// 3 viewport rows, but the cursor row uses ECH at cursor (col 1),
// so we expect 3 erases of 11 cells each.
count := strings.Count(got, "\x1b[11X")
if count != 4 {
t.Fatalf("expected 4 ECH-11 sequences, got %d in %q", count, got)
if count != 3 {
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.
vr := newViewportRenderer(newTerminalLayout(120, 40))
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)
}
}
@@ -277,7 +276,7 @@ func TestViewportRendererFlagsScrollVerbs(t *testing.T) {
func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[37;1H\n"))
_ = vr.Render([]byte("\x1b[36;1H\n"))
if !vr.TookScrollAction() {
t.Fatalf("LF at viewport bottom should flag scroll")
}
@@ -285,7 +284,7 @@ func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T)
func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[36;1H\n"))
_ = vr.Render([]byte("\x1b[35;1H\n"))
if vr.TookScrollAction() {
t.Fatalf("LF before viewport bottom should not flag scroll")
}
@@ -312,7 +311,7 @@ func TestViewportRendererClampsCUUAtViewportTop(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to viewport row 1 then CUU by 50.
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)
}
// 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) {
// 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.
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") {
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) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to row 35 then CNL by 50 → safe step is 2 (childRows-35).
got := string(vr.Render([]byte("\x1b[35;10H\x1b[50E")))
// CUP to row 34 then CNL by 50 → safe step is 2 (childRows-34).
got := string(vr.Render([]byte("\x1b[34;10H\x1b[50E")))
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_text", "text": "Rename process" },
{ "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_text", "text": "renamed-pane" },
{ "type": "send_chord", "chord": "enter" },

View File

@@ -5,7 +5,7 @@
"scripts": [
{
"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": [
@@ -19,13 +19,13 @@
{ "type": "mark_raw", "save_as": "before_scroll" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "LINEFEED DONE", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{
"type": "assert_raw_since_regex",
"from": "before_scroll",
"regex": "Agent Tree",
"regex": "LINEFEED DONE",
"timeout_ms": 2000
},
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "Agent Tree" },
{ "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.
//
// 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
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
@@ -219,7 +219,7 @@ func toolCatalog() []toolDescriptor {
},
{
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{
"process_id": stringProp("Target process id."),
"pattern": stringProp("Regex pattern."),
@@ -249,7 +249,7 @@ func toolCatalog() []toolDescriptor {
},
{
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{
"target_process_id": stringProp("Recipient process id."),
"message": stringProp("Message body."),
@@ -283,7 +283,7 @@ func toolCatalog() []toolDescriptor {
},
{
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{
"watched": arrayOfStringsProp("Process ids to watch."),
"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",
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{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),