11 Commits

Author SHA1 Message Date
ef9b8e71c6 Release v0.0.5
All checks were successful
release / build-linux-amd64 (push) Successful in 11m51s
2026-05-15 20:56:38 +01:00
e64060e40f Calm down the focused-section labels in the command palette
Focused-section rows are now bare verbs (Rename, Close, Stop, Restart,
Delete, Edit) instead of repeating the focused name. The title bar
already carries the subject, and the row hint preserves fuzzy-search
matches like "close codex". Section banners are replaced by a single
blank spacer row so the verbs themselves carry the visual weight,
and the Open section no longer lists "Switch to <current>" for the
pane that's already focused.
2026-05-15 20:30:31 +01:00
e4ab8c2136 Merge pull request 'Add stackable toast notifications' (#5) from worktree-toast-notifications into main 2026-05-15 20:28:10 +01:00
f312b6d345 Add stackable toast notifications
Replaces the single-slot status-line flash with a top-right toast
stack over the focused pane. flashError, flashTransient, and
notifyAttention all push onto the same stack (cap 5, FIFO drop).
Ctrl-N dismisses the most recent toast; empty stack falls through to
the focused PTY so readline / nano / emacs / opencode bindings keep
working. A new "Clear notifications" palette item empties the stack.
2026-05-15 20:26:35 +01:00
e6f5a94fae Trim actioned perf-audit items; add palette polish TODO
Removes the 2026-05-15 perf audit findings that have either shipped
(see CHANGELOG) or are tracked elsewhere, and replaces them with the
remaining palette-refinement notes: generic labels for focused
actions ("Close current agent") and a higher-level concern that the
palette has grown cluttered as features were added.
2026-05-15 19:53:51 +01:00
c1ecba0624 Use mise to install zig + go in release CI; cut 0.0.4
All checks were successful
release / build-linux-amd64 (push) Successful in 13m7s
`mlugg/setup-zig` was chasing mirrors for ~4 minutes on every run
(see v0.0.1 / v0.0.2 logs) and `actions/setup-go` was spending
another ~4 minutes downloading Go before patterm started building.
mise already manages the project's zig pin; adding `go = "1.26.3"`
to `.mise.toml` (matching go.mod) lets `jdx/mise-action@v2` install
both with one cached step. Subsequent runs reuse the mise cache
instead of re-resolving mirror URLs and re-downloading toolchains.

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

Also rolls the existing CHANGELOG `[Unreleased]` work into a
dated `[0.0.3]` section and adds the CI fix to its Fixed list.
2026-05-15 19:14:21 +01:00
6d90cd7185 Match Solo summary cadence options 2026-05-15 19:13:54 +01:00
d648d5b775 Add auto-summary settings 2026-05-15 19:09:21 +01:00
1bf51bb784 Merge pull request 'Overhaul command palette UX' (#4) from feat/palette-ux-overhaul into main
Reviewed-on: #4
2026-05-15 18:25:38 +01:00
21 changed files with 2219 additions and 282 deletions

View File

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

View File

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

View File

@@ -6,6 +6,73 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.0.5] - 2026-05-15
### Changed
- Replaced the single-slot status-line "flash" with a stackable toast
surface anchored at the top-right of the focused pane. `flashError`,
`flashTransient`, and MCP `request_human_attention` now push onto
the toast stack (cap 5, oldest drops). Toasts persist until
dismissed with `Ctrl-N`, or cleared via the new
"Clear notifications" palette command. The status line no longer
shows the `[!]` prefix.
- `Ctrl-N` is consumed by the host only when there is a toast to
dismiss; an empty stack lets `Ctrl-N` pass through to the focused
child so readline / nano / emacs / opencode keep their bindings.
- Command palette is calmer when something is focused. Focused-section
rows now read as bare verbs (`Rename`, `Close`, `Stop`, `Restart`,
`Delete`, `Edit`) instead of repeating the focused name (`Close
agent: codex`); the title bar's `on: codex` / `pad: notes.md`
carries the subject. Fuzzy queries still match the dropped context
through the row hint (e.g. typing `close codex` still finds the
Close row).
- Dashed `── Focused ──` / `── Open ──` / `── Spawn ──` section
banners are gone. Sections are separated by a single blank spacer
row, so the action labels themselves carry the visual weight.
- The Open section no longer lists a `Switch to <current>` row for
the pane you're already focused on.
## [0.0.4] - 2026-05-15
### Changed
- Release workflow (`.gitea/workflows/release.yml`) now provisions
Zig and Go through `jdx/mise-action@v2`, reading the versions from
`.mise.toml` (zig 0.15.2, go 1.26.3). Both toolchains were
previously installed via `mlugg/setup-zig` and `actions/setup-go`,
whose mirror chase / GitHub fetch combined for ~8 minutes per run
before any patterm code compiled. mise pulls each tool once and
caches the install dir, so subsequent runs hit the cache instead of
re-downloading. `make deps` still resolves zig via `mise which zig`
with a PATH fallback; `go.mod` already pinned `go 1.26.3`, so the
new `go` entry in `.mise.toml` just keeps CI and local builds on
the same toolchain.
- A Go module/build cache step (`actions/cache@v4`, keyed on
`go.sum`) was added so `go build` doesn't re-download dependencies
on every tag push.
## [0.0.3] - 2026-05-15
### Added
- Auto-summarization for top-level agent tabs. patterm now loads
`$XDG_CONFIG_HOME/patterm/settings.json`, enables Codex-based
summaries by default (`gpt-5.4-mini`; OpenCode defaults to
`opencode-go/minimax-m2.7`), and can run Codex, OpenCode, or opt-in
Claude summarizers with configurable model names. Summary
attempts are armed by meaningful human input, wait for recent output
to go quiet, and respect a minimum cadence so unchanged tabs are not
summarized on a timer. The active thread summary appears under the
top tab title and in the sidebar below the Agent Tree section.
- Settings overlay reachable from the command palette via
`Open Settings`. The searchable Settings picker opens
`Agents / Auto-summarization`, where users can enable/disable
summaries, choose provider, edit provider model names, cycle cadence,
test the selected summarizer (`patterm okay`), summarize the current
top-level agent immediately, and explicitly save or cancel draft
settings changes. Cadence choices match Solo: `15s`, `30s`, and
`1m`; the value is a minimum quiet/activity gap before another
summary attempt for the same top-level agent, not a background
periodic timer.
### Changed
- Command palette UX overhaul. The single flat list grew section
bands (`── Focused ──`, `── Open ──`, `── Spawn ──`, `── Quit ──`)
@@ -47,6 +114,15 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
the command field.
### Fixed
- Error/status flashes now restore the currently focused pane instead
of drawing the empty-state hint over a running agent or process.
- Release workflow (`.gitea/workflows/release.yml`) now uses
`mlugg/setup-zig@v2` instead of the deprecated `@v1`. v1 hard-coded
the pre-0.14 tarball name (`zig-linux-x86_64-<ver>.tar.xz`), so
every mirror and the official `ziglang.org/builds` returned 404 for
Zig 0.15.2 and the v0.0.1 / v0.0.2 tag pushes never produced a
release asset. v2 uses the post-0.14 `zig-x86_64-linux-<ver>.tar.xz`
layout, so the runner can fetch Zig and build patterm.
- Typing into a focused child while its emulator viewport is
scrolled up into scrollback history now auto-snaps the viewport
back to the live area. Previously the keystroke reached the

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

View File

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

View File

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

View File

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

View File

@@ -57,22 +57,45 @@ func TestPaletteDropsGlobalCloseList(t *testing.T) {
// -- Phase 2: section headers and cursor skip ------------------------
func TestPaletteSectionHeadersPresent(t *testing.T) {
func TestPaletteSectionsSeparatedBySpacers(t *testing.T) {
// Section-named dashed headers are gone; groups are visually
// separated by a single non-selectable blank row. Verify that the
// build emits one such spacer between every pair of adjacent groups
// and never a leading spacer.
c := makeFakeChild("a", "claude", KindAgent)
p := newPalette([]*Child{c}, "a", "", preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
wantSections := []string{"Focused", "Open", "Spawn", "Quit"}
for _, w := range wantSections {
found := false
for _, it := range p.items {
if it.action.kind == "header" && strings.Contains(it.label, w) {
found = true
break
other := makeFakeChild("b", "worker", KindCommand)
p := newPalette([]*Child{c, other}, "a", "",
preset.Set{Agents: []*preset.Preset{{Name: "codex"}}})
if len(p.items) == 0 {
t.Fatalf("palette built no items")
}
if p.items[0].action.kind == "header" {
t.Fatalf("first row is a spacer; should be a selectable item")
}
transitions := 0
prevGroup := p.items[0].group
for i := 1; i < len(p.items); i++ {
it := p.items[i]
if it.group != prevGroup {
if it.action.kind != "header" || it.label != "" {
t.Fatalf("group transition at %d not a blank spacer: %+v", i, it)
}
transitions++
// The row immediately after the spacer must be selectable.
if i+1 >= len(p.items) || p.items[i+1].action.kind == "header" {
t.Fatalf("spacer at %d not followed by selectable row", i)
}
prevGroup = p.items[i+1].group
}
if !found {
t.Errorf("section header %q missing from items", w)
// No dashed banners anywhere.
if it.action.kind == "header" && strings.Contains(it.label, "──") {
t.Errorf("dashed section header still present at %d: %q", i, it.label)
}
}
if transitions == 0 {
t.Fatalf("no section transitions found in palette items")
}
}
func TestPaletteCursorSkipsHeaders(t *testing.T) {
@@ -321,6 +344,32 @@ func TestPaletteAltDigitQuickPick(t *testing.T) {
}
}
func TestAutoSummaryCadenceCyclesSoloValues(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{}, defaultSettings())
p.mode = paletteModeAutoSummary
for i, row := range autoSummaryRows() {
if row.key == "cadence" {
p.cursor = i
break
}
}
if p.settings.AutoSummary.Cadence != "1m" {
t.Fatalf("initial cadence = %q", p.settings.AutoSummary.Cadence)
}
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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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