20 Commits

Author SHA1 Message Date
fe25fcf043 Release v0.0.7
All checks were successful
release / build-linux-amd64 (push) Successful in 12m5s
2026-05-18 13:02:46 +01:00
2fa00ad510 Show idle state in the top tab bar
Each agent tab now prefixes its label with the same one-rune idle
indicator the sidebar uses (✕ error, ? permission, ◐ thinking, ○ idle,
● working), so the state of every open agent is visible without
opening or focusing each tab. Tab redraws now fire on idle-state
changes in addition to sidebar redraws.
2026-05-18 13:02:35 +01:00
34b41be1df Cancel pending timers when a child is closed
Stale timer bodies were re-delivered to the orchestrator pane after
the parent had already processed the sub-agent's reply and called
close_process. The timer registry held no link to the child
lifecycle, so timers owned by or watching the closed child lingered
until something triggered a fire — e.g. a trailing classifier tick
for the now-removed child.

Add an OnChildClosed hook to ChildEventListener, emit it from
Session.Close (and the terminal-corpse path in reapChild), and have
the timer manager prune the registry: cancel timers owned by the
closed child; remove the closed child from each timer's watched
list (cancel the timer outright when watched empties).

Natural exit deliberately does not route through this hook — the
classifier already emits an idle transition on exit which delivers
any legitimate "fire when sub-agent finishes" semantics exactly
once; cancelling on exit would swallow that.
2026-05-18 12:37:32 +01:00
de60b93bc6 Use built-in agent preset defaults 2026-05-18 11:28:00 +01:00
67b994f629 Clean up auto-summary settings menu 2026-05-18 10:17:25 +01:00
f10598601f Finish settings TODO cleanup 2026-05-18 10:05:26 +01:00
cadd4c8f64 Release v0.0.6
All checks were successful
release / build-linux-amd64 (push) Successful in 11m48s
2026-05-15 21:55:09 +01:00
98d1c059cf summarizer tweaks 2026-05-15 21:54:14 +01:00
cf65d5d707 Wrap toast bodies, slim the dismiss hint, and stop flicker
Toasts now render three content rows with word-wrapped bodies. The
in-toast "Ctrl-N · N more" hint is replaced by a short
"Ctrl-N · dismiss" entry on the status strip that only appears
while a notification is live.

The box stops flickering while the focused child repaints its TUI:
the overlay is stitched onto the per-chunk PTY write under outMu
and bracketed by DECSET 2026 so supporting terminals buffer the
child's redraw and the box paint into a single frame.
2026-05-15 21:24:18 +01:00
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
36 changed files with 3256 additions and 480 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,6 +6,135 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.0.7] - 2026-05-18
### Added
- The top tab bar now prefixes each agent tab's label with its
idle-state glyph (✕ error, ? permission, ◐ thinking, ○ idle, ●
working), matching the sidebar's vocabulary so the state of every
open agent is visible without opening or focusing each tab.
### Changed
- Built-in agent presets (`claude`, `codex`, `opencode`) now live in
memory and user preset files merge over them by name instead of
patterm writing default preset files into `$XDG_CONFIG_HOME`. Add
`"disabled": true` in a matching user preset to hide a built-in.
- Generated MCP config files for agent launches now live under the
runtime agent directory instead of `$XDG_CONFIG_HOME/patterm/mcp`.
- Auto-summarization settings now save as soon as a changed row is
applied, including cadence/provider/toggle changes and model edits,
without requiring a separate save step.
- The Agents / Auto-summarization settings screen no longer shows
explicit Save, Cancel, or Back rows, and its footer copy no longer
describes a separate save/cancel flow.
- Auto-summarization setting rows now visually separate grey labels
from regular-colour values.
- The active-thread summary in the tab bar is now constrained to the
active tab's width instead of spanning the whole top row.
- Sidebar summary text now wraps from the full summary text instead of
using an ellipsized single-line value.
### Fixed
- Claude permission prompts are now detected from the rendered pane as
well as the recent output tail, so the sidebar marks the pane as
waiting for permission even while `Calling patterm...` continues to
repaint.
- Removed the redundant "Back to Settings" row from the
Agents / Auto-summarization settings screen.
- Pending `timer_*` entries are now cancelled when their owning or
watched child is closed via `close_process`, preventing stale
timer bodies from being re-delivered to the orchestrator pane
after the work has already been handled.
## [0.0.6] - 2026-05-15
### Changed
- Toast notifications now reserve three content rows and word-wrap
the message body inside the box, replacing the previous
single-line+ellipsis layout. The `Ctrl-N · N more` inline hint is
gone; instead the host status strip surfaces a `Ctrl-N · dismiss`
hint, shown only while a notification is on screen so the chord
doesn't advertise itself when it has nothing to dismiss.
### Fixed
- Auto-summary no longer fails immediately with `codex summarizer:
error: unexpected argument '--ask-for-approval' found`. The codex
CLI dropped that flag; we now rely on `--sandbox read-only` (which
already implies no approvals) instead of passing it.
- Toast box no longer flickers / half-erases while the focused
child (claude, codex, opencode, etc.) repaints its TUI. The
overlay is now stitched onto the end of the per-chunk PTY write
under `outMu`, and wrapped in DECSET 2026 (synchronized output)
brackets so terminals that support it batch the child's redraw +
the box paint into a single frame instead of racing cell-by-cell.
## [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 ──`)
@@ -47,6 +176,15 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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

23
SPEC.md
View File

@@ -39,7 +39,7 @@ The tool is one Go process that owns: the TUI, all PTYs, vt-emulated grids, sess
## 3. Project state layout
Scratchpads (user data) live under `$XDG_DATA_HOME`; presets and config live under `$XDG_CONFIG_HOME`.
Scratchpads (user data) live under `$XDG_DATA_HOME`; user-authored preset overlays and config live under `$XDG_CONFIG_HOME`.
```
$XDG_DATA_HOME/patterm/
@@ -53,12 +53,12 @@ $XDG_DATA_HOME/patterm/
└── <agent-written>.md
$XDG_CONFIG_HOME/patterm/
├── config.json # global settings (theme, default keymap, etc.)
├── settings.json # global settings, written only after the user changes settings
└── presets/
├── agents/
│ ├── claude.json # ships as default
│ ├── codex.json # ships as default
│ ├── opencode.json # ships as default
│ ├── claude.json # optional overlay for built-in claude
│ ├── codex.json # optional overlay for built-in codex
│ ├── opencode.json # optional overlay for built-in opencode
│ └── <user-defined>.json
└── processes/
├── dev.json # e.g. { "name": "bun run dev", "argv": ["bun", "run", "dev"] }
@@ -66,7 +66,7 @@ $XDG_CONFIG_HOME/patterm/
└── <user-defined>.json
```
Both preset directories are scanned at startup; every file found becomes a palette entry ("Spawn agent: claude", "Run process: bun run dev", …). Presets are project-agnostic in v1 — the same set is available in every project. Per-project overrides can be added later.
patterm always has built-in agent presets for `claude`, `codex`, and `opencode`. User preset files are scanned at startup and merged into matching built-ins by `name`, or added as standalone custom presets when the name is new. A matching file with `"disabled": true` hides a built-in. Startup does not write default preset files. Presets are project-agnostic in v1 — the same set is available in every project. Per-project overrides can be added later.
Project key = `sha256(realpath(project_dir))[:16]`. Used only as a scratchpad directory name — there is no daemon to look up.
@@ -121,7 +121,7 @@ Scratchpads and command-preset trust grants persist across runs. Sessions and ch
Almost all application functions are driven through a single command palette opened with `Ctrl-K`. The palette is a fuzzy-searchable list of commands, scoped to whatever makes sense for the current focus. Two kinds of entries appear:
- **Built-in commands** — "Switch to session…", "Focus pane…", "Take input control", "Release control to orchestrator", "Open scratchpad…", "Kill child…", "Quit", etc.
- **Preset commands** — one entry per file under `$XDG_CONFIG_HOME/patterm/presets/`. Agent presets surface as "Spawn agent: codex" / "Spawn agent: claude" / …; process presets surface as "Run process: bun run dev" / "Run process: vitest" / …. The label comes from the preset's `name` field; the action is "launch this preset into a new pane."
- **Preset commands** — one entry per built-in or user-defined preset. Agent presets surface as "Spawn agent: codex" / "Spawn agent: claude" / …; process presets surface as "Run process: bun run dev" / "Run process: vitest" / …. The label comes from the preset's `name` field; the action is "launch this preset into a new pane."
Selecting a preset either launches it immediately (no required args) or opens a sub-palette for optional args — namely an **initial prompt** (agent presets only), which patterm injects into the spawned PTY's input after the agent is ready (§8). The orchestrator equivalent of this — `spawn_agent` / `spawn_process` MCP tools — uses the exact same machinery: pick a preset by name, optionally supply an initial prompt, patterm handles the rest.
@@ -365,11 +365,11 @@ Risks acknowledged: the orchestrator's reading of the prompt is a vision/parsing
## 10. Presets
Presets are user-editable JSON files that describe how to launch something. patterm itself has no hard-coded agent or process types — every spawnable thing is a preset. Two flavours:
Presets describe how to launch something. patterm has built-in defaults for common agent CLIs, and user-editable JSON files can override, disable, or add presets. Two flavours:
### Agent presets
`$XDG_CONFIG_HOME/patterm/presets/agents/<name>.json`. Launches a vendor LLM CLI with MCP wired up and the conversation-protocol addendum injected.
Built-in agent presets launch vendor LLM CLIs with MCP wired up and the conversation-protocol addendum injected. `$XDG_CONFIG_HOME/patterm/presets/agents/<name>.json` can overlay a built-in by `name` or define a new agent preset.
| Field | Purpose |
|---|---|
@@ -377,17 +377,18 @@ Presets are user-editable JSON files that describe how to launch something. patt
| `argv` | Full launch argv (e.g. `["claude"]`, `["codex", "--no-tui-banner"]`) |
| `env` | Env vars to set (merged over inherited env) |
| `working_dir` | Defaults to the project root |
| `disabled` | If `true`, hides a built-in preset with the same `name` |
| `mcp_injection` | How to point this CLI at patterm's stdio proxy. One of: `{ "kind": "flag", "flag": "--mcp-config", "config_path": "..." }`, `{ "kind": "config_file", "path": "~/.codex/config.toml", "merge_key": "mcp_servers" }`, `{ "kind": "env_var", "var": "MCP_CONFIG_PATH" }` |
| `ready_signal` | How to detect the TUI is ready (default: 1s idle after launch). Override per-CLI if needed. |
| `chrome_trim_hints` | Optional regexes / row ranges for stripping vendor chrome in grid reads |
Default presets shipped: `claude`, `codex`, `opencode`. Authoring these is per-vendor research — each CLI has its own MCP config conventions, ready states, and TUI chrome. Users can copy and edit them, or add new ones (e.g. a second `claude` preset that launches with a specific model or system prompt file).
Built-in presets: `claude`, `codex`, `opencode`. Authoring these is per-vendor research — each CLI has its own MCP config conventions, ready states, idle detection, and TUI chrome. Users can add small overlay files for built-ins, disable built-ins, or add new presets (e.g. a second `claude-sonnet` preset that launches with a specific model or system prompt file).
MCP config flow: at startup, for each agent preset, patterm renders a small JSON pointing at its own `mcp-stdio` proxy subcommand (`patterm mcp-stdio --socket <pid-sock> --identity <token>`) into a per-preset temp file. The launch then uses the preset's `mcp_injection` strategy to hand that path to the CLI. The user's global vendor config is never mutated.
### Process presets
`$XDG_CONFIG_HOME/patterm/presets/processes/<name>.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt.
`$XDG_CONFIG_HOME/patterm/presets/processes/<name>.json`. Launches a raw command in a PTY — no MCP, no addendum, no system prompt. There are no built-in process presets.
| Field | Purpose |
|---|---|

115
TODO.md
View File

@@ -1,115 +0,0 @@
# Perf Audit (reviewed 2026-05-15)
Findings that survived the 2026-05-15 review pass. Low and marginal
items from the original sweep were removed; remaining items have enough
measured or workflow evidence to justify action.
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
```
The current pipeline still has large 120 fps headroom. The remaining
renderer concern is multi-MiB styled replay latency and allocation
churn, not normal steady-state frame budget.
- [ ] **viewport renderer allocates heavily on SGR/CSI-heavy chunks.** [MEDIUM]
- Review evidence: five benchmark reps confirmed
`ViewportRenderer_StyledLines` at about 4,325 allocs per 16 KiB
chunk (~91.5 KB/op, roughly 1 alloc per 3.8 input bytes), and
`ViewportRenderer_RatatuiBurst` at about 17,306 allocs per chunk
(~365 KB/op). A 5 MiB styled resume benchmark allocated about
31 MB across 1.38M objects.
- Likely hot paths: generic CSI/SGR output in
`internal/app/viewport_renderer.go` sends many sequences through
`vr.shifter.Shift(vr.buf)`, while `internal/app/cursorshift.go`
returns a fresh `[]byte` via `pending.String()` on every
`Shift` call and parses CSI params through `string(raw)` /
`strings.Split`. The mode-helper `string(params)` conversions
are real, but probably not the main SGR-heavy cost.
- Fix direction: make `cursorShifter` write into caller-owned
scratch output or directly into the viewport renderer's pending
builder; parse CSI params from byte slices; pre-grow/reuse
renderer and shifter buffers. Re-run styled-lines, ratatui, and
5 MiB resume benchmarks; use pprof when available to confirm the
top allocation sites.
- [ ] **large styled resume/replay dumps spend visible time in viewport rendering.** [MEDIUM]
- Review evidence: `BenchmarkSessionResume_5MiBStyled` measured
about 58 ms median and 63 ms p95 over five reps. The plain 5 MiB
benchmark was about 23-24 ms with only 21 allocs. The live path
renders focused PTY chunks through `renderer.Render`, then still
pays emulator writes, ring writes, event dispatch, stdout writes,
and real terminal paint.
- Scope: this is not a Codex steady-state throughput limit. A
100 KB/s stream is far below the styled renderer's ~80-90 MB/s
ceiling. It matters for multi-MiB burst replay, resume/startup
dumps, and dense full-screen churn.
- Fix direction: do the allocation fix first, since it should also
improve throughput. After that, invest further only if styled
resume traces remain user-visible or the styled-lines benchmark
is still under roughly 300 MB/s.
- [ ] **wait_for_pattern re-scans the entire stream/grid while waiting.** [MEDIUM]
- `internal/app/host.go:476-493` (the `check` closure). On
`scope="scrollback"` it calls `c.StreamRead(0)` followed by
`stripANSIBytes(nil, b)`, so each check can copy, strip, and
search the full 1 MiB ring. On `scope="grid"` it calls
`PlainText()` and runs the regex against the full grid string.
- Caveat from review: the current chunk notifier coalesces bursts
with a buffered channel and has a 500 ms fallback, so this is not
necessarily one full scan per PTY chunk. It is still meaningful
for active waits on chatty panes.
- Fix direction: for `scrollback`, track the last checked stream
offset and search only new output plus a bounded overlap/scratch
buffer so matches spanning chunks are not missed. For `grid`,
dedupe on `ScreenVersion()` and skip work when the version has
not changed.
- [ ] **search_output rebuilds and searches whole scrollback on every call.** [MEDIUM]
- `internal/app/host.go:428-437` compiles a fresh regex, reads the
stream from offset 0, strips ANSI for `kind="rendered"`, converts
the full buffer to a string, and splits it into lines before
applying `limit`. This is meaningful when agents poll the same
pattern; it is low impact for ad hoc searches.
- Fix direction: cache compiled regexes by pattern; cache stripped
rendered output by child id and stream end offset; avoid
`strings.Split` over the whole ring when only the first `limit`
matches are needed. Prefer an incremental search shape if this
becomes the standard "watch for marker" path.
# 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

@@ -1,61 +0,0 @@
claude + new │ Processes
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────│ ─────────────────────────
- abc1234 if no tag exists yet
4. Wire version into the release workflow
Update .gitea/workflows/release.yml lines 31-35 to inject the pushed tag:
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o dist/patterm-${{ github.ref_name }}-linux-amd64 \
./cmd/patterm
github.ref_name is the tag name (e.g. v0.0.1) because the workflow only
triggers on tags: ['v*'].
5. Update inline doc comment
cmd/patterm/main.go header comment (lines 5-11) — add the --version form
to the usage block. SPEC.md/CLAUDE.md already use --, no change needed there.
Out of scope
- Surfacing version in MCP whoami (the hardcoded "version": "0.1.0" in
internal/mcp/protocol.go:27 is the MCP protocol version, not the patterm
binary version — leave it).
- Renaming any existing flags.
- Adding short forms like -p for --project.
Critical files
- cmd/patterm/main.go — import swap, --version wiring, version var, header comment
- cmd/patterm/debug_harness.go — import swap
- Makefile lines 38-39 — VERSION var + ldflags
- .gitea/workflows/release.yml lines 31-35 — ldflags
- go.mod / go.sum — add github.com/spf13/pflag
Verification
1. go build -o ./bin/patterm ./cmd/patterm (without Makefile) → still builds, version reports dev.
2. make patterm → ./bin/patterm --version prints patterm v0.0.1 (commit <sha>, built <date>).
3. ./bin/patterm -h → help text shows --project string and --version lines.
4. ./bin/patterm -project /tmp → pflag rejects with usage error (confirms -- is enforced).
5. ./bin/patterm --project /tmp → starts normally.
6. ./bin/patterm mcp-stdio --socket /tmp/s --identity x → existing path still works (will fail to connect, but should parse flags fine).
7. ./bin/patterm debug-harness --scenario internal/harness/scenarios/spawn_process_via_palette.json → harness still runs.
8. go test ./... and go test ./internal/harness/... — both green.
9. Push a temporary tag locally and inspect git describe output; confirm release workflow's ${{ github.ref_name }} substitution matches the tag.
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
Claude has written up a plan and is ready to execute. Would you like to proceed?
1. Yes, and use auto mode
2. Yes, manually approve edits
3. No, refine with Ultraplan on Claude Code on the web
4. Tell Claude what to change
shift+tab to approve with this feedback
ctrl-g to edit in VS Code · ~/.claude/plans/flags-in-this-project-vectorized-gosling.md
claude · you have control Ctrl-A/D · tabs · Ctrl-W/S · tree · Ctrl-K · palette

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.
@@ -158,18 +162,33 @@ func Run(ctx context.Context, opts Options) error {
go sess.runClassifier(ctx)
st := &uiState{
sess: sess,
presets: presets,
launcher: launcher,
pads: pads,
chromeWake: make(chan struct{}, 1),
trust: trustStore,
timers: host.timers,
hostCols: cols,
hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
metrics: metrics,
sess: sess,
presets: presets,
launcher: launcher,
pads: pads,
chromeWake: make(chan struct{}, 1),
trust: trustStore,
timers: host.timers,
hostCols: cols,
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))
}
})
sess.SetMetrics(metrics)
host.attention = st
host.focus = st
@@ -177,6 +196,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 +418,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 +430,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 +452,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 +504,41 @@ func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...)
}
func (st *uiState) activeSummaryText(width int) string {
text := st.activeSummaryRaw()
if text == "" || width <= 0 {
return ""
}
if visibleLen(text) > width {
text = clipRunes(text, width-1) + "…"
}
return text
}
func (st *uiState) activeSummaryRaw() string {
if 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 ""
}
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 +729,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() {
@@ -707,6 +763,9 @@ func (st *uiState) scratchpadsChanged() {
// 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 {
@@ -770,17 +829,27 @@ func (st *uiState) OnChildSpawned(c *Child) {
st.drawStatusLine()
}
// OnChildStateChanged repaints the sidebar whenever a child's
// idle-state badge flips. Cheap — the badge is the only chrome that
// reflects state today, and drawSidebar bails when the cached frame
// hasn't changed.
// OnChildStateChanged repaints the sidebar and tab bar whenever a
// child's idle-state badge flips. Cheap — both draws bail when the
// cached frame hasn't changed.
func (st *uiState) OnChildStateChanged(string, IdleState) {
st.drawTabBar()
st.drawSidebar()
}
// OnChildClosed is the explicit-removal hook (close_process or the
// terminal-corpse cleanup in reapChild). The UI already reflects
// removals via the OnChildExited path and the children-map view, so
// this is a no-op here — the timerManager is the consumer that
// cares.
func (st *uiState) OnChildClosed(string) {}
// 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()
@@ -868,6 +937,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
@@ -910,14 +982,19 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.metrics.recordRender(time.Since(rstart))
}
}
// One write covers the autowrap-disable prelude, the chunk, and the
// autowrap-restore postlude — three syscalls collapsed into one
// under outMu. The three sequences were already emitted atomically
// under the lock; coalescing just halves the syscall count.
wrapped := make([]byte, 0, len(out)+10)
// One write covers the autowrap-disable prelude, the chunk, the
// autowrap-restore postlude, and (when a toast is up) the toast
// overlay — four syscalls collapsed into one under outMu. The
// sequences were already emitted atomically under the lock;
// coalescing just halves the syscall count and makes claude's
// continuous redraws + our toast layer land in the same frame so
// the box doesn't flicker as the child paints over its cells.
overlay := st.toastOverlayBytes()
wrapped := make([]byte, 0, len(out)+len(overlay)+10)
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
wrapped = append(wrapped, overlay...)
var wstart time.Time
if st.metrics != nil {
wstart = time.Now()
@@ -1104,8 +1181,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)
@@ -1145,13 +1220,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
}
@@ -1169,6 +1237,12 @@ func (st *uiState) drawStatusLine() {
hints = append(hints, "Ctrl-R · restart")
}
}
// Surface the toast-dismiss chord only while a notification is on
// screen — the hint is noise otherwise, and Ctrl-N falls through
// to the focused PTY when the stack is empty.
if st.toasts.length() > 0 {
hints = append(hints, "Ctrl-N · dismiss")
}
right := strings.Join(hints, " · ")
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
hints = hints[1:]
@@ -1207,8 +1281,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)
@@ -1218,7 +1290,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) {
@@ -1349,6 +1424,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 {
@@ -1361,6 +1437,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()
}
@@ -1532,6 +1611,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
@@ -1557,6 +1641,11 @@ func (st *uiState) processStdin(chunk []byte) {
adv = 1
}
i += adv
if action.kind == "settings-save" {
st.applySettingsAction(action)
st.renderPaletteLocked()
continue
}
if done {
a := action
pendingAction = &a
@@ -1630,6 +1719,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
@@ -1711,6 +1816,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
@@ -1763,7 +1873,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
@@ -1916,6 +2029,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)
@@ -1936,9 +2054,78 @@ func (st *uiState) closePalette(action paletteAction) {
case "proc-restart":
st.handleProcRestart(action.childID)
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()
@@ -2116,28 +2303,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.
@@ -2181,8 +2358,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
@@ -2206,8 +2384,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

@@ -50,8 +50,14 @@ func (s *Session) classifyOne(c *Child) {
idleMS := c.IdleMS()
titleIdleMS := c.TitleIdleMS()
title := c.Title()
tail := c.tailBytes(classifierTailBytes)
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail)
tail := stripANSIBytes(nil, c.tailBytes(classifierTailBytes))
var screen []byte
if em := c.Emulator(); em != nil {
if txt, err := em.ScreenText(); err == nil {
screen = []byte(txt)
}
}
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail, screen)
if c.setIdleState(state, reason) {
s.emitStateChanged(c.ID, state)
}

View File

@@ -111,6 +111,13 @@ func (d *debugCapture) OnChildStateChanged(id string, state IdleState) {
})
}
func (d *debugCapture) OnChildClosed(id string) {
d.writeEvent("child_closed", map[string]any{
"time": time.Now().Format(time.RFC3339Nano),
"id": id,
})
}
func (d *debugCapture) OnPTYOut(childID string, chunk []byte) {
if len(chunk) == 0 {
return

View File

@@ -86,10 +86,10 @@ func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, pres
return h
}
// timerListenerAdapter forwards OnChildStateChanged into the timer
// manager and ignores the other ChildEventListener methods. The
// session's listener API is by-interface, so we wrap the manager
// rather than make it implement the full surface.
// timerListenerAdapter forwards OnChildStateChanged and OnChildClosed
// into the timer manager and ignores the other ChildEventListener
// methods. The session's listener API is by-interface, so we wrap
// the manager rather than make it implement the full surface.
type timerListenerAdapter struct{ m *timerManager }
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
@@ -98,6 +98,9 @@ func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
a.m.onChildStateChanged(id, st)
}
func (a timerListenerAdapter) OnChildClosed(id string) {
a.m.onChildClosed(id)
}
func (h *toolHost) SetSize(cols, rows uint16) {
h.sizeMu.Lock()
@@ -553,6 +556,7 @@ func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
}
}
func (n *chunkNotifier) OnChildStateChanged(string, IdleState) {}
func (n *chunkNotifier) OnChildClosed(string) {}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)

View File

@@ -118,7 +118,8 @@ func compilePatterns(ps []string) []*regexp.Regexp {
// - titleIdleMS: ms since the last OSC title change (0 if no title yet)
// - title: current OSC title
// - tail: recent output bytes for regex matching
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail []byte) (IdleState, string) {
// - screen: current rendered screen text for persistent prompt matching
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail, screen []byte) (IdleState, string) {
if exited {
if exitNonZero {
return StateError, "process exited non-zero"
@@ -128,14 +129,14 @@ func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titl
if cfg == nil {
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
}
if len(tail) > 0 {
if matchAny(cfg.errorRegexes, tail) {
if len(tail) > 0 || len(screen) > 0 {
if matchAny(cfg.errorRegexes, tail, screen) {
return StateError, "error regex matched"
}
if matchAny(cfg.permissionRegexes, tail) {
if matchAny(cfg.permissionRegexes, tail, screen) {
return StatePermission, "permission regex matched"
}
if matchAny(cfg.thinkingRegexes, tail) {
if matchAny(cfg.thinkingRegexes, tail, screen) {
return StateThinking, "thinking regex matched"
}
}
@@ -172,10 +173,12 @@ func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) {
return StateIdle, "quiet for threshold"
}
func matchAny(res []*regexp.Regexp, tail []byte) bool {
func matchAny(res []*regexp.Regexp, texts ...[]byte) bool {
for _, re := range res {
if re.Match(tail) {
return true
for _, text := range texts {
if len(text) > 0 && re.Match(text) {
return true
}
}
}
return false

View File

@@ -30,7 +30,7 @@ func TestClassifyOutputActivity(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil)
got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil, nil)
if got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
@@ -41,18 +41,18 @@ func TestClassifyOutputActivity(t *testing.T) {
func TestClassifyTitleStability(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000}
// Title change recent → working.
if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil); got != StateWorking {
if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil, nil); got != StateWorking {
t.Fatalf("recent title change: got %q", got)
}
// Title stable past threshold → idle.
if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil); got != StateIdle {
if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil, nil); got != StateIdle {
t.Fatalf("stable title: got %q", got)
}
// No title yet: fall back to output activity.
if got, _ := classify(cfg, false, false, 100, 0, "", nil); got != StateWorking {
if got, _ := classify(cfg, false, false, 100, 0, "", nil, nil); got != StateWorking {
t.Fatalf("no title yet, recent output: got %q", got)
}
if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle {
if got, _ := classify(cfg, false, false, 5000, 0, "", nil, nil); got != StateIdle {
t.Fatalf("no title yet, output idle: got %q", got)
}
}
@@ -67,46 +67,51 @@ func TestClassifyTitleStatus(t *testing.T) {
"error": StateError,
},
}
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking {
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil, nil); got != StateThinking {
t.Fatalf("thinking title: got %q", got)
}
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil); got != StatePermission {
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil, nil); got != StatePermission {
t.Fatalf("permission title: got %q", got)
}
// No match in map → fall back to stability.
if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil); got != StateIdle {
if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil, nil); got != StateIdle {
t.Fatalf("unmatched title, stable: got %q", got)
}
}
func TestClassifyPromoterRegex(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOutputActivity,
idleThresholdMS: 2000,
permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)},
errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)},
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)},
strategy: StrategyOutputActivity,
idleThresholdMS: 2000,
permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)},
errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)},
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)},
}
// Permission promoter beats idle.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); got != StatePermission {
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]"), nil); got != StatePermission {
t.Fatalf("permission promoter: got %q", got)
}
// Error trumps permission.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?")); got != StateError {
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?"), nil); got != StateError {
t.Fatalf("error promoter beats permission: got %q", got)
}
// Thinking promoter on idle output.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…")); got != StateThinking {
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…"), nil); got != StateThinking {
t.Fatalf("thinking promoter: got %q", got)
}
// Rendered-screen prompts still promote even when the raw tail no
// longer contains the original prompt bytes.
if got, _ := classify(cfg, false, false, 100, 0, "", []byte("Calling patterm..."), []byte("Approve? [y/n]")); got != StatePermission {
t.Fatalf("screen permission promoter: got %q", got)
}
}
func TestClassifyExitTerminal(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
if got, _ := classify(cfg, true, true, 0, 0, "", nil); got != StateError {
if got, _ := classify(cfg, true, true, 0, 0, "", nil, nil); got != StateError {
t.Fatalf("non-zero exit: got %q", got)
}
if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle {
if got, _ := classify(cfg, true, false, 0, 0, "", nil, nil); got != StateIdle {
t.Fatalf("clean exit: got %q", got)
}
}

View File

@@ -261,15 +261,11 @@ func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir
}
func (l *Launcher) writeMCPConfig(identity string) (string, error) {
dir, err := preset.ConfigDir()
dir, err := mcpRuntimeDir(identity)
if err != nil {
return "", err
}
dir = filepath.Join(dir, "mcp")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
path := filepath.Join(dir, identity+".json")
path := filepath.Join(dir, "mcp.json")
cfg := map[string]any{
"mcpServers": map[string]any{
"patterm": map[string]any{

View File

@@ -0,0 +1,30 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestWriteMCPConfigUsesRuntimeDir(t *testing.T) {
runtimeDir := t.TempDir()
configHome := filepath.Join(t.TempDir(), "config")
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
t.Setenv("XDG_CONFIG_HOME", configHome)
l := &Launcher{bin: "patterm", mcpSocket: "/tmp/patterm.sock"}
path, err := l.writeMCPConfig("abc123")
if err != nil {
t.Fatalf("writeMCPConfig: %v", err)
}
if !strings.HasPrefix(path, filepath.Join(runtimeDir, "patterm", "agents", "abc123")) {
t.Fatalf("path = %q, want under runtime dir", path)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("config file stat: %v", err)
}
if _, err := os.Stat(filepath.Join(configHome, "patterm")); !os.IsNotExist(err) {
t.Fatalf("writeMCPConfig created XDG config dir or unexpected stat error: %v", err)
}
}

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)
}
}

View File

@@ -37,6 +37,9 @@ type paletteAction struct {
// For *-rename-submit actions, the user-typed new name.
newName string
// For settings actions, the updated settings snapshot to persist.
settings *settings
}
// Group ids order the section bands the palette renders when no query
@@ -47,16 +50,10 @@ const (
groupFocused = iota
groupOpen
groupSpawn
groupSettings
groupQuit
)
var groupLabels = map[int]string{
groupFocused: "Focused",
groupOpen: "Open",
groupSpawn: "Spawn",
groupQuit: "Quit",
}
type paletteItem struct {
label string
hint string
@@ -77,6 +74,9 @@ const (
paletteModePicker paletteMode = iota
paletteModeSpawnForm
paletteModeRenameForm
paletteModeSettings
paletteModeAutoSummary
paletteModeSettingsInput
)
// spawnProcessForm is the state for the "Spawn process…" two-field
@@ -101,6 +101,12 @@ type renameForm struct {
subjectLine string // e.g. "scratchpad: notes.md" rendered above the input
}
type settingsInputForm struct {
title string
field string
value []rune
}
// paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct {
@@ -110,12 +116,14 @@ type paletteState struct {
focused string
focusedPad string
presets preset.Set
settings settings
items []paletteItem
mode paletteMode
form *spawnProcessForm
renameForm *renameForm
mode paletteMode
form *spawnProcessForm
renameForm *renameForm
settingsInput *settingsInputForm
// showHelp swaps the item list for a static keybinding cheat-sheet
// until the next keystroke. Toggled by `?` in picker mode.
@@ -171,8 +179,12 @@ func findChildByID(children []*Child, id string) *Child {
return nil
}
func newPalette(children []*Child, focused, focusedPad string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets}
func newPalette(children []*Child, focused, focusedPad string, presets preset.Set, appSettings ...settings) *paletteState {
st := defaultSettings()
if len(appSettings) > 0 {
st = appSettings[0].clone()
}
p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets, settings: st}
p.rebuild()
return p
}
@@ -184,8 +196,10 @@ func (p *paletteState) rebuild() {
all := p.buildItems(macro)
if rest == "" {
// No textual filter: render with section headers between groups.
p.items = itemsWithHeaders(all)
// No textual filter: render with blank spacer rows between
// groups so sections read as scannable bands without dashed
// headers stealing visual weight.
p.items = itemsWithSpacers(all)
p.clampCursor()
return
}
@@ -222,25 +236,28 @@ func (p *paletteState) rebuild() {
}
// buildItems assembles every selectable row in fixed group order
// (Focused → Open → Spawn → Quit). Headers are added by
// itemsWithHeaders for the no-query case; scored mode drops them.
// (Focused → Open → Spawn → Quit). Blank spacer rows are added by
// itemsWithSpacers for the no-query case; scored mode drops them.
// When macro is non-empty the result is filtered down to the kinds
// that macro retains.
func (p *paletteState) buildItems(macro string) []paletteItem {
var out []paletteItem
// Group 0: Focused — context-aware actions for whatever owns focus.
// A focused scratchpad shadows any focused child.
// A focused scratchpad shadows any focused child. Labels are bare
// verbs because the title bar already carries the subject ("on:
// codex" / "pad: notes.md"); the noun + name move into the hint so
// fuzzy queries like "close codex" still surface the row.
switch {
case p.focusedPad != "":
name := p.focusedPad
out = append(out,
paletteItem{label: "Delete scratchpad: " + name, hint: "remove the file from disk",
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
paletteItem{label: "Rename scratchpad: " + name, hint: "inline rename · enter to commit",
action: paletteAction{kind: "pad-rename-form", padName: name}, group: groupFocused},
paletteItem{label: "Edit scratchpad: " + name, hint: "open in external editor (zed)",
paletteItem{label: "Edit", hint: "edit scratchpad · " + name + " (opens $EDITOR)",
action: paletteAction{kind: "pad-edit", padName: name}, group: groupFocused},
paletteItem{label: "Rename", hint: "rename scratchpad · " + name,
action: paletteAction{kind: "pad-rename-form", padName: name}, group: groupFocused},
paletteItem{label: "Delete", hint: "delete scratchpad · " + name,
action: paletteAction{kind: "pad-delete", padName: name}, group: groupFocused},
)
case p.focused != "":
if c := findChildByID(p.children, p.focused); c != nil {
@@ -248,40 +265,39 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
switch c.Kind {
case KindAgent:
out = append(out,
paletteItem{label: "Rename agent: " + name, hint: "inline rename · enter to commit",
paletteItem{label: "Rename", hint: "rename agent · " + name,
action: paletteAction{kind: "agent-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Close agent: " + name, hint: "SIGTERM " + strings.Join(c.Argv, " "),
paletteItem{label: "Close", hint: "close agent · " + name + " (SIGTERM)",
action: paletteAction{kind: "agent-close", childID: c.ID}, group: groupFocused},
)
default:
out = append(out,
paletteItem{label: "Rename process: " + name, hint: "inline rename · enter to commit",
paletteItem{label: "Rename", hint: "rename process · " + name,
action: paletteAction{kind: "proc-rename-form", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete process: " + name, hint: "remove entry; SIGKILL if alive",
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
paletteItem{label: "Stop process: " + name, hint: "SIGTERM · keep entry for restart",
paletteItem{label: "Stop", hint: "stop process · " + name + " (SIGTERM, keeps entry)",
action: paletteAction{kind: "proc-stop", childID: c.ID}, group: groupFocused},
paletteItem{label: "Restart process: " + name, hint: "SIGTERM then start with same argv",
paletteItem{label: "Restart", hint: "restart process · " + name,
action: paletteAction{kind: "proc-restart", childID: c.ID}, group: groupFocused},
paletteItem{label: "Delete", hint: "delete process · " + name + " (SIGKILL if alive)",
action: paletteAction{kind: "proc-delete", childID: c.ID}, group: groupFocused},
)
}
}
}
// Group 1: Open — switch entries for every running child. Dead
// Group 1: Open — switch entries for every running child *other than*
// the one already focused (no point offering a no-op switch). Dead
// agents are filtered out (no restart path); dead command processes
// remain so they can be restarted. The currently-focused child is
// marked with a leading ▶ instead of the older "• … (current)" suffix
// so the row reads cleaner.
// remain so they can be restarted.
for _, c := range p.children {
if c.ID == p.focused {
continue
}
if c.Kind == KindAgent && c.Status() != StatusRunning {
continue
}
label := "Switch to " + c.DisplayName()
hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "▶ " + label
}
if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]"
}
@@ -325,7 +341,21 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
group: groupSpawn,
})
// Group 3: Quit.
// Group 3: Settings.
out = append(out, paletteItem{
label: "Open Settings",
hint: "configure agents and auto-summary",
action: paletteAction{kind: "settings-open"},
group: groupSettings,
})
out = append(out, paletteItem{
label: "Clear notifications",
hint: "dismiss all toasts in the top-right of the focused pane",
action: paletteAction{kind: "toasts-clear"},
group: groupSettings,
})
// Group 4: Quit.
out = append(out, paletteItem{
label: "Quit",
hint: "exit patterm; SIGTERM every child",
@@ -349,9 +379,11 @@ func (p *paletteState) buildItems(macro string) []paletteItem {
return out
}
// itemsWithHeaders splices a non-selectable header row in front of
// each new group so the (unfiltered) list reads as scannable bands.
func itemsWithHeaders(items []paletteItem) []paletteItem {
// itemsWithSpacers splices a non-selectable blank row between groups
// so the (unfiltered) list reads as scannable bands without dashed
// section headers stealing weight from the actions themselves. The
// first group never gets a leading spacer.
func itemsWithSpacers(items []paletteItem) []paletteItem {
if len(items) == 0 {
return nil
}
@@ -359,16 +391,13 @@ func itemsWithHeaders(items []paletteItem) []paletteItem {
currentGroup := -1
for _, it := range items {
if it.group != currentGroup {
currentGroup = it.group
label, ok := groupLabels[it.group]
if !ok {
label = ""
if currentGroup != -1 {
result = append(result, paletteItem{
action: paletteAction{kind: "header"},
group: it.group,
})
}
result = append(result, paletteItem{
label: "── " + label + " ──",
action: paletteAction{kind: "header"},
group: it.group,
})
currentGroup = it.group
}
result = append(result, it)
}
@@ -519,6 +548,15 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
if p.mode == paletteModeRenameForm {
return p.handleRenameInput(chunk, i)
}
if p.mode == paletteModeSettings {
return p.handleSettingsInput(chunk, i)
}
if p.mode == paletteModeAutoSummary {
return p.handleAutoSummaryInput(chunk, i)
}
if p.mode == paletteModeSettingsInput {
return p.handleSettingsTextInput(chunk, i)
}
b := chunk[i]
@@ -602,6 +640,12 @@ func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
return paletteAction{}, false, adv
case "settings-open":
p.mode = paletteModeSettings
p.query = nil
p.cursor = 0
p.rebuildSettings()
return paletteAction{}, false, adv
case "pad-rename-form":
p.enterRenameForm("pad", a.padName, a.padName, "scratchpad: "+a.padName)
return paletteAction{}, false, adv
@@ -1112,6 +1156,415 @@ func (p *paletteState) focusedSubject() string {
return ""
}
func (p *paletteState) rebuildSettings() {
items := []paletteItem{{
label: "Agents / Auto-summarization",
hint: "provider, models, cadence, test",
action: paletteAction{kind: "settings-auto-summary"},
group: groupSettings,
}}
q := strings.TrimSpace(strings.ToLower(string(p.query)))
if q == "" {
p.items = items
p.cursor = 0
return
}
p.items = p.items[:0]
for _, it := range items {
if strings.Contains(strings.ToLower(it.label+" "+it.hint), q) {
p.items = append(p.items, it)
}
}
p.clampCursor()
}
func (p *paletteState) handleSettingsInput(chunk []byte, i int) (paletteAction, bool, int) {
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
switch final {
case 'A':
p.cursorUp()
case 'B':
p.cursorDown()
}
return paletteAction{}, false, n
}
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
if len(p.items) == 0 {
return paletteAction{}, false, 1
}
a := p.items[p.cursor].action
if a.kind == "settings-auto-summary" {
p.mode = paletteModeAutoSummary
p.cursor = 0
return paletteAction{}, false, 1
}
case 0x7f, 0x08:
p.backspace()
p.rebuildSettings()
case 0x15:
p.query = nil
p.rebuildSettings()
case 0x0e:
p.cursorDown()
case 0x10:
p.cursorUp()
default:
if b >= 0x20 && b < 0x7f {
p.query = append(p.query, rune(b))
p.rebuildSettings()
}
}
return paletteAction{}, false, 1
}
func (p *paletteState) handleAutoSummaryInput(chunk []byte, i int) (paletteAction, bool, int) {
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
switch final {
case 'A':
p.cursor--
if p.cursor < 0 {
p.cursor = len(autoSummaryRows()) - 1
}
case 'B':
p.cursor++
if p.cursor >= len(autoSummaryRows()) {
p.cursor = 0
}
}
return paletteAction{}, false, n
}
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
return p.activateAutoSummaryRow()
case 0x0e:
p.cursor++
case 0x10:
p.cursor--
}
if p.cursor < 0 {
p.cursor = len(autoSummaryRows()) - 1
}
if p.cursor >= len(autoSummaryRows()) {
p.cursor = 0
}
return paletteAction{}, false, 1
}
func (p *paletteState) handleSettingsTextInput(chunk []byte, i int) (paletteAction, bool, int) {
if p.settingsInput == nil {
p.mode = paletteModeAutoSummary
return paletteAction{}, false, 1
}
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
return paletteAction{}, false, n
}
p.mode = paletteModeAutoSummary
return paletteAction{}, false, 1
}
switch b {
case '\r', '\n':
changed := p.applySettingsInput()
p.mode = paletteModeAutoSummary
if changed {
return p.settingsAction("settings-save"), false, 1
}
case 0x7f, 0x08:
if len(p.settingsInput.value) > 0 {
p.settingsInput.value = p.settingsInput.value[:len(p.settingsInput.value)-1]
}
case 0x15:
p.settingsInput.value = nil
default:
if b >= 0x20 && b < 0x7f {
p.settingsInput.value = append(p.settingsInput.value, rune(b))
}
}
return paletteAction{}, false, 1
}
type autoSummaryRow struct {
key string
label string
}
func autoSummaryRows() []autoSummaryRow {
return []autoSummaryRow{
{key: "enabled", label: "Enabled"},
{key: "provider", label: "Provider"},
{key: "codex_model", label: "Codex model"},
{key: "opencode_model", label: "OpenCode model"},
{key: "claude_model", label: "Claude model"},
{key: "cadence", label: "Cadence"},
{key: "test", label: "Test summarizer"},
{key: "run_now", label: "Summarize current top-level agent now"},
}
}
func (p *paletteState) activateAutoSummaryRow() (paletteAction, bool, int) {
rows := autoSummaryRows()
if p.cursor < 0 || p.cursor >= len(rows) {
return paletteAction{}, false, 1
}
switch rows[p.cursor].key {
case "enabled":
p.settings.AutoSummary.Enabled = !p.settings.AutoSummary.Enabled
p.settings.normalize()
return p.settingsAction("settings-save"), false, 1
case "provider":
switch p.settings.AutoSummary.Provider {
case "codex":
p.settings.AutoSummary.Provider = "opencode"
case "opencode":
p.settings.AutoSummary.Provider = "claude"
default:
p.settings.AutoSummary.Provider = "codex"
}
p.settings.normalize()
return p.settingsAction("settings-save"), false, 1
case "codex_model", "opencode_model", "claude_model":
provider := strings.TrimSuffix(rows[p.cursor].key, "_model")
p.settingsInput = &settingsInputForm{
title: provider + " model",
field: rows[p.cursor].key,
value: []rune(p.settings.AutoSummary.modelFor(provider)),
}
p.mode = paletteModeSettingsInput
case "cadence":
switch p.settings.AutoSummary.Cadence {
case "15s":
p.settings.AutoSummary.Cadence = "30s"
case "30s":
p.settings.AutoSummary.Cadence = "1m"
default:
p.settings.AutoSummary.Cadence = "15s"
}
p.settings.normalize()
return p.settingsAction("settings-save"), false, 1
case "test":
return p.settingsAction("settings-test"), true, 1
case "run_now":
return p.settingsAction("settings-run-now"), true, 1
}
p.settings.normalize()
return paletteAction{}, false, 1
}
func (p *paletteState) applySettingsInput() bool {
if p.settingsInput == nil {
return false
}
val := strings.TrimSpace(string(p.settingsInput.value))
if val == "" {
return false
}
if p.settings.AutoSummary.Models == nil {
p.settings.AutoSummary.Models = defaultSummaryModels()
}
changed := false
switch p.settingsInput.field {
case "codex_model":
changed = p.settings.AutoSummary.Models["codex"] != val
p.settings.AutoSummary.Models["codex"] = val
case "opencode_model":
changed = p.settings.AutoSummary.Models["opencode"] != val
p.settings.AutoSummary.Models["opencode"] = val
case "claude_model":
changed = p.settings.AutoSummary.Models["claude"] != val
p.settings.AutoSummary.Models["claude"] = val
}
p.settings.normalize()
return changed
}
func (p *paletteState) settingsAction(kind string) paletteAction {
st := p.settings.clone()
return paletteAction{kind: kind, settings: &st}
}
func (p *paletteState) renderSettings(out writeFlusher, cols, rows int) {
p.renderSimplePicker(out, cols, rows, "Settings", "esc close", "search settings")
}
func (p *paletteState) renderSimplePicker(out writeFlusher, cols, rows int, title, hint, placeholder string) {
width, leftPad, content := paletteBox(cols)
maxItems := rows - 7
if maxItems > 10 {
maxItems = 10
}
if maxItems < 1 {
maxItems = 1
}
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
query := string(p.query)
if query == "" {
query = styleDim + placeholder + styleReset
}
pad := content - 2 - visibleLen(query)
if pad < 0 {
pad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "" + styleReset + " " + query + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
p.renderItemRows(&b, &row, leftPad, width, content, maxItems)
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ open · esc close · ↑↓ navigate" + styleReset
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func (p *paletteState) renderAutoSummary(out writeFlusher, cols, rows int) {
width, leftPad, content := paletteBox(cols)
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
title := "Auto-summarization"
hint := "esc close"
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
lines := p.autoSummaryDisplayRows()
for i, line := range lines {
moveTo(&b, row, leftPad)
prefix := " "
if i == p.cursor {
prefix = styleAccent + "▎" + styleReset + " "
line = styleBold + line + styleReset
}
pad := content - visibleLen(prefix) - visibleLen(line)
if pad < 0 {
pad = 0
}
b.WriteString(styleBorder + "│" + styleReset + " " + prefix + line + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
row++
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ edit/toggle · esc close" + styleReset
if visibleLen(footer) > content {
footer = clipRunes(footer, content-1) + "…"
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func (p *paletteState) autoSummaryDisplayRows() []string {
a := p.settings.AutoSummary
enabled := "off"
if a.Enabled {
enabled = "on"
}
values := map[string]string{
"enabled": enabled,
"provider": a.Provider,
"codex_model": a.modelFor("codex"),
"opencode_model": a.modelFor("opencode"),
"claude_model": a.modelFor("claude"),
"cadence": a.Cadence + " minimum after activity",
}
var out []string
for _, row := range autoSummaryRows() {
if v, ok := values[row.key]; ok {
out = append(out, styleHint+row.label+":"+styleReset+" "+v)
} else {
out = append(out, row.label)
}
}
return out
}
func (p *paletteState) renderSettingsInput(out writeFlusher, cols, rows int) {
if p.settingsInput == nil {
p.settingsInput = &settingsInputForm{title: "Setting"}
}
width, leftPad, content := paletteBox(cols)
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
title := p.settingsInput.title
hint := "esc back"
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " + strings.Repeat("─", max(2, width-visibleLen(title)-visibleLen(hint)-9)) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
value := string(p.settingsInput.value)
if visibleLen(value) > content-2 {
value = clipRunes(value, content-3) + "…"
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "" + styleReset + " " + value + strings.Repeat(" ", max(0, content-2-visibleLen(value))) + " " + styleBorder + "│" + styleReset)
inputRow := row
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := styleHint + "↵ apply · esc back · ⌃u clear" + styleReset
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + footer + strings.Repeat(" ", max(0, content-visibleLen(footer))) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
moveTo(&b, inputRow, leftPad+4+visibleLen(value))
b.WriteString("\x1b[?25h")
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func paletteBox(cols int) (width, leftPad, content int) {
if cols < 32 {
cols = 32
}
width = cols - 8
if width > 72 {
width = 72
}
if width < 40 {
width = cols - 2
}
if width < 32 {
width = 32
}
leftPad = (cols - width) / 2
if leftPad < 1 {
leftPad = 1
}
content = width - 4
return width, leftPad, content
}
// render draws the palette onto out. Layout is a rounded box with a
// title bar, query line, chip strip, divider, item list, divider, and
// footer. The caller is responsible for the screen clear before the
@@ -1125,6 +1578,18 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
p.renderRename(out, cols, rows)
return
}
if p.mode == paletteModeSettings {
p.renderSettings(out, cols, rows)
return
}
if p.mode == paletteModeAutoSummary {
p.renderAutoSummary(out, cols, rows)
return
}
if p.mode == paletteModeSettingsInput {
p.renderSettingsInput(out, cols, rows)
return
}
if cols < 32 {
cols = 32
}

View File

@@ -31,16 +31,17 @@ func findItem(p *paletteState, want string) (int, *paletteItem) {
func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
// pad-delete is the first selectable row; the Focused section header
// (a non-selectable row) sits above it.
if i, _ := findItem(p, "pad-delete"); i != 1 {
t.Fatalf("pad-delete at %d; want 1 (after Focused header)", i)
// 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 {
@@ -83,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

@@ -1,6 +1,7 @@
package app
import (
"bytes"
"strings"
"testing"
@@ -57,22 +58,45 @@ func TestPaletteDropsGlobalCloseList(t *testing.T) {
// -- Phase 2: section headers and cursor skip ------------------------
func TestPaletteSectionHeadersPresent(t *testing.T) {
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)
p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
wantSections := []string{"Focused", "Open", "Spawn", "Quit"}
for _, w := range wantSections {
found := false
for _, it := range p.items {
if it.action.kind == "header" && strings.Contains(it.label, w) {
found = true
break
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
}
if !found {
t.Errorf("section header %q missing from items", w)
// 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) {
@@ -321,6 +345,107 @@ func TestPaletteAltDigitQuickPick(t *testing.T) {
}
}
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)
}
action, done, _ := p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("first cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "15s" {
t.Fatalf("first cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
action, done, _ = p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("second cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "30s" {
t.Fatalf("second cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
action, done, _ = p.activateAutoSummaryRow()
if done || action.kind != "settings-save" {
t.Fatalf("third cycle action = %+v done=%v, want settings-save without close", action, done)
}
if p.settings.AutoSummary.Cadence != "1m" {
t.Fatalf("third cycle cadence = %q", p.settings.AutoSummary.Cadence)
}
}
func TestAutoSummaryScreenOmitsExplicitSaveCancelBackRows(t *testing.T) {
omitted := map[string]bool{
"Save settings": true,
"Cancel": true,
"Back to Settings": true,
}
for _, row := range autoSummaryRows() {
if omitted[row.label] {
t.Fatalf("auto-summary settings should not show %q", row.label)
}
}
}
func TestAutoSummaryRenderOmitsStaleSettingsHelp(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeAutoSummary
var b bytes.Buffer
p.renderAutoSummary(wrapWriter(&b), 100, 30)
out := b.String()
for _, text := range []string{
"Save settings",
"Cancel",
"Back to Settings",
"changes save",
"applies immediately",
} {
if strings.Contains(out, text) {
t.Fatalf("auto-summary render should not contain %q:\n%s", text, out)
}
}
}
func TestAutoSummaryValueRowsStyleLabelAndValueSeparately(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
rows := p.autoSummaryDisplayRows()
for _, row := range rows {
if strings.Contains(row, "Cadence:") {
if !strings.HasPrefix(row, styleHint+"Cadence:"+styleReset+" ") {
t.Fatalf("cadence row styling = %q", row)
}
if strings.Contains(strings.TrimPrefix(row, styleHint+"Cadence:"+styleReset+" "), styleHint) {
t.Fatalf("cadence value should use regular text styling: %q", row)
}
return
}
}
t.Fatal("missing cadence display row")
}
func TestAutoSummaryTextInputSavesWhenSubmitted(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeSettingsInput
p.settingsInput = &settingsInputForm{
title: "codex model",
field: "codex_model",
value: []rune("custom-model"),
}
action, done, _ := p.handleSettingsTextInput([]byte{'\r'}, 0)
if done || action.kind != "settings-save" {
t.Fatalf("submit action = %+v done=%v, want settings-save without close", action, done)
}
if got := p.settings.AutoSummary.modelFor("codex"); got != "custom-model" {
t.Fatalf("codex model = %q", got)
}
}
func TestPaletteFormCtrlRTogglesRelaunchFromCommandField(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm

View File

@@ -91,6 +91,12 @@ type ChildEventListener interface {
// updates a child's IdleState. Listeners use this to repaint the
// sidebar badge and to evaluate idle-aware timers.
OnChildStateChanged(childID string, state IdleState)
// OnChildClosed fires when a child is being removed from the
// session (either via close_process, or — for agent/terminal
// kinds — when the PTY exits and the entry will never be
// restarted). It signals that any pending references to childID
// (e.g. timers owned by or watching it) should be dropped.
OnChildClosed(childID string)
}
func NewSession(projectDir, projectKey string) *Session {
@@ -167,6 +173,12 @@ func (s *Session) emitStateChanged(id string, state IdleState) {
}
}
func (s *Session) emitClosed(id string) {
for _, l := range s.listenersSnapshot() {
l.OnChildClosed(id)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
@@ -374,6 +386,11 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
}
}
s.mu.Unlock()
// Notify listeners outside s.mu so they can take their own locks
// without inversion. Timer manager uses this to drop pending
// timers owned by or watching the closed child — otherwise the
// next classifier tick can deliver a stale fire to the parent.
s.emitClosed(id)
s.forgetPersisted(id)
return nil
}
@@ -486,6 +503,7 @@ func (s *Session) reapChild(c *Child, runID uint64) {
}
}
s.mu.Unlock()
s.emitClosed(c.ID)
}
}

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.activeSummaryRaw(); 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,48 @@ 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 = ""
}
for visibleLen(word) > width {
out = append(out, clipRunes(word, width))
word = string([]rune(word)[width:])
}
if word != "" {
cur = word
}
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
}

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", "--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,90 @@
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.Contains(strings.Join(long, ""), "…") {
t.Fatalf("long word should wrap without ellipsis: %#v", long)
}
for _, line := range long {
if visibleLen(line) > 8 {
t.Fatalf("line %q exceeds width", line)
}
}
}
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
@@ -59,11 +59,14 @@ func (st *uiState) drawTabBar() {
newHintW := utf8.RuneCountInString(newHint) + 2 // " + new " framing
type tabRect struct {
startCol int
width int
label string
active bool
startCol int
width int
label string
glyph string
glyphStyle string
active bool
}
activeTab := -1
// Reserve space at the right edge for "+ new". If there are too
// many tabs to fit even at minTabWidth, drop tabs from the right
@@ -114,9 +117,16 @@ func (st *uiState) drawTabBar() {
if i < extra {
w++
}
active := c.ID == focus
glyph, glyphStyle := tabIdleGlyph(c.IdleState(), active)
label := c.DisplayName()
labelW := utf8.RuneCountInString(label)
maxLabelW := w - 2 // one pad on each side
// Reserve room for the glyph + its trailing space when present
// (1 + 1 runes), on top of the one-cell pad on each side.
maxLabelW := w - 2
if glyph != "" {
maxLabelW -= 2
}
if maxLabelW < 1 {
maxLabelW = 1
}
@@ -129,17 +139,23 @@ func (st *uiState) drawTabBar() {
labelW = utf8.RuneCountInString(label)
}
tabs = append(tabs, tabRect{
startCol: col,
width: w,
label: label,
active: c.ID == focus,
startCol: col,
width: w,
label: label,
glyph: glyph,
glyphStyle: glyphStyle,
active: active,
})
if tabs[len(tabs)-1].active {
activeTab = len(tabs) - 1
}
col += w
}
}
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,32 +163,47 @@ 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.
// Row 1: centre-ish glyph+label inside the tab cell.
labelW := utf8.RuneCountInString(t.label)
leftPad := (t.width - labelW) / 2
visibleW := labelW
if t.glyph != "" {
visibleW += 2 // glyph + separator space
}
leftPad := (t.width - visibleW) / 2
if leftPad < 1 {
leftPad = 1
}
rightPad := t.width - labelW - leftPad
rightPad := t.width - visibleW - leftPad
if rightPad < 0 {
rightPad = 0
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
cellStyle := styleHint
if t.active {
b.WriteString(styleActive)
} else {
b.WriteString(styleHint)
cellStyle = styleActive
}
fmt.Fprintf(&b, "\x1b[1;%dH", t.startCol)
b.WriteString(cellStyle)
b.WriteString(strings.Repeat(" ", leftPad))
if t.glyph != "" {
// Glyph uses its own colour so error/permission states pop
// regardless of tab focus, matching the sidebar's vocabulary.
b.WriteString(styleReset)
b.WriteString(t.glyphStyle)
b.WriteString(t.glyph)
b.WriteString(styleReset)
b.WriteString(cellStyle)
b.WriteString(" ")
}
b.WriteString(t.label)
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 +220,18 @@ 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 activeTab >= 0 {
tab := tabs[activeTab]
summaryWidth := tab.width - 2
if summary := st.activeSummaryText(summaryWidth); summary != "" {
fmt.Fprintf(&b, "\x1b[2;%dH %s%s%s", tab.startCol, styleDim, summary, styleReset)
}
}
frame := b.String()
st.chromeCacheMu.Lock()
if frame == st.tabBarCache {
@@ -212,3 +251,29 @@ func (st *uiState) drawTabBar() {
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
}
// tabIdleGlyph returns the one-rune state indicator (and its SGR style)
// to render before a tab's label. Mirrors the sidebar's vocabulary so
// users learn the symbols in one place: ✕ error, ? permission, ◐
// thinking, ○ idle, ● working. Returns ("", "") for StateUnknown so the
// first frame after spawn doesn't show a misleading badge.
func tabIdleGlyph(state IdleState, active bool) (string, string) {
base := styleHint
if active {
base = styleAccent
}
switch state {
case StateError:
return "✕", styleError
case StatePermission:
return "?", styleAccent
case StateThinking:
return "◐", base
case StateIdle:
return "○", base
case StateWorking:
return "●", base
default:
return "", ""
}
}

View File

@@ -296,6 +296,65 @@ func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
}
}
// onChildClosed drops pending timer references to childID. Called
// from Session.Close (and the terminal-corpse cleanup in reapChild)
// via the session listener bus — a deliberate signal from the host
// that childID is gone and the parent is not waiting on it anymore.
//
// Semantics:
// - timers owned by childID are cancelled and deleted: their owner
// is gone, so even if defaultFireFn's IsLive guard would no-op
// the delivery, the entry has no business surviving a close.
// - timers watching childID have childID pruned from t.watched
// (and t.idleBaseline). If t.watched becomes empty the timer is
// cancelled and deleted; we deliberately do NOT synthesise a
// fire here. The parent already received any legitimate idle
// transition before close_process — see allWatchedIdleLocked's
// "treat as satisfied" comment, which only applies to a
// concurrent re-evaluation, not to this explicit-removal hook.
//
// The natural-exit path (reapChild → emitExit for agent/command
// kinds) is NOT routed through here: the classifier emits a final
// idle transition on exit, which fires and deletes any watching
// timers exactly once. Cancelling on exit would swallow that
// legitimate fire and leave the parent never notified.
func (m *timerManager) onChildClosed(childID string) {
m.mu.Lock()
defer m.mu.Unlock()
for id, t := range m.timers {
if t.ownerID == childID {
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
continue
}
if !contains(t.watched, childID) {
continue
}
pruned := t.watched[:0]
for _, w := range t.watched {
if w != childID {
pruned = append(pruned, w)
}
}
t.watched = pruned
if t.idleBaseline != nil {
delete(t.idleBaseline, childID)
}
if len(t.watched) == 0 {
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
}
}
}
// allWatchedIdleLocked reports whether every watched child is now
// idle. Called with m.mu held — uses live Child.IdleState() under the
// child's own atomic, not under m.mu.

View File

@@ -411,3 +411,178 @@ func TestTimerRecordsRemovedOnIdleFire(t *testing.T) {
t.Fatalf("fired idle timer %s was not removed from registry", resp.ID)
}
}
// TestTimerCloseChildPrunesWatched covers the happy partial-prune
// case: an idle_any timer watches two children, one is closed, the
// timer stays pending and the remaining child can still satisfy it.
func TestTimerCloseChildPrunesWatched(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
working := StateWorking
a.idleState.Store(&working)
b.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_a")
mgr.mu.Lock()
t1, ok := mgr.timers[resp.ID]
if !ok {
mgr.mu.Unlock()
t.Fatalf("timer was removed but still has live watched")
}
watched := append([]string(nil), t1.watched...)
mgr.mu.Unlock()
if len(watched) != 1 || watched[0] != "p_b" {
t.Fatalf("watched after close: %v, want [p_b]", watched)
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("close synthesised a fire: %+v", got)
}
// p_b can still satisfy the timer.
idle := StateIdle
b.idleState.Store(&idle)
mgr.onChildStateChanged("p_b", StateIdle)
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("post-prune fire: %+v", got)
}
}
// TestTimerCloseLastWatchedCancels is the regression for the
// reported stale-fire symptom: the only watched child is closed,
// so the timer must be cancelled — no synthetic fire, and the
// registry entry must be gone so a trailing classifier tick for the
// removed child cannot re-deliver later.
func TestTimerCloseLastWatchedCancels(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "stale body", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_a")
mgr.mu.Lock()
_, stillThere := mgr.timers[resp.ID]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("timer with no remaining watched should be removed")
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("close synthesised a fire: %+v", got)
}
// Simulate the trailing classifier tick for the now-closed child —
// must not fire.
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("trailing state change re-fired: %+v", got)
}
}
// TestTimerCloseChildIdleAllPartialPrune mirrors the idle_any
// partial-prune for idle_all: pruning a watched child shrinks the
// list; the remaining child going idle then satisfies the timer.
func TestTimerCloseChildIdleAllPartialPrune(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
working := StateWorking
a.idleState.Store(&working)
b.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAll: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
mgr.onChildClosed("p_a")
idle := StateIdle
b.idleState.Store(&idle)
mgr.onChildStateChanged("p_b", StateIdle)
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("idle_all after partial prune: %+v", got)
}
}
// TestTimerCloseOwnerCancelsDelay ensures a delay timer is dropped
// when its owner is closed: no delivery, registry empty, the
// underlying time.Timer is stopped.
func TestTimerCloseOwnerCancelsDelay(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "x", "", 0.1)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
mgr.onChildClosed("p_owner")
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("delay timer was not removed when owner closed")
}
time.Sleep(200 * time.Millisecond) // past the original firesAt
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("delay timer fired after owner close: %+v", got)
}
}
// TestTimerCloseWatchedSubAgent is the exact shape of the reported
// stale-fire bug: orchestrator registers a watcher on a sub-agent,
// the sub-agent is closed, and the orchestrator must receive
// nothing (no stale body delivered after close_process).
func TestTimerCloseWatchedSubAgent(t *testing.T) {
sess, mgr, rec := newTestManager(t)
parent := fakeChild("p_owner")
sub := fakeChild("p_sub")
addChild(sess, parent)
addChild(sess, sub)
working := StateWorking
sub.idleState.Store(&working)
if _, err := mgr.TimerFireWhenIdleAny(
"p_owner",
"codex-review-591 finished. Read your own pane …",
"", []string{"p_sub"}, 0,
); err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
mgr.onChildClosed("p_sub")
// Trailing classifier emission for the closed sub-agent must
// not deliver anything to the parent.
mgr.onChildStateChanged("p_sub", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("stale fire delivered to parent after sub-agent close: %+v", got)
}
}

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

@@ -0,0 +1,361 @@
package app
import (
"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
// toastContentRows is how many lines of message body each toast box
// reserves. The dismiss hint lives on the host status strip, so the
// box itself is purely the message.
const toastContentRows = 3
// 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.
//
// The status strip also gains/loses the "Ctrl-N · dismiss" hint as
// the stack toggles between empty and non-empty, so we redraw it
// here too rather than waiting for the chrome ticker.
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()
}
st.drawStatusLine()
}
// 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() {
bytes := st.toastOverlayBytes()
if len(bytes) == 0 {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(bytes)
}
// toastOverlayBytes builds the toast layer as a single byte buffer
// without writing to stdout. Returns nil when the stack is empty or
// the layout can't accommodate a box. Callers either write it
// directly (renderToasts) or stitch it onto the end of another
// stdout write so claude/codex/opencode redraws that paint over the
// top-right region can't leave the toast half-erased.
func (st *uiState) toastOverlayBytes() []byte {
items := st.toasts.snapshot()
if len(items) == 0 {
return nil
}
st.mu.Lock()
palOpen := st.palette != nil
st.mu.Unlock()
if palOpen {
return nil
}
layout := st.layoutSnapshot()
paneCols := int(layout.childCols())
paneRows := int(layout.childRows())
if paneCols < toastBoxMinWidth+2 || paneRows < toastContentRows+2 {
return nil
}
boxWidth := toastBoxMaxWidth
if max := paneCols - 4; max < boxWidth {
boxWidth = max
}
if boxWidth < toastBoxMinWidth {
return nil
}
contentWidth := boxWidth - 4 // 2 border cells + 2 inner padding
// Reserve two columns for the icon prefix on row 1 so wrapped rows
// indent under the body text rather than under the glyph.
const iconCols = 2
bodyRoom := contentWidth - iconCols
if bodyRoom < 1 {
return nil
}
var b strings.Builder
// Wrap the whole overlay in DECSET 2026 (synchronized output)
// brackets so terminals that support BSU/ESU buffer the box paint
// into a single frame — without this, claude's continuous redraws
// and our overlay race on each cell, producing visible flicker.
// Terminals that don't recognise 2026 ignore the brackets, so the
// fallback behaviour is the same as before.
b.WriteString("\x1b[?2026h\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]
height := toastContentRows + 2
// Stop if we'd run off the bottom of the pane.
if row+height > int(layout.mainTop)+paneRows {
break
}
border := toastBorderStyle(t.kind)
wrapped := wrapToastBody(t.text, bodyRoom)
// Top border.
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("╭")
b.WriteString(strings.Repeat("─", boxWidth-2))
b.WriteString("╮")
b.WriteString(styleReset)
row++
// Content rows. Row 0 carries the kind glyph; rows 1..N indent
// by iconCols spaces so wrapped text lines up under the body.
for i := 0; i < toastContentRows; i++ {
moveTo(&b, row, col)
b.WriteString(border)
b.WriteString("│")
b.WriteString(styleReset)
b.WriteString(" ")
if i == 0 {
b.WriteString(toastIcon(t.kind))
} else {
b.WriteString(strings.Repeat(" ", iconCols))
}
line := wrapped[i]
b.WriteString(line)
b.WriteString(strings.Repeat(" ", max(0, bodyRoom-visibleLen(line))))
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\x1b[?2026l")
return []byte(b.String())
}
func toastBorderStyle(kind toastKind) string {
switch kind {
case toastError:
return styleError
case toastAttention:
return styleAccent
default:
return styleBorder
}
}
// wrapToastBody word-wraps text into exactly toastContentRows lines,
// each at most width visible runes wide. Short messages are padded
// with empty trailing lines so callers can iterate a fixed-size
// slice; messages that don't fit get ellipsized on the last line.
func wrapToastBody(text string, width int) []string {
out := make([]string, toastContentRows)
if width < 1 {
return out
}
all := wrapToastWords(text, width)
if len(all) > toastContentRows {
all = all[:toastContentRows]
last := all[len(all)-1]
if visibleLen(last) >= width {
last = clipRunes(last, width-1) + "…"
} else {
last = last + "…"
}
all[len(all)-1] = last
}
for i, l := range all {
out[i] = l
}
return out
}
// wrapToastWords is a small word-wrapper sized for toast bodies:
// greedy, breaks overlong words on rune boundaries, drops collapsing
// whitespace via strings.Fields.
func wrapToastWords(text string, width int) []string {
var lines []string
var cur string
flush := func() {
if cur != "" {
lines = append(lines, cur)
cur = ""
}
}
for _, word := range strings.Fields(text) {
for visibleLen(word) > width {
flush()
head := clipRunes(word, width)
lines = append(lines, head)
word = word[len(head):]
}
if word == "" {
continue
}
if cur == "" {
cur = word
continue
}
if visibleLen(cur)+1+visibleLen(word) <= width {
cur += " " + word
continue
}
flush()
cur = word
}
flush()
return lines
}
func toastIcon(kind toastKind) string {
switch kind {
case toastError:
return styleError + "✗ " + styleReset
case toastAttention:
return styleAccent + "! " + styleReset
default:
return styleHint + "• " + styleReset
}
}

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

@@ -0,0 +1,164 @@
package app
import (
"strings"
"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)
}
}
func TestWrapToastBodyFixedHeight(t *testing.T) {
got := wrapToastBody("short", 20)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
if got[0] != "short" {
t.Fatalf("line 0 = %q, want \"short\"", got[0])
}
if got[1] != "" || got[2] != "" {
t.Fatalf("trailing pads not empty: %#v", got)
}
}
func TestWrapToastBodyWrapsOnWordBoundary(t *testing.T) {
got := wrapToastBody("the quick brown fox jumps over", 10)
// Expect greedy fill: "the quick" (9), "brown fox" (9), "jumps over" (10).
want := []string{"the quick", "brown fox", "jumps over"}
for i, w := range want {
if got[i] != w {
t.Fatalf("line %d = %q, want %q (full=%#v)", i, got[i], w, got)
}
}
}
func TestWrapToastBodyEllipsizesOverflow(t *testing.T) {
got := wrapToastBody("alpha beta gamma delta epsilon zeta eta theta", 6)
if len(got) != toastContentRows {
t.Fatalf("len = %d, want %d", len(got), toastContentRows)
}
last := got[toastContentRows-1]
if !strings.HasSuffix(last, "…") {
t.Fatalf("overflow should ellipsize last line, got %q (full=%#v)", last, got)
}
if visibleLen(last) > 6 {
t.Fatalf("last line %q exceeds width 6", last)
}
}
func TestWrapToastBodyBreaksOverlongWord(t *testing.T) {
got := wrapToastBody("supercalifragilistic", 6)
if got[0] != "superc" {
t.Fatalf("line 0 = %q, want \"superc\"", got[0])
}
if got[1] != "alifra" {
t.Fatalf("line 1 = %q, want \"alifra\"", got[1])
}
// Third line should hold the rest (possibly ellipsized).
if got[2] == "" {
t.Fatalf("line 2 unexpectedly empty: %#v", got)
}
}
func TestWrapToastBodyEmptyInput(t *testing.T) {
got := wrapToastBody("", 20)
for i, l := range got {
if l != "" {
t.Fatalf("line %d = %q, want \"\"", i, l)
}
}
}

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

@@ -0,0 +1,37 @@
{
"name": "idle_screen_permission_prompt",
"presets": {
"processes": [
{
"name": "screen-permission",
"argv": [
"sh",
"-lc",
"printf '\\033[2J\\033[HCalling patterm...\\n\\nTool use\\n\\nDo you want to proceed?\\n 1. Yes\\n'; i=0; while [ $i -lt 300 ]; do printf '\\033[HCalling patterm... %03d' $i; i=$((i+1)); done; sleep 60"
],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500,
"permission_patterns": ["Do you want to proceed\\?"]
}
}
]
},
"trust": ["screen-permission"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "screen-permission", "name": "screen-permission"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "permission",
"timeout_ms": 4000
}
]
}

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

@@ -4,6 +4,7 @@
package preset
import (
"bytes"
"encoding/json"
"errors"
"fmt"
@@ -35,15 +36,16 @@ type Preset struct {
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Disabled bool `json:"disabled,omitempty"`
// Process-only.
Shell bool `json:"shell,omitempty"`
// Agent-only. SPEC §10.
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
IdleDetection *IdleDetection `json:"idle_detection,omitempty"`
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
IdleDetection *IdleDetection `json:"idle_detection,omitempty"`
}
// IdleDetection configures steady-state idle classification for an
@@ -119,28 +121,22 @@ type Set struct {
Processes []*Preset
}
// Load scans the standard locations under $XDG_CONFIG_HOME/patterm/
// presets/{agents,processes}/*.json. Unknown files are skipped with a
// warning to stderr; the spec is forgiving here.
// Load returns the built-in presets plus user overlays from
// $XDG_CONFIG_HOME/patterm/presets/{agents,processes}/*.json. Startup
// does not write default files; user files only override or extend the
// in-memory defaults. A user overlay with {"disabled": true} hides a
// built-in preset of the same name.
func Load() (Set, error) {
base, err := ConfigDir()
if err != nil {
return Set{}, err
}
if err := os.MkdirAll(base, 0o700); err != nil {
return Set{}, fmt.Errorf("preset: mkdir %s: %w", base, err)
}
// Make sure the default-preset files exist on first run. Idempotent.
if err := ensureDefaults(base); err != nil {
return Set{}, err
}
agents, err := loadDir(filepath.Join(base, "presets", "agents"), KindAgent)
agents, err := loadWithDefaults(filepath.Join(base, "presets", "agents"), KindAgent, defaultAgentPresets())
if err != nil {
return Set{}, err
}
procs, err := loadDir(filepath.Join(base, "presets", "processes"), KindCommand)
procs, err := loadWithDefaults(filepath.Join(base, "presets", "processes"), KindCommand, nil)
if err != nil {
return Set{}, err
}
@@ -160,51 +156,154 @@ func ConfigDir() (string, error) {
return filepath.Join(home, ".config", "patterm"), nil
}
func loadDir(dir string, kind Kind) ([]*Preset, error) {
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("preset: mkdir %s: %w", dir, err)
func loadWithDefaults(dir string, kind Kind, defaults []*Preset) ([]*Preset, error) {
byName := make(map[string]*Preset, len(defaults))
for _, p := range defaults {
cp := clonePreset(p)
cp.Kind = kind
byName[cp.Name] = cp
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return sortedPresets(byName), nil
}
return nil, fmt.Errorf("preset: read %s: %w", dir, err)
}
var out []*Preset
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
path := filepath.Join(dir, e.Name())
p, err := loadFile(path, kind)
p, err := loadFileOverlay(path, kind, byName)
if err != nil {
fmt.Fprintf(os.Stderr, "patterm: preset %s: %v\n", path, err)
continue
}
if p.Disabled {
delete(byName, p.Name)
continue
}
byName[p.Name] = p
}
return sortedPresets(byName), nil
}
func sortedPresets(byName map[string]*Preset) []*Preset {
out := make([]*Preset, 0, len(byName))
for _, p := range byName {
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
return out
}
func loadFile(path string, kind Kind) (*Preset, error) {
func loadFileOverlay(path string, kind Kind, defaults map[string]*Preset) (*Preset, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var header struct {
Name string `json:"name"`
Disabled bool `json:"disabled,omitempty"`
}
if err := json.Unmarshal(b, &header); err != nil {
return nil, err
}
if header.Name == "" {
return nil, errors.New("missing 'name'")
}
if def := defaults[header.Name]; def != nil {
p, err := mergePreset(def, b)
if err != nil {
return nil, err
}
p.Path = path
p.Kind = kind
return p, validatePreset(p)
}
var p Preset
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
p.Path = path
p.Kind = kind
return &p, validatePreset(&p)
}
func validatePreset(p *Preset) error {
if p.Name == "" {
return errors.New("missing 'name'")
}
if p.Disabled {
return nil
}
if len(p.Argv) == 0 && !p.Shell {
return errors.New("missing 'argv'")
}
return nil
}
func mergePreset(def *Preset, overlay []byte) (*Preset, error) {
base, err := presetMap(def)
if err != nil {
return nil, err
}
var over map[string]any
dec := json.NewDecoder(bytes.NewReader(overlay))
dec.UseNumber()
if err := dec.Decode(&over); err != nil {
return nil, err
}
deepMerge(base, over)
b, err := json.Marshal(base)
if err != nil {
return nil, err
}
var p Preset
if err := json.Unmarshal(b, &p); err != nil {
return nil, err
}
if p.Name == "" {
return nil, errors.New("missing 'name'")
}
if len(p.Argv) == 0 && !p.Shell {
return nil, errors.New("missing 'argv'")
}
p.Path = path
p.Kind = kind
return &p, nil
}
func presetMap(p *Preset) (map[string]any, error) {
b, err := json.Marshal(p)
if err != nil {
return nil, err
}
var m map[string]any
dec := json.NewDecoder(bytes.NewReader(b))
dec.UseNumber()
if err := dec.Decode(&m); err != nil {
return nil, err
}
return m, nil
}
func deepMerge(dst, src map[string]any) {
for k, v := range src {
if sm, ok := v.(map[string]any); ok {
if dm, ok := dst[k].(map[string]any); ok {
deepMerge(dm, sm)
continue
}
}
dst[k] = v
}
}
func clonePreset(p *Preset) *Preset {
if p == nil {
return nil
}
b, _ := json.Marshal(p)
var out Preset
_ = json.Unmarshal(b, &out)
return &out
}
// ResolvedArgv returns the argv to actually exec, handling the
// process-preset "shell: true" case (SPEC §10).
func (p *Preset) ResolvedArgv() []string {
@@ -214,17 +313,9 @@ func (p *Preset) ResolvedArgv() []string {
return p.Argv
}
// ensureDefaults writes default agent presets (claude/codex/opencode)
// and a sample process preset on first run. Never overwrites existing
// user files.
func ensureDefaults(base string) error {
defaults := []struct {
rel string
body string
}{
{
"presets/agents/claude.json",
`{
func defaultAgentPresets() []*Preset {
bodies := []string{
`{
"name": "claude",
"argv": ["claude"],
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
@@ -249,10 +340,7 @@ func ensureDefaults(base string) error {
]
}
`,
},
{
"presets/agents/codex.json",
`{
`{
"name": "codex",
"argv": ["codex"],
"mcp_injection": {
@@ -275,10 +363,7 @@ func ensureDefaults(base string) error {
]
}
`,
},
{
"presets/agents/opencode.json",
`{
`{
"name": "opencode",
"argv": ["opencode"],
"mcp_injection": {
@@ -301,19 +386,15 @@ func ensureDefaults(base string) error {
]
}
`,
},
}
for _, d := range defaults {
full := filepath.Join(base, d.rel)
if _, err := os.Stat(full); err == nil {
continue
}
if err := os.MkdirAll(filepath.Dir(full), 0o700); err != nil {
return err
}
if err := os.WriteFile(full, []byte(d.body), 0o600); err != nil {
return err
out := make([]*Preset, 0, len(bodies))
for _, body := range bodies {
var p Preset
if err := json.Unmarshal([]byte(body), &p); err != nil {
panic(err)
}
p.Kind = KindAgent
out = append(out, &p)
}
return nil
return out
}

View File

@@ -0,0 +1,124 @@
package preset
import (
"os"
"path/filepath"
"testing"
)
func TestLoadUsesBuiltInDefaultsWithoutWritingConfig(t *testing.T) {
configHome := filepath.Join(t.TempDir(), "config")
t.Setenv("XDG_CONFIG_HOME", configHome)
set, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if _, err := os.Stat(filepath.Join(configHome, "patterm")); !os.IsNotExist(err) {
t.Fatalf("Load created config dir or unexpected stat error: %v", err)
}
if len(set.Agents) != 3 {
t.Fatalf("agents len = %d, want 3", len(set.Agents))
}
claude := presetByName(set.Agents, "claude")
if claude == nil {
t.Fatal("missing built-in claude preset")
}
if claude.IdleDetection == nil || len(claude.IdleDetection.PermissionPatterns) == 0 {
t.Fatalf("built-in claude missing permission patterns: %+v", claude.IdleDetection)
}
}
func TestLoadMergesUserOverlayIntoBuiltInPreset(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
dir := filepath.Join(configHome, "patterm", "presets", "agents")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "claude.json"), `{
"name": "claude",
"argv": ["claude", "--model", "sonnet"],
"idle_detection": { "idle_threshold_ms": 3500 }
}`)
set, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
claude := presetByName(set.Agents, "claude")
if claude == nil {
t.Fatal("missing claude preset")
}
if got := claude.Argv; len(got) != 3 || got[0] != "claude" || got[2] != "sonnet" {
t.Fatalf("argv = %#v", got)
}
if claude.IdleDetection.IdleThresholdMS != 3500 {
t.Fatalf("idle threshold = %d", claude.IdleDetection.IdleThresholdMS)
}
if len(claude.IdleDetection.PermissionPatterns) == 0 {
t.Fatalf("permission patterns were not inherited: %+v", claude.IdleDetection)
}
if claude.MCPInjection == nil || claude.MCPInjection.Kind != "flag" {
t.Fatalf("mcp injection was not inherited: %+v", claude.MCPInjection)
}
}
func TestLoadCanDisableBuiltInPreset(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
dir := filepath.Join(configHome, "patterm", "presets", "agents")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "opencode.json"), `{"name":"opencode","disabled":true}`)
set, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if presetByName(set.Agents, "opencode") != nil {
t.Fatal("opencode preset was not disabled")
}
if presetByName(set.Agents, "claude") == nil || presetByName(set.Agents, "codex") == nil {
t.Fatalf("other built-ins missing: %+v", set.Agents)
}
}
func TestLoadAddsCustomUserPreset(t *testing.T) {
configHome := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configHome)
dir := filepath.Join(configHome, "patterm", "presets", "processes")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "test.json"), `{"name":"test","argv":["go","test","./..."]}`)
set, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
proc := presetByName(set.Processes, "test")
if proc == nil {
t.Fatal("missing custom process preset")
}
if proc.Kind != KindCommand {
t.Fatalf("kind = %q", proc.Kind)
}
}
func presetByName(ps []*Preset, name string) *Preset {
for _, p := range ps {
if p.Name == name {
return p
}
}
return nil
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}