15 Commits

Author SHA1 Message Date
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
81bc77366f Overhaul command palette UX
Six-phase sweep: section headers (Focused / Open / Spawn / Quit) with
header-skip cursor; chip strip mirroring sw/sp/k macros, driven by
Tab; unified Spawn verbs across agent / process / terminal / custom;
dropped duplicate global Close list in favor of Ctrl-X inline close
on a Switch row plus the [Close] chip; scored matching (prefix >
word-boundary > substring > fuzzy) with matched-char highlighting;
title bar surfaces focus subject; rename forms split long subject
onto its own row; new Alt-1..9 quick-pick, Home/End, ? help overlay,
and Ctrl-R relaunch toggle inside the spawn-process form. Scroll
indicator and cursor/total counter round out the footer.
2026-05-15 16:41:44 +01:00
23 changed files with 3633 additions and 462 deletions

View File

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

View File

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

View File

@@ -6,7 +6,145 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [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 ### 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 ──`)
so the rows are scannable at a glance; cursor navigation skips
the dim header rows transparently. A chip strip — `[All] Open
Spawn Close` — sits below the query line and tracks the active
macro filter; `Tab` / `Shift-Tab` cycle through the chips, and
the typed-prefix macros (`sw `, `sp `, `k `) still work and now
collapse the whole prefix on a single backspace instead of
leaving a stray `sw` behind. The title bar surfaces the current
focus subject (`on: <child>` / `pad: <name>`) so the user knows
which Focused row is targeting what. The duplicate global Close
list is gone — close is reachable via the Focused-section action,
the `k ` macro / `[Close]` chip, or the new `Ctrl-X` inline close
on a Switch row. The "(current)" marker on the focused Switch row
became a leading ``. The empty-state hint now reads `no matches
· ⌫ to widen` instead of bare `no matches`. The middle divider
shows a `▼ N more` / `▲ N above` scroll indicator when the list
overflows, and the footer carries a `cursor/total` counter.
- Spawn verbs are unified on **Spawn**: `Run process: …` →
`Spawn process: …`, `New Terminal` → `Spawn terminal`, and the
freeform-form row is now `Spawn process… (custom)` so the
trailing ellipsis still signals it opens a form.
- Filtering switched from binary fuzzy-include to scored ranking.
Prefix matches beat word-boundary matches beat substring matches
beat scattered-fuzzy matches; ties fall back to section order so
a Focused-section hit always outranks an equally tight Spawn
hit. The matched characters in the rendered label render in
accent+bold so the user can see why a row matched.
- Rename forms split the long subject (`scratchpad:
some-really-long-name.md`) onto its own dim row above the input
so the title bar no longer truncates with an ellipsis when the
subject name is wide.
- New palette accelerators: `Alt-1` … `Alt-9` quick-pick the Nth
visible row, `Home` / `End` jump to first / last selectable row,
`?` (with empty query) opens an inline keybinding cheat-sheet
which any further keystroke dismisses, and `Ctrl-R` inside the
Spawn-process form toggles "Relaunch on exit" without leaving
the command field.
### Fixed
- Error/status flashes now restore the currently focused pane instead
of drawing the empty-state hint over a running agent or process.
- Release workflow (`.gitea/workflows/release.yml`) now uses
`mlugg/setup-zig@v2` instead of the deprecated `@v1`. v1 hard-coded
the pre-0.14 tarball name (`zig-linux-x86_64-<ver>.tar.xz`), so
every mirror and the official `ziglang.org/builds` returned 404 for
Zig 0.15.2 and the v0.0.1 / v0.0.2 tag pushes never produced a
release asset. v2 uses the post-0.14 `zig-x86_64-linux-<ver>.tar.xz`
layout, so the runner can fetch Zig and build patterm.
- Typing into a focused child while its emulator viewport is - Typing into a focused child while its emulator viewport is
scrolled up into scrollback history now auto-snaps the viewport scrolled up into scrollback history now auto-snaps the viewport
back to the live area. Previously the keystroke reached the back to the live area. Previously the keystroke reached the

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

@@ -55,6 +55,10 @@ func Run(ctx context.Context, opts Options) error {
if err != nil { if err != nil {
return fmt.Errorf("app: load presets: %w", err) return fmt.Errorf("app: load presets: %w", err)
} }
appSettings, settingsPath, err := loadSettings()
if err != nil {
logf("settings load: %v", err)
}
// Ensure the per-project scratchpad dir exists so MCP and the UI // Ensure the per-project scratchpad dir exists so MCP and the UI
// can read/write into it. SPEC §3. // can read/write into it. SPEC §3.
@@ -169,7 +173,22 @@ func Run(ctx context.Context, opts Options) error {
hostRows: rows, hostRows: rows,
stdinTTY: term.IsTerminal(int(os.Stdin.Fd())), stdinTTY: term.IsTerminal(int(os.Stdin.Fd())),
metrics: metrics, metrics: metrics,
settings: appSettings,
settingsPath: settingsPath,
ctx: ctx,
} }
st.summaries = newSummaryManager(sess, opts.ProjectDir, presets, func() autoSummarySettings {
st.settingsMu.Lock()
defer st.settingsMu.Unlock()
return st.settings.AutoSummary.clone()
}, func() {
st.markChromeDirty()
st.markSidebarDirty()
}, func(_ string, result summaryState) {
if result.Error != "" {
st.flashError(fmt.Sprintf("summary: %v", result.Error))
}
})
sess.SetMetrics(metrics) sess.SetMetrics(metrics)
host.attention = st host.attention = st
host.focus = st host.focus = st
@@ -177,6 +196,7 @@ func Run(ctx context.Context, opts Options) error {
host.scratch = st host.scratch = st
st.lastExit.Store(-1) st.lastExit.Store(-1)
sess.Subscribe(st) sess.Subscribe(st)
go st.summaries.run(ctx)
st.enterScreen() st.enterScreen()
st.renderEmptyState() st.renderEmptyState()
@@ -398,7 +418,6 @@ type uiState struct {
// switch resets the offset cleanly. // switch resets the offset cleanly.
padOffsetName string padOffsetName string
// activeAgentID tracks which top-level agent tab "owns" the agent // activeAgentID tracks which top-level agent tab "owns" the agent
// tree section of the sidebar. It only updates when focus lands on // tree section of the sidebar. It only updates when focus lands on
// an agent (or one of its sub-agents), so the agent tree stays // an agent (or one of its sub-agents), so the agent tree stays
@@ -411,10 +430,11 @@ type uiState struct {
repaintNextPTY string repaintNextPTY string
repaintNextPTYBudget int repaintNextPTYBudget int
// attention is the latest request_human_attention surfaced via MCP; // toasts is the stackable notification surface. flashError,
// rendered in the status line until cleared. // flashTransient, and notifyAttention all push onto it; the user
attentionText string // dismisses entries with Ctrl-N or the "Clear notifications"
attentionAt string // palette command.
toasts toastStack
// pendingTrust is the most recent trust prompt — surfaced in the // pendingTrust is the most recent trust prompt — surfaced in the
// status line until the user resolves it with Ctrl-K. v1 keeps the // status line until the user resolves it with Ctrl-K. v1 keeps the
@@ -432,6 +452,12 @@ type uiState struct {
// check on the disabled path. // check on the disabled path.
metrics *metricsTracker metrics *metricsTracker
settingsMu sync.Mutex
settings settings
settingsPath string
ctx context.Context
summaries *summaryManager
// chromeCacheMu guards the last-rendered byte cache for each chrome // chromeCacheMu guards the last-rendered byte cache for each chrome
// element. The tab bar, sidebar, and status line all repaint on // element. The tab bar, sidebar, and status line all repaint on
// many state changes and on every PTY chunk, but their content // many state changes and on every PTY chunk, but their content
@@ -478,6 +504,33 @@ func (st *uiState) dbgf(format string, args ...any) {
logf(format, args...) logf(format, args...)
} }
func (st *uiState) activeSummaryText(width int) string {
if width <= 0 || st.summaries == nil {
return ""
}
st.settingsMu.Lock()
enabled := st.settings.AutoSummary.Enabled
st.settingsMu.Unlock()
if !enabled {
return ""
}
st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
return ""
}
sum := st.summaries.Summary(active)
text := strings.TrimSpace(sum.Text)
if text == "" {
return ""
}
if visibleLen(text) > width {
text = clipRunes(text, width-1) + "…"
}
return text
}
// trustRequest is one outstanding SPEC §7 trust prompt: an agent tried // trustRequest is one outstanding SPEC §7 trust prompt: an agent tried
// to spawn / start / restart against an untrusted command preset and // to spawn / start / restart against an untrusted command preset and
// the host wants user confirmation before the next attempt succeeds. // the host wants user confirmation before the next attempt succeeds.
@@ -668,20 +721,15 @@ func (st *uiState) updateActiveAgentLocked(c *Child) {
} }
// notifyAttention is the request_human_attention sink (SPEC §7). We // notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most // push a toast onto the stack; the focused-pane render path picks it
// recent ask so the status line keeps showing it. The sidebar-blink is // up. The sidebar-blink is deferred until the §4 chrome lands.
// deferred until the §4 chrome lands.
func (st *uiState) notifyAttention(childID, reason string) { func (st *uiState) notifyAttention(childID, reason string) {
c := st.sess.FindChild(childID) c := st.sess.FindChild(childID)
name := childID name := childID
if c != nil { if c != nil {
name = c.DisplayName() name = c.DisplayName()
} }
st.mu.Lock() st.notifyToast(toastAttention, fmt.Sprintf("%s — %s", name, reason))
st.attentionText = fmt.Sprintf("attention: %s — %s", name, reason)
st.attentionAt = childID
st.mu.Unlock()
st.drawStatusLine()
} }
func (st *uiState) scratchpadsChanged() { func (st *uiState) scratchpadsChanged() {
@@ -707,6 +755,9 @@ func (st *uiState) scratchpadsChanged() {
// on whatever the user was watching; the new child is still surfaced in // 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. // the sidebar/tab bar so it's reachable via the palette or select_process.
func (st *uiState) OnChildSpawned(c *Child) { func (st *uiState) OnChildSpawned(c *Child) {
if st.summaries != nil {
st.summaries.RegisterChild(c)
}
if c.ParentID != "" { if c.ParentID != "" {
st.mu.Lock() st.mu.Lock()
if st.palette != nil { if st.palette != nil {
@@ -781,6 +832,9 @@ func (st *uiState) OnChildStateChanged(string, IdleState) {
// OnChildExited drops focus and shows the empty state if it was the // OnChildExited drops focus and shows the empty state if it was the
// focused child. // focused child.
func (st *uiState) OnChildExited(c *Child) { func (st *uiState) OnChildExited(c *Child) {
if st.summaries != nil {
st.summaries.UnregisterChild(c.ID)
}
st.lastExit.Store(int32(c.ExitCode())) st.lastExit.Store(int32(c.ExitCode()))
st.marquee.reset() st.marquee.reset()
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
@@ -868,6 +922,9 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
if st.metrics != nil { if st.metrics != nil {
entry = time.Now() entry = time.Now()
} }
if st.summaries != nil {
st.summaries.ObserveOutput(childID)
}
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.mu.Lock() st.mu.Lock()
focus := st.focusedID focus := st.focusedID
@@ -910,14 +967,19 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.metrics.recordRender(time.Since(rstart)) st.metrics.recordRender(time.Since(rstart))
} }
} }
// One write covers the autowrap-disable prelude, the chunk, and the // One write covers the autowrap-disable prelude, the chunk, the
// autowrap-restore postlude — three syscalls collapsed into one // autowrap-restore postlude, and (when a toast is up) the toast
// under outMu. The three sequences were already emitted atomically // overlay — four syscalls collapsed into one under outMu. The
// under the lock; coalescing just halves the syscall count. // sequences were already emitted atomically under the lock;
wrapped := make([]byte, 0, len(out)+10) // 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, "\x1b[?7l"...)
wrapped = append(wrapped, out...) wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...) wrapped = append(wrapped, "\x1b[?7h"...)
wrapped = append(wrapped, overlay...)
var wstart time.Time var wstart time.Time
if st.metrics != nil { if st.metrics != nil {
wstart = time.Now() wstart = time.Now()
@@ -1104,8 +1166,6 @@ func (st *uiState) drawStatusLine() {
palOpen := st.palette != nil palOpen := st.palette != nil
focusID := st.focusedID focusID := st.focusedID
focusName := st.focusedName focusName := st.focusedName
attention := st.attentionText
attentionAt := st.attentionAt
var trustMsg string var trustMsg string
if st.pendingTrust != nil { if st.pendingTrust != nil {
trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName) trustMsg = fmt.Sprintf("trust preset %q? [y]es / [n]o", st.pendingTrust.presetName)
@@ -1145,13 +1205,6 @@ func (st *uiState) drawStatusLine() {
left = owner left = owner
} }
} }
if attention != "" && attentionAt == focusID {
left = "[!] " + attention
}
if attention != "" && attentionAt == "" {
// Sticky attention/flash from somewhere outside the focused pane.
left = "[!] " + attention
}
if trustMsg != "" { if trustMsg != "" {
left = "[trust] " + trustMsg left = "[trust] " + trustMsg
} }
@@ -1169,6 +1222,12 @@ func (st *uiState) drawStatusLine() {
hints = append(hints, "Ctrl-R · restart") 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, " · ") right := strings.Join(hints, " · ")
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 { for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
hints = hints[1:] hints = hints[1:]
@@ -1207,8 +1266,6 @@ func (st *uiState) drawStatusLine() {
// child is focused. // child is focused.
func (st *uiState) renderEmptyState() { func (st *uiState) renderEmptyState() {
layout := st.layoutSnapshot() layout := st.layoutSnapshot()
st.outMu.Lock()
defer st.outMu.Unlock()
line := "Press Ctrl-K to spawn an agent or process" line := "Press Ctrl-K to spawn an agent or process"
row := int(layout.mainTop) + (int(layout.childRows()) / 2) row := int(layout.mainTop) + (int(layout.childRows()) / 2)
col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2) col := int(layout.mainLeft) + ((int(layout.childCols()) - len(line)) / 2)
@@ -1218,7 +1275,10 @@ func (st *uiState) renderEmptyState() {
if col < int(layout.mainLeft) { if col < int(layout.mainLeft) {
col = int(layout.mainLeft) col = int(layout.mainLeft)
} }
st.outMu.Lock()
fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line) fmt.Fprintf(os.Stdout, "\x1b[?25l\x1b[H\x1b[2J\x1b[%d;%dH\x1b[2m%s\x1b[0m", row, col, line)
st.outMu.Unlock()
st.renderToasts()
} }
func (st *uiState) hostSizeSnapshot() (uint16, uint16) { func (st *uiState) hostSizeSnapshot() (uint16, uint16) {
@@ -1349,6 +1409,7 @@ func (st *uiState) processStdin(chunk []byte) {
var pendingViewportBottom bool var pendingViewportBottom bool
var pendingPadStep int var pendingPadStep int
var pendingPadExit bool var pendingPadExit bool
var pendingDismissToast bool
flushForward := func() { flushForward := func() {
if len(forward) == 0 { if len(forward) == 0 {
@@ -1361,6 +1422,9 @@ func (st *uiState) processStdin(chunk []byte) {
// writes so claude / codex / opencode don't treat a // writes so claude / codex / opencode don't treat a
// "text\r" batch as a paste. // "text\r" batch as a paste.
_ = c.InjectAsUser(forward) _ = c.InjectAsUser(forward)
if st.summaries != nil {
st.summaries.ObserveHumanInput(c.ID, forward)
}
if prev != OwnerUser { if prev != OwnerUser {
go st.drawStatusLine() go st.drawStatusLine()
} }
@@ -1532,6 +1596,11 @@ func (st *uiState) processStdin(chunk []byte) {
} else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 's'); hit { } else if hit, _ := matchCtrlChar(chunk, i, 's'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'n'); hit {
// Ctrl-N is the toast dismiss key. In pad view we
// allow it through the chord block so the handler
// below can fire even though pads otherwise swallow
// bytes.
} else { } else {
i++ i++
continue continue
@@ -1630,6 +1699,22 @@ func (st *uiState) processStdin(chunk []byte) {
break break
} }
} }
// Ctrl-N dismisses the most recent toast. We only consume the
// chord when there's actually a toast to dismiss; otherwise the
// bytes fall through to the focused PTY so readline /
// nano / emacs / opencode keep working in shells and editors.
if hit, adv := matchCtrlChar(chunk, i, 'n'); hit {
if st.toasts.length() > 0 {
flushForward()
pendingDismissToast = true
i += adv
continue
}
forward = append(forward, chunk[i:i+adv]...)
i += adv
continue
}
// Ctrl-B snaps the focused child's emulator viewport back to the // Ctrl-B snaps the focused child's emulator viewport back to the
// active area. Use this as the escape hatch from a scrolled-up // active area. Use this as the escape hatch from a scrolled-up
// state — wheel scrolls move the viewport into the libghostty // state — wheel scrolls move the viewport into the libghostty
@@ -1711,6 +1796,11 @@ func (st *uiState) processStdin(chunk []byte) {
if pendingPadExit { if pendingPadExit {
st.exitPadView() st.exitPadView()
} }
if pendingDismissToast {
if st.toasts.dismissTop() {
st.refreshToastSurface()
}
}
} }
// scrollFocusedViewport scrolls the focused child's emulator viewport by // scrollFocusedViewport scrolls the focused child's emulator viewport by
@@ -1763,7 +1853,10 @@ func (st *uiState) scrollFocusedViewportToBottom() {
} }
func (st *uiState) openPaletteLocked() { func (st *uiState) openPaletteLocked() {
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets) st.settingsMu.Lock()
appSettings := st.settings.clone()
st.settingsMu.Unlock()
st.palette = newPalette(st.sess.Children(), st.focusedID, st.focusedPad, st.presets, appSettings)
// Push a "no kitty flags" entry onto the host terminal's keyboard // Push a "no kitty flags" entry onto the host terminal's keyboard
// stack so palette input arrives in plain legacy form regardless of // stack so palette input arrives in plain legacy form regardless of
// what the focused child pushed. Codex/ratatui enables kitty mode // what the focused child pushed. Codex/ratatui enables kitty mode
@@ -1916,6 +2009,11 @@ func (st *uiState) closePalette(action paletteAction) {
case "quit": case "quit":
st.requestExit() st.requestExit()
case "toasts-clear":
if st.toasts.clear() {
st.refreshToastSurface()
}
case "pad-delete": case "pad-delete":
st.handlePadDelete(action.padName) st.handlePadDelete(action.padName)
@@ -1936,9 +2034,85 @@ func (st *uiState) closePalette(action paletteAction) {
case "proc-restart": case "proc-restart":
st.handleProcRestart(action.childID) st.handleProcRestart(action.childID)
case "settings-close":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
case "settings-test":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
go st.testSummarizer()
case "settings-run-now":
st.applySettingsAction(action)
restoreView()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
st.runSummaryNow()
} }
} }
func (st *uiState) applySettingsAction(action paletteAction) {
if action.settings == nil {
return
}
next := action.settings.clone()
st.settingsMu.Lock()
path := st.settingsPath
st.settingsMu.Unlock()
if err := saveSettings(path, next); err != nil {
st.flashError(fmt.Sprintf("save settings: %v", err))
return
}
st.settingsMu.Lock()
st.settings = next
st.settingsMu.Unlock()
}
func (st *uiState) testSummarizer() {
if st.summaries == nil {
return
}
base := st.ctx
if base == nil {
base = context.Background()
}
ctx, cancel := context.WithTimeout(base, summaryTimeout)
defer cancel()
if err := st.summaries.Test(ctx); err != nil {
st.flashError(fmt.Sprintf("summarizer test: %v", err))
return
}
st.flashTransient("summarizer test passed")
}
func (st *uiState) runSummaryNow() {
if st.summaries == nil {
return
}
st.mu.Lock()
active := st.activeAgentID
st.mu.Unlock()
if active == "" {
st.flashError("no active top-level agent to summarize")
return
}
ctx := st.ctx
if ctx == nil {
ctx = context.Background()
}
st.summaries.RunNow(ctx, active)
st.flashTransient("summary requested")
}
func (st *uiState) handlePadDelete(name string) { func (st *uiState) handlePadDelete(name string) {
if name == "" || st.pads == nil { if name == "" || st.pads == nil {
st.repaintFocused() st.repaintFocused()
@@ -2116,28 +2290,18 @@ func (st *uiState) handleProcRestart(childID string) {
st.drawStatusLine() st.drawStatusLine()
} }
// flashError surfaces a spawn/etc. failure in the status line until the // flashError surfaces a spawn/etc. failure as an error toast over the
// next attention update overwrites it. stderr is hidden under the alt // focused pane. stderr is hidden under the alt screen so we can't rely
// screen so we can't rely on Fprintln(os.Stderr). // on Fprintln(os.Stderr).
func (st *uiState) flashError(msg string) { func (st *uiState) flashError(msg string) {
st.mu.Lock() st.notifyToast(toastError, msg)
st.attentionText = msg
st.attentionAt = "" // shows on every focus until cleared
st.mu.Unlock()
st.renderEmptyState()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
} }
// flashTransient is the softer cousin of flashError used for // flashTransient is the softer cousin of flashError used for
// trust-prompt resolutions. Same status-line surface; the prefix differs. // trust-prompt resolutions and other ack-style notices. Same
// stackable surface, info styling.
func (st *uiState) flashTransient(msg string) { func (st *uiState) flashTransient(msg string) {
st.mu.Lock() st.notifyToast(toastInfo, msg)
st.attentionText = msg
st.attentionAt = ""
st.mu.Unlock()
st.drawStatusLine()
} }
// repaintFocused redraws the current focused child's screen snapshot. // repaintFocused redraws the current focused child's screen snapshot.
@@ -2181,8 +2345,9 @@ func (st *uiState) repaintFocused() {
} }
st.mu.Unlock() st.mu.Unlock()
st.outMu.Lock() st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out) _, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
} }
// repaintFocusedPad paints the focused scratchpad's content into the // repaintFocusedPad paints the focused scratchpad's content into the
@@ -2206,8 +2371,9 @@ func (st *uiState) repaintFocusedPad() {
return return
} }
st.outMu.Lock() st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out) _, _ = os.Stdout.Write(out)
st.outMu.Unlock()
st.renderToasts()
} }
// renderPadView builds the bytes that paint a scratchpad's content // renderPadView builds the bytes that paint a scratchpad's content

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -331,6 +331,16 @@ func (st *uiState) drawSidebar() {
write(prefix + openStyle + nameCell + styleReset + suffix) write(prefix + openStyle + nameCell + styleReset + suffix)
} }
if summary := st.activeSummaryText(width - 4); summary != "" && row+2 <= maxRow {
write("")
for _, line := range wrapSidebarSummary(summary, width-4) {
if row > maxRow {
break
}
write(" " + styleDim + line + styleReset)
}
}
// Scratchpads list — names only. The preview pane used to live // Scratchpads list — names only. The preview pane used to live
// here and clobbered the main viewport when content overflowed the // here and clobbered the main viewport when content overflowed the
// rail. Focus moves to a pad via Ctrl+W/S; the content renders in // rail. Focus moves to a pad via Ctrl+W/S; the content renders in
@@ -390,3 +400,42 @@ func (st *uiState) drawSidebar() {
fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame) fmt.Fprintf(os.Stdout, "\x1b7%s\x1b8", frame)
st.outMu.Unlock() st.outMu.Unlock()
} }
func wrapSidebarSummary(s string, width int) []string {
if width < 1 {
width = 1
}
words := strings.Fields(s)
if len(words) == 0 {
return nil
}
var out []string
var cur string
for _, word := range words {
if visibleLen(word) > width {
if cur != "" {
out = append(out, cur)
cur = ""
}
out = append(out, clipRunes(word, width-1)+"…")
continue
}
if cur == "" {
cur = word
continue
}
if visibleLen(cur)+1+visibleLen(word) <= width {
cur += " " + word
continue
}
out = append(out, cur)
cur = word
}
if cur != "" {
out = append(out, cur)
}
if len(out) > 3 {
out = out[:3]
}
return out
}

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

View File

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

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

View File

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

View File

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

View File

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

View File

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