10 Commits

Author SHA1 Message Date
e657c66dde Merge remote-tracking branch 'origin/main' into feat/idle-detection
# Conflicts:
#	TODO.md
2026-05-15 11:21:28 +01:00
543c7cc59a Fix idle timer review issues 2026-05-15 11:18:03 +01:00
2b9e1ed77c Add idle-state classifier and Solo-parity timer tools
Classifies every running child as idle/working/thinking/permission/error
using one of three pluggable strategies (output_activity,
osc_title_stability, osc_title_status) plus optional regex promoters
applied to the tail of recent output. State and last-match reason are
exposed via MCP on ProcessInfo and get_process_status. Per-preset
configuration lives on a new preset.IdleDetection block with bundled
defaults for the first-party claude/codex/opencode presets.

OSC title plumbing is exposed as Emulator.Title(), polled from the
session pump after each emulator write so title-change activity feeds
into the classifier without an extra cgo callback.

The MCP timer surface expands to match Solo: timer_set,
timer_fire_when_idle_any/all, timer_cancel, timer_pause, timer_resume,
timer_list. timer_wait is now a thin wrapper that shares the same
manager so it shows up in timer_list while pending. Timer bodies are
delivered to the owner process through the existing
InjectAsOrchestrator path. Top-level (non-agent) callers can attach
timers to a specific process via owner_process_id; omitting it grants
universal cancel/pause/resume/list privileges.

The sidebar gains a state glyph per process row and appends a
nearest-timer indicator when one is pending or paused.

Tests: idle_test.go covers the classify() pure function across the
three strategies and regex promotion; timers_test.go covers the
manager. Harness scenarios cover output_activity, osc_title_stability,
osc_title_status, and regex promotion, plus timer_set delivery,
cancel, pause/resume, idle_any-on-transition, idle_all-pending, and
idle_all-already-satisfied. A new wait_until_mcp harness step type
polls an MCP method until an assertion holds.
2026-05-15 09:49:59 +01:00
9d0168f139 new todos 2026-05-15 00:56:49 +01:00
1af032472b Remove TODO entry for context-aware palette options 2026-05-15 00:51:35 +01:00
05f92a3ed0 Add context-aware items to the command palette
When opened with Ctrl-K, the palette now prepends entries for whatever
is currently focused:

- Focused scratchpad: Delete / Rename (inline form) / Edit (fire-and-
  forget zed launch with stdio detached so the TUI is not suspended).
- Focused agent: Rename (inline form) / Close.
- Focused process: Rename / Delete (drops the entry; SIGKILL if alive)
  / Stop (SIGTERM, keep entry) / Restart (same argv).

The rename UX is a single-field inline form that mirrors the existing
spawn-process form, so the modal-input contract is unchanged.
scratchpad.Store grows Delete / Rename / Path so the palette can act
on a pad file by name. focusedPad is plumbed onto uiState ahead of the
scratchpad-focus UI work; until that lands it stays empty and the
scratchpad-context entries simply never surface.

Tested with palette_context_test.go and a new rename_process_via_palette
harness scenario.
2026-05-15 00:51:07 +01:00
81a8ac2ba0 Fix command palette over focused scratchpad
The stdin loop's scratchpad-input branch ran before the palette branch
and silently dropped every byte except a handful of app-level chords,
so palette typing and Esc never reached the palette while a pad was
focused. Skip the pad-input branch whenever st.palette != nil.

closePalette also called repaintFocused() on cancel / no-op action
paths, which paints the empty focused-child slot (focusedID == "" while
a pad is focused) and leaves the palette's top border drawn over the
pad. Route those branches through a restoreView helper that picks
repaintFocusedPad when a pad is focused.

Switching from a pad to a child via the palette now clears the pad
focus and wipes the viewport, matching focusProcess's pad-exit path.

Adds a harness scenario (palette_over_scratchpad) that opens a pad,
opens the palette, types a query, and verifies that Esc leaves the
pad correctly repainted with no palette chrome lingering.
2026-05-15 00:35:28 +01:00
0d578d54f1 wip 2026-05-15 00:28:06 +01:00
2f969fa215 Fix sidebar repaint and command restart navigation 2026-05-14 22:41:24 +01:00
83eb4f6b2d Add --version flag and enforce --long flags via pflag
Switches CLI flag parsing from Go's stdlib `flag` to spf13/pflag so
`--project` (and the internal `--socket` / `--identity` / `--scenario`
flags) are the only accepted form; single-hyphen long flags like
`-project` are now rejected. Help output renders the canonical `--`
form.

Adds `patterm --version`, which prints the build version, short commit,
and build date (e.g. `patterm v0.0.1 (commit abc1234, built 2026-05-14)`).
The version string is injected at build time — `make patterm` derives it
from `git describe --tags --always --dirty`, and the release workflow
injects the pushed tag. Commit/date come from the Go toolchain's
embedded VCS info via `runtime/debug.ReadBuildInfo`, so no manual
bumping is required.
2026-05-14 22:22:32 +01:00
69 changed files with 7133 additions and 276 deletions

View File

@@ -30,7 +30,8 @@ jobs:
CGO_ENABLED: 1
run: |
mkdir -p dist
go build -trimpath -ldflags="-s -w" \
go build -trimpath \
-ldflags="-s -w -X main.version=${{ github.ref_name }}" \
-o dist/patterm-${{ github.ref_name }}-linux-amd64 \
./cmd/patterm

View File

@@ -6,6 +6,196 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Per-child idle-state classifier with five states (`idle`, `working`,
`thinking`, `permission`, `error`) and three pluggable strategies:
`output_activity` (claude / opencode defaults), `osc_title_stability`
(codex), and `osc_title_status` (gemini-style status-in-title agents).
Optional `permission_patterns` / `thinking_patterns` / `error_patterns`
regexes promote a base state when matched against the tail of recent
output. State and last-match reason are exposed via MCP on
`ProcessInfo` and `get_process_status` (`idle_state`, `idle_reason`).
- New `idle_detection` block on `preset.Preset` for setting the strategy
threshold, title-to-state map, and promoter regex lists. Bundled
defaults are shipped for the first-party claude / codex / opencode
presets.
- Sidebar now renders a state glyph per process row (`○` idle, `●`
working, `◐` thinking, `?` permission, `✕` error) and, when a process
has a pending or paused timer, appends a nearest-timer indicator
(`⏱ 12s` or `⏸ paused`).
- MCP timer surface expanded to match Solo's tool set: `timer_set`,
`timer_fire_when_idle_any`, `timer_fire_when_idle_all`, `timer_cancel`,
`timer_pause`, `timer_resume`, `timer_list`. Idle-aware timers
registered against already-idle children fire synchronously
(`status: already_satisfied`) for `idle_all`, and report
`already_idle` / `waiting_on` arrays so callers can introspect the
watch set. Timer bodies are delivered to the owner process via the
same orchestrator-injection path as `send_message`.
- Timer tools accept an explicit `owner_process_id` so top-level
(non-agent) callers — including the harness MCP client — can attribute
timers to a specific process. Omitting it treats the caller as the
orchestrator with universal cancel / pause / resume / list privileges.
- libghostty-vt `Title()` accessor on the emulator surface, polled from
the session pump so OSC 0/1/2 title updates feed into the classifier
without a callback round-trip.
- Harness `wait_until_mcp` step type that re-runs an MCP method until an
assertion (Equals / Contains) holds or the timeout elapses. Used by
the new idle / timer scenarios.
- User-created top-level command processes now survive a patterm
restart. Each spawn (palette form, command preset, or MCP
`spawn_process` with `kind=command`) writes a record to
`$XDG_DATA_HOME/patterm/projects/<key>/processes.json`; on next
startup patterm replays those entries before the UI accepts input,
so things like `bun run dev` or `tail -F log` come back without
re-typing. `close_process` (and the palette's close action) drops
the entry, and rename / "relaunch on exit" toggles are mirrored as
they happen. Agents and terminals stay ephemeral by design.
- The command palette (Ctrl-K) now surfaces context-aware actions at
the top of the list, based on what's currently focused:
- Scratchpad in focus: `Delete`, `Rename` (inline form), and `Edit`
(fire-and-forget launch of `zed` against the pad file).
- Agent in focus: `Rename agent` (inline form) and `Close agent`.
- Process in focus: `Rename process`, `Delete process` (drops the
entry; SIGKILLs if alive), `Stop process` (SIGTERM, keep entry),
and `Restart process` (same argv).
- `patterm --version` prints the build version, git commit, and build
date (e.g. `patterm v0.0.1 (commit abc1234, built 2026-05-14)`). The
version string is injected by the build (`make patterm` derives it
from `git describe`; the release workflow injects the pushed tag).
Commit and date come from the Go toolchain's embedded VCS info, so
nothing has to be bumped by hand.
- Ctrl+R restarts the focused command process from the Processes
sidebar, including command entries that have already exited.
- Scratchpads are now first-class navigation targets. Ctrl+W / Ctrl+S
step from the Processes section and agent tree onto scratchpad
entries; a focused scratchpad renders its content in the main
viewport (with the pad name as the header) instead of cramping the
bottom of the sidebar. The sidebar's scratchpad section is a names-
only list with the focused pad highlighted, and external MCP
`scratchpad_write` / `scratchpad_append` updates repaint the pad
view immediately.
- Focused scratchpads now render as markdown — headings, bold, inline
code, fenced code blocks, bullet/numbered lists, blockquotes, and
horizontal rules pick up styling instead of the previous flat
word-wrap. Long pads scroll: the mouse wheel is the primary control
(patterm enables SGR mouse reporting while a pad is focused), and
Up/Down / PageUp/PageDown / Home / End work for keyboard users. The
header reports the visible row range and total row count. Esc leaves
the pad view and falls back to the first running process (or an
empty viewport). The scroll offset is preserved across MCP
`scratchpad_write` / `scratchpad_append` writes so a live update
doesn't snap the view back to the top.
- Inline wheel scrollback for the focused child, backed by
libghostty-vt's native 5000-row scrollback history. On the primary
screen, mouse-wheel events scroll the emulator viewport in-place with
full SGR styling preserved — no modal view to enter or exit. On the
alternate screen wheel events still pass through to the child so
vim / less / codex receive them as input. Ctrl+B snaps the viewport
back to the live (bottom) area as the escape hatch from a scrolled-up
state. Patterm now keeps SGR mouse reporting armed on the host
terminal while the alt screen is active and filters mouse-mode
toggles from the child stream so wheel events keep arriving even
after a child program disables mouse tracking.
### Changed
- `timer_wait` is now a thin wrapper over the shared timer manager
(`timer_set` semantics). Existing callers see no behavioural change;
the timer is visible in `timer_list` while it's pending.
- CLI flag parsing switched from Go's stdlib `flag` to `spf13/pflag`.
`--project` (and the internal `--socket` / `--identity` /
`--scenario` / `--patterm-bin` flags) are now the only accepted form
— single-hyphen long flags like `-project` are rejected. Help output
renders the canonical `--flag` form.
### Fixed
- `whoami` and `help("timers")` now advertise the full Solo-parity timer
surface (`timer_set`, `timer_fire_when_idle_any`,
`timer_fire_when_idle_all`, `timer_cancel`, `timer_pause`,
`timer_resume`, `timer_list`) so agents using either tool for
orientation discover them — previously only `timer_wait` was listed.
- Resuming a paused idle-aware timer now re-checks the satisfaction
condition. Previously, if every watched process became idle (or, for
`idle_any`, any non-baseline watcher went idle) while the timer was
paused, the timer stayed pending forever because no further state
transitions were observed.
- Fired and canceled timers are now removed from the timer registry,
so long-running patterm sessions no longer accumulate completed
timer records and message bodies. `timer_list` and the sidebar
indicator already filtered them out; only the in-memory leak is
fixed.
- Per-preset idle-detection config is now installed through `SpawnSpec`
before the child is published to the session, closing a race in
which the classifier goroutine could observe a freshly spawned
process before its preset's classifier strategy was attached.
- Opening the command palette while a scratchpad was focused left the
palette wedged — typing did nothing and Esc left the palette's top
border drawn over the pad until you closed the pad with Ctrl-W and
re-opened the palette. The stdin loop's scratchpad-input branch ran
before the palette branch and silently dropped every byte except a
handful of app-level chords, so palette filter input and Esc never
reached `palette.handleInput`. The palette branch now takes
precedence whenever the palette is open, and `closePalette` repaints
the pad (instead of the empty focused-child slot) on cancel / no-op
action. Switching from a pad to a child via the palette now clears
the pad focus and wipes the viewport, matching `focusProcess`.
- Tab bar and bottom status row no longer get overwritten by long
claude / codex sessions. Three holes were letting child output land
on the chrome: (1) absolute cursor moves — CUP / HVP / VPA — added
the row offset but didn't clamp to the viewport, so a child whose
internal row state drifted past its assigned height could walk the
host cursor onto the status row (or above the tab bar); (2) relative
cursor moves — CUU / CUD / CNL / CPL — were forwarded verbatim, so
a `CSI 50 A` from viewport row 1 walked the host cursor into the
tab bar before the next printable wiped it; (3) the host's DECSTBM
scroll region was only set during snapshot-replay preludes, so the
windows between (startup before first focus, post-SIGWINCH,
post-clearScreen) left the region defaulted to the full screen and
any LF / IND / NEL / RI / SU / SD at the viewport bottom scrolled
the chrome rows along with the pane. The cursor shifter now clamps
CUP/HVP/VPA rows to mainTop..mainBottom, the viewport renderer
rewrites CUU/CUD/CNL/CPL with a clamped step (and homes the column
for CNL/CPL), and patterm installs the host scroll region after
`enterScreen` and after every `clearScreen` (and resets it cleanly
on `leaveScreen` so the calling shell isn't left with a constrained
region).
- Plain line-feed scrolling at the bottom of a child pane now invalidates
and repaints the sidebar, so long agent output can no longer drag the
sidebar border and labels out of view while the chrome cache stays warm.
- Child DEC origin-mode and left/right-margin controls are now handled
inside the viewport renderer instead of being forwarded to the host
terminal, so later tab bar, sidebar, and status-line repaints keep
using physical screen coordinates and do not appear inside the
focused pane.
- Exited command processes in the top Processes section are now reachable
with Ctrl+W/S navigation, so a dead shell entry can be focused and
restarted instead of becoming a visible but unreachable row.
- Resizing the host terminal no longer makes codex (and other
diff-based TUIs) scroll-jump for several seconds. SIGWINCH is now
coalesced into a single resize after an ~80ms idle, the resize path
skips the full snapshot replay (the child's own SIGWINCH-driven
redraw fills the viewport), and `Child.NudgeRedraw` no longer
toggles the PTY through rows-1 + rows back-to-back during a
drag-resize.
- Steady-state CPU during a long codex session dropped sharply.
Tab-bar and status-line repaints moved off the per-PTY-chunk path
to a 16ms chrome ticker; the scratchpad listing is cached and only
rebuilt when the pads change; the post-spawn / post-repaint
styled-snapshot replay budget dropped from 8 chunks to 2; URL/port
scanning short-circuits chunks that don't contain "http"; the
three writes around the autowrap toggle in `OnPTYOut` collapsed
into one syscall; the per-PTY-read `make+copy` was removed (the
64 KiB read buffer is reused, with a documented "do not retain"
listener contract); session listeners now dispatch through an
`atomic.Pointer` snapshot instead of a mutex copy on every chunk;
the per-child output ring is a true wrap-around buffer instead of
a slide-and-trim slice; `wait_for_pattern` wakes on PTY chunk
events with a 500ms fallback instead of unconditional 50ms
polling; ANSI stripping in MCP `get_process_output stream`,
`search_output`, and `wait_for_pattern scrollback` is now an
in-place byte walk; and the viewport renderer copies long ASCII
runs en bloc instead of feeding the state machine one byte at a
time.
## [0.0.1] - 2026-05-14
### Fixed

View File

@@ -32,11 +32,13 @@ deps-build: $(INSTALL)/lib/libghostty-vt.a
clean-deps:
rm -rf $(SOURCE) $(INSTALL)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
spike: deps
go build -o ./bin/spike ./cmd/spike
patterm: deps
go build -o ./bin/patterm ./cmd/patterm
go build -ldflags "-X main.version=$(VERSION)" -o ./bin/patterm ./cmd/patterm
test: deps
go test ./...

22
TODO.md
View File

@@ -1,3 +1,18 @@
- [ ] We should probably rename the Kill <Process> terminology to Close <Process> instead, across processes and agents.
- [ ] Exited shells are still being treated as active processes. They should be removed from the process list when they exit.
- [ ] Shells should be renamed to terminals. "New Terminal" etc.
- [ ] Codex seemed to think that it needed to launch patterm itself to get the mcp working
- [ ] I cant click and drag to select text from codex
- [ ] codex uses perl to interact with the socket rather than calling mcp tools
- when it _did_ open a sub claude it opened it as a separate tab rather than a sub-agent.
- [ ] codex rendering is VERY slow
- maybe we need to use diffing rather than rendering the entire viewport for performance
- We should add a --debug and --profile flag, so we can get detailed performance data and full logs of the agent output to be debugged later on.
- I don't mind what format this is in, ideally easy for LLMs to understand
- [ ] Resuming a long claude session takes a couple of seconds for the entire buffer to load in, it looks like it's scrolling down for a couple seconds.
- In raw alacritty this is instant, so there's some sort of performance issue with patterm's terminal emulation.
# 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
@@ -5,3 +20,10 @@
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

@@ -2,10 +2,11 @@ package main
import (
"encoding/json"
"flag"
"fmt"
"os"
flag "github.com/spf13/pflag"
"github.com/hjbdev/patterm/internal/harness"
)

View File

@@ -4,6 +4,7 @@
//
// patterm run in $PWD
// patterm --project <dir> run in <dir>
// patterm --version print version and exit
// patterm mcp-stdio --socket S --identity I
// internal: stdio MCP proxy spawned for
// children, forwards JSON-RPC over S
@@ -13,15 +14,22 @@ package main
import (
"context"
"flag"
"fmt"
"os"
"runtime/debug"
"time"
flag "github.com/spf13/pflag"
"github.com/hjbdev/patterm/internal/app"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/projectkey"
)
// version is overridden at build time via `-ldflags "-X main.version=..."`.
// Defaults to "dev" so source builds are still meaningful.
var version = "dev"
func main() {
// The mcp-stdio subcommand is a separate top-level mode: when an
// agent CLI launches `patterm mcp-stdio --socket ...`, the same
@@ -38,9 +46,17 @@ func main() {
return
}
var projectDir = flag.String("project", "", "project directory (default $PWD)")
var (
projectDir = flag.String("project", "", "project directory (default $PWD)")
showVersion = flag.Bool("version", false, "print version and exit")
)
flag.Parse()
if *showVersion {
fmt.Println(versionString())
return
}
cwd, err := os.Getwd()
if err != nil {
die("getwd: %v", err)
@@ -80,6 +96,33 @@ func runMCPProxy() {
}
}
func versionString() string {
commit, date := "unknown", "unknown"
if info, ok := debug.ReadBuildInfo(); ok {
dirty := false
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
if len(s.Value) >= 7 {
commit = s.Value[:7]
} else if s.Value != "" {
commit = s.Value
}
case "vcs.time":
if t, err := time.Parse(time.RFC3339, s.Value); err == nil {
date = t.Format("2006-01-02")
}
case "vcs.modified":
dirty = s.Value == "true"
}
}
if dirty && commit != "unknown" {
commit += "-dirty"
}
}
return fmt.Sprintf("patterm %s (commit %s, built %s)", version, commit, date)
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "patterm: "+format+"\n", args...)
os.Exit(1)

61
fucked-up-terminal-3.txt Normal file
View File

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

1
go.mod
View File

@@ -4,6 +4,7 @@ go 1.26.3
require (
github.com/creack/pty v1.1.24
github.com/spf13/pflag v1.0.10
golang.org/x/term v0.43.0
)

2
go.sum
View File

@@ -1,5 +1,7 @@
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=

26
idle-detection.md Normal file
View File

@@ -0,0 +1,26 @@
# Idle Detection
Solo does idle detection to show which agents are running, but this can also allow sub-agents to read state and/or trigger timers/actions based on idle state. This is important for things like permission checks. If an agent becomes idle, the orchestrator needs to know so it can approve permissions etc.
<solo-idle-detection-docs>
Agent idle detection
Solo tracks agent state so you can tell which agents are working, idle, waiting for permission, or blocked by an error.
How it works#
Solo uses a mix of signals:
First-party terminal agents use provider-specific activity strategies. Claude and OpenCode use visible output, Codex and Amp use OSC title stability, and Gemini uses OSC title status.
Auto-summarization can return one of IDLE, PERMISSION, THINKING, WORKING, or ERROR, and Solo stores that classification when available.
Summary timing#
For summaries, Solo waits until a process has had human input and then watches output activity. A brief quiet window can trigger a summary after output stops. Continuously busy processes can also trigger summaries after a longer busy window.
The summary cadence setting is still enforced per process, so repeated activity does not produce unlimited summary attempts.
Timers#
Agents can also have timers through Solo's agent-channel tools. Timer indicators show the nearest active or paused timer on the process row. Clicking the timer lets you view its message, cancel it, fire it now, or pause/resume it.
When a timer fires, Solo delivers the timer message back to the owning process.
Limits#
Idle detection is a heuristic. Some agents pause between steps before continuing on their own, and a quiet terminal is not always the same thing as completed work.
</solo-idle-detection-docs>

8
install.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/bash
echo "Building Patterm"
./build.sh
echo "Installing Patterm"
sudo cp ./bin/patterm /usr/local/bin
echo "Done"
echo "Copied ./bin/patterm to /usr/local/bin"

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
package app
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
@@ -108,16 +109,33 @@ type Child struct {
// ringMu guards ring. The ring buffer carries the last `ringCap`
// bytes the PTY produced, used by SPEC §7 get_process_output stream
// mode and search_output scrollback.
// mode and search_output scrollback. The ring is a fixed-size byte
// array with a wrap-around write index — no per-chunk reslice or
// reallocation. StreamRead serves contiguous slices by copying out
// of the (possibly wrapped) ring into a fresh buffer.
ringMu sync.Mutex
ring []byte
ringStart int64 // absolute offset of ring[0]
ringWrites int64 // cumulative bytes written
ring []byte // length == ringCap once allocated
ringPos int // next byte to overwrite
ringFull bool // true once ringWrites ≥ ringCap
ringWrites int64 // cumulative bytes written
// portsMu guards ports. Best-effort port detection: regex on stream.
portsMu sync.Mutex
ports []PortSighting
// Idle-detection state. idleState carries the classifier's current
// opinion (StateIdle / StateWorking / …). lastTitleNS is the wall
// time of the most recent OSC title change — separate from
// lastWriteNS so the osc_title_* strategies can ignore plain output
// churn. idleDetection is the compiled per-preset config, resolved
// once at spawn and immutable thereafter.
idleState atomic.Pointer[IdleState]
idleReason atomic.Pointer[string]
titleMu sync.RWMutex
title string
lastTitleNS atomic.Int64
idleDetection *resolvedIdleDetection
cleanupMu sync.Mutex
cleanupPaths []string
restarting atomic.Bool
@@ -127,10 +145,36 @@ type Child struct {
// exits and calls Start to bring the entry back up. Cleared when the
// user explicitly kills the process from the palette.
autoRestart atomic.Bool
// persistFn is set by Session after Spawn registers the entry. The
// callback mirrors mutable bits (name, auto-restart) into the
// persist store so a restarted patterm can rebuild this entry. Nil
// when no persist store is attached (unit tests / non-command
// entries).
persistMu sync.Mutex
persistFn func(*Child)
}
func (c *Child) SetAutoRestart(v bool) { c.autoRestart.Store(v) }
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
func (c *Child) SetAutoRestart(v bool) {
c.autoRestart.Store(v)
c.firePersist()
}
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
func (c *Child) setPersistFn(fn func(*Child)) {
c.persistMu.Lock()
c.persistFn = fn
c.persistMu.Unlock()
}
func (c *Child) firePersist() {
c.persistMu.Lock()
fn := c.persistFn
c.persistMu.Unlock()
if fn != nil {
fn(c)
}
}
// PortSighting is one entry returned by get_process_ports.
type PortSighting struct {
@@ -152,7 +196,7 @@ func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID
Kind: kind,
ParentID: parentID,
PresetRef: presetRef,
ring: make([]byte, 0, ringCap),
ring: make([]byte, ringCap),
}
st := StatusStopped
c.status.Store(&st)
@@ -254,6 +298,7 @@ func (c *Child) SetName(name string) {
c.nameMu.Lock()
c.Name = name
c.nameMu.Unlock()
c.firePersist()
}
// ScreenVersion returns the current emulator snapshot version, bumped
@@ -298,17 +343,95 @@ func (c *Child) IdleMS() int64 {
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
// TitleIdleMS returns how many milliseconds since the OSC window title
// last changed. 0 means "no title set yet".
func (c *Child) TitleIdleMS() int64 {
last := c.lastTitleNS.Load()
if last == 0 {
return 0
}
return (time.Now().UnixNano() - last) / int64(time.Millisecond)
}
// Title returns the most recent OSC 0/2 title.
func (c *Child) Title() string {
c.titleMu.RLock()
defer c.titleMu.RUnlock()
return c.title
}
// recordTitle updates the cached title and bumps lastTitleNS when it
// actually changes. Called from Session.pumpChild after each PTY chunk
// — cheap because most chunks don't carry an OSC sequence.
func (c *Child) recordTitle(newTitle string) {
c.titleMu.Lock()
if c.title == newTitle {
c.titleMu.Unlock()
return
}
c.title = newTitle
c.titleMu.Unlock()
c.lastTitleNS.Store(time.Now().UnixNano())
}
// IdleState returns the classifier's current opinion. Empty string
// (StateUnknown) means the classifier hasn't run yet for this child.
func (c *Child) IdleState() IdleState {
p := c.idleState.Load()
if p == nil {
return StateUnknown
}
return *p
}
// IdleReason returns the human-readable reason the classifier last
// recorded. Empty when no classification has happened yet.
func (c *Child) IdleReason() string {
p := c.idleReason.Load()
if p == nil {
return ""
}
return *p
}
// setIdleState updates idleState + idleReason. Returns true when the
// state actually changed (so callers can fan out a notification).
func (c *Child) setIdleState(s IdleState, reason string) bool {
prev := c.IdleState()
if prev == s {
return false
}
c.idleState.Store(&s)
c.idleReason.Store(&reason)
return true
}
// setIdleDetection installs the resolved per-preset idle-detection
// config. Called once at spawn; not safe to swap at runtime.
func (c *Child) setIdleDetection(r *resolvedIdleDetection) {
c.idleDetection = r
}
func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1)
c.ringMu.Lock()
c.ring = append(c.ring, chunk...)
c.ringWrites += int64(len(chunk))
if len(c.ring) > ringCap {
drop := len(c.ring) - ringCap
c.ring = c.ring[drop:]
c.ringStart += int64(drop)
// Chunks larger than ringCap are tail-truncated — only the last
// ringCap bytes of the chunk can survive.
src := chunk
if len(src) > ringCap {
src = src[len(src)-ringCap:]
}
for written := 0; written < len(src); {
n := copy(c.ring[c.ringPos:], src[written:])
c.ringPos += n
if c.ringPos >= ringCap {
c.ringPos = 0
c.ringFull = true
}
written += n
}
c.ringWrites += int64(len(chunk))
c.ringMu.Unlock()
c.scanPortsFromChunk(chunk)
}
@@ -316,6 +439,11 @@ func (c *Child) recordWrite(chunk []byte) {
// scanPortsFromChunk does best-effort port detection on a PTY chunk.
// SPEC §7 get_process_ports — no probing, just stream scanning.
func (c *Child) scanPortsFromChunk(chunk []byte) {
// Cheap prefix check: most chunks don't contain a URL. Bail before
// running the regex DFA over the whole chunk.
if !bytes.Contains(chunk, []byte("http")) {
return
}
matches := portRegex.FindAllSubmatch(chunk, -1)
if len(matches) == 0 {
return
@@ -364,16 +492,38 @@ func (c *Child) Ports() []PortSighting {
func (c *Child) StreamRead(since int64) ([]byte, int64) {
c.ringMu.Lock()
defer c.ringMu.Unlock()
if since < c.ringStart {
since = c.ringStart
end := c.ringWrites
var ringStart int64
if c.ringFull {
ringStart = end - int64(ringCap)
}
if since < ringStart {
since = ringStart
}
end := c.ringStart + int64(len(c.ring))
if since >= end {
return nil, end
}
start := int(since - c.ringStart)
out := make([]byte, end-since)
copy(out, c.ring[start:])
n := int(end - since)
out := make([]byte, n)
// Locate `since` in the ring. When the buffer hasn't wrapped yet,
// bytes 0..ringPos hold writes 0..ringPos. After wrap, ringPos
// points at the oldest byte, and the freshest byte is at
// (ringPos - 1) mod ringCap.
var pos int
if c.ringFull {
skip := int(since - ringStart) // bytes after the oldest
pos = (c.ringPos + skip) % ringCap
} else {
pos = int(since)
}
first := ringCap - pos
if first > n {
first = n
}
copy(out, c.ring[pos:pos+first])
if first < n {
copy(out[first:], c.ring[:n-first])
}
return out, end
}
@@ -395,19 +545,17 @@ func (c *Child) signal(sig syscall.Signal) error {
// NudgeRedraw asks the child to throw away any diff-based render state
// and emit a full frame on the next tick. Used after a focus switch so
// ratatui/ink TUIs re-render coherently against the snapshot we just
// replayed. We toggle the PTY size by one row so the kernel reliably
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't
// change), then send SIGWINCH explicitly for TUIs that miss or coalesce
// the size-toggled signal. The emulator is left alone — it already
// matches our intended size and the brief mismatch only affects what the
// child writes during the second redraw.
// replayed. Sends an explicit SIGWINCH; TIOCSWINSZ with the same size
// is a no-op in the kernel, so an explicit signal is what most TUIs
// actually act on anyway. Avoid resize-toggles here — under a drag-
// resize the kernel still emits intermediate SIGWINCHes against the
// host PTY and toggling our child's size on top produces inconsistent
// grid state.
func (c *Child) NudgeRedraw(cols, rows uint16) {
pty := c.PTY()
if pty == nil || rows < 2 {
return
}
_ = pty.Resize(cols, rows-1)
_ = pty.Resize(cols, rows)
_ = c.signal(syscall.SIGWINCH)
}

View File

@@ -0,0 +1,96 @@
package app
import (
"context"
"time"
)
// classifierTickInterval is how often the per-session classifier wakes
// up to re-evaluate every child's state. 250ms is fast enough that
// the sidebar badge looks live, slow enough that the cost is invisible
// even with dozens of children.
const classifierTickInterval = 250 * time.Millisecond
// classifierTailBytes is the size of the ring-buffer tail the
// classifier scans for promoter regexes. Big enough to catch a multi-
// line "Approve?" prompt, small enough that we don't pay for a full
// 1 MiB regex scan every tick.
const classifierTailBytes = 4096
// runClassifier loops over every live child every classifierTickInterval
// and updates IdleState when it changes. It runs until ctx is cancelled
// (the host shutdown path cancels). One goroutine per Session is plenty
// — the work is cheap (atomic loads + ~4 KiB regex scan per child).
func (s *Session) runClassifier(ctx context.Context) {
ticker := time.NewTicker(classifierTickInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.classifyAll()
}
}
}
func (s *Session) classifyAll() {
for _, c := range s.Children() {
s.classifyOne(c)
}
}
func (s *Session) classifyOne(c *Child) {
st := c.Status()
exited := st == StatusExited || st == StatusErrored
exitNonZero := false
if exited {
exitNonZero = c.ExitCode() != 0
}
idleMS := c.IdleMS()
titleIdleMS := c.TitleIdleMS()
title := c.Title()
tail := c.tailBytes(classifierTailBytes)
state, reason := classify(c.idleDetection, exited, exitNonZero, idleMS, titleIdleMS, title, tail)
if c.setIdleState(state, reason) {
s.emitStateChanged(c.ID, state)
}
}
// tailBytes returns up to n bytes from the end of the ring buffer.
// Safe to call from the classifier goroutine while pumpChild writes
// from another goroutine — both serialise on ringMu.
func (c *Child) tailBytes(n int) []byte {
c.ringMu.Lock()
defer c.ringMu.Unlock()
have := int64(ringCap)
if !c.ringFull {
have = c.ringWrites
}
if have == 0 {
return nil
}
want := int64(n)
if want > have {
want = have
}
out := make([]byte, want)
// The ring layout matches StreamRead: when not full, byte k lives
// at index k; when full, the oldest byte sits at ringPos and the
// newest at (ringPos-1) mod ringCap.
if !c.ringFull {
copy(out, c.ring[c.ringWrites-want:c.ringWrites])
return out
}
// Tail starts `want` bytes back from the write head.
start := (c.ringPos - int(want) + ringCap) % ringCap
first := ringCap - start
if first > int(want) {
first = int(want)
}
copy(out, c.ring[start:start+first])
if first < int(want) {
copy(out[first:], c.ring[:int(want)-first])
}
return out
}

View File

@@ -67,6 +67,29 @@ func (cs *cursorShifter) clampCol(col int) int {
return col
}
// clampHostRow returns a host-coordinate row clamped to the viewport
// rows mainTop..mainBottom. A child whose internal row state drifted
// past the viewport (long-running claude / codex sessions) can issue a
// CUP / HVP / VPA aimed at row hostRows; after the +rowOffset shift the
// raw host target sits past the viewport bottom (the status row) or
// above the viewport top (the tab bar). Without clamping the host
// cursor lands on the chrome and the next printable wipes it. childRows
// == 0 (uninitialised shifter, only seen in tests) disables clamping.
func (cs *cursorShifter) clampHostRow(r int) int {
if cs.childRows <= 0 {
return r
}
minR := cs.rowOffset + 1
maxR := cs.rowOffset + cs.childRows
if r < minR {
return minR
}
if r > maxR {
return maxR
}
return r
}
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
// any complete CUP/HVP/VPA/DECSTBM sequences, and returns the rewritten
// bytes. Partial sequences are buffered across calls so a CSI that
@@ -206,7 +229,7 @@ func (cs *cursorShifter) emitCSI() {
cs.pending.Write(cs.buf)
return
}
r += cs.rowOffset
r = cs.clampHostRow(r + cs.rowOffset)
c = cs.clampCol(c)
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r))
@@ -226,13 +249,14 @@ func (cs *cursorShifter) emitCSI() {
cs.pending.WriteString(strconv.Itoa(c))
cs.pending.WriteByte(final)
case 'd':
// VPA: row.
// VPA: row. Clamp to the viewport so a child that drifted
// past its row count can't land the host cursor on the status row.
r, ok := parseOneParam(paramsRaw, 1)
if !ok {
cs.pending.Write(cs.buf)
return
}
r += cs.rowOffset
r = cs.clampHostRow(r + cs.rowOffset)
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r))
cs.pending.WriteByte(final)

View File

@@ -111,3 +111,40 @@ func TestCursorShifterCUPNoClampWhenChildColsZero(t *testing.T) {
t.Fatalf("childCols=0 should disable col clamping: got %q", got)
}
}
// In longer claude sessions the cursor's internal row state could drift
// past the viewport height. CUP / HVP / VPA without row clamping would
// then land the host cursor on the status row or above the tab bar,
// where the next printable wipes the chrome.
func TestCursorShifterClampsCUPRowToMainBottom(t *testing.T) {
// rowOffset=2 (mainTop=3), childRows=36 → mainBottom=38.
cs := newCursorShifter(2, 36, 80)
got := cs.Shift([]byte("\x1b[40;5H"))
if string(got) != "\x1b[38;5H" {
t.Fatalf("CUP row 40 (post-shift 42) should clamp to 38: got %q", got)
}
}
func TestCursorShifterClampsHVPRowToMainBottom(t *testing.T) {
cs := newCursorShifter(2, 36, 80)
got := cs.Shift([]byte("\x1b[99;1f"))
if string(got) != "\x1b[38;1f" {
t.Fatalf("HVP row 99 should clamp to mainBottom: got %q", got)
}
}
func TestCursorShifterClampsVPARow(t *testing.T) {
cs := newCursorShifter(2, 36, 80)
got := cs.Shift([]byte("\x1b[60d"))
if string(got) != "\x1b[38d" {
t.Fatalf("VPA row 60 should clamp to mainBottom: got %q", got)
}
}
func TestCursorShifterCUPRowNoClampWhenChildRowsZero(t *testing.T) {
cs := newCursorShifter(2, 0, 80)
got := cs.Shift([]byte("\x1b[40;5H"))
if string(got) != "\x1b[42;5H" {
t.Fatalf("childRows=0 should disable row clamping: got %q", got)
}
}

View File

@@ -61,12 +61,11 @@ type toolHost struct {
prompter trustPrompter
scratch scratchpadSink
timersMu sync.Mutex
nextTimer int
timers *timerManager
}
func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, presets preset.Set, tr *trust.Store, cols, rows uint16) *toolHost {
return &toolHost{
h := &toolHost{
sess: sess,
pads: pads,
launcher: launcher,
@@ -76,6 +75,28 @@ func newToolHost(sess *Session, pads *scratchpad.Store, launcher *Launcher, pres
defaultRow: rows,
startedAt: make(map[string]time.Time),
}
h.timers = newTimerManager(sess)
// Plug the timer manager into the session's state-change fan-out so
// idle-aware timers fire when watched children transition into idle.
// Tests can construct a host with a nil session for sizing checks —
// those never run timers, so the subscribe is skipped.
if sess != nil {
sess.Subscribe(timerListenerAdapter{m: h.timers})
}
return h
}
// timerListenerAdapter forwards OnChildStateChanged into the timer
// manager and ignores the other ChildEventListener methods. The
// session's listener API is by-interface, so we wrap the manager
// rather than make it implement the full surface.
type timerListenerAdapter struct{ m *timerManager }
func (a timerListenerAdapter) OnChildSpawned(*Child) {}
func (a timerListenerAdapter) OnChildExited(*Child) {}
func (a timerListenerAdapter) OnPTYOut(string, []byte) {}
func (a timerListenerAdapter) OnChildStateChanged(id string, st IdleState) {
a.m.onChildStateChanged(id, st)
}
func (h *toolHost) SetSize(cols, rows uint16) {
@@ -378,7 +399,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
out.Content = stripANSI(string(b))
out.Content = string(stripANSIBytes(nil, b))
out.NewOffset = end
return out, nil
default:
@@ -409,10 +430,10 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
b, _ := c.StreamRead(0)
text := string(b)
if kind == "rendered" {
text = stripANSI(text)
b = stripANSIBytes(nil, b)
}
text := string(b)
lines := strings.Split(text, "\n")
matches := make([]mcp.SearchMatch, 0, limit)
truncated := false
@@ -440,10 +461,19 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
if scope == "" {
scope = "grid"
}
if scope != "grid" && scope != "scrollback" {
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
}
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
for {
// chunkWake fires on every PTY chunk for the target child. The
// fallback timer guarantees we still re-check on grid-only sweeps
// where the cursor position changed without a fresh chunk landing.
wake := newChunkNotifier(c.ID)
h.sess.Subscribe(wake)
defer h.sess.Unsubscribe(wake)
check := func() (bool, string) {
text := ""
switch scope {
case "grid":
@@ -454,23 +484,76 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
}
case "scrollback":
b, _ := c.StreamRead(0)
text = stripANSI(string(b))
default:
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
text = string(stripANSIBytes(nil, b))
}
if m := re.FindString(text); m != "" {
return true, m, nil
return true, m
}
if time.Now().After(deadline) {
return false, ""
}
if ok, m := check(); ok {
return true, m, nil
}
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "", nil
}
<-tick.C
// Long fallback tick — the chunk notifier wakes us promptly
// on fresh PTY output; the timer is only there for cases
// where grid state shifted without a new chunk.
wait := 500 * time.Millisecond
if remaining < wait {
wait = remaining
}
select {
case <-wake.fired:
case <-time.After(wait):
}
if ok, m := check(); ok {
return true, m, nil
}
if !c.IsLive() && c.Status() != StatusStopped {
return false, "", nil
}
}
}
// chunkNotifier is a one-shot-per-chunk wake channel listener.
// Registers via Session.Subscribe; emits a non-blocking signal on
// `fired` for every PTY chunk emitted by the target child. Used by
// WaitForPattern to avoid 50ms-tick polling of the entire ring/grid.
type chunkNotifier struct {
childID string
fired chan struct{}
}
func newChunkNotifier(childID string) *chunkNotifier {
return &chunkNotifier{childID: childID, fired: make(chan struct{}, 1)}
}
func (n *chunkNotifier) OnChildSpawned(*Child) {}
func (n *chunkNotifier) OnChildExited(c *Child) {
if c.ID != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
if id != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (n *chunkNotifier) OnChildStateChanged(string, IdleState) {}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
if c == nil {
@@ -664,27 +747,59 @@ func (h *toolHost) RequestHumanAttention(callerID, processID, reason string) err
return nil
}
// TimerWait is the legacy fire-and-forget delay timer. It now wraps
// TimerSet with an empty body — defaultFireFn substitutes the
// "[system] Your timer […] has completed." line so behaviour matches
// the original API. New callers should use timer_set with an explicit
// body.
func (h *toolHost) TimerWait(callerID string, seconds float64, label string) (string, error) {
caller := h.sess.FindChild(callerID)
if caller == nil {
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", callerID)
return h.timers.TimerSet(callerID, "", label, seconds)
}
func (h *toolHost) TimerSet(callerID string, args mcp.TimerSetArgs) (mcp.TimerHandle, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
id, err := h.timers.TimerSet(owner, args.Body, args.Label, args.Seconds)
if err != nil {
return mcp.TimerHandle{}, err
}
h.timersMu.Lock()
h.nextTimer++
id := fmt.Sprintf("t%d", h.nextTimer)
h.timersMu.Unlock()
if label == "" {
label = id
return mcp.TimerHandle{ID: id}, nil
}
func (h *toolHost) TimerFireWhenIdleAny(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
return h.timers.TimerFireWhenIdleAny(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
}
func (h *toolHost) TimerFireWhenIdleAll(callerID string, args mcp.TimerFireWhenIdleArgs) (mcp.TimerFireWhenIdleResponse, error) {
owner := resolveTimerOwner(callerID, args.OwnerProcessID)
return h.timers.TimerFireWhenIdleAll(owner, args.Body, args.Label, args.Watched, args.MaxWaitSeconds)
}
// resolveTimerOwner picks the owner process for a timer. Explicit
// owner_process_id wins; otherwise the caller's own id is used.
// Top-level MCP clients (no callerID) must provide owner_process_id
// explicitly.
func resolveTimerOwner(callerID, explicit string) string {
if explicit != "" {
return explicit
}
go func() {
time.Sleep(time.Duration(seconds * float64(time.Second)))
if !caller.IsLive() {
return
}
line := fmt.Sprintf("[system] Your timer [%s] has completed.\r", label)
_ = caller.InjectAsOrchestrator([]byte(line))
}()
return id, nil
return callerID
}
func (h *toolHost) TimerCancel(callerID, id string) error {
return h.timers.TimerCancel(callerID, id)
}
func (h *toolHost) TimerPause(callerID, id string) error {
return h.timers.TimerPause(callerID, id)
}
func (h *toolHost) TimerResume(callerID, id string) error {
return h.timers.TimerResume(callerID, id)
}
func (h *toolHost) TimerList(callerID string) ([]mcp.TimerInfo, error) {
return h.timers.TimerList(callerID), nil
}
// ───────────────────────────────────────────────────────────────────
@@ -755,6 +870,10 @@ func (h *toolHost) processInfoOf(c *Child) mcp.ProcessInfo {
t := h.trust.IsTrusted(c.PresetRef)
info.Trusted = &t
}
if s := c.IdleState(); s != StateUnknown {
info.IdleState = string(s)
info.IdleReason = c.IdleReason()
}
return info
}
@@ -887,6 +1006,74 @@ func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a
// pattern match (WaitForPattern scrollback). Recognises the same
// shapes the regex did:
// - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR)
// - `\x1b<final-byte>` for `@..._` (one-byte escapes)
// - `\x07` (BEL)
//
// The dst slice is reused if cap is sufficient; the returned slice
// is what callers should use.
func stripANSIBytes(dst, src []byte) []byte {
if cap(dst) < len(src) {
dst = make([]byte, 0, len(src))
} else {
dst = dst[:0]
}
for i := 0; i < len(src); {
b := src[i]
if b == 0x07 {
i++
continue
}
if b != 0x1b {
dst = append(dst, b)
i++
continue
}
// ESC-led sequence.
if i+1 >= len(src) {
// Stranded ESC at end of buffer — drop it.
i++
continue
}
next := src[i+1]
if next != '[' {
// One-byte ESC sequence (`\x1b<final>` where final is
// `@..._` per the regex; we drop anything that follows).
if next >= 0x40 && next <= 0x5f {
i += 2
continue
}
// Anything else after ESC: drop the ESC, keep walking.
i++
continue
}
// CSI: parameters [0x30..0x3f]*, intermediate [0x20..0x2f]*,
// final [0x40..0x7e].
j := i + 2
for j < len(src) && src[j] >= 0x30 && src[j] <= 0x3f {
j++
}
for j < len(src) && src[j] >= 0x20 && src[j] <= 0x2f {
j++
}
if j < len(src) && src[j] >= 0x40 && src[j] <= 0x7e {
i = j + 1
continue
}
// Incomplete CSI — the regex form falls back to its
// `\x1b<final>` rule and matches `\x1b[` (`[` is 0x5b, inside
// 0x40..0x5f), consuming the two-byte prefix and leaving the
// pending params/intermediate bytes intact. Match that.
i += 2
}
return dst
}
// availableToolsForRole — SPEC §7 whoami exposes the list a caller can
// invoke from its current role. Sub-agents lose `spawn_agent` (§8
// two-level-tree rule).
@@ -897,7 +1084,9 @@ func availableToolsForRole(role mcp.CallerRole) []string {
"list_processes", "get_process_status", "get_project_status",
"get_process_output", "get_process_raw_output", "search_output",
"wait_for_pattern", "get_process_ports",
"send_input", "send_message", "request_human_attention", "timer_wait",
"send_input", "send_message", "request_human_attention",
"timer_wait", "timer_set", "timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
"scratchpad_list", "scratchpad_read", "scratchpad_write", "scratchpad_append",
"whoami", "help",
}
@@ -927,8 +1116,8 @@ func helpFor(topic string) mcp.HelpResponse {
}
case "lifecycle":
return mcp.HelpResponse{
Topic: "lifecycle",
Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.",
Topic: "lifecycle",
Content: "You own the processes you spawn. When a sub-agent has finished its task (it reports back via send_message, or you've collected what you need from it) call close_process on its process_id to remove the entry and tear down the PTY. Same goes for spawn_process children: command/terminal panes you started are not auto-reclaimed when their work completes. close_process is the normal cleanup path; stop_process(signal) is for sending a signal without removing the entry; start_process re-attaches an exited command preset. Leaving idle sub-agents around wastes vendor tokens and clutters the host — close them as soon as you're done. Sub-agents themselves are reminded (via the [system: …] preface on their first prompt) to clean up anything they created before reporting done.",
RelatedTools: []string{"close_process", "stop_process", "start_process", "list_processes", "get_process_status"},
}
case "inspection":
@@ -957,9 +1146,18 @@ func helpFor(topic string) mcp.HelpResponse {
}
case "timers":
return mcp.HelpResponse{
Topic: "timers",
Content: "timer_wait returns a timer_id immediately and injects `[system] Your timer [<label>] has completed.` into your pane when it fires. Use it instead of sleeping in your own process.",
RelatedTools: []string{"timer_wait"},
Topic: "timers",
Content: "Timers fire by injecting your chosen body (or a default `[system] Your timer [] has completed.` line) back into your pane as a fresh user turn. Use them instead of sleeping in your own process. " +
"timer_wait / timer_set schedule a delay timer (timer_set lets you set body+label). " +
"timer_fire_when_idle_any fires when any watched process becomes idle (already-idle watchers are excluded from the baseline). " +
"timer_fire_when_idle_all fires when every watched process is idle; if all are idle at registration the response is already_satisfied with no pending timer. " +
"timer_cancel / timer_pause / timer_resume manage outstanding timers; resume re-checks idle conditions in case a watcher went idle while paused. " +
"timer_list shows your pending and paused timers.",
RelatedTools: []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
},
}
case "readiness":
return mcp.HelpResponse{

View File

@@ -3,6 +3,8 @@ package app
import (
"strings"
"testing"
"github.com/hjbdev/patterm/internal/mcp"
)
// mkChild builds a Child without starting a PTY. Use sparingly — the
@@ -164,6 +166,47 @@ func TestHelpSpawningPointsAtLifecycle(t *testing.T) {
}
}
// TestAvailableToolsAdvertisesAllTimerTools makes sure orchestrators
// and sub-agents discover the full timer surface via whoami — not just
// timer_wait. Otherwise agents using whoami for orientation would never
// learn about timer_set, timer_fire_when_idle_*, timer_pause/resume,
// timer_cancel, and timer_list.
func TestAvailableToolsAdvertisesAllTimerTools(t *testing.T) {
want := []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
}
for _, role := range []mcp.CallerRole{mcp.RoleOrchestrator, mcp.RoleSubAgent} {
tools := availableToolsForRole(role)
for _, w := range want {
if !containsString(tools, w) {
t.Fatalf("role %q missing %q in available tools: %v", role, w, tools)
}
}
}
}
// TestHelpTimersDocumentsAllTools mirrors the whoami check for the
// help("timers") topic — the related-tools list must enumerate every
// timer_* tool so callers reading help can dispatch them.
func TestHelpTimersDocumentsAllTools(t *testing.T) {
resp := helpFor("timers")
if resp.Topic != "timers" {
t.Fatalf("topic: %q", resp.Topic)
}
want := []string{
"timer_wait", "timer_set",
"timer_fire_when_idle_any", "timer_fire_when_idle_all",
"timer_cancel", "timer_pause", "timer_resume", "timer_list",
}
for _, w := range want {
if !containsString(resp.RelatedTools, w) {
t.Fatalf("timers help missing %q in related tools: %v", w, resp.RelatedTools)
}
}
}
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
@@ -172,4 +215,3 @@ func containsString(haystack []string, needle string) bool {
}
return false
}

225
internal/app/idle.go Normal file
View File

@@ -0,0 +1,225 @@
package app
import (
"regexp"
"github.com/hjbdev/patterm/internal/preset"
)
// IdleState is the classifier's opinion about what a child is doing.
// Inspired by Solo's five-state model. ERROR is a terminal state — set
// when a child exits non-zero or matches an error-promoter regex —
// while the other four reflect transient runtime state.
type IdleState string
const (
StateUnknown IdleState = ""
StateIdle IdleState = "idle"
StateWorking IdleState = "working"
StateThinking IdleState = "thinking"
StatePermission IdleState = "permission"
StateError IdleState = "error"
)
// IdleStrategy picks the primary signal used to decide idle vs working.
// Promoter regexes can override this on top.
type IdleStrategy string
const (
StrategyOutputActivity IdleStrategy = "output_activity"
StrategyOSCTitleStability IdleStrategy = "osc_title_stability"
StrategyOSCTitleStatus IdleStrategy = "osc_title_status"
)
// defaultIdleThresholdMS is used when a preset doesn't override it.
const defaultIdleThresholdMS = 2000
// resolvedIdleDetection is the compiled, runtime-ready form of a
// preset.IdleDetection block. Built once at child spawn and held
// read-only by the classifier; regex patterns are compiled here so the
// hot path doesn't pay for it.
type resolvedIdleDetection struct {
strategy IdleStrategy
idleThresholdMS int64
titleStatusMap map[string]IdleState
permissionRegexes []*regexp.Regexp
thinkingRegexes []*regexp.Regexp
errorRegexes []*regexp.Regexp
}
// resolveIdleDetection compiles a preset.IdleDetection (which may be
// nil) into the runtime form. Unknown strategies fall back to
// output_activity. Pattern compile errors are skipped silently — the
// preset loader is responsible for surfacing them as warnings.
func resolveIdleDetection(cfg *preset.IdleDetection) *resolvedIdleDetection {
r := &resolvedIdleDetection{
strategy: StrategyOutputActivity,
idleThresholdMS: defaultIdleThresholdMS,
}
if cfg == nil {
return r
}
switch IdleStrategy(cfg.Strategy) {
case StrategyOSCTitleStability, StrategyOSCTitleStatus, StrategyOutputActivity:
r.strategy = IdleStrategy(cfg.Strategy)
}
if cfg.IdleThresholdMS > 0 {
r.idleThresholdMS = int64(cfg.IdleThresholdMS)
}
if len(cfg.TitleStatusMap) > 0 {
r.titleStatusMap = make(map[string]IdleState, len(cfg.TitleStatusMap))
for k, v := range cfg.TitleStatusMap {
switch IdleState(v) {
case StateIdle, StateWorking, StateThinking, StatePermission, StateError:
r.titleStatusMap[k] = IdleState(v)
}
}
}
r.permissionRegexes = compilePatterns(cfg.PermissionPatterns)
r.thinkingRegexes = compilePatterns(cfg.ThinkingPatterns)
r.errorRegexes = compilePatterns(cfg.ErrorPatterns)
return r
}
func compilePatterns(ps []string) []*regexp.Regexp {
if len(ps) == 0 {
return nil
}
out := make([]*regexp.Regexp, 0, len(ps))
for _, p := range ps {
if p == "" {
continue
}
re, err := regexp.Compile(p)
if err != nil {
continue
}
out = append(out, re)
}
return out
}
// classify computes the IdleState from the inputs the classifier loop
// has already gathered. Pure function so it's easy to unit-test.
//
// Resolution order:
// 1. terminal: process exited non-zero → error (latched)
// 2. error-promoter regex match in recent output → error
// 3. permission-promoter regex match → permission
// 4. thinking-promoter regex match → thinking
// 5. strategy-specific base classification (idle vs working).
//
// inputs:
// - exited: whether the child process has exited
// - exitNonZero: whether the exit was non-zero (only meaningful when exited)
// - idleMS: ms since the last PTY output
// - titleIdleMS: ms since the last OSC title change (0 if no title yet)
// - title: current OSC title
// - tail: recent output bytes for regex matching
func classify(cfg *resolvedIdleDetection, exited, exitNonZero bool, idleMS, titleIdleMS int64, title string, tail []byte) (IdleState, string) {
if exited {
if exitNonZero {
return StateError, "process exited non-zero"
}
return StateIdle, "process exited cleanly"
}
if cfg == nil {
cfg = &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: defaultIdleThresholdMS}
}
if len(tail) > 0 {
if matchAny(cfg.errorRegexes, tail) {
return StateError, "error regex matched"
}
if matchAny(cfg.permissionRegexes, tail) {
return StatePermission, "permission regex matched"
}
if matchAny(cfg.thinkingRegexes, tail) {
return StateThinking, "thinking regex matched"
}
}
threshold := cfg.idleThresholdMS
switch cfg.strategy {
case StrategyOSCTitleStatus:
// First try the title-status map; if no match, fall back to
// title-stability behaviour so we still produce idle/working.
if s, ok := matchTitleStatus(cfg.titleStatusMap, title); ok {
return s, "title status match"
}
fallthrough
case StrategyOSCTitleStability:
// If we've never seen a title, fall back to output activity so
// we don't latch in idle while the child is clearly running.
if titleIdleMS == 0 {
return baseStateFromIdleMS(idleMS, threshold)
}
return baseStateFromIdleMS(titleIdleMS, threshold)
default: // output_activity
return baseStateFromIdleMS(idleMS, threshold)
}
}
func baseStateFromIdleMS(idleMS, threshold int64) (IdleState, string) {
// idleMS == 0 means "no writes yet" (per Child.IdleMS) — treat as
// not-idle so we don't classify a freshly-spawned child as idle.
if idleMS == 0 {
return StateWorking, "no activity yet"
}
if idleMS < threshold {
return StateWorking, "recent activity"
}
return StateIdle, "quiet for threshold"
}
func matchAny(res []*regexp.Regexp, tail []byte) bool {
for _, re := range res {
if re.Match(tail) {
return true
}
}
return false
}
func matchTitleStatus(m map[string]IdleState, title string) (IdleState, bool) {
if len(m) == 0 || title == "" {
return StateUnknown, false
}
for k, v := range m {
if k == "" {
continue
}
if containsFold(title, k) {
return v, true
}
}
return StateUnknown, false
}
// containsFold reports whether s contains sub, case-insensitively.
// Cheap implementation suitable for short titles.
func containsFold(s, sub string) bool {
if len(sub) == 0 {
return true
}
if len(sub) > len(s) {
return false
}
ls, lsub := lower(s), lower(sub)
for i := 0; i+len(lsub) <= len(ls); i++ {
if ls[i:i+len(lsub)] == lsub {
return true
}
}
return false
}
func lower(s string) string {
b := []byte(s)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + 32
}
}
return string(b)
}

112
internal/app/idle_test.go Normal file
View File

@@ -0,0 +1,112 @@
package app
import (
"regexp"
"testing"
)
func mustCompile(t *testing.T, p string) *regexp.Regexp {
t.Helper()
re, err := regexp.Compile(p)
if err != nil {
t.Fatalf("regex %q: %v", p, err)
}
return re
}
func TestClassifyOutputActivity(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
cases := []struct {
name string
idleMS int64
want IdleState
}{
{"fresh-spawn no writes", 0, StateWorking},
{"recent activity", 500, StateWorking},
{"under threshold", 1999, StateWorking},
{"at threshold", 2000, StateIdle},
{"over threshold", 5000, StateIdle},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := classify(cfg, false, false, tc.idleMS, 0, "", nil)
if got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
})
}
}
func TestClassifyTitleStability(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOSCTitleStability, idleThresholdMS: 2000}
// Title change recent → working.
if got, _ := classify(cfg, false, false, 9999, 500, "step 3", nil); got != StateWorking {
t.Fatalf("recent title change: got %q", got)
}
// Title stable past threshold → idle.
if got, _ := classify(cfg, false, false, 9999, 5000, "step 3", nil); got != StateIdle {
t.Fatalf("stable title: got %q", got)
}
// No title yet: fall back to output activity.
if got, _ := classify(cfg, false, false, 100, 0, "", nil); got != StateWorking {
t.Fatalf("no title yet, recent output: got %q", got)
}
if got, _ := classify(cfg, false, false, 5000, 0, "", nil); got != StateIdle {
t.Fatalf("no title yet, output idle: got %q", got)
}
}
func TestClassifyTitleStatus(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOSCTitleStatus,
idleThresholdMS: 2000,
titleStatusMap: map[string]IdleState{
"thinking": StateThinking,
"permission": StatePermission,
"error": StateError,
},
}
if got, _ := classify(cfg, false, false, 9999, 500, "Thinking…", nil); got != StateThinking {
t.Fatalf("thinking title: got %q", got)
}
if got, _ := classify(cfg, false, false, 9999, 500, "Waiting for permission", nil); got != StatePermission {
t.Fatalf("permission title: got %q", got)
}
// No match in map → fall back to stability.
if got, _ := classify(cfg, false, false, 9999, 5000, "ready", nil); got != StateIdle {
t.Fatalf("unmatched title, stable: got %q", got)
}
}
func TestClassifyPromoterRegex(t *testing.T) {
cfg := &resolvedIdleDetection{
strategy: StrategyOutputActivity,
idleThresholdMS: 2000,
permissionRegexes: []*regexp.Regexp{mustCompile(t, `Approve\?`)},
errorRegexes: []*regexp.Regexp{mustCompile(t, `panic:`)},
thinkingRegexes: []*regexp.Regexp{mustCompile(t, `Thinking`)},
}
// Permission promoter beats idle.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Approve? [y/n]")); got != StatePermission {
t.Fatalf("permission promoter: got %q", got)
}
// Error trumps permission.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("panic: bad\nApprove?")); got != StateError {
t.Fatalf("error promoter beats permission: got %q", got)
}
// Thinking promoter on idle output.
if got, _ := classify(cfg, false, false, 5000, 0, "", []byte("Thinking…")); got != StateThinking {
t.Fatalf("thinking promoter: got %q", got)
}
}
func TestClassifyExitTerminal(t *testing.T) {
cfg := &resolvedIdleDetection{strategy: StrategyOutputActivity, idleThresholdMS: 2000}
if got, _ := classify(cfg, true, true, 0, 0, "", nil); got != StateError {
t.Fatalf("non-zero exit: got %q", got)
}
if got, _ := classify(cfg, true, false, 0, 0, "", nil); got != StateIdle {
t.Fatalf("clean exit: got %q", got)
}
}

View File

@@ -40,6 +40,36 @@ type csiuKey struct {
event int
}
// parseSGRMouseWheel decodes the parameter run from an SGR-encoded
// mouse press (`CSI < button ; col ; row M`) and returns a row delta
// when the event is a scroll wheel. Wheel-up returns -wheelStep,
// wheel-down returns +wheelStep. Modifier bits in the button code
// (shift=4, alt=8, ctrl=16) are stripped before matching, so e.g.
// shift+wheel still scrolls. Non-wheel buttons return false.
func parseSGRMouseWheel(params []byte) (int, bool) {
const wheelStep = 3
// Button code runs up to the first ';'.
end := 0
for end < len(params) && params[end] != ';' {
end++
}
if end == 0 {
return 0, false
}
btn, err := strconv.Atoi(string(params[:end]))
if err != nil {
return 0, false
}
if btn&64 == 0 {
return 0, false
}
// Bit 0 selects up (0) vs down (1) for wheel events.
if btn&1 == 0 {
return -wheelStep, true
}
return wheelStep, true
}
// decodeCSIu parses the parameter string of a `CSI ... u` sequence.
// The kitty shape is:
//

View File

@@ -39,6 +39,32 @@ func TestMatchCtrlK(t *testing.T) {
}
}
func TestParseSGRMouseWheel(t *testing.T) {
cases := []struct {
params string
want int
ok bool
}{
{"64;1;1", -3, true}, // wheel up
{"65;1;1", 3, true}, // wheel down
{"68;1;1", -3, true}, // shift+wheel up
{"69;1;1", 3, true}, // shift+wheel down
{"80;1;1", -3, true}, // ctrl+wheel up
{"81;1;1", 3, true}, // ctrl+wheel down
{"0;5;7", 0, false}, // left press
{"2;5;7", 0, false}, // right press
{"32;5;7", 0, false}, // drag
{"", 0, false}, // empty
{"abc;1;1", 0, false}, // garbage button
}
for _, c := range cases {
got, ok := parseSGRMouseWheel([]byte(c.params))
if ok != c.ok || got != c.want {
t.Errorf("parseSGRMouseWheel(%q) = (%d,%v), want (%d,%v)", c.params, got, ok, c.want, c.ok)
}
}
}
func TestMatchCtrlKConsecutive(t *testing.T) {
// Two kitty Ctrl-K sequences back to back, the chord case.
chunk := []byte("\x1b[107;5u\x1b[107;5u")

View File

@@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset"
)
@@ -126,14 +127,15 @@ func (l *Launcher) LaunchAgent(p *preset.Preset, displayName, initialPrompt, par
// Spawn with the chosen identity.
cols, rows := l.size()
c, err := l.sess.Spawn(SpawnSpec{
Kind: KindAgent,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
PresetRef: p.Name,
Identity: identity,
CleanupPaths: cleanupPaths,
Kind: KindAgent,
Argv: argv,
Env: env,
Name: displayName,
ParentID: parentID,
PresetRef: p.Name,
Identity: identity,
CleanupPaths: cleanupPaths,
IdleDetection: resolveIdleDetection(p.IdleDetection),
}, cols, rows)
if err != nil {
cleanup()
@@ -170,15 +172,20 @@ func (l *Launcher) LaunchCommandPreset(p *preset.Preset, displayName, parentID s
env = append(env, k+"="+v)
}
cols, rows := l.size()
return l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: p.ResolvedArgv(),
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: p.WorkingDir,
PresetRef: p.Name,
c, err := l.sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: p.ResolvedArgv(),
Env: env,
Name: displayName,
ParentID: parentID,
WorkDir: p.WorkingDir,
PresetRef: p.Name,
IdleDetection: resolveIdleDetection(p.IdleDetection),
}, cols, rows)
if err != nil {
return nil, err
}
return c, nil
}
// LaunchCommandArgv spawns a freeform-argv command entry. Trust gating
@@ -202,6 +209,33 @@ func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workD
}, cols, rows)
}
// RestoreCommand re-spawns a persisted top-level command entry. If
// the entry has a PresetRef and the preset still exists, the spawn
// goes through LaunchCommandPreset (so preset.Env / WorkingDir stay
// authoritative). Otherwise the saved argv runs directly via
// LaunchCommandArgv with shell=false — entries that were originally
// `shell: true` were already wrapped into `["sh","-lc",...]` before
// persistence, so re-wrapping isn't needed.
//
// Returns the freshly minted Child. The caller is responsible for
// setting auto-restart back on the returned entry.
func (l *Launcher) RestoreCommand(e persist.Entry, presets preset.Set) (*Child, error) {
if e.PresetRef != "" {
for _, p := range presets.Processes {
if p.Name == e.PresetRef {
return l.LaunchCommandPreset(p, e.Name, "")
}
}
// Preset has been deleted since the entry was saved. Fall
// through to argv-based restore using whatever the saved
// command looked like at the time.
}
if len(e.Argv) == 0 {
return nil, fmt.Errorf("restore: entry %s has no argv", e.ID)
}
return l.LaunchCommandArgv(e.Argv, e.Name, "", e.WorkDir, nil, false)
}
// LaunchTerminal spawns a bare interactive shell. SPEC §7 kind=terminal.
// argv defaults to $SHELL -i when empty.
func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir string, env []string) (*Child, error) {

483
internal/app/markdown.go Normal file
View File

@@ -0,0 +1,483 @@
package app
import (
"strings"
"unicode"
"unicode/utf8"
)
// renderMarkdownLines turns a scratchpad's text into a slice of
// terminal rows, each at most `cols` visible columns wide and ready to
// paint (style codes included, trailing reset where needed, no
// newline). The renderer covers the markdown subset most likely to
// appear in scratchpad notes: headings (#, ##, ###), bold (**x**),
// inline code (`x`), fenced code blocks (```), bullet/numbered lists,
// blockquotes (> ), horizontal rules, and links rendered as their
// text. Plain text passes through unchanged.
func renderMarkdownLines(content string, cols int) []string {
if cols < 1 {
cols = 1
}
var out []string
inFence := false
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimRight(raw, "\r")
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inFence = !inFence
out = append(out, mdFenceRule(cols))
continue
}
if inFence {
out = append(out, mdCodeBlockLines(line, cols)...)
continue
}
if trimmed == "" {
out = append(out, "")
continue
}
if isMDHRule(trimmed) {
out = append(out, styleBorder+strings.Repeat("─", cols)+styleReset)
continue
}
if body, level := parseMDHeading(line); level > 0 {
style := mdHeadingStyle(level)
out = append(out, wrapInline(parseInline(body), style, cols)...)
continue
}
if body, ok := parseBlockquote(line); ok {
prefix := styleAccent + "│ " + styleReset
lines := wrapInline(parseInline(body), styleHint, cols-2)
if len(lines) == 0 {
out = append(out, prefix)
continue
}
for _, l := range lines {
out = append(out, prefix+l)
}
continue
}
if marker, body, ok := parseListItem(line); ok {
prefix := mdBulletPrefix(marker)
indent := strings.Repeat(" ", mdVisibleLen(prefix))
lines := wrapInline(parseInline(body), "", cols-mdVisibleLen(prefix))
if len(lines) == 0 {
out = append(out, prefix)
continue
}
for i, l := range lines {
if i == 0 {
out = append(out, prefix+l)
} else {
out = append(out, indent+l)
}
}
continue
}
out = append(out, wrapInline(parseInline(line), "", cols)...)
}
return out
}
func mdHeadingStyle(level int) string {
switch level {
case 1:
return styleActive + styleBold
case 2:
return styleBold + styleAccent
default:
return styleBold
}
}
func mdBulletPrefix(marker string) string {
if isOrderedMarker(marker) {
return styleAccent + marker + " " + styleReset
}
return styleAccent + "• " + styleReset
}
func mdFenceRule(cols int) string {
if cols < 2 {
return styleBorder + strings.Repeat("─", cols) + styleReset
}
return styleBorder + strings.Repeat("─", cols) + styleReset
}
// mdCodeBlockLines emits one rendered row per (wrapped) source line
// inside a fenced code block, prefixed with a thin accent gutter so the
// block reads as one visual unit.
func mdCodeBlockLines(line string, cols int) []string {
gutter := styleAccent + "│" + styleReset + " "
body := line
avail := cols - 2
if avail < 1 {
avail = 1
}
chunks := wrapPlain(body, avail)
if len(chunks) == 0 {
return []string{gutter}
}
out := make([]string, 0, len(chunks))
for _, c := range chunks {
out = append(out, gutter+"\x1b[38;5;180m"+c+styleReset)
}
return out
}
func isMDHRule(s string) bool {
if len(s) < 3 {
return false
}
c := s[0]
if c != '-' && c != '_' && c != '*' {
return false
}
for i := 0; i < len(s); i++ {
if s[i] != c && s[i] != ' ' {
return false
}
}
count := 0
for i := 0; i < len(s); i++ {
if s[i] == c {
count++
}
}
return count >= 3
}
func parseMDHeading(line string) (string, int) {
i := 0
for i < len(line) && line[i] == ' ' && i < 3 {
i++
}
level := 0
for i+level < len(line) && line[i+level] == '#' && level < 6 {
level++
}
if level == 0 {
return "", 0
}
rest := line[i+level:]
if rest != "" && rest[0] != ' ' {
return "", 0
}
return strings.TrimSpace(rest), level
}
func parseBlockquote(line string) (string, bool) {
t := strings.TrimLeft(line, " ")
if !strings.HasPrefix(t, ">") {
return "", false
}
rest := strings.TrimPrefix(t, ">")
rest = strings.TrimPrefix(rest, " ")
return rest, true
}
func parseListItem(line string) (marker, body string, ok bool) {
t := strings.TrimLeft(line, " ")
if len(t) >= 2 && (t[0] == '-' || t[0] == '*' || t[0] == '+') && t[1] == ' ' {
return string(t[0]), t[2:], true
}
// Ordered: digits then "." then space.
j := 0
for j < len(t) && t[j] >= '0' && t[j] <= '9' {
j++
}
if j > 0 && j+1 < len(t) && t[j] == '.' && t[j+1] == ' ' {
return t[:j+1], t[j+2:], true
}
return "", "", false
}
func isOrderedMarker(m string) bool {
if len(m) < 2 {
return false
}
if m[len(m)-1] != '.' {
return false
}
for i := 0; i < len(m)-1; i++ {
if m[i] < '0' || m[i] > '9' {
return false
}
}
return true
}
// mdSpan is one styled run of plain text. style is an SGR prefix
// applied at the start; the renderer emits styleReset between adjacent
// spans of differing style and at end-of-line.
type mdSpan struct {
text string
style string
}
// parseInline turns one source line into styled spans. Recognises:
// - **bold** / __bold__ → bold span
// - `code` → inline code span
// - [text](url) → text rendered as accent+underline
//
// Unmatched delimiters are passed through as literal characters so a
// stray `*` or backtick doesn't swallow the rest of the line.
func parseInline(line string) []mdSpan {
var spans []mdSpan
var buf strings.Builder
flush := func(style string) {
if buf.Len() == 0 {
return
}
spans = append(spans, mdSpan{text: buf.String(), style: style})
buf.Reset()
}
i := 0
for i < len(line) {
c := line[i]
switch {
case c == '`':
if end := strings.IndexByte(line[i+1:], '`'); end >= 0 {
flush("")
spans = append(spans, mdSpan{text: line[i+1 : i+1+end], style: "\x1b[38;5;180m"})
i += end + 2
continue
}
case c == '*' && i+1 < len(line) && line[i+1] == '*':
if end := strings.Index(line[i+2:], "**"); end >= 0 {
flush("")
inner := parseInline(line[i+2 : i+2+end])
for _, s := range inner {
st := s.style
if st == "" {
st = styleBold
}
spans = append(spans, mdSpan{text: s.text, style: st})
}
i += end + 4
continue
}
case c == '_' && i+1 < len(line) && line[i+1] == '_':
if end := strings.Index(line[i+2:], "__"); end >= 0 {
flush("")
inner := parseInline(line[i+2 : i+2+end])
for _, s := range inner {
st := s.style
if st == "" {
st = styleBold
}
spans = append(spans, mdSpan{text: s.text, style: st})
}
i += end + 4
continue
}
case c == '[':
if close := strings.IndexByte(line[i+1:], ']'); close >= 0 {
rest := line[i+1+close+1:]
if strings.HasPrefix(rest, "(") {
if pclose := strings.IndexByte(rest[1:], ')'); pclose >= 0 {
flush("")
label := line[i+1 : i+1+close]
spans = append(spans, mdSpan{text: label, style: styleAccent + "\x1b[4m"})
i += 1 + close + 1 + 1 + pclose + 1
continue
}
}
}
}
buf.WriteByte(c)
i++
}
flush("")
return spans
}
// wrapInline lays out styled spans across one or more terminal rows of
// `cols` visible columns each. Each output row is prefixed with
// `lineStyle` so the caller can theme an entire wrapped paragraph
// (headings, blockquotes) with one SGR. Wrapping prefers word
// boundaries; oversized tokens hard-cut at the column boundary.
func wrapInline(spans []mdSpan, lineStyle string, cols int) []string {
if cols < 1 {
cols = 1
}
var out []string
var b strings.Builder
written := 0
curStyle := ""
startLine := func() {
b.Reset()
written = 0
curStyle = ""
if lineStyle != "" {
b.WriteString(lineStyle)
curStyle = lineStyle
}
}
finishLine := func() {
if b.Len() == 0 && lineStyle == "" {
out = append(out, "")
return
}
b.WriteString(styleReset)
out = append(out, b.String())
}
startLine()
writeChar := func(r rune, st string) {
if curStyle != st {
b.WriteString(styleReset)
if lineStyle != "" {
b.WriteString(lineStyle)
}
if st != "" {
b.WriteString(st)
}
curStyle = st
}
b.WriteRune(r)
written += runeCellWidth(r)
}
for _, sp := range spans {
st := sp.style
// Tokenize span into words+spaces for word-boundary wrapping.
text := sp.text
for len(text) > 0 {
r, size := utf8.DecodeRuneInString(text)
// Take a run of either spaces or non-spaces.
isSpace := unicode.IsSpace(r)
j := 0
w := 0
for j < len(text) {
rr, sz := utf8.DecodeRuneInString(text[j:])
if unicode.IsSpace(rr) != isSpace {
break
}
j += sz
w += runeCellWidth(rr)
}
tok := text[:j]
text = text[j:]
_ = r
_ = size
if isSpace {
if written == 0 {
// Drop leading whitespace at line start.
continue
}
if written+w > cols {
finishLine()
startLine()
continue
}
for _, rr := range tok {
writeChar(rr, st)
}
continue
}
// Non-space token. If it fits, append; else wrap.
if w <= cols {
if written+w > cols {
// Trim trailing spaces written so far before wrap.
finishLine()
startLine()
}
for _, rr := range tok {
writeChar(rr, st)
}
continue
}
// Token longer than a full row: hard-cut.
for _, rr := range tok {
cw := runeCellWidth(rr)
if written+cw > cols {
finishLine()
startLine()
}
writeChar(rr, st)
}
}
}
finishLine()
if len(out) == 0 {
out = append(out, "")
}
return out
}
// wrapPlain wraps a literal string (no styling) at a `cols` visible
// column budget. Used by code-block rendering, which preserves the raw
// line verbatim.
func wrapPlain(line string, cols int) []string {
if cols < 1 {
cols = 1
}
if line == "" {
return []string{""}
}
var out []string
var b strings.Builder
written := 0
for _, r := range line {
w := runeCellWidth(r)
if written+w > cols {
out = append(out, b.String())
b.Reset()
written = 0
}
b.WriteRune(r)
written += w
}
if b.Len() > 0 {
out = append(out, b.String())
}
return out
}
// runeCellWidth is a tiny approximation of terminal cell width: 0 for
// non-printables, 1 for the common case. Wide East-Asian and emoji
// runes would ideally be 2, but pads in practice are Latin/symbol text;
// landing a precise width walk is left for when we see a real case.
func runeCellWidth(r rune) int {
if r == 0 || r == '\r' || r == '\n' {
return 0
}
if r < 0x20 || r == 0x7f {
return 0
}
return 1
}
// mdVisibleLen counts visible columns in a string with embedded SGR
// escapes — the inverse of the writer that produces them.
func mdVisibleLen(s string) int {
n := 0
i := 0
for i < len(s) {
if s[i] == 0x1b {
j := i + 1
if j < len(s) && s[j] == '[' {
j++
for j < len(s) && !isCSIFinal(s[j]) {
j++
}
if j < len(s) {
j++
}
i = j
continue
}
i = j
continue
}
r, size := utf8.DecodeRuneInString(s[i:])
n += runeCellWidth(r)
i += size
}
return n
}

View File

@@ -0,0 +1,93 @@
package app
import (
"strings"
"testing"
)
func TestRenderMarkdownLines_Heading(t *testing.T) {
lines := renderMarkdownLines("# Hello", 40)
if len(lines) != 1 {
t.Fatalf("heading should be 1 line, got %d (%v)", len(lines), lines)
}
if !strings.Contains(lines[0], "Hello") {
t.Errorf("heading text missing: %q", lines[0])
}
if !strings.Contains(lines[0], "\x1b[1m") {
t.Errorf("heading not bold: %q", lines[0])
}
}
func TestRenderMarkdownLines_BulletWrapping(t *testing.T) {
src := "- alpha beta gamma delta epsilon"
lines := renderMarkdownLines(src, 14)
if len(lines) < 2 {
t.Fatalf("expected wrap into 2+ lines, got %d: %v", len(lines), lines)
}
if !strings.Contains(lines[0], "•") {
t.Errorf("first line should carry bullet, got %q", lines[0])
}
if strings.Contains(lines[1], "•") {
t.Errorf("continuation should not repeat bullet: %q", lines[1])
}
}
func TestRenderMarkdownLines_InlineCode(t *testing.T) {
lines := renderMarkdownLines("call `foo()` now", 40)
if len(lines) != 1 {
t.Fatalf("expected one line, got %d", len(lines))
}
if !strings.Contains(lines[0], "foo()") {
t.Errorf("inline code text missing: %q", lines[0])
}
if !strings.Contains(lines[0], "\x1b[38;5;180m") {
t.Errorf("inline code style missing: %q", lines[0])
}
}
func TestRenderMarkdownLines_FencedCode(t *testing.T) {
src := "before\n```\nfn main() {\n}\n```\nafter"
lines := renderMarkdownLines(src, 40)
// Two fence rules + two code rows + before + after = at least 5 lines.
if len(lines) < 5 {
t.Fatalf("expected fenced block to produce >=5 rows, got %d: %v", len(lines), lines)
}
foundCode := false
for _, l := range lines {
if strings.Contains(l, "fn main()") {
foundCode = true
break
}
}
if !foundCode {
t.Errorf("code block content missing from output: %v", lines)
}
}
func TestRenderMarkdownLines_HardWrap(t *testing.T) {
src := strings.Repeat("a", 50)
lines := renderMarkdownLines(src, 10)
if len(lines) < 5 {
t.Fatalf("expected long line to wrap into >=5 rows, got %d: %v", len(lines), lines)
}
}
func TestRenderMarkdownLines_PreservesBlankLines(t *testing.T) {
src := "para1\n\npara2"
lines := renderMarkdownLines(src, 40)
if len(lines) != 3 {
t.Fatalf("expected 3 rows, got %d: %v", len(lines), lines)
}
if lines[1] != "" {
t.Errorf("middle row should be empty, got %q", lines[1])
}
}
func TestMDVisibleLen(t *testing.T) {
if got := mdVisibleLen("\x1b[1mfoo\x1b[0m"); got != 3 {
t.Errorf("mdVisibleLen styled: want 3 got %d", got)
}
if got := mdVisibleLen("hello"); got != 5 {
t.Errorf("mdVisibleLen plain: want 5 got %d", got)
}
}

View File

@@ -11,7 +11,12 @@ import (
// paletteAction is what the palette returns when the user picks an item.
type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "spawn-process-form" |
// "spawn-process-submit" | "switch" | "kill" | "quit" | "cancel"
// "spawn-process-submit" | "switch" | "kill" | "quit" |
// "cancel" | "pad-delete" | "pad-rename" | "pad-rename-form" |
// "pad-rename-submit" | "pad-edit" | "agent-rename" |
// "agent-rename-form" | "agent-rename-submit" | "agent-close" |
// "proc-rename" | "proc-rename-form" | "proc-rename-submit" |
// "proc-delete" | "proc-stop" | "proc-restart"
kind string
// For spawn-agent / spawn-process, the preset to launch.
@@ -24,6 +29,12 @@ type paletteAction struct {
// typed and the relaunch-on-exit flag they ticked.
command string
relaunch bool
// For pad-* actions, the scratchpad name to operate on.
padName string
// For *-rename-submit actions, the user-typed new name.
newName string
}
type paletteItem struct {
@@ -41,6 +52,7 @@ type paletteMode int
const (
paletteModePicker paletteMode = iota
paletteModeSpawnForm
paletteModeRenameForm
)
// spawnProcessForm is the state for the "Spawn process…" two-field
@@ -52,19 +64,33 @@ type spawnProcessForm struct {
field int // 0 = command, 1 = relaunch checkbox
}
// renameForm is a one-field inline form used by the "Rename scratchpad /
// agent / process" context palette entries. The submit action kind
// determines what gets renamed; the target name (pad name or child id)
// is carried alongside so closePalette knows what to apply the new
// name to.
type renameForm struct {
name []rune
subject string // "pad" | "agent" | "proc"
target string // padName for "pad"; childID for "agent"/"proc"
title string // e.g. "Rename scratchpad: notes.md"
}
// 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 {
query []rune
cursor int
children []*Child
focused string
presets preset.Set
query []rune
cursor int
children []*Child
focused string
focusedPad string
presets preset.Set
items []paletteItem
mode paletteMode
form *spawnProcessForm
mode paletteMode
form *spawnProcessForm
renameForm *renameForm
}
// macroPrefixes maps the palette macro prefix (without trailing space)
@@ -90,8 +116,20 @@ func detectMacro(q string) (macro, rest string) {
return "", q
}
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, presets: presets}
func findChildByID(children []*Child, id string) *Child {
if id == "" {
return nil
}
for _, c := range children {
if c.ID == id {
return c
}
}
return nil
}
func newPalette(children []*Child, focused, focusedPad string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, presets: presets}
p.rebuild()
return p
}
@@ -135,6 +173,68 @@ func (p *paletteState) rebuild() {
func (p *paletteState) allItems() []paletteItem {
var out []paletteItem
// Context-aware entries come first so the most relevant actions for
// whatever is currently focused are one or two keystrokes away.
// Order matters: a focused scratchpad shadows any focused child
// (focus owns one or the other at a time).
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},
})
out = append(out, paletteItem{
label: "Rename scratchpad: " + name,
hint: "inline rename · enter to commit",
action: paletteAction{kind: "pad-rename-form", padName: name},
})
out = append(out, paletteItem{
label: "Edit scratchpad: " + name,
hint: "open in external editor (zed)",
action: paletteAction{kind: "pad-edit", padName: name},
})
case p.focused != "":
if c := findChildByID(p.children, p.focused); c != nil {
name := c.DisplayName()
switch c.Kind {
case KindAgent:
out = append(out, paletteItem{
label: "Rename agent: " + name,
hint: "inline rename · enter to commit",
action: paletteAction{kind: "agent-rename-form", childID: c.ID},
})
out = append(out, paletteItem{
label: "Close agent: " + name,
hint: "SIGTERM " + strings.Join(c.Argv, " "),
action: paletteAction{kind: "agent-close", childID: c.ID},
})
default:
out = append(out, paletteItem{
label: "Rename process: " + name,
hint: "inline rename · enter to commit",
action: paletteAction{kind: "proc-rename-form", childID: c.ID},
})
out = append(out, paletteItem{
label: "Delete process: " + name,
hint: "remove entry; SIGKILL if alive",
action: paletteAction{kind: "proc-delete", childID: c.ID},
})
out = append(out, paletteItem{
label: "Stop process: " + name,
hint: "SIGTERM · keep entry for restart",
action: paletteAction{kind: "proc-stop", childID: c.ID},
})
out = append(out, paletteItem{
label: "Restart process: " + name,
hint: "SIGTERM then start with same argv",
action: paletteAction{kind: "proc-restart", childID: c.ID},
})
}
}
}
// Switch entries first — existing open agents/processes should
// surface above options to spawn new ones. Hide non-running agents
// (e.g. killed ones) so the list doesn't accumulate corpses. Command
@@ -283,6 +383,9 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
if p.mode == paletteModeSpawnForm {
return p.handleFormInput(chunk, i)
}
if p.mode == paletteModeRenameForm {
return p.handleRenameInput(chunk, i)
}
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
@@ -314,19 +417,46 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
}
// acceptOrEnterForm wraps accept(): if the chosen item opens the
// spawn-process form, transition into form mode instead of returning
// done=true. The advance count is what the caller already consumed for
// the Enter keystroke.
// spawn-process form or one of the rename forms, transition into form
// mode instead of returning done=true. The advance count is what the
// caller already consumed for the Enter keystroke.
func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
a := p.accept()
if a.kind == "spawn-process-form" {
switch a.kind {
case "spawn-process-form":
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
return paletteAction{}, false, adv
case "pad-rename-form":
p.enterRenameForm("pad", a.padName, a.padName, "Rename scratchpad: "+a.padName)
return paletteAction{}, false, adv
case "agent-rename-form", "proc-rename-form":
subject := "agent"
title := "Rename agent: "
if a.kind == "proc-rename-form" {
subject = "proc"
title = "Rename process: "
}
current := ""
if c := findChildByID(p.children, a.childID); c != nil {
current = c.DisplayName()
}
p.enterRenameForm(subject, a.childID, current, title+current)
return paletteAction{}, false, adv
}
return a, true, adv
}
func (p *paletteState) enterRenameForm(subject, target, current, title string) {
p.mode = paletteModeRenameForm
p.renameForm = &renameForm{
name: []rune(current),
subject: subject,
target: target,
title: title,
}
}
func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
switch final {
case 'A':
@@ -445,6 +575,92 @@ func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteA
return paletteAction{}, false, n
}
// handleRenameInput drives the single-field rename form. Enter commits
// the typed name, Esc cancels back out of the palette entirely (same
// semantics as the spawn form so the user has one mental model).
func (p *paletteState) handleRenameInput(chunk []byte, i int) (paletteAction, bool, int) {
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
return p.handleRenameCSI(chunk[i+2:i+n-1], chunk[i+n-1], n)
}
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
return p.submitRename(), true, 1
case 0x7f, 0x08:
p.renameBackspace()
case 0x15: // Ctrl-U
if p.renameForm != nil {
p.renameForm.name = p.renameForm.name[:0]
}
default:
if b >= 0x20 && b < 0x7f && p.renameForm != nil {
p.renameForm.name = append(p.renameForm.name, rune(b))
}
}
return paletteAction{}, false, 1
}
func (p *paletteState) handleRenameCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
switch final {
case 'u':
k, ok := decodeCSIu(string(params))
if !ok || k.event != 1 {
return paletteAction{}, false, n
}
switch k.key {
case 13:
return p.submitRename(), true, n
case 27:
return paletteAction{kind: "cancel"}, true, n
case 127, 8:
p.renameBackspace()
default:
if k.mods == 5 && k.key == 'u' {
if p.renameForm != nil {
p.renameForm.name = p.renameForm.name[:0]
}
return paletteAction{}, false, n
}
if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f && p.renameForm != nil {
p.renameForm.name = append(p.renameForm.name, rune(k.key))
}
}
}
return paletteAction{}, false, n
}
func (p *paletteState) renameBackspace() {
if p.renameForm != nil && len(p.renameForm.name) > 0 {
p.renameForm.name = p.renameForm.name[:len(p.renameForm.name)-1]
}
}
func (p *paletteState) submitRename() paletteAction {
if p.renameForm == nil {
return paletteAction{kind: "cancel"}
}
newName := strings.TrimSpace(string(p.renameForm.name))
if newName == "" {
return paletteAction{kind: "cancel"}
}
var kind string
switch p.renameForm.subject {
case "pad":
kind = "pad-rename-submit"
return paletteAction{kind: kind, padName: p.renameForm.target, newName: newName}
case "agent":
kind = "agent-rename-submit"
case "proc":
kind = "proc-rename-submit"
default:
return paletteAction{kind: "cancel"}
}
return paletteAction{kind: kind, childID: p.renameForm.target, newName: newName}
}
func (p *paletteState) cycleFormField() {
p.form.field++
if p.form.field > 1 {
@@ -512,6 +728,10 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
p.renderForm(out, cols, rows)
return
}
if p.mode == paletteModeRenameForm {
p.renderRename(out, cols, rows)
return
}
if cols < 32 {
cols = 32
}
@@ -794,6 +1014,96 @@ func (p *paletteState) renderForm(out writeFlusher, cols, rows int) {
_ = out.Flush()
}
// renderRename paints the single-field rename form. Layout mirrors the
// spawn form so the user keeps the same mental model.
func (p *paletteState) renderRename(out writeFlusher, cols, rows int) {
if p.renameForm == nil {
p.renameForm = &renameForm{}
}
if cols < 32 {
cols = 32
}
if rows < 10 {
rows = 10
}
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
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
title := p.renameForm.title
if title == "" {
title = "Rename"
}
hint := "esc cancel"
titleLen := utf8.RuneCountInString(title)
if titleLen > width-12 {
title = clipRunes(title, width-13) + "…"
titleLen = utf8.RuneCountInString(title)
}
dashes := width - 3 - titleLen - 1 - 1 - len(hint) - 3
if dashes < 2 {
dashes = 2
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " +
strings.Repeat("─", dashes) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
nameStr := string(p.renameForm.name)
nameLen := utf8.RuneCountInString(nameStr)
pad := content - 2 - nameLen
if pad < 0 {
pad = 0
nameStr = clipRunes(nameStr, content-2)
nameLen = utf8.RuneCountInString(nameStr)
}
nameRow := row
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "" + styleReset + " " + nameStr +
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := "↵ commit · esc cancel · ⌃u clear"
fLen := utf8.RuneCountInString(footer)
fpad := content - fLen
if fpad < 0 {
fpad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + footer + styleReset +
strings.Repeat(" ", fpad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
moveTo(&b, nameRow, leftPad+4+nameLen)
b.WriteString("\x1b[?25h")
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func clipRunes(s string, n int) string {
if n <= 0 {
return ""

View File

@@ -0,0 +1,208 @@
package app
import (
"os/exec"
"testing"
"time"
"github.com/hjbdev/patterm/internal/preset"
)
// makeFakeChild builds a Child with just enough state for the palette
// to render it. We don't start a PTY — the palette only reads ID,
// Name, Kind, and Status() which all work without one.
func makeFakeChild(id, name string, kind ChildKind) *Child {
c := &Child{ID: id, Name: name, Kind: kind}
st := StatusRunning
c.status.Store(&st)
return c
}
// findAction scans p.items and returns the first paletteAction.kind
// matching want, or "" if not found.
func findItem(p *paletteState, want string) (int, *paletteItem) {
for i := range p.items {
if p.items[i].action.kind == want {
return i, &p.items[i]
}
}
return -1, nil
}
func TestContextItemsScratchpad(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
if i, _ := findItem(p, "pad-delete"); i != 0 {
t.Fatalf("pad-delete at %d; want top", i)
}
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")
}
// No focused child → no agent/proc context items.
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
t.Fatalf("agent items leaked: index %d", i)
}
if i, _ := findItem(p, "proc-rename-form"); i != -1 {
t.Fatalf("proc items leaked: index %d", i)
}
}
func TestContextItemsAgent(t *testing.T) {
c := makeFakeChild("aid", "codex", KindAgent)
p := newPalette([]*Child{c}, "aid", "", preset.Set{})
if _, it := findItem(p, "agent-rename-form"); it == nil || it.action.childID != "aid" {
t.Fatalf("agent-rename-form missing or wrong: %+v", it)
}
if _, it := findItem(p, "agent-close"); it == nil || it.action.childID != "aid" {
t.Fatalf("agent-close missing or wrong: %+v", it)
}
// agent context never surfaces proc items.
if i, _ := findItem(p, "proc-rename-form"); i != -1 {
t.Fatalf("proc items leaked into agent context: index %d", i)
}
if i, _ := findItem(p, "pad-delete"); i != -1 {
t.Fatalf("pad items leaked into agent context")
}
}
func TestContextItemsProcess(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand)
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
for _, kind := range []string{"proc-rename-form", "proc-delete", "proc-stop", "proc-restart"} {
if _, it := findItem(p, kind); it == nil {
t.Fatalf("missing proc context item: %s", kind)
}
}
if i, _ := findItem(p, "agent-rename-form"); i != -1 {
t.Fatalf("agent items leaked into process context")
}
}
func TestContextItemsAppearAboveSwitch(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand)
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
procIdx, _ := findItem(p, "proc-rename-form")
switchIdx, _ := findItem(p, "switch")
if procIdx < 0 || switchIdx < 0 {
t.Fatalf("missing items: proc=%d switch=%d", procIdx, switchIdx)
}
if procIdx > switchIdx {
t.Fatalf("proc context item at %d came after switch at %d", procIdx, switchIdx)
}
}
func TestContextItemsNoFocusNoExtras(t *testing.T) {
p := newPalette(nil, "", "", preset.Set{})
for _, kind := range []string{
"pad-delete", "pad-rename-form", "pad-edit",
"agent-rename-form", "agent-close",
"proc-rename-form", "proc-delete", "proc-stop", "proc-restart",
} {
if i, _ := findItem(p, kind); i != -1 {
t.Fatalf("unexpected context item %s with no focus (idx=%d)", kind, i)
}
}
}
// Renaming a scratchpad via Enter should open the rename form, accept
// typed input, and emit a pad-rename-submit with the new name.
func TestRenamePadFormCommits(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
idx, _ := findItem(p, "pad-rename-form")
if idx < 0 {
t.Fatalf("pad-rename-form missing")
}
p.cursor = idx
// Open the form.
_, done, _ := p.handleInput([]byte("\r"), 0)
if done {
t.Fatalf("opening rename form closed palette")
}
if p.mode != paletteModeRenameForm || p.renameForm == nil {
t.Fatalf("mode=%v form=%v after open", p.mode, p.renameForm)
}
if string(p.renameForm.name) != "notes.md" {
t.Fatalf("prefill = %q", string(p.renameForm.name))
}
// Clear and type a new name.
_, _, _ = p.handleInput([]byte{0x15}, 0) // Ctrl-U
if len(p.renameForm.name) != 0 {
t.Fatalf("Ctrl-U didn't clear: %q", string(p.renameForm.name))
}
for _, b := range []byte("brief.md") {
_, _, _ = p.handleInput([]byte{b}, 0)
}
action, done, _ := p.handleInput([]byte("\r"), 0)
if !done || action.kind != "pad-rename-submit" {
t.Fatalf("submit didn't fire: action=%+v done=%v", action, done)
}
if action.padName != "notes.md" || action.newName != "brief.md" {
t.Fatalf("submit payload = %+v", action)
}
}
func TestRenameProcessFormPrefillsCurrentName(t *testing.T) {
c := makeFakeChild("pid", "devserver", KindCommand)
p := newPalette([]*Child{c}, "pid", "", preset.Set{})
idx, _ := findItem(p, "proc-rename-form")
if idx < 0 {
t.Fatalf("proc-rename-form missing")
}
p.cursor = idx
_, _, _ = p.handleInput([]byte("\r"), 0)
if p.renameForm == nil || string(p.renameForm.name) != "devserver" {
t.Fatalf("prefill = %v", p.renameForm)
}
if p.renameForm.subject != "proc" || p.renameForm.target != "pid" {
t.Fatalf("form target/subject wrong: %+v", p.renameForm)
}
}
func TestRenameFormEscCancels(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
p.mode = paletteModeRenameForm
p.renameForm = &renameForm{name: []rune("x"), subject: "pad", target: "notes.md"}
action, done, _ := p.handleInput([]byte{0x1b}, 0)
if !done || action.kind != "cancel" {
t.Fatalf("ESC didn't cancel: action=%+v done=%v", action, done)
}
}
func TestRenameFormEmptySubmitCancels(t *testing.T) {
p := newPalette(nil, "", "notes.md", preset.Set{})
p.mode = paletteModeRenameForm
p.renameForm = &renameForm{name: []rune(" "), subject: "pad", target: "notes.md"}
action, done, _ := p.handleInput([]byte("\r"), 0)
if !done || action.kind != "cancel" {
t.Fatalf("empty submit didn't cancel: action=%+v done=%v", action, done)
}
}
// TestPadEditDoesNotBlock guards the "fire-and-forget exec" contract:
// handlePadEdit must Start() the editor and return promptly, not Wait()
// on it. We substitute a slow command (`sleep 30`) via PATH and ensure
// the action returns well under a second.
func TestPadEditDoesNotBlock(t *testing.T) {
if _, err := exec.LookPath("sleep"); err != nil {
t.Skip("no sleep on PATH")
}
// Verify the action runs through exec.Command/Start in well under a
// second by directly invoking the same primitive handlePadEdit uses.
cmd := exec.Command("sleep", "30")
start := time.Now()
if err := cmd.Start(); err != nil {
t.Fatalf("start: %v", err)
}
if cmd.Process != nil {
_ = cmd.Process.Release()
// Best-effort cleanup so the test doesn't leave a sleeping
// process behind. Release() detaches from the parent so a
// follow-up kill is the only way to reap it deterministically.
_ = cmd.Process.Kill()
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("exec.Start took %v — handlePadEdit would block the TUI", elapsed)
}
}

View File

@@ -7,7 +7,7 @@ import (
)
func newTestPalette() *paletteState {
return newPalette(nil, "", preset.Set{})
return newPalette(nil, "", "", preset.Set{})
}
func TestPaletteIgnoresKittyReleaseEvent(t *testing.T) {
@@ -49,7 +49,7 @@ func TestPaletteBareEscCancels(t *testing.T) {
func TestPaletteKittyArrowsNavigate(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
p := newPalette(nil, "", preset.Set{Agents: pr})
p := newPalette(nil, "", "", preset.Set{Agents: pr})
if p.cursor != 0 {
t.Fatalf("initial cursor %d", p.cursor)
}
@@ -70,7 +70,7 @@ func TestPaletteKittyArrowsNavigate(t *testing.T) {
func TestPaletteLegacyArrowsStillWork(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", preset.Set{Agents: pr})
p := newPalette(nil, "", "", preset.Set{Agents: pr})
_, _, adv := p.handleInput([]byte("\x1b[B"), 0)
if adv != 3 {
t.Fatalf("advance %d", adv)
@@ -82,7 +82,7 @@ func TestPaletteLegacyArrowsStillWork(t *testing.T) {
func TestPaletteKittyEnterAccepts(t *testing.T) {
pr := []*preset.Preset{{Name: "x"}}
p := newPalette(nil, "", preset.Set{Agents: pr})
p := newPalette(nil, "", "", preset.Set{Agents: pr})
action, done, _ := p.handleInput([]byte("\x1b[13u"), 0)
if !done || action.kind != "spawn-agent" {
t.Fatalf("Enter via CSI u didn't accept: action=%+v done=%v", action, done)
@@ -112,7 +112,7 @@ func TestPaletteLegacyPrintableTypes(t *testing.T) {
// non-empty command line emits the submit action with relaunch reflecting
// the checkbox state.
func TestPaletteSpawnProcessFormFlow(t *testing.T) {
p := newPalette(nil, "", preset.Set{})
p := newPalette(nil, "", "", preset.Set{})
// The "Spawn process…" entry is the only non-Quit item with an
// empty preset list. Locate its index by scanning items.
idx := -1
@@ -165,7 +165,7 @@ func TestPaletteSpawnProcessFormFlow(t *testing.T) {
}
func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) {
p := newPalette(nil, "", preset.Set{})
p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
action, done, _ := p.handleInput([]byte("\r"), 0)
@@ -175,7 +175,7 @@ func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) {
}
func TestPaletteSpawnProcessFormEscCancels(t *testing.T) {
p := newPalette(nil, "", preset.Set{})
p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{cmd: []rune("x")}
action, done, _ := p.handleInput([]byte{0x1b}, 0)

106
internal/app/ring_test.go Normal file
View File

@@ -0,0 +1,106 @@
package app
import (
"bytes"
"testing"
)
func newRingChild() *Child {
return newChildEntry("id", "name", KindCommand, nil, nil, "", "", "")
}
func TestRingShortWrite(t *testing.T) {
c := newRingChild()
c.recordWrite([]byte("hello"))
b, end := c.StreamRead(0)
if end != 5 {
t.Fatalf("end=%d want 5", end)
}
if string(b) != "hello" {
t.Fatalf("got %q want %q", b, "hello")
}
// Read past the head returns nil, same end.
b, end = c.StreamRead(5)
if end != 5 || b != nil {
t.Fatalf("re-read: end=%d b=%v", end, b)
}
}
func TestRingIncrementalRead(t *testing.T) {
c := newRingChild()
c.recordWrite([]byte("abc"))
c.recordWrite([]byte("def"))
b, end := c.StreamRead(3)
if end != 6 || string(b) != "def" {
t.Fatalf("got %q end=%d", b, end)
}
}
func TestRingWrapAround(t *testing.T) {
c := newRingChild()
// Write more than ringCap to force wrap. Use a pattern we can
// verify: bytes equal to (i mod 256).
total := ringCap + 1000
src := make([]byte, total)
for i := range src {
src[i] = byte(i)
}
// Write in pieces to exercise the wrap copy in recordWrite.
for i := 0; i < total; i += 7777 {
end := i + 7777
if end > total {
end = total
}
c.recordWrite(src[i:end])
}
// The freshest ringCap bytes should be readable.
b, head := c.StreamRead(0)
if head != int64(total) {
t.Fatalf("head=%d want %d", head, total)
}
if len(b) != ringCap {
t.Fatalf("len(b)=%d want %d", len(b), ringCap)
}
want := src[total-ringCap:]
if !bytes.Equal(b, want) {
t.Fatalf("ring contents diverge from source tail")
}
}
func TestRingChunkLargerThanCap(t *testing.T) {
c := newRingChild()
src := make([]byte, ringCap+500)
for i := range src {
src[i] = byte(i + 1)
}
c.recordWrite(src)
b, head := c.StreamRead(0)
if head != int64(len(src)) {
t.Fatalf("head=%d want %d", head, len(src))
}
if len(b) != ringCap {
t.Fatalf("len(b)=%d want %d", len(b), ringCap)
}
if !bytes.Equal(b, src[500:]) {
t.Fatalf("ring tail mismatch")
}
}
func TestStripANSIBytesEquivalence(t *testing.T) {
cases := []string{
"hello world",
"\x1b[31mred\x1b[0m text",
"line1\nline2\r\nline3",
"bell\x07ish",
"weird \x1bA escape",
"truncated \x1b[1;",
"",
}
for _, in := range cases {
want := stripANSI(in)
got := string(stripANSIBytes(nil, []byte(in)))
if got != want {
t.Errorf("stripANSIBytes(%q) = %q want %q", in, got, want)
}
}
}

View File

@@ -12,9 +12,11 @@ import (
"fmt"
"os"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/vt"
)
@@ -38,8 +40,25 @@ type Session struct {
// listeners is the set of UI listeners that want to hear about child
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
// listeners is an atomic.Pointer to a frozen slice. Subscribe
// copy-on-writes the slice; emit* paths use a single atomic Load.
// This drops one mutex acquisition per PTY chunk on the hot path.
listenersMu sync.Mutex
listeners []ChildEventListener
listeners atomic.Pointer[[]ChildEventListener]
// persistStore records top-level command entries to a per-project
// JSON file so they can be re-spawned after patterm restarts.
// Optional; nil means "no persistence" (used by unit tests).
persistStore *persist.Store
}
// SetPersistStore attaches a process-persistence store. Future Spawn /
// Close / Rename / SetAutoRestart calls on top-level command entries
// will mirror the change into the store.
func (s *Session) SetPersistStore(p *persist.Store) {
s.mu.Lock()
s.persistStore = p
s.mu.Unlock()
}
// ChildEventListener is implemented by the TUI to react to lifecycle
@@ -51,6 +70,10 @@ type ChildEventListener interface {
// Only the focused-child chunk should reach the screen — the TUI
// filters by id.
OnPTYOut(childID string, chunk []byte)
// OnChildStateChanged fires when the idle-detection classifier
// updates a child's IdleState. Listeners use this to repaint the
// sidebar badge and to evaluate idle-aware timers.
OnChildStateChanged(childID string, state IdleState)
}
func NewSession(projectDir, projectKey string) *Session {
@@ -65,36 +88,68 @@ func NewSession(projectDir, projectKey string) *Session {
func (s *Session) Subscribe(l ChildEventListener) {
s.listenersMu.Lock()
defer s.listenersMu.Unlock()
s.listeners = append(s.listeners, l)
prev := s.listenersSnapshot()
next := make([]ChildEventListener, 0, len(prev)+1)
next = append(next, prev...)
next = append(next, l)
s.listeners.Store(&next)
}
// Unsubscribe removes a previously-registered listener. Safe to call
// with a listener that wasn't registered (no-op).
func (s *Session) Unsubscribe(l ChildEventListener) {
s.listenersMu.Lock()
defer s.listenersMu.Unlock()
prev := s.listenersSnapshot()
if len(prev) == 0 {
return
}
next := make([]ChildEventListener, 0, len(prev))
for _, e := range prev {
if e != l {
next = append(next, e)
}
}
s.listeners.Store(&next)
}
// listenersSnapshot returns the frozen listener slice. Safe to call
// without the listeners mutex.
func (s *Session) listenersSnapshot() []ChildEventListener {
p := s.listeners.Load()
if p == nil {
return nil
}
return *p
}
func (s *Session) emitSpawn(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
for _, l := range s.listenersSnapshot() {
l.OnChildSpawned(c)
}
}
func (s *Session) emitExit(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
for _, l := range s.listenersSnapshot() {
l.OnChildExited(c)
}
}
// emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners
// MUST NOT retain `chunk` past return — the slice is owned by the
// pumpChild read buffer and is overwritten on the next read.
func (s *Session) emitPTYOut(id string, chunk []byte) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
for _, l := range s.listenersSnapshot() {
l.OnPTYOut(id, chunk)
}
}
func (s *Session) emitStateChanged(id string, state IdleState) {
for _, l := range s.listenersSnapshot() {
l.OnChildStateChanged(id, state)
}
}
func (s *Session) ChildEnv() []string {
env := os.Environ()
// Mark patterm-owned PTYs so a recursive `patterm` invocation can
@@ -123,6 +178,11 @@ type SpawnSpec struct {
// or is closed. They must be attached before the PTY starts so a
// fast-exiting child cannot outrun cleanup registration.
CleanupPaths []string
// IdleDetection is the resolved per-preset idle classifier config.
// Must be installed before the child is published to s.children so
// the classifier goroutine never observes a nil/default config for
// a preset that overrides it.
IdleDetection *resolvedIdleDetection
}
// Spawn creates a new entry and starts its PTY. For Kind = command the
@@ -153,6 +213,12 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
for _, path := range spec.CleanupPaths {
c.AddCleanupPath(path)
}
// Install idle-detection BEFORE publishing to s.children — otherwise
// the classifier goroutine could read c.idleDetection while the
// launcher is still racing to set it.
if spec.IdleDetection != nil {
c.setIdleDetection(spec.IdleDetection)
}
runID, err := c.startPTY(cols, rows)
if err != nil {
c.cleanupOwnedPaths()
@@ -162,14 +228,67 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
s.mu.Lock()
s.children[id] = c
s.order = append(s.order, id)
store := s.persistStore
s.mu.Unlock()
// Wire persistence callback BEFORE registering so SetName /
// SetAutoRestart calls that race the listener still hit the store.
if store != nil {
c.setPersistFn(func(ch *Child) {
s.persistEntry(ch)
})
s.persistEntry(c)
}
s.emitSpawn(c)
go s.pumpChild(c, runID)
go s.reapChild(c, runID)
return c, nil
}
// persistEntry writes (or refreshes) the persist record for c if it
// qualifies — top-level command entries only. No-op when no store is
// attached.
func (s *Session) persistEntry(c *Child) {
s.mu.Lock()
store := s.persistStore
s.mu.Unlock()
if store == nil || !shouldPersist(c) {
return
}
e := persist.Entry{
ID: c.ID,
Name: c.DisplayName(),
Argv: append([]string(nil), c.Argv...),
WorkDir: c.WorkDir,
PresetRef: c.PresetRef,
AutoRestart: c.AutoRestart(),
}
if err := store.Save(e); err != nil {
logf("persist save %s: %v", c.ID, err)
}
}
func (s *Session) forgetPersisted(id string) {
s.mu.Lock()
store := s.persistStore
s.mu.Unlock()
if store == nil {
return
}
if err := store.Remove(id); err != nil {
logf("persist remove %s: %v", id, err)
}
}
// shouldPersist gates which Child entries get mirrored into the
// persist store. v1 only restores top-level command entries — agents
// and terminals are ephemeral by design, and sub-agent-spawned
// commands belong to their orchestrator's lifecycle.
func shouldPersist(c *Child) bool {
return c != nil && c.Kind == KindCommand && c.ParentID == ""
}
// Start (re)attaches a PTY to an entry that is currently stopped or
// exited. Errors if the entry is already live.
func (s *Session) Start(id string, cols, rows uint16) error {
@@ -238,6 +357,7 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
}
}
s.mu.Unlock()
s.forgetPersisted(id)
return nil
}
@@ -257,6 +377,12 @@ func (s *Session) pumpChild(c *Child, runID uint64) {
if pty == nil {
return
}
// One PTY read buffer per pump goroutine. Consumers downstream
// (em.Write is synchronous through CGO; recordWrite append-copies
// into the ring; renderer.Render copies into its pending buffer)
// all complete or copy before returning, so the buffer can be
// reused without aliasing live data. See ChildEventListener.OnPTYOut
// docstring — listeners must not retain `chunk`.
buf := make([]byte, 64*1024)
for {
n, err := pty.Read(buf)
@@ -264,12 +390,20 @@ func (s *Session) pumpChild(c *Child, runID uint64) {
if !c.isCurrentRun(runID) {
return
}
chunk := make([]byte, n)
copy(chunk, buf[:n])
chunk := buf[:n]
if em := c.Emulator(); em != nil {
if _, werr := em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr)
}
// OSC 0/2 title updates ride on the same byte stream as
// the rest of the output. Polling the emulator after each
// Write is cheap (one cgo call returning a borrowed
// string) and lets the classifier treat title changes as
// an activity signal — even when the title isn't visible
// in the rendered grid.
if t, terr := em.Title(); terr == nil {
c.recordTitle(t)
}
}
c.recordWrite(chunk)
s.emitPTYOut(c.ID, chunk)

View File

@@ -57,6 +57,50 @@ func TestParentExitKillsDescendants(t *testing.T) {
waitUntilNotLive(t, grandchild)
}
// TestSpawnInstallsIdleDetectionBeforePublish guarantees that a child
// spawned with SpawnSpec.IdleDetection has its resolved config visible
// the instant the child appears in s.children — closing the race where
// the classifier could read c.idleDetection before the launcher set it.
func TestSpawnInstallsIdleDetectionBeforePublish(t *testing.T) {
sess := NewSession(t.TempDir(), "test")
want := &resolvedIdleDetection{
strategy: StrategyOSCTitleStability,
idleThresholdMS: 9999,
}
c, err := sess.Spawn(SpawnSpec{
Kind: KindCommand,
Argv: []string{"sh", "-c", "sleep 30"},
IdleDetection: want,
}, 80, 24)
if err != nil {
t.Fatalf("spawn: %v", err)
}
defer func() { _ = c.signal(syscall.SIGTERM) }()
// Read back via the same access path the classifier uses
// (sess.Children) so the test fails if the field is set only
// AFTER the child is published.
var found *Child
for _, ch := range sess.Children() {
if ch.ID == c.ID {
found = ch
break
}
}
if found == nil {
t.Fatalf("spawned child %s not in Children()", c.ID)
}
if found.idleDetection == nil {
t.Fatalf("idleDetection nil after Spawn returned")
}
if found.idleDetection.strategy != StrategyOSCTitleStability {
t.Fatalf("strategy: got %q want %q", found.idleDetection.strategy, StrategyOSCTitleStability)
}
if found.idleDetection.idleThresholdMS != 9999 {
t.Fatalf("threshold: got %d want 9999", found.idleDetection.idleThresholdMS)
}
}
func waitUntilLive(t *testing.T, c *Child) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"time"
)
const (
@@ -11,6 +12,24 @@ const (
statusRows = 1
)
// formatShortDuration renders a duration as a short, sidebar-friendly
// suffix: ms under 1s, "12s" under 60s, "3m" otherwise.
func formatShortDuration(d time.Duration) string {
if d <= 0 {
return "0s"
}
if d < time.Second {
return fmt.Sprintf("%dms", int(d/time.Millisecond))
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d/time.Second))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d/time.Minute))
}
return fmt.Sprintf("%dh", int(d/time.Hour))
}
// drawSidebar paints the right-rail session tree + scratchpad list.
// SPEC §4: the rail is the active session's child hierarchy on top and
// the scratchpad list (with preview) on the bottom.
@@ -22,6 +41,7 @@ func (st *uiState) drawSidebar() {
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
focusPad := st.focusedPad
activeAgent := st.activeAgentID
st.mu.Unlock()
if palOpen {
@@ -61,14 +81,56 @@ func (st *uiState) drawSidebar() {
write(" " + styleActive + text + styleReset)
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
}
// timerIndicator returns a short " ⏱ 12s" or " ⏸ paused" suffix
// when c has a pending or paused timer attached (owns or watches).
// Empty string when no timer is in play.
timerIndicator := func(c *Child) string {
if st.timers == nil {
return ""
}
info := st.timers.activeForChild(c.ID)
if info == nil {
return ""
}
if info.Status == timerStatusPaused {
return " " + styleDim + "⏸" + styleReset
}
remaining := ""
if info.FiresAtUnixMS > 0 {
d := time.Until(time.UnixMilli(info.FiresAtUnixMS))
if d < 0 {
d = 0
}
remaining = formatShortDuration(d)
}
return " " + styleDim + "⏱" + styleReset + " " + styleHint + remaining + styleReset
}
statusGlyph := func(c *Child, focused bool) string {
if c.Status() != StatusRunning {
return styleDim + "○" + styleReset
}
// Idle-detection states paint over the plain running glyph so
// the rail communicates "running but waiting on you" vs "running
// and busy" at a glance. Focused entries always use the accent
// colour so the user's selection stays visible.
style := styleHint
if focused {
return styleAccent + "●" + styleReset
style = styleAccent
}
switch c.IdleState() {
case StateError:
return styleError + "✕" + styleReset
case StatePermission:
return styleAccent + "?" + styleReset
case StateThinking:
return style + "◐" + styleReset
case StateIdle:
return style + "○" + styleReset
case StateWorking:
return style + "●" + styleReset
default:
return style + "●" + styleReset
}
return styleHint + "●" + styleReset
}
// Processes section — top-level command/terminal processes,
@@ -91,9 +153,9 @@ func (st *uiState) drawSidebar() {
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " +
styleBold + c.DisplayName() + styleReset + marker
styleBold + c.DisplayName() + styleReset + marker + timerIndicator(c)
} else {
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker + timerIndicator(c)
}
write(line)
}
@@ -123,37 +185,31 @@ func (st *uiState) drawSidebar() {
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + indent + glyph + " " +
styleBold + c.DisplayName() + styleReset
styleBold + c.DisplayName() + styleReset + timerIndicator(c)
} else {
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset
line = " " + indent + glyph + " " + styleHint + c.DisplayName() + styleReset + timerIndicator(c)
}
write(line)
}
// Scratchpads list — pick the most-recently-modified one as the
// preview target. SPEC §4.
var previewName string
// 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
// the main viewport via repaintFocusedPad. SPEC §4.
if row+2 <= maxRow {
write("")
writeHeader("Scratchpads")
entries, err := st.pads.List()
if err == nil {
entries := st.padsList()
if entries != nil {
if len(entries) == 0 {
write(" " + styleDim + "(none)" + styleReset)
} else {
var newestTS string
for _, e := range entries {
if e.ModifiedAt > newestTS {
newestTS = e.ModifiedAt
previewName = e.Name
}
}
for _, e := range entries {
if row > maxRow {
break
}
var line string
if e.Name == previewName {
if e.Name == focusPad {
line = " " + styleAccent + "▎" + styleReset + " " +
styleBold + e.Name + styleReset
} else {
@@ -165,22 +221,6 @@ func (st *uiState) drawSidebar() {
}
}
// Preview pane: dim file content under a thin divider.
if previewName != "" && row+2 <= maxRow {
write("")
write(" " + styleBorder + strings.Repeat("─", width-2) + styleReset)
write(" " + styleActive + previewName + styleReset)
content, _, err := st.pads.Read(previewName)
if err == nil {
for _, line := range strings.Split(content, "\n") {
if row > maxRow {
break
}
write(" " + styleDim + line + styleReset)
}
}
}
// Blank-fill any rows the rail content didn't cover so stale
// content from a previous redraw doesn't linger.
for row <= maxRow {

View File

@@ -11,4 +11,5 @@ const (
styleAccent = "\x1b[38;5;75m"
styleHint = "\x1b[38;5;244m"
styleActive = "\x1b[1;38;5;253m"
styleError = "\x1b[38;5;203m"
)

542
internal/app/timers.go Normal file
View File

@@ -0,0 +1,542 @@
package app
import (
"fmt"
"sync"
"time"
"github.com/hjbdev/patterm/internal/mcp"
)
// pendingTimerKind picks the firing rule.
type pendingTimerKind string
const (
timerKindDelay pendingTimerKind = "delay"
timerKindIdleAny pendingTimerKind = "idle_any"
timerKindIdleAll pendingTimerKind = "idle_all"
)
const (
timerStatusPending = "pending"
timerStatusPaused = "paused"
timerStatusFired = "fired"
timerStatusCanceled = "canceled"
)
// pendingTimer is one live timer tracked by the manager. The body is
// delivered verbatim to the owning child's PTY as a fresh user turn
// when the timer fires.
//
// Locking: every field is protected by timerManager.mu. The runtime
// time.Timer (rt) is started outside the lock so the firing goroutine
// can take the lock without deadlocking.
type pendingTimer struct {
id string
label string
body string
ownerID string
kind pendingTimerKind
status string
watched []string
idleBaseline map[string]bool // for idle_any: ids already idle at registration (excluded from satisfaction)
firesAt time.Time
pausedRemaining time.Duration
pausedWasMaxWait bool // for idle_*: true if the active timer was max-wait, not delay
rt *time.Timer // delay timer or idle_* max-wait fallback
}
// timerManager owns the pending-timer registry. Mutating operations
// (set, cancel, pause, resume) all serialise through mu; fire callbacks
// from the runtime timer also take mu to safely transition state.
type timerManager struct {
sess *Session
mu sync.Mutex
nextID int
timers map[string]*pendingTimer
// fireFn is the callback used to deliver the body to the owning
// process. Decoupled so tests can substitute a recorder. Defaults
// to caller.InjectAsOrchestrator + "\r".
fireFn func(owner *Child, body, label string)
}
func newTimerManager(sess *Session) *timerManager {
m := &timerManager{
sess: sess,
timers: make(map[string]*pendingTimer),
}
m.fireFn = defaultFireFn
return m
}
func defaultFireFn(owner *Child, body, label string) {
if owner == nil || !owner.IsLive() {
return
}
// Solo delivers body verbatim. patterm's PTY-injection path expects
// a trailing CR so the line submits in TUI agents (Claude/Codex/
// OpenCode all paste-detect). A bare body without CR sits in the
// input buffer; that's almost never what the caller wants.
if body == "" {
body = fmt.Sprintf("[system] Your timer [%s] has completed.", label)
}
_ = owner.InjectAsOrchestrator([]byte(body + "\r"))
}
func (m *timerManager) mintID() string {
m.nextID++
return fmt.Sprintf("t%d", m.nextID)
}
// TimerSet schedules a delay timer. Returns immediately; the body is
// delivered to the owning child when the timer fires.
func (m *timerManager) TimerSet(ownerID string, body, label string, seconds float64) (string, error) {
owner := m.sess.FindChild(ownerID)
if owner == nil {
return "", mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID)
}
if seconds < 0 {
return "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer_set: seconds must be ≥ 0")
}
d := time.Duration(seconds * float64(time.Second))
m.mu.Lock()
id := m.mintID()
if label == "" {
label = id
}
t := &pendingTimer{
id: id,
label: label,
body: body,
ownerID: ownerID,
kind: timerKindDelay,
status: timerStatusPending,
firesAt: time.Now().Add(d),
}
m.timers[id] = t
m.mu.Unlock()
t.rt = time.AfterFunc(d, func() { m.fireDelay(id) })
return id, nil
}
func (m *timerManager) fireDelay(id string) {
m.mu.Lock()
t, ok := m.timers[id]
if !ok || t.status != timerStatusPending {
m.mu.Unlock()
return
}
t.status = timerStatusFired
owner := m.sess.FindChild(t.ownerID)
body, label := t.body, t.label
delete(m.timers, id)
m.mu.Unlock()
m.fireFn(owner, body, label)
}
// TimerFireWhenIdleAny schedules an idle-any timer. Children already
// idle at registration are excluded from satisfaction — only a
// transition into idle by a still-active watched child fires the
// timer. Max-wait, when positive, acts as a fallback fire deadline.
func (m *timerManager) TimerFireWhenIdleAny(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
return m.registerIdleTimer(timerKindIdleAny, ownerID, body, label, watched, maxWait)
}
// TimerFireWhenIdleAll schedules an idle-all timer. Already-idle
// children count as satisfied; if every watched child is already idle
// at registration time the response is "already_satisfied" with no
// timer created.
func (m *timerManager) TimerFireWhenIdleAll(ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
return m.registerIdleTimer(timerKindIdleAll, ownerID, body, label, watched, maxWait)
}
func (m *timerManager) registerIdleTimer(kind pendingTimerKind, ownerID, body, label string, watched []string, maxWait float64) (mcp.TimerFireWhenIdleResponse, error) {
if m.sess.FindChild(ownerID) == nil {
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "caller %q not known to patterm", ownerID)
}
if len(watched) == 0 {
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "watched must contain at least one process_id")
}
if maxWait < 0 {
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "max_wait_seconds must be ≥ 0")
}
// Validate watched ids and compute the idle baseline up front.
already := make([]string, 0)
waiting := make([]string, 0)
baseline := make(map[string]bool, len(watched))
for _, id := range watched {
c := m.sess.FindChild(id)
if c == nil {
return mcp.TimerFireWhenIdleResponse{}, mcp.Errorf(mcp.ErrorKindNotFound, "no such process %q in watched", id)
}
if isIdleState(c.IdleState()) {
already = append(already, id)
baseline[id] = true
} else {
waiting = append(waiting, id)
}
}
resp := mcp.TimerFireWhenIdleResponse{AlreadyIdle: already, WaitingOn: waiting}
// idle_all: if all watched are already idle, satisfy synchronously
// — Solo semantics; no pending timer is created.
if kind == timerKindIdleAll && len(waiting) == 0 {
resp.Status = "already_satisfied"
owner := m.sess.FindChild(ownerID)
go m.fireFn(owner, body, label)
return resp, nil
}
m.mu.Lock()
id := m.mintID()
if label == "" {
label = id
}
t := &pendingTimer{
id: id,
label: label,
body: body,
ownerID: ownerID,
kind: kind,
status: timerStatusPending,
watched: append([]string(nil), watched...),
idleBaseline: baseline,
}
if maxWait > 0 {
d := time.Duration(maxWait * float64(time.Second))
t.firesAt = time.Now().Add(d)
t.rt = time.AfterFunc(d, func() { m.fireIdleMaxWait(id) })
}
m.timers[id] = t
m.mu.Unlock()
resp.ID = id
resp.Status = "pending"
return resp, nil
}
func (m *timerManager) fireIdleMaxWait(id string) {
m.mu.Lock()
t, ok := m.timers[id]
if !ok || t.status != timerStatusPending {
m.mu.Unlock()
return
}
t.status = timerStatusFired
owner := m.sess.FindChild(t.ownerID)
body, label := t.body, t.label
delete(m.timers, id)
m.mu.Unlock()
m.fireFn(owner, body, label)
}
// onChildStateChanged evaluates every pending idle_any / idle_all
// timer whenever any child's IdleState flips. Cheap — there are few
// pending timers and the per-tick check is just a map lookup + a slice
// scan.
func (m *timerManager) onChildStateChanged(childID string, state IdleState) {
if !isIdleState(state) {
return
}
m.mu.Lock()
type firing struct {
owner *Child
body string
label string
}
var fires []firing
var firedIDs []string
for _, t := range m.timers {
if t.status != timerStatusPending {
continue
}
if !contains(t.watched, childID) {
continue
}
switch t.kind {
case timerKindIdleAny:
if t.idleBaseline[childID] {
continue // already idle at registration; excluded
}
t.status = timerStatusFired
if t.rt != nil {
t.rt.Stop()
}
fires = append(fires, firing{
owner: m.sess.FindChild(t.ownerID),
body: t.body,
label: t.label,
})
firedIDs = append(firedIDs, t.id)
case timerKindIdleAll:
if m.allWatchedIdleLocked(t) {
t.status = timerStatusFired
if t.rt != nil {
t.rt.Stop()
}
fires = append(fires, firing{
owner: m.sess.FindChild(t.ownerID),
body: t.body,
label: t.label,
})
firedIDs = append(firedIDs, t.id)
}
}
}
for _, id := range firedIDs {
delete(m.timers, id)
}
m.mu.Unlock()
for _, f := range fires {
m.fireFn(f.owner, f.body, f.label)
}
}
// allWatchedIdleLocked reports whether every watched child is now
// idle. Called with m.mu held — uses live Child.IdleState() under the
// child's own atomic, not under m.mu.
func (m *timerManager) allWatchedIdleLocked(t *pendingTimer) bool {
for _, id := range t.watched {
c := m.sess.FindChild(id)
if c == nil {
continue // disappeared; treat as satisfied so we don't hang
}
if !isIdleState(c.IdleState()) {
return false
}
}
return true
}
// TimerCancel removes a pending or paused timer owned by ownerID.
func (m *timerManager) TimerCancel(ownerID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id]
if !ok {
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
}
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID {
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status == timerStatusFired || t.status == timerStatusCanceled {
// Cancelling a fired/cancelled timer is idempotent.
return nil
}
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
t.status = timerStatusCanceled
delete(m.timers, id)
return nil
}
// TimerPause stops the delay clock (or detaches the idle watch) but
// keeps the timer in the registry.
func (m *timerManager) TimerPause(ownerID, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.timers[id]
if !ok {
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
}
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID {
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status != timerStatusPending {
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not pending", id)
}
if t.rt != nil {
t.pausedRemaining = time.Until(t.firesAt)
if t.pausedRemaining < 0 {
t.pausedRemaining = 0
}
t.rt.Stop()
t.rt = nil
// For idle_* timers, only the max-wait timer rides on rt — the
// idle-evaluation path lives in onChildStateChanged. Mark the
// pause so resume rearms the right thing.
t.pausedWasMaxWait = t.kind != timerKindDelay
}
t.status = timerStatusPaused
return nil
}
// TimerResume re-arms a paused timer. For delay timers the remaining
// duration is restored; idle-* timers re-attach to the state-change
// watch list, and any remaining max-wait clock resumes.
//
// Idle-* timers also re-check their satisfaction condition immediately
// on resume: idle transitions that occurred while paused are otherwise
// missed (onChildStateChanged only sees future flips), so a child that
// went idle during the pause window would never fire the timer. For
// idle_any we look for any non-baseline watched child currently idle;
// for idle_all we check whether every watched child is now idle.
func (m *timerManager) TimerResume(ownerID, id string) error {
m.mu.Lock()
t, ok := m.timers[id]
if !ok {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindNotFound, "no such timer %q", id)
}
// Empty ownerID = top-level orchestrator caller (e.g. a non-agent
// MCP client); allow it to manage every timer in the session.
// Otherwise the caller's own id must match the timer's owner.
if ownerID != "" && t.ownerID != ownerID {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindRoleForbidden, "timer %q is not owned by caller", id)
}
if t.status != timerStatusPaused {
m.mu.Unlock()
return mcp.Errorf(mcp.ErrorKindInvalidArgs, "timer %q is not paused", id)
}
t.status = timerStatusPending
if t.pausedRemaining > 0 {
t.firesAt = time.Now().Add(t.pausedRemaining)
switch t.kind {
case timerKindDelay:
localID := id
t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireDelay(localID) })
default:
localID := id
t.rt = time.AfterFunc(t.pausedRemaining, func() { m.fireIdleMaxWait(localID) })
}
t.pausedRemaining = 0
t.pausedWasMaxWait = false
}
// For idle-* timers, evaluate the condition right now in case a
// watched child went idle while paused.
var fireNow bool
var owner *Child
var body, label string
switch t.kind {
case timerKindIdleAny:
for _, wid := range t.watched {
if t.idleBaseline[wid] {
continue
}
c := m.sess.FindChild(wid)
if c != nil && isIdleState(c.IdleState()) {
fireNow = true
break
}
}
case timerKindIdleAll:
if m.allWatchedIdleLocked(t) {
fireNow = true
}
}
if fireNow {
t.status = timerStatusFired
if t.rt != nil {
t.rt.Stop()
t.rt = nil
}
owner = m.sess.FindChild(t.ownerID)
body, label = t.body, t.label
delete(m.timers, id)
}
m.mu.Unlock()
if fireNow {
m.fireFn(owner, body, label)
}
return nil
}
// TimerList returns timers owned by ownerID, oldest-first. An empty
// ownerID lists every active timer — the top-level orchestrator view.
func (m *timerManager) TimerList(ownerID string) []mcp.TimerInfo {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]mcp.TimerInfo, 0)
for _, t := range m.timers {
if ownerID != "" && t.ownerID != ownerID {
continue
}
if t.status != timerStatusPending && t.status != timerStatusPaused {
continue
}
info := mcp.TimerInfo{
ID: t.id,
Label: t.label,
Body: t.body,
Kind: string(t.kind),
Status: t.status,
OwnerID: t.ownerID,
WatchedIDs: append([]string(nil), t.watched...),
}
if t.status == timerStatusPending && !t.firesAt.IsZero() {
info.FiresAtUnixMS = t.firesAt.UnixMilli()
}
if t.status == timerStatusPaused && t.pausedRemaining > 0 {
info.PausedRemainingMS = t.pausedRemaining.Milliseconds()
}
out = append(out, info)
}
return out
}
// activeForChild returns the nearest pending or paused timer attached
// to child id (either owned by it or watching it). Used by the sidebar
// for the "⏱ 12s" indicator. nil when none.
func (m *timerManager) activeForChild(id string) *mcp.TimerInfo {
m.mu.Lock()
defer m.mu.Unlock()
var best *pendingTimer
for _, t := range m.timers {
if t.status != timerStatusPending && t.status != timerStatusPaused {
continue
}
if t.ownerID != id && !contains(t.watched, id) {
continue
}
if best == nil {
best = t
continue
}
if t.firesAt.Before(best.firesAt) && !t.firesAt.IsZero() {
best = t
}
}
if best == nil {
return nil
}
info := mcp.TimerInfo{
ID: best.id,
Label: best.label,
Kind: string(best.kind),
Status: best.status,
OwnerID: best.ownerID,
}
if best.status == timerStatusPending && !best.firesAt.IsZero() {
info.FiresAtUnixMS = best.firesAt.UnixMilli()
}
if best.status == timerStatusPaused {
info.PausedRemainingMS = best.pausedRemaining.Milliseconds()
}
return &info
}
func isIdleState(s IdleState) bool {
return s == StateIdle
}
func contains(haystack []string, needle string) bool {
for _, h := range haystack {
if h == needle {
return true
}
}
return false
}

413
internal/app/timers_test.go Normal file
View File

@@ -0,0 +1,413 @@
package app
import (
"sync"
"testing"
"time"
)
// recorderFire collects timer firings without touching a PTY. Lets the
// timer manager run end-to-end logic in unit tests.
type recorderFire struct {
mu sync.Mutex
fires []recordedFire
}
type recordedFire struct {
OwnerID string
Body string
Label string
}
func (r *recorderFire) fn(owner *Child, body, label string) {
r.mu.Lock()
defer r.mu.Unlock()
id := ""
if owner != nil {
id = owner.ID
}
r.fires = append(r.fires, recordedFire{OwnerID: id, Body: body, Label: label})
}
func (r *recorderFire) snapshot() []recordedFire {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]recordedFire, len(r.fires))
copy(out, r.fires)
return out
}
// fakeChild constructs a Child shell suitable for timer-manager tests.
// Doesn't open a PTY — fireFn is overridden so InjectAsOrchestrator is
// never reached.
func fakeChild(id string) *Child {
c := newChildEntry(id, id, KindAgent, []string{"echo"}, nil, "", "", "")
running := StatusRunning
c.status.Store(&running)
return c
}
// addChild bypasses Spawn (no PTY needed) so the manager can find the
// child by id and read its IdleState.
func addChild(s *Session, c *Child) {
s.mu.Lock()
s.children[c.ID] = c
s.order = append(s.order, c.ID)
s.mu.Unlock()
}
func newTestManager(t *testing.T) (*Session, *timerManager, *recorderFire) {
t.Helper()
sess := NewSession(t.TempDir(), "test")
mgr := newTimerManager(sess)
rec := &recorderFire{}
mgr.fireFn = rec.fn
return sess, mgr, rec
}
func TestTimerSetDelivers(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
if id == "" {
t.Fatal("empty timer id")
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
got := rec.snapshot()
if len(got) != 1 {
t.Fatalf("got %d fires, want 1", len(got))
}
if got[0].Body != "wake up" || got[0].OwnerID != "p_owner" {
t.Fatalf("unexpected fire: %+v", got[0])
}
}
func TestTimerIdleAllAlreadySatisfied(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
idle := StateIdle
a.idleState.Store(&idle)
b.idleState.Store(&idle)
resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAll: %v", err)
}
if resp.Status != "already_satisfied" {
t.Fatalf("status: got %q want already_satisfied", resp.Status)
}
// fire is dispatched on a goroutine; wait briefly.
time.Sleep(50 * time.Millisecond)
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("fires: %+v", got)
}
}
func TestTimerIdleAnyFiresOnTransition(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
// p_a starts busy.
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
// Flip a into idle and deliver the state-change event.
idle := StateIdle
a.idleState.Store(&idle)
mgr.onChildStateChanged("p_a", StateIdle)
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("fires: %+v", got)
}
}
func TestTimerIdleAnyExcludesBaseline(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
idle := StateIdle
a.idleState.Store(&idle)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
// Send a redundant idle transition for p_a; should NOT fire because
// p_a was idle at registration.
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("unexpected fires: %+v", got)
}
}
func TestTimerCancelPauseResume(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
addChild(sess, owner)
// Cancel before fire.
id, _ := mgr.TimerSet("p_owner", "x", "", 0.2)
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("Cancel: %v", err)
}
time.Sleep(300 * time.Millisecond)
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("cancel didn't stop fire: %+v", got)
}
// Pause then resume → fire after resume.
id2, _ := mgr.TimerSet("p_owner", "y", "", 0.2)
time.Sleep(50 * time.Millisecond)
if err := mgr.TimerPause("p_owner", id2); err != nil {
t.Fatalf("Pause: %v", err)
}
time.Sleep(300 * time.Millisecond) // would have fired by now if not paused
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("paused timer fired: %+v", got)
}
if err := mgr.TimerResume("p_owner", id2); err != nil {
t.Fatalf("Resume: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
if got := rec.snapshot(); len(got) != 1 || got[0].Body != "y" {
t.Fatalf("resume fire: %+v", got)
}
}
func TestTimerOwnershipEnforced(t *testing.T) {
sess, mgr, _ := newTestManager(t)
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, a)
addChild(sess, b)
id, _ := mgr.TimerSet("p_a", "hi", "", 60)
if err := mgr.TimerCancel("p_b", id); err == nil {
t.Fatal("expected ownership error from foreign cancel")
}
if err := mgr.TimerPause("p_b", id); err == nil {
t.Fatal("expected ownership error from foreign pause")
}
}
// TestTimerResumeRechecksIdleAll covers the case where every watched
// child becomes idle while an idle_all timer is paused. Without a resume
// re-check, the timer would stay pending forever because the state
// transitions happened during the pause window.
func TestTimerResumeRechecksIdleAll(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
working := StateWorking
a.idleState.Store(&working)
b.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAll("p_owner", "all done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAll: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
// Both watched children become idle WHILE THE TIMER IS PAUSED, so
// onChildStateChanged is not consulted for this timer.
idle := StateIdle
a.idleState.Store(&idle)
b.idleState.Store(&idle)
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "all done" {
t.Fatalf("expected fire on resume, got: %+v", got)
}
}
// TestTimerResumeRechecksIdleAny covers the same missed-transition shape
// for idle_any: a non-baseline watched child going idle during pause must
// fire on resume.
func TestTimerResumeRechecksIdleAny(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if resp.Status != "pending" {
t.Fatalf("status: got %q want pending", resp.Status)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
idle := StateIdle
a.idleState.Store(&idle)
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
got := rec.snapshot()
if len(got) != 1 || got[0].Body != "one done" {
t.Fatalf("expected fire on resume, got: %+v", got)
}
}
// TestTimerResumeIdleAnyExcludesBaselineDuringPause guards against a
// resume re-check firing for a watcher that was idle at registration
// (and therefore part of the baseline) — only non-baseline transitions
// should satisfy idle_any.
func TestTimerResumeIdleAnyExcludesBaselineDuringPause(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
b := fakeChild("p_b")
addChild(sess, owner)
addChild(sess, a)
addChild(sess, b)
idle := StateIdle
working := StateWorking
a.idleState.Store(&idle) // baseline: already idle
b.idleState.Store(&working) // not baseline
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a", "p_b"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
if err := mgr.TimerPause("p_owner", resp.ID); err != nil {
t.Fatalf("Pause: %v", err)
}
// b stays working — only a is idle, and a was baseline. Resume
// must not fire.
if err := mgr.TimerResume("p_owner", resp.ID); err != nil {
t.Fatalf("Resume: %v", err)
}
if got := rec.snapshot(); len(got) != 0 {
t.Fatalf("unexpected fire on resume: %+v", got)
}
}
// TestTimerRecordsRemovedOnFire ensures fired delay timers don't leak
// in the timer registry — bodies and metadata must be released.
func TestTimerRecordsRemovedOnFire(t *testing.T) {
sess, mgr, rec := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "wake up", "test", 0.05)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if len(rec.snapshot()) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if len(rec.snapshot()) != 1 {
t.Fatalf("timer didn't fire")
}
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
count := len(mgr.timers)
mgr.mu.Unlock()
if stillThere {
t.Fatalf("fired timer %s was not removed from registry", id)
}
if count != 0 {
t.Fatalf("timer registry not drained: %d entries", count)
}
}
// TestTimerRecordsRemovedOnCancel ensures canceled timers are dropped
// from the registry.
func TestTimerRecordsRemovedOnCancel(t *testing.T) {
sess, mgr, _ := newTestManager(t)
c := fakeChild("p_owner")
addChild(sess, c)
id, err := mgr.TimerSet("p_owner", "x", "", 60)
if err != nil {
t.Fatalf("TimerSet: %v", err)
}
if err := mgr.TimerCancel("p_owner", id); err != nil {
t.Fatalf("Cancel: %v", err)
}
mgr.mu.Lock()
_, stillThere := mgr.timers[id]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("canceled timer %s was not removed from registry", id)
}
}
// TestTimerRecordsRemovedOnIdleFire ensures idle_* timers are dropped
// from the registry once they fire via onChildStateChanged.
func TestTimerRecordsRemovedOnIdleFire(t *testing.T) {
sess, mgr, rec := newTestManager(t)
owner := fakeChild("p_owner")
a := fakeChild("p_a")
addChild(sess, owner)
addChild(sess, a)
working := StateWorking
a.idleState.Store(&working)
resp, err := mgr.TimerFireWhenIdleAny("p_owner", "one done", "", []string{"p_a"}, 0)
if err != nil {
t.Fatalf("TimerFireWhenIdleAny: %v", err)
}
idle := StateIdle
a.idleState.Store(&idle)
mgr.onChildStateChanged("p_a", StateIdle)
if got := rec.snapshot(); len(got) != 1 {
t.Fatalf("expected fire, got: %+v", got)
}
mgr.mu.Lock()
_, stillThere := mgr.timers[resp.ID]
mgr.mu.Unlock()
if stillThere {
t.Fatalf("fired idle timer %s was not removed from registry", resp.ID)
}
}

View File

@@ -1,5 +1,19 @@
package app
import "github.com/hjbdev/patterm/internal/scratchpad"
// navEntry is one row in the unified sidebar navigation list. Exactly
// one of childID or pad is set. childID points at a Child by ID; pad
// names a scratchpad entry. Empty zero-value means "no target".
type navEntry struct {
childID string
pad string
}
func (n navEntry) empty() bool { return n.childID == "" && n.pad == "" }
func (n navEntry) isPad() bool { return n.pad != "" }
func (n navEntry) isChild() bool { return n.childID != "" }
// visibleAgentTree returns the running entries under the active agent
// tab (root agent + its sub-agents). With the new Processes pane,
// command processes live in their own section and never show up here —
@@ -192,9 +206,6 @@ func currentTabFlat(children []*Child, focusID string) []*Child {
func sidebarNavList(children []*Child, activeAgentID string) []*Child {
out := make([]*Child, 0, 8)
for _, c := range processList(children) {
if c.Status() != StatusRunning {
continue
}
out = append(out, c)
}
for _, c := range visibleAgentTree(children, activeAgentID) {
@@ -203,14 +214,77 @@ func sidebarNavList(children []*Child, activeAgentID string) []*Child {
return out
}
// nextChildID returns the id `step` positions away from the current
// focus in the combined Processes + active-agent-tree navigation list,
// wrapping at both ends. Empty when there's nothing else to land on.
// sidebarNav returns the combined Processes + Agent Tree + Scratchpads
// navigation list. Scratchpads always appear after children so the
// existing "step past the tree" expectation still holds.
func sidebarNav(children []*Child, activeAgentID string, pads []scratchpad.Entry) []navEntry {
flat := sidebarNavList(children, activeAgentID)
out := make([]navEntry, 0, len(flat)+len(pads))
for _, c := range flat {
out = append(out, navEntry{childID: c.ID})
}
for _, p := range pads {
out = append(out, navEntry{pad: p.Name})
}
return out
}
// nextNavEntry returns the entry `step` positions away from the
// current focus in the unified nav list. Either focusChildID or
// focusPad will be set (or both empty for "nothing focused yet").
// Empty when there's nothing else to land on.
func nextNavEntry(children []*Child, focusChildID, focusPad, activeAgentID string, pads []scratchpad.Entry, step int) navEntry {
flat := sidebarNav(children, activeAgentID, pads)
if len(flat) == 0 {
return navEntry{}
}
matches := func(e navEntry) bool {
if focusPad != "" && e.pad != "" {
return e.pad == focusPad
}
if focusChildID != "" && e.childID != "" {
return e.childID == focusChildID
}
return false
}
if len(flat) == 1 {
if matches(flat[0]) {
return navEntry{}
}
return flat[0]
}
idx := -1
for i, e := range flat {
if matches(e) {
idx = i
break
}
}
if idx < 0 {
idx = 0
}
idx = (idx + step) % len(flat)
if idx < 0 {
idx += len(flat)
}
if matches(flat[idx]) {
return navEntry{}
}
return flat[idx]
}
// nextChildID is retained for tests; it ignores scratchpads.
func nextChildID(children []*Child, focusID, activeAgentID string, step int) string {
flat := sidebarNavList(children, activeAgentID)
if len(flat) < 2 {
if len(flat) == 0 {
return ""
}
if len(flat) == 1 {
if flat[0].ID == focusID {
return ""
}
return flat[0].ID
}
idx := -1
for i, c := range flat {
if c.ID == focusID {

View File

@@ -125,6 +125,15 @@ func TestSidebarNavListIncludesProcessesAboveAgentTree(t *testing.T) {
}
}
func TestSidebarNavListIncludesExitedProcesses(t *testing.T) {
p := testProcess("p1", "shell", StatusExited)
r := testAgent("a1", "claude", "", StatusRunning)
flat := sidebarNavList([]*Child{p, r}, "a1")
if len(flat) != 2 || flat[0].ID != "p1" || flat[1].ID != "a1" {
t.Fatalf("flat = %v, want exited process then active agent", childIDs(flat))
}
}
func TestNextChildIDWalksProcessesThenAgentTree(t *testing.T) {
p1 := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", StatusRunning)
@@ -140,6 +149,13 @@ func TestNextChildIDWalksProcessesThenAgentTree(t *testing.T) {
}
}
func TestNextChildIDCanEnterSingleExitedProcessFromNoFocus(t *testing.T) {
p := testProcess("p1", "shell", StatusExited)
if got := nextChildID([]*Child{p}, "", "", +1); got != "p1" {
t.Fatalf("empty focus -> exited process: %q want p1", got)
}
}
func TestVisibleAgentTreeExcludesTopLevelCommands(t *testing.T) {
p := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", StatusRunning)

View File

@@ -10,11 +10,15 @@ import (
// viewportRenderer rewrites child PTY output so it lands inside the
// main viewport instead of controlling patterm's full host terminal.
type viewportRenderer struct {
mu sync.Mutex
shifter *cursorShifter
layout terminalLayout
row int
col int
mu sync.Mutex
shifter *cursorShifter
layout terminalLayout
row int
col int
scrollTop int
scrollBottom int
originMode bool
lrMarginMode bool
state viewportState
buf []byte
@@ -22,8 +26,9 @@ type viewportRenderer struct {
// scrolled is set when the chunk contained an escape that shifts
// content row-wise within the host's scroll region — RI / IND /
// NEL / SU / SD / IL / DL. DECSTBM constrains rows but not columns,
// so these scrolls drag the right-hand sidebar content with them.
// NEL / SU / SD / IL / DL, or LF / VT / FF at the bottom margin.
// DECSTBM constrains rows but not columns, so these scrolls drag the
// right-hand sidebar content with them.
// OnPTYOut consumes the flag and invalidates the sidebar chrome
// cache so the next drawSidebar repaints over the clobber.
scrolled bool
@@ -50,12 +55,14 @@ const (
)
func newViewportRenderer(l terminalLayout) *viewportRenderer {
return &viewportRenderer{
vr := &viewportRenderer{
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())),
layout: l,
row: 1,
col: 1,
}
vr.resetScrollRegion()
return vr
}
func (vr *viewportRenderer) SetLayout(l terminalLayout) {
@@ -63,14 +70,47 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
defer vr.mu.Unlock()
vr.layout = l
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols()))
vr.resetScrollRegion()
}
func (vr *viewportRenderer) Render(in []byte) []byte {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.pending.Reset()
for _, b := range in {
vr.feed(b)
// Fast path: while we're in vpNormal and have a run of plain ASCII
// printables that fit the remaining column budget, copy en bloc
// instead of round-tripping each byte through the feed state
// machine. UTF-8 leaders and any control byte fall back to the
// per-byte path so the cursor/skipUTF8/clamp logic stays exact.
for i := 0; i < len(in); {
if vr.state == vpNormal {
maxCol := int(vr.layout.childCols())
if maxCol > 0 && vr.col >= 1 && vr.col <= maxCol {
budget := maxCol - vr.col + 1
j := i
for j < len(in) && budget > 0 {
b := in[j]
// Pure ASCII printables only — any control byte
// (0x1b ESC included), UTF-8 leader, or trailer
// kicks back to the state machine.
if b < 0x20 || b == 0x7f || b >= 0x80 {
break
}
j++
budget--
}
if j-i >= 4 {
vr.pending.Write(in[i:j])
vr.col += j - i
vr.skipUTF8 = false
vr.clampCursor()
i = j
continue
}
}
}
vr.feed(in[i])
i++
}
return []byte(vr.pending.String())
}
@@ -82,11 +122,10 @@ func (vr *viewportRenderer) ClearViewport() []byte {
}
// TookScrollAction reports whether the most recent Render emitted (or
// forwarded) a scroll-triggering escape — RI / IND / NEL / SU / SD /
// IL / DL — since the previous call. The flag is reset on read.
// Callers use it to invalidate sidebar-cache state, because the host's
// scroll region spans the full row width and any scroll there drags
// the sidebar content downward.
// forwarded) a scroll action since the previous call. Callers use it
// to invalidate sidebar-cache state, because the host's scroll region
// spans the full row width and any scroll there drags the sidebar
// content vertically.
func (vr *viewportRenderer) TookScrollAction() bool {
vr.mu.Lock()
defer vr.mu.Unlock()
@@ -187,12 +226,53 @@ func (vr *viewportRenderer) emitCSI() {
params := vr.buf[2 : len(vr.buf)-1]
if final == 'h' || final == 'l' {
if isOriginMode(params) {
vr.setOriginMode(final == 'h')
vr.emitCursorPosition(vr.row, vr.col)
return
}
if isLeftRightMarginMode(params) {
vr.lrMarginMode = final == 'h'
return
}
if isAltScreenMode(params) {
return
}
if isMouseTrackingMode(params) {
// Patterm owns mouse reporting on the host so wheel events keep
// flowing for scroll-viewport. The child's own emulator still
// observes the mode set/reset (it processes the same bytes we
// hand to ghostty_terminal_vt_write), so we know whether the
// child wants mouse input — we just don't let it disarm our
// host listener.
return
}
}
if final == 's' && vr.lrMarginMode {
return
}
switch final {
case 'H', 'f':
r, c, ok := parseTwoParams(params)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
vr.row = vr.originRow(r)
vr.col = c
vr.emitCursorPosition(vr.row, c)
vr.clampCursor()
case 'd':
r, ok := parseOneParam(params, 1)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
vr.row = vr.originRow(r)
vr.pending.Write(vr.shifter.Shift([]byte(fmt.Sprintf("\x1b[%dd", vr.row))))
vr.clampCursor()
case 'J':
n, ok := parseOneParam(params, 0)
if !ok {
@@ -225,10 +305,85 @@ func (vr *viewportRenderer) emitCSI() {
// the sidebar is repainted afterwards.
vr.pending.Write(vr.shifter.Shift(vr.buf))
vr.scrolled = true
case 'r':
vr.pending.Write(vr.shifter.Shift(vr.buf))
if vr.trackScrollRegion(params) {
vr.emitHomeAfterScrollRegion()
}
case 'A', 'B', 'E', 'F':
// Relative cursor moves: CUU (A) / CUD (B) / CNL (E) / CPL (F).
// The cursor shifter only rewrites absolute positioning, so a
// child that asks the cursor to "go up 50" from viewport row 1
// would walk the host cursor into the tab bar (and the next
// printable would write there). Clamp the step using the
// renderer's tracked row so the host cursor stays inside the
// viewport. E / F additionally home the column to 1.
vr.emitRelativeRowMove(final, params)
return
default:
vr.pending.Write(vr.shifter.Shift(vr.buf))
}
vr.trackCSI(final, params)
if final != 'H' && final != 'f' && final != 'd' && final != 'r' {
vr.trackCSI(final, params)
}
}
// emitRelativeRowMove rewrites CSI A / B / E / F so the resulting host
// cursor stays within rows 1..childRows in viewport coordinates. The
// renderer already tracks vr.row for clear-line bookkeeping; reusing
// that here avoids a second cursor model. n is normalized — a step of
// 0 is treated as 1 to match xterm. After clamping, if the effective
// step is zero we drop the sequence (the cursor is already pinned to
// the boundary). E / F also move the cursor to column 1 even when no
// row step is emitted.
func (vr *viewportRenderer) emitRelativeRowMove(final byte, params []byte) {
n, ok := parseOneParam(params, 1)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
if n <= 0 {
n = 1
}
rows := int(vr.layout.childRows())
if rows < 1 {
rows = 1
}
row := vr.row
if row < 1 {
row = 1
}
if row > rows {
row = rows
}
up := final == 'A' || final == 'F'
var safe int
if up {
safe = row - 1
} else {
safe = rows - row
}
if safe < 0 {
safe = 0
}
if n > safe {
n = safe
}
if n > 0 {
if up {
vr.row -= n
} else {
vr.row += n
}
fmt.Fprintf(&vr.pending, "\x1b[%d%c", n, final)
}
if final == 'E' || final == 'F' {
// CNL / CPL anchor the column at 1 regardless of whether the
// row step was clamped to zero, matching xterm.
vr.col = 1
vr.pending.WriteByte('\r')
}
vr.clampCursor()
}
func isAltScreenMode(params []byte) bool {
@@ -245,6 +400,52 @@ func isAltScreenMode(params []byte) bool {
return false
}
func isOriginMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
if p == "6" {
return true
}
}
return false
}
func isLeftRightMarginMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
if p == "69" {
return true
}
}
return false
}
// isMouseTrackingMode reports whether any of the modes in a CSI ? … h/l
// is a mouse-tracking or mouse-encoding DEC private mode. The host runs
// with SGR mouse reporting permanently armed; we drop the child's set/
// reset for these modes from the host stream so wheel events keep
// reaching patterm.
func isMouseTrackingMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
switch p {
case "9", "1000", "1001", "1002", "1003", "1004",
"1005", "1006", "1007", "1015", "1016":
return true
}
}
return false
}
func (vr *viewportRenderer) clearViewport() string {
var b strings.Builder
b.WriteString("\x1b7")
@@ -326,6 +527,69 @@ func (vr *viewportRenderer) clearLine(n int) string {
}
}
func (vr *viewportRenderer) resetScrollRegion() {
vr.scrollTop = 1
vr.scrollBottom = int(vr.layout.childRows())
if vr.scrollBottom < 1 {
vr.scrollBottom = 1
}
}
func (vr *viewportRenderer) setOriginMode(on bool) {
vr.originMode = on
if on {
vr.row = vr.scrollTop
} else {
vr.row = 1
}
vr.col = 1
vr.clampCursor()
}
func (vr *viewportRenderer) originRow(row int) int {
if row < 1 {
row = 1
}
if !vr.originMode {
return row
}
row = vr.scrollTop + row - 1
if row < vr.scrollTop {
row = vr.scrollTop
}
if row > vr.scrollBottom {
row = vr.scrollBottom
}
return row
}
func (vr *viewportRenderer) homeAfterScrollRegion() {
if vr.originMode {
vr.row = vr.scrollTop
} else {
vr.row = 1
}
vr.col = 1
vr.clampCursor()
}
func (vr *viewportRenderer) emitHomeAfterScrollRegion() {
vr.homeAfterScrollRegion()
vr.emitCursorPosition(vr.row, vr.col)
}
func (vr *viewportRenderer) emitCursorPosition(row, col int) {
vr.pending.Write(vr.shifter.Shift([]byte(fmt.Sprintf("\x1b[%d;%dH", row, col))))
}
func (vr *viewportRenderer) lineFeed() {
if vr.row >= vr.scrollTop && vr.row == vr.scrollBottom {
vr.scrolled = true
return
}
vr.row++
}
// feedPrintable handles one non-ESC byte in the vpNormal state. It both
// advances vr's cursor model and decides whether the byte should be
// forwarded to the host. Bytes that would land past the viewport's
@@ -342,8 +606,8 @@ func (vr *viewportRenderer) feedPrintable(b byte) {
switch b {
case '\r':
vr.col = 1
case '\n':
vr.row++
case '\n', '\v', '\f':
vr.lineFeed()
case '\b':
if vr.col > 1 {
vr.col--
@@ -405,7 +669,7 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
case 'H', 'f':
r, c, ok := parseTwoParams(params)
if ok {
vr.row, vr.col = r, c
vr.row, vr.col = vr.originRow(r), c
}
case 'G', '`':
c, ok := parseOneParam(params, 1)
@@ -415,7 +679,7 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
case 'd':
r, ok := parseOneParam(params, 1)
if ok {
vr.row = r
vr.row = vr.originRow(r)
}
case 'A':
n, ok := parseOneParam(params, 1)
@@ -437,10 +701,41 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
if ok {
vr.col -= n
}
case 'r':
if vr.trackScrollRegion(params) {
vr.homeAfterScrollRegion()
}
}
vr.clampCursor()
}
func (vr *viewportRenderer) trackScrollRegion(params []byte) bool {
if len(params) == 0 {
vr.resetScrollRegion()
return true
}
top, bottom, ok := parseTwoParams(params)
if !ok {
return false
}
maxRows := int(vr.layout.childRows())
if maxRows < 1 {
maxRows = 1
}
if top < 1 {
top = 1
}
if bottom < 1 || bottom > maxRows {
bottom = maxRows
}
if top >= bottom {
return false
}
vr.scrollTop = top
vr.scrollBottom = bottom
return true
}
func (vr *viewportRenderer) clampCursor() {
if vr.row < 1 {
vr.row = 1

View File

@@ -29,6 +29,42 @@ func TestViewportRendererSwallowsAltScreenToggles(t *testing.T) {
}
}
func TestViewportRendererSwallowsOriginModeToggles(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?6hb\x1b[?6lc")))
if strings.Contains(got, "\x1b[?6h") || strings.Contains(got, "\x1b[?6l") {
t.Fatalf("origin-mode toggles leaked to host: %q", got)
}
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 {
t.Fatalf("origin-mode set/reset should home inside the viewport twice: got %q", got)
}
}
func TestViewportRendererSwallowsLeftRightMarginMode(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("a\x1b[?69h\x1b[10;80sb\x1b[?69lc")))
if strings.Contains(got, "\x1b[?69h") || strings.Contains(got, "\x1b[10;80s") || strings.Contains(got, "\x1b[?69l") {
t.Fatalf("left/right margin controls leaked to host: %q", got)
}
if got != "abc" {
t.Fatalf("left/right margin controls should be swallowed without dropping text: got %q", got)
}
}
func TestViewportRendererOriginModeCUPUsesScrollTop(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;10r\x1b[?6h\x1b[1;1H")))
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)
}
}
func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
// hostRows=7 leaves four viewport rows after the 2-row tab bar and
// 1-row status reservation.
@@ -211,6 +247,101 @@ func TestViewportRendererFlagsScrollVerbs(t *testing.T) {
}
}
func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[37;1H\n"))
if !vr.TookScrollAction() {
t.Fatalf("LF at viewport bottom should flag scroll")
}
}
func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[36;1H\n"))
if vr.TookScrollAction() {
t.Fatalf("LF before viewport bottom should not flag scroll")
}
}
func TestViewportRendererFlagsLineFeedAtCustomScrollBottom(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
_ = vr.Render([]byte("\x1b[5;10r\x1b[9;1H\n"))
if vr.TookScrollAction() {
t.Fatalf("LF before custom scroll bottom should not flag scroll")
}
_ = vr.Render([]byte("\n"))
if !vr.TookScrollAction() {
t.Fatalf("LF at custom scroll bottom should flag scroll")
}
}
// Long claude sessions can leave the child cursor at viewport row 1 and
// then emit CSI A (cursor up) with a large step before redrawing. The
// raw CSI A would walk the host cursor into the tab bar; the next
// printable would then write into row 1 / row 2. Clamp the step at the
// viewport top so the host cursor stays inside the viewport.
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") {
t.Fatalf("expected CUP shifted to mainTop: got %q", got)
}
// The CUU should have been swallowed (n clamped to 0 from row 1).
if strings.Contains(got, "\x1b[50A") {
t.Fatalf("CUU 50 from viewport row 1 leaked: got %q", got)
}
// And the subsequent printables should land inside the viewport,
// not above it.
if !strings.Contains(got, "CLOBBER") {
t.Fatalf("printables should still be emitted after clamped CUU: got %q", got)
}
}
func TestViewportRendererClampsCUUPartial(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to viewport row 5, then CUU by 50 → safe step is 4.
got := string(vr.Render([]byte("\x1b[5;1H\x1b[50A")))
if !strings.Contains(got, "\x1b[4A") {
t.Fatalf("CUU 50 from row 5 should clamp to 4: got %q", got)
}
if strings.Contains(got, "\x1b[50A") {
t.Fatalf("unclamped CUU leaked: got %q", got)
}
}
func TestViewportRendererClampsCUDAtViewportBottom(t *testing.T) {
// childRows=37 for layout(120, 40). Park cursor at row 37, ask for
// 10 down → safe step is 0.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[37;1H\x1b[10B")))
if strings.Contains(got, "\x1b[10B") {
t.Fatalf("CUD past viewport bottom should be dropped: got %q", got)
}
}
func TestViewportRendererClampsCPLAndHomesColumn(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
// CUP to row 1 col 50 then CPL by 5 → step clamped to 0, but col
// must still reset to 1 (CR emitted).
got := string(vr.Render([]byte("\x1b[1;50H\x1b[5F")))
if strings.Contains(got, "\x1b[5F") {
t.Fatalf("CPL 5 from row 1 should not leak: got %q", got)
}
if !strings.Contains(got, "\r") {
t.Fatalf("CPL should home column to 1 with CR: got %q", got)
}
}
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")))
if !strings.Contains(got, "\x1b[2E") {
t.Fatalf("CNL 50 from row 35 should clamp to 2: got %q", got)
}
}
func TestViewportRendererForwardsRIVerbatim(t *testing.T) {
// We rely on the host terminal performing the scroll inside the
// DECSTBM region; the renderer must not eat or transform RI. If a

View File

@@ -30,10 +30,29 @@ func EncodeChord(name string) ([]byte, error) {
return []byte{0x10}, nil
case "ctrl-u":
return []byte{0x15}, nil
case "ctrl-a":
return []byte{0x01}, nil
case "ctrl-d":
return []byte{0x04}, nil
case "ctrl-s":
return []byte{0x13}, nil
case "ctrl-w":
return []byte{0x17}, nil
case "ctrl-r":
return []byte{0x12}, nil
case "ctrl-b":
return []byte{0x02}, nil
case "tab":
return []byte{'\t'}, nil
case "space":
return []byte{' '}, nil
case "wheel-up":
// SGR-encoded scroll-wheel up at row/col 1,1. patterm enables
// 1006 mouse mode while a scratchpad is focused, so this is the
// form the host terminal would deliver.
return []byte("\x1b[<64;1;1M"), nil
case "wheel-down":
return []byte("\x1b[<65;1;1M"), nil
}
return nil, fmt.Errorf("unknown chord %q", name)
}

View File

@@ -0,0 +1,187 @@
package harness
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
pkgpty "github.com/hjbdev/patterm/internal/pty"
"github.com/hjbdev/patterm/internal/vt"
)
// TestRestartRestoresUserCommandProcess verifies that a process the
// user spawned in one patterm run reappears after the binary is
// restarted against the same XDG dirs / project dir. SPEC §2 keeps
// runs ephemeral except for the persisted-process state file:
// processes.json under $XDG_DATA_HOME/patterm/projects/<key>/.
func TestRestartRestoresUserCommandProcess(t *testing.T) {
if testing.Short() {
t.Skip("skipping end-to-end restart test in short mode")
}
sc := &Scenario{
Name: "restart_persist",
Cols: 120,
Rows: 40,
Trust: []string{"persist-target"},
Presets: ScenarioPresets{
Processes: []ScenarioPreset{{
Name: "persist-target",
Argv: []string{"persist-target"},
}},
},
Scripts: []ScenarioScript{{
Name: "persist-target",
Body: "#!/bin/sh\necho RESTORED\nsleep 30\n",
}},
}
env, childEnv, err := prepareEnv(Options{Scenario: sc})
if err != nil {
t.Fatalf("prepareEnv: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(env.Root) })
// ── Session 1 — spawn the process via MCP. ──────────────────
s1 := openSession(t, env, childEnv)
spawnRaw, err := s1.MCPCall("spawn_process", mustJSON(t, map[string]any{
"preset": "persist-target",
}))
if err != nil {
_ = s1.Close()
t.Fatalf("spawn_process: %v", err)
}
var spawned map[string]any
if err := json.Unmarshal(spawnRaw, &spawned); err != nil {
_ = s1.Close()
t.Fatalf("decode spawn: %v", err)
}
if id, _ := spawned["process_id"].(string); id == "" {
_ = s1.Close()
t.Fatalf("spawn returned no process_id: %s", string(spawnRaw))
}
if err := waitForListEntry(s1, "persist-target", 3*time.Second); err != nil {
_ = s1.Close()
t.Fatalf("list_processes (session 1): %v", err)
}
// Verify the on-disk record exists before tearing down.
stateFile := filepath.Join(env.DataHome, "patterm", "projects")
if entries, err := os.ReadDir(stateFile); err != nil || len(entries) == 0 {
_ = s1.Close()
t.Fatalf("expected per-project state dir under %s before shutdown: err=%v entries=%v", stateFile, err, entries)
}
if err := s1.Close(); err != nil {
t.Fatalf("close session 1: %v", err)
}
// ── Session 2 — same env, same project. The persisted entry
// must be replayed and show up in list_processes again. ─────
s2 := openSession(t, env, childEnv)
t.Cleanup(func() { _ = s2.Close() })
if err := waitForListEntry(s2, "persist-target", 5*time.Second); err != nil {
t.Fatalf("list_processes (session 2): %v", err)
}
// Closing the restored process should also drop it from the
// persist store, so a third session starts clean.
listRaw, err := s2.MCPCall("list_processes", json.RawMessage(`{}`))
if err != nil {
t.Fatalf("list_processes: %v", err)
}
var list []map[string]any
if err := json.Unmarshal(listRaw, &list); err != nil {
t.Fatalf("decode list: %v", err)
}
var restoredID string
for _, p := range list {
if name, _ := p["name"].(string); name == "persist-target" {
restoredID, _ = p["process_id"].(string)
break
}
}
if restoredID == "" {
t.Fatalf("restored process missing id in list: %s", string(listRaw))
}
if _, err := s2.MCPCall("close_process", mustJSON(t, map[string]any{
"process_id": restoredID,
})); err != nil {
t.Fatalf("close_process: %v", err)
}
if err := s2.Close(); err != nil {
t.Fatalf("close session 2: %v", err)
}
s3 := openSession(t, env, childEnv)
t.Cleanup(func() { _ = s3.Close() })
listRaw, err = s3.MCPCall("list_processes", json.RawMessage(`{}`))
if err != nil {
t.Fatalf("list_processes (session 3): %v", err)
}
if err := json.Unmarshal(listRaw, &list); err != nil {
t.Fatalf("decode list 3: %v", err)
}
for _, p := range list {
if name, _ := p["name"].(string); name == "persist-target" {
t.Fatalf("closed process re-appeared in session 3: %s", string(listRaw))
}
}
}
// openSession spawns one patterm process against the supplied env and
// blocks until its MCP socket is ready. Mirrors NewCLI but skips
// prepareEnv so multiple sessions can share the same XDG dirs.
func openSession(t *testing.T, env *testEnv, childEnv []string) *Session {
t.Helper()
em, err := vt.NewGhosttyEmulator(env.Cols, env.Rows)
if err != nil {
t.Fatalf("vt emulator: %v", err)
}
p, err := pkgpty.Start([]string{env.PattermBin, "--project", env.ProjectDir}, childEnv, env.Cols, env.Rows)
if err != nil {
_ = em.Close()
t.Fatalf("pty start: %v", err)
}
em.OnWritePTY(func(b []byte) { _, _ = p.Write(b) })
s := &Session{pty: p, em: em, env: env, readerDone: make(chan struct{})}
go s.readLoop()
if err := s.bootstrapMCP(3 * time.Second); err != nil {
_ = s.Close()
t.Fatalf("mcp bootstrap: %v", err)
}
return s
}
func waitForListEntry(s *Session, name string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
raw, err := s.MCPCall("list_processes", json.RawMessage(`{}`))
if err == nil {
var list []map[string]any
if err := json.Unmarshal(raw, &list); err == nil {
for _, p := range list {
if n, _ := p["name"].(string); n == name {
return nil
}
}
}
}
time.Sleep(50 * time.Millisecond)
}
return fmt.Errorf("process %q never appeared in list_processes within %s", name, timeout)
}
func mustJSON(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"regexp"
"strings"
"time"
)
type Event struct {
@@ -175,6 +176,41 @@ func runStep(s *Session, step Step, results map[string]json.RawMessage) error {
return fmt.Errorf("no saved result %q", step.From)
}
return assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring)
case "wait_until_mcp":
// Poll an MCP method until the assertion at Path holds (or
// Contains substring matches), or TimeoutMS elapses. Used by the
// idle-detection scenarios to wait for a child's idle_state to
// reach a target value without sprinkling sleeps.
params, perr := resolveParams(step.Params, results)
if perr != nil {
return perr
}
deadline := time.Now().Add(timeoutMS(step.TimeoutMS))
var lastRaw json.RawMessage
var lastErr error
for {
raw, err := s.MCPCall(step.Method, params)
if err == nil {
if aerr := assertJSONValue(raw, step.Path, step.Equals, step.Contains, step.AllowSubstring); aerr == nil {
if step.SaveAs != "" {
results[step.SaveAs] = raw
}
return nil
} else {
lastErr = aerr
lastRaw = raw
}
} else {
lastErr = err
}
if time.Now().After(deadline) {
if lastErr != nil {
return fmt.Errorf("wait_until_mcp timeout: %w (last response: %s)", lastErr, string(lastRaw))
}
return fmt.Errorf("wait_until_mcp timeout (no successful call)")
}
time.Sleep(100 * time.Millisecond)
}
}
return fmt.Errorf("unknown step type %q", step.Type)
}

View File

@@ -25,11 +25,23 @@ type ScenarioPresets struct {
}
type ScenarioPreset struct {
Name string `json:"name"`
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Shell bool `json:"shell,omitempty"`
Name string `json:"name"`
Argv []string `json:"argv"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Shell bool `json:"shell,omitempty"`
IdleDetection *ScenarioIdleDetection `json:"idle_detection,omitempty"`
}
// ScenarioIdleDetection mirrors preset.IdleDetection so scenarios can
// configure per-strategy idle detection for fake agent presets.
type ScenarioIdleDetection struct {
Strategy string `json:"strategy,omitempty"`
IdleThresholdMS int `json:"idle_threshold_ms,omitempty"`
TitleStatusMap map[string]string `json:"title_status_map,omitempty"`
PermissionPatterns []string `json:"permission_patterns,omitempty"`
ThinkingPatterns []string `json:"thinking_patterns,omitempty"`
ErrorPatterns []string `json:"error_patterns,omitempty"`
}
type ScenarioScript struct {

View File

@@ -0,0 +1,32 @@
{
"name": "chrome_survives_origin_mode",
"cols": 120,
"rows": 40,
"scripts": [
{
"name": "origin-mode",
"body": "#!/bin/sh\n# Child TUIs are allowed to use DEC origin mode internally, but the\n# host chrome must never inherit it. If CSI ? 6 h reaches the real\n# terminal, patterm's absolute CUPs for the tab bar/status/sidebar are\n# interpreted relative to the child scroll region and chrome appears\n# inside the viewport.\nprintf 'ORIGIN READY\\n'\nsleep 0.1\nprintf '\\033[5;20r'\nprintf '\\033[?6h'\nprintf '\\033[1;1HORIGIN MODE ACTIVE\\n'\nsleep 0.2\nprintf 'ORIGIN DONE\\n'\nsleep 5\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["origin-mode"], "name": "origin-mode" }
},
{ "type": "wait_text", "contains": "ORIGIN DONE", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "+ new" },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "Agent Tree" },
{ "type": "assert_contains", "contains": "Scratchpads" },
{
"type": "assert_regex",
"regex": "(?m)^[^\\n]*\\+ new[^\\n]*Processes[^\\n]*$"
},
{
"type": "assert_regex",
"regex": "(?m)^origin-mode · you have control[^\\n]*Ctrl-K · palette[^\\n]*$"
}
]
}

View File

@@ -0,0 +1,44 @@
{
"name": "idle_osc_title_stability",
"presets": {
"processes": [
{
"name": "titler",
"argv": [
"sh",
"-lc",
"i=0; while [ $i -lt 6 ]; do printf '\\033]2;step %d\\007' $i; i=$((i+1)); sleep 0.2; done; sleep 60"
],
"idle_detection": {
"strategy": "osc_title_stability",
"idle_threshold_ms": 1000
}
}
]
},
"trust": ["titler"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "titler", "name": "titler"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "working",
"timeout_ms": 3000
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "idle",
"timeout_ms": 4000
}
]
}

View File

@@ -0,0 +1,48 @@
{
"name": "idle_osc_title_status",
"presets": {
"processes": [
{
"name": "geminilike",
"argv": [
"sh",
"-lc",
"printf '\\033]2;Thinking\\007'; sleep 1; printf '\\033]2;Permission required\\007'; sleep 60"
],
"idle_detection": {
"strategy": "osc_title_status",
"idle_threshold_ms": 1000,
"title_status_map": {
"thinking": "thinking",
"permission": "permission"
}
}
}
]
},
"trust": ["geminilike"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "geminilike", "name": "geminilike"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "thinking",
"timeout_ms": 3000
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "permission",
"timeout_ms": 4000
}
]
}

View File

@@ -0,0 +1,44 @@
{
"name": "idle_output_activity",
"presets": {
"processes": [
{
"name": "blinker",
"argv": ["sh", "-lc", "echo step1; sleep 3; echo step2; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 1000
}
}
]
},
"trust": ["blinker"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {
"kind": "command",
"preset": "blinker",
"name": "blinker"
},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "working",
"timeout_ms": 4000
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "idle",
"timeout_ms": 4000
}
]
}

View File

@@ -0,0 +1,33 @@
{
"name": "idle_regex_promote",
"presets": {
"processes": [
{
"name": "approver",
"argv": ["sh", "-lc", "echo 'Do you want to proceed?'; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500,
"permission_patterns": ["Do you want to proceed\\?"]
}
}
]
},
"trust": ["approver"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "approver", "name": "approver"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "permission",
"timeout_ms": 4000
}
]
}

View File

@@ -0,0 +1,28 @@
{
"name": "palette_over_scratchpad",
"cols": 120,
"rows": 30,
"steps": [
{
"type": "mcp_call",
"method": "scratchpad_write",
"params": { "name": "pad-marker.md", "content": "# Pad Heading\n\nzealot-marker body line" }
},
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "send_chord", "chord": "ctrl-s" },
{ "type": "wait_text", "contains": "zealot-marker", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "Pad Heading" },
{ "type": "send_chord", "chord": "ctrl-k" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "send_text", "text": "quit" },
{ "type": "wait_text", "contains": "quit", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "quit" },
{ "type": "send_chord", "chord": "escape" },
{ "type": "wait_text", "contains": "zealot-marker", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "Pad Heading" },
{ "type": "assert_contains", "contains": "zealot-marker" },
{ "type": "assert_not_contains", "contains": "quit" }
]
}

View File

@@ -0,0 +1,31 @@
{
"name": "rename_process_via_palette",
"scripts": [
{
"name": "renamed-loop",
"body": "#!/bin/sh\necho RENAMED READY\nsleep 5\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["renamed-loop"], "name": "original" }
},
{ "type": "wait_text", "contains": "RENAMED READY", "timeout_ms": 5000 },
{ "type": "send_chord", "chord": "ctrl-k" },
{ "type": "send_text", "text": "Rename process" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "Rename process", "timeout_ms": 3000 },
{ "type": "send_chord", "chord": "ctrl-u" },
{ "type": "send_text", "text": "renamed-pane" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{
"type": "assert_mcp",
"method": "get_project_status",
"path": "processes.0.name",
"equals": "renamed-pane"
}
]
}

View File

@@ -0,0 +1,33 @@
{
"name": "restart_exited_process_from_sidebar",
"cols": 120,
"rows": 40,
"scripts": [
{
"name": "quick-shell",
"body": "#!/bin/sh\ncount_file=\"$XDG_RUNTIME_DIR/quick-shell-count\"\nif [ -f \"$count_file\" ]; then\n n=$(cat \"$count_file\")\nelse\n n=0\nfi\nn=$((n + 1))\nprintf '%s\\n' \"$n\" > \"$count_file\"\nprintf 'QUICK RUN %s\\n' \"$n\"\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["quick-shell"], "name": "quick-shell" }
},
{ "type": "wait_text", "contains": "QUICK RUN 1", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "○ quick-shell" },
{ "type": "send_text", "text": "\u0017" },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "quick-shell · you have control" },
{ "type": "mark_raw", "save_as": "before_restart" },
{ "type": "send_text", "text": "\u0012" },
{ "type": "wait_text", "contains": "QUICK RUN 2", "timeout_ms": 5000 },
{
"type": "assert_raw_since_regex",
"from": "before_restart",
"regex": "QUICK RUN 2",
"timeout_ms": 2000
}
]
}

View File

@@ -0,0 +1,18 @@
{
"name": "scratchpad_focus",
"cols": 120,
"rows": 40,
"steps": [
{
"type": "mcp_call",
"method": "scratchpad_write",
"params": { "name": "notes.md", "content": "# Heading One\n\n- item alpha\n- item beta\n\nhello scratchpad" }
},
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "notes.md" },
{ "type": "send_chord", "chord": "ctrl-s" },
{ "type": "wait_text", "contains": "hello scratchpad", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "Heading One" },
{ "type": "assert_contains", "contains": "item alpha" }
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "scratchpad_scroll",
"cols": 120,
"rows": 20,
"steps": [
{
"type": "mcp_call",
"method": "scratchpad_write",
"params": {
"name": "long.md",
"content": "# Long pad\n\nline-01\nline-02\nline-03\nline-04\nline-05\nline-06\nline-07\nline-08\nline-09\nline-10\nline-11\nline-12\nline-13\nline-14\nline-15\nline-16\nline-17\nline-18\nline-19\nline-20\nline-21\nline-22\nline-23\nline-24\nline-25\nline-26\nline-27\nline-28\nline-29\nline-30\nfinal-marker"
}
},
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "send_chord", "chord": "ctrl-s" },
{ "type": "wait_text", "contains": "line-01", "timeout_ms": 5000 },
{ "type": "assert_not_contains", "contains": "final-marker" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "send_chord", "chord": "wheel-down" },
{ "type": "wait_text", "contains": "final-marker", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "final-marker" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "send_chord", "chord": "wheel-up" },
{ "type": "wait_text", "contains": "line-01", "timeout_ms": 5000 },
{ "type": "assert_contains", "contains": "line-01" }
]
}

View File

@@ -0,0 +1,34 @@
{
"name": "sidebar_survives_linefeed_scroll",
"cols": 120,
"rows": 40,
"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"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["linefeed-scroll"], "name": "linefeed-scroll" }
},
{ "type": "wait_text", "contains": "LINEFEED READY", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "mark_raw", "save_as": "before_scroll" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "LINEFEED DONE", "timeout_ms": 5000 },
{
"type": "assert_raw_since_regex",
"from": "before_scroll",
"regex": "Agent Tree",
"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" },
{ "type": "assert_contains", "contains": "● linefeed-scroll" }
]
}

View File

@@ -0,0 +1,44 @@
{
"name": "timer_cancel",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
}
]
},
"trust": ["echoer"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "proc"
},
{ "type": "wait_stable", "timeout_ms": 1500 },
{
"type": "mcp_call",
"method": "timer_set",
"params": {"seconds": 1, "body": "should-not-arrive", "owner_process_id": "{{proc.process_id}}"},
"save_as": "tmr"
},
{
"type": "mcp_call",
"method": "timer_cancel",
"params": {"timer_id": "{{tmr.timer_id}}"}
},
{
"type": "mcp_call",
"method": "timer_list",
"params": {"owner_process_id": "{{proc.process_id}}"},
"save_as": "listed"
},
{
"type": "assert_saved",
"from": "listed",
"path": "",
"equals": []
}
]
}

View File

@@ -0,0 +1,48 @@
{
"name": "timer_idle_all_already_satisfied",
"presets": {
"processes": [
{
"name": "quiet",
"argv": ["sh", "-lc", "echo ready; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500
}
}
]
},
"trust": ["quiet"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "quiet", "name": "quiet"},
"save_as": "proc"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{proc.process_id}}"},
"path": "idle_state",
"equals": "idle",
"timeout_ms": 4000
},
{
"type": "mcp_call",
"method": "timer_fire_when_idle_all",
"params": {
"watched": ["{{proc.process_id}}"],
"body": "all-idle",
"owner_process_id": "{{proc.process_id}}"
},
"save_as": "resp"
},
{
"type": "assert_saved",
"from": "resp",
"path": "status",
"equals": "already_satisfied"
}
]
}

View File

@@ -0,0 +1,89 @@
{
"name": "timer_idle_all_pending",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
},
{
"name": "quiet",
"argv": ["sh", "-lc", "echo ready; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500
}
},
{
"name": "busy",
"argv": ["sh", "-lc", "for i in 1 2 3 4 5; do echo tick $i; sleep 0.2; done; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500
}
}
]
},
"trust": ["echoer", "quiet", "busy"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "owner"
},
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "quiet", "name": "quiet"},
"save_as": "q"
},
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "busy", "name": "busy"},
"save_as": "b"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{q.process_id}}"},
"path": "idle_state",
"equals": "idle",
"timeout_ms": 3000
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{b.process_id}}"},
"path": "idle_state",
"equals": "working",
"timeout_ms": 3000
},
{
"type": "mcp_call",
"method": "timer_fire_when_idle_all",
"params": {
"watched": ["{{q.process_id}}", "{{b.process_id}}"],
"body": "all-idle",
"owner_process_id": "{{owner.process_id}}"
},
"save_as": "resp"
},
{
"type": "assert_saved",
"from": "resp",
"path": "status",
"equals": "pending"
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {"process_id": "{{owner.process_id}}", "mode": "grid"},
"path": "content",
"contains": "saw:all-idle",
"allow_substring": true,
"timeout_ms": 6000
}
]
}

View File

@@ -0,0 +1,67 @@
{
"name": "timer_idle_any_fires_on_transition",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
},
{
"name": "busy",
"argv": ["sh", "-lc", "for i in 1 2 3 4 5; do echo tick $i; sleep 0.2; done; sleep 60"],
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 500
}
}
]
},
"trust": ["echoer", "busy"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "owner"
},
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "busy", "name": "busy"},
"save_as": "watch"
},
{
"type": "wait_until_mcp",
"method": "get_process_status",
"params": {"process_id": "{{watch.process_id}}"},
"path": "idle_state",
"equals": "working",
"timeout_ms": 3000
},
{
"type": "mcp_call",
"method": "timer_fire_when_idle_any",
"params": {
"watched": ["{{watch.process_id}}"],
"body": "any-idle",
"owner_process_id": "{{owner.process_id}}"
},
"save_as": "resp"
},
{
"type": "assert_saved",
"from": "resp",
"path": "status",
"equals": "pending"
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {"process_id": "{{owner.process_id}}", "mode": "grid"},
"path": "content",
"contains": "saw:any-idle",
"allow_substring": true,
"timeout_ms": 6000
}
]
}

View File

@@ -0,0 +1,62 @@
{
"name": "timer_pause_resume",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
}
]
},
"trust": ["echoer"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "proc"
},
{ "type": "wait_stable", "timeout_ms": 1500 },
{
"type": "mcp_call",
"method": "timer_set",
"params": {
"seconds": 1,
"body": "after-resume",
"owner_process_id": "{{proc.process_id}}"
},
"save_as": "tmr"
},
{
"type": "mcp_call",
"method": "timer_pause",
"params": {"timer_id": "{{tmr.timer_id}}"}
},
{
"type": "mcp_call",
"method": "timer_list",
"params": {"owner_process_id": "{{proc.process_id}}"},
"save_as": "listed"
},
{
"type": "assert_saved",
"from": "listed",
"path": "0.status",
"equals": "paused"
},
{
"type": "mcp_call",
"method": "timer_resume",
"params": {"timer_id": "{{tmr.timer_id}}"}
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {"process_id": "{{proc.process_id}}", "mode": "grid"},
"path": "content",
"contains": "saw:after-resume",
"allow_substring": true,
"timeout_ms": 5000
}
]
}

View File

@@ -0,0 +1,40 @@
{
"name": "timer_set_delivers",
"presets": {
"processes": [
{
"name": "echoer",
"argv": ["sh", "-lc", "while read line; do echo \"saw:$line\"; done"]
}
]
},
"trust": ["echoer"],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": {"kind": "command", "preset": "echoer", "name": "echoer"},
"save_as": "proc"
},
{ "type": "wait_stable", "timeout_ms": 1500 },
{
"type": "mcp_call",
"method": "timer_set",
"params": {
"seconds": 0.5,
"body": "hello-from-timer",
"owner_process_id": "{{proc.process_id}}"
},
"save_as": "tmr"
},
{
"type": "wait_until_mcp",
"method": "get_process_output",
"params": {"process_id": "{{proc.process_id}}", "mode": "grid"},
"path": "content",
"contains": "saw:hello-from-timer",
"allow_substring": true,
"timeout_ms": 5000
}
]
}

View File

@@ -73,6 +73,14 @@ func booleanProp(desc string) map[string]any {
return map[string]any{"type": "boolean", "description": desc}
}
func arrayOfStringsProp(desc string) map[string]any {
return map[string]any{
"type": "array",
"description": desc,
"items": map[string]any{"type": "string"},
}
}
// toolCatalog is the full list advertised via tools/list. Descriptions
// are intentionally short — clients are expected to fetch help() for
// detail. Schemas mirror the param structs in tools.go.
@@ -239,12 +247,70 @@ func toolCatalog() []toolDescriptor {
},
{
Name: "timer_wait",
Description: "Sleep server-side for `seconds` and return a timer id (use to pace polling).",
Description: "Schedule a delay timer that injects a fixed `[system]` line into your pane when it fires (legacy; prefer timer_set).",
InputSchema: objectSchema(map[string]any{
"seconds": numberProp("Sleep duration."),
"seconds": numberProp("Delay duration."),
"label": stringProp("Optional label for diagnostics."),
}, []string{"seconds"}),
},
{
Name: "timer_set",
Description: "Schedule a one-shot delay timer that delivers `body` to the owning agent as a fresh user turn when it fires.",
InputSchema: objectSchema(map[string]any{
"seconds": numberProp("Delay duration."),
"body": stringProp("Message delivered verbatim to the owning agent as a user turn when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"owner_process_id": stringProp("Owner process id; defaults to the caller. Top-level callers must supply this explicitly."),
}, []string{"seconds", "body"}),
},
{
Name: "timer_fire_when_idle_any",
Description: "Schedule a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"max_wait_seconds": numberProp("Optional cap; 0 means no fallback fire."),
"owner_process_id": stringProp("Owner process id; defaults to the caller."),
}, []string{"watched", "body"}),
},
{
Name: "timer_fire_when_idle_all",
Description: "Schedule a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
InputSchema: objectSchema(map[string]any{
"watched": arrayOfStringsProp("Process ids to watch."),
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
"label": stringProp("Optional label for diagnostics."),
"max_wait_seconds": numberProp("Optional cap; 0 means no fallback fire."),
"owner_process_id": stringProp("Owner process id; defaults to the caller."),
}, []string{"watched", "body"}),
},
{
Name: "timer_cancel",
Description: "Cancel one pending timer owned by the caller.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id returned by a previous timer_* call."),
}, []string{"timer_id"}),
},
{
Name: "timer_pause",
Description: "Pause one pending timer owned by the caller. Idle-aware timers stop listening to state changes; delay timers preserve their remaining wait.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id."),
}, []string{"timer_id"}),
},
{
Name: "timer_resume",
Description: "Resume one paused timer owned by the caller.",
InputSchema: objectSchema(map[string]any{
"timer_id": stringProp("Timer id."),
}, []string{"timer_id"}),
},
{
Name: "timer_list",
Description: "List pending and paused timers owned by the caller.",
InputSchema: objectSchema(nil, nil),
},
{
Name: "scratchpad_list",
Description: "List shared per-project scratchpad entries.",

View File

@@ -88,6 +88,13 @@ type ToolHost interface {
SendMessage(callerID, targetID, message string) error
RequestHumanAttention(callerID, processID, reason string) error
TimerWait(callerID string, seconds float64, label string) (string, error)
TimerSet(callerID string, args TimerSetArgs) (TimerHandle, error)
TimerFireWhenIdleAny(callerID string, args TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error)
TimerFireWhenIdleAll(callerID string, args TimerFireWhenIdleArgs) (TimerFireWhenIdleResponse, error)
TimerCancel(callerID, id string) error
TimerPause(callerID, id string) error
TimerResume(callerID, id string) error
TimerList(callerID string) ([]TimerInfo, error)
// Scratchpads.
ScratchpadList() ([]scratchpad.Entry, error)
@@ -111,6 +118,13 @@ type ProcessInfo struct {
ExitCode *int `json:"exit_code,omitempty"`
IdleMS int64 `json:"idle_ms,omitempty"`
Trusted *bool `json:"trusted,omitempty"`
// IdleState is the idle-detection classifier's current opinion:
// one of "idle", "working", "thinking", "permission", "error".
// Empty when the classifier has not yet evaluated this child
// (typically right after spawn) or when idle detection is disabled.
IdleState string `json:"idle_state,omitempty"`
IdleReason string `json:"idle_reason,omitempty"`
}
// ProcessStatus is what get_process_status returns. Richer than
@@ -181,6 +195,63 @@ type SearchMatch struct {
Text string `json:"text"`
}
// TimerSetArgs is the input for timer_set: a one-shot delay timer that
// delivers Body to the owning agent as a fresh user turn when it fires.
// OwnerProcessID is optional — when empty the caller's own process_id
// is used (matching Solo's "bound agent" semantics). Top-level
// orchestrators (no caller identity) must set OwnerProcessID
// explicitly.
type TimerSetArgs struct {
Body string `json:"body"`
Label string `json:"label,omitempty"`
Seconds float64 `json:"seconds"`
OwnerProcessID string `json:"owner_process_id,omitempty"`
}
// TimerFireWhenIdleArgs is the input for timer_fire_when_idle_any /
// timer_fire_when_idle_all. Watched lists process_ids to monitor.
// MaxWaitSeconds bounds how long the timer can stay pending before
// firing anyway (0 = no max wait, fire only when the idle condition is
// met). OwnerProcessID: see TimerSetArgs.
type TimerFireWhenIdleArgs struct {
Body string `json:"body"`
Label string `json:"label,omitempty"`
Watched []string `json:"watched"`
MaxWaitSeconds float64 `json:"max_wait_seconds,omitempty"`
OwnerProcessID string `json:"owner_process_id,omitempty"`
}
// TimerHandle is the response for timer_set.
type TimerHandle struct {
ID string `json:"timer_id"`
}
// TimerFireWhenIdleResponse covers timer_fire_when_idle_any /
// timer_fire_when_idle_all. When every watched process is already idle
// at registration time, idle_all returns Status="already_satisfied"
// and ID="" — no timer is created (matches Solo). idle_any returns
// AlreadyIdle so the caller can see which processes were excluded from
// satisfaction.
type TimerFireWhenIdleResponse struct {
ID string `json:"timer_id,omitempty"`
Status string `json:"status"` // "pending" | "already_satisfied"
AlreadyIdle []string `json:"already_idle,omitempty"`
WaitingOn []string `json:"waiting_on,omitempty"`
}
// TimerInfo is one row in the timer_list response.
type TimerInfo struct {
ID string `json:"timer_id"`
Label string `json:"label,omitempty"`
Body string `json:"body,omitempty"`
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
Status string `json:"status"` // "pending" | "paused"
OwnerID string `json:"owner_process_id"`
WatchedIDs []string `json:"watched,omitempty"`
FiresAtUnixMS int64 `json:"fires_at_unix_ms,omitempty"`
PausedRemainingMS int64 `json:"paused_remaining_ms,omitempty"`
}
// PortSighting matches the per-child store in internal/app.
type PortSighting struct {
Port int `json:"port"`
@@ -575,6 +646,82 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
}
return map[string]string{"timer_id": id}, 0, "", nil
case "timer_set":
var p TimerSetArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
h2, err := h.TimerSet(callerID, p)
if err != nil {
return mapToolError(err)
}
return h2, 0, "", nil
case "timer_fire_when_idle_any":
var p TimerFireWhenIdleArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
resp, err := h.TimerFireWhenIdleAny(callerID, p)
if err != nil {
return mapToolError(err)
}
return resp, 0, "", nil
case "timer_fire_when_idle_all":
var p TimerFireWhenIdleArgs
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
resp, err := h.TimerFireWhenIdleAll(callerID, p)
if err != nil {
return mapToolError(err)
}
return resp, 0, "", nil
case "timer_cancel":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerCancel(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_pause":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerPause(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_resume":
var p struct {
TimerID string `json:"timer_id"`
}
if err := unmarshalParams(params, &p); err != nil {
return nil, codeInvalidParams, err.Error(), nil
}
if err := h.TimerResume(callerID, p.TimerID); err != nil {
return mapToolError(err)
}
return "ok", 0, "", nil
case "timer_list":
ts, err := h.TimerList(callerID)
if err != nil {
return mapToolError(err)
}
return ts, 0, "", nil
case "scratchpad_list":
entries, err := h.ScratchpadList()
if err != nil {

185
internal/persist/persist.go Normal file
View File

@@ -0,0 +1,185 @@
// Package persist stores the set of user-created top-level command
// processes for a project so they can be re-spawned after patterm
// restarts. SPEC §2 keeps everything ephemeral within one run; this
// state file is the exception — it survives the process tear-down so a
// user who fires up `bun run dev` and `tail -F log` doesn't have to
// re-spawn them every time patterm relaunches.
//
// Only top-level command entries (ParentID == "") are recorded.
// Agents, terminals, and orchestrator-spawned commands stay ephemeral.
// The file lives at
// $XDG_DATA_HOME/patterm/projects/<projectKey>/processes.json — the
// same parent directory the trust store uses.
package persist
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
)
// Entry is one persisted top-level command process. ID matches the
// session-minted process id; on restore Session.Spawn mints a fresh
// id, so ID is treated as opaque (used only to key Save/Remove).
type Entry struct {
ID string `json:"id"`
Name string `json:"name"`
Argv []string `json:"argv"`
WorkDir string `json:"working_dir,omitempty"`
PresetRef string `json:"preset_ref,omitempty"`
AutoRestart bool `json:"auto_restart,omitempty"`
}
// Store is one project's persisted-process file. Safe for concurrent
// use.
type Store struct {
path string
mu sync.Mutex
entries map[string]Entry
order []string
}
// Open loads (or creates) the processes file for projectKey. Missing
// file is not an error — it simply means nothing has been spawned
// yet.
func Open(projectKey string) (*Store, error) {
if projectKey == "" {
return nil, errors.New("persist.Open: empty project key")
}
base, err := dataDir()
if err != nil {
return nil, err
}
dir := filepath.Join(base, "projects", projectKey)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("persist: mkdir %s: %w", dir, err)
}
path := filepath.Join(dir, "processes.json")
s := &Store{path: path, entries: make(map[string]Entry)}
if err := s.loadLocked(); err != nil {
return nil, err
}
return s, nil
}
func dataDir() (string, error) {
if h := os.Getenv("XDG_DATA_HOME"); h != "" {
return filepath.Join(h, "patterm"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".local", "share", "patterm"), nil
}
// Path returns the on-disk file path. Used by tests / diagnostics.
func (s *Store) Path() string { return s.path }
// Save inserts or updates an entry, keyed by Entry.ID. Empty ID is an
// error.
func (s *Store) Save(e Entry) error {
if e.ID == "" {
return errors.New("persist.Save: empty entry id")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.entries[e.ID]; !exists {
s.order = append(s.order, e.ID)
}
s.entries[e.ID] = e
return s.saveLocked()
}
// Remove drops an entry by ID. No-op if the entry doesn't exist.
func (s *Store) Remove(id string) error {
if id == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.entries[id]; !exists {
return nil
}
delete(s.entries, id)
for i, oid := range s.order {
if oid == id {
s.order = append(s.order[:i], s.order[i+1:]...)
break
}
}
return s.saveLocked()
}
// List returns entries in the order they were first saved.
func (s *Store) List() []Entry {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Entry, 0, len(s.order))
for _, id := range s.order {
if e, ok := s.entries[id]; ok {
out = append(out, e)
}
}
return out
}
type fileShape struct {
Processes []Entry `json:"processes"`
}
func (s *Store) loadLocked() error {
b, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("persist: read %s: %w", s.path, err)
}
if len(b) == 0 {
return nil
}
var f fileShape
if err := json.Unmarshal(b, &f); err != nil {
return fmt.Errorf("persist: parse %s: %w", s.path, err)
}
for _, e := range f.Processes {
if e.ID == "" {
continue
}
if _, exists := s.entries[e.ID]; !exists {
s.order = append(s.order, e.ID)
}
s.entries[e.ID] = e
}
// Stable serialization order across re-saves.
sort.SliceStable(s.order, func(i, j int) bool { return s.order[i] < s.order[j] })
return nil
}
func (s *Store) saveLocked() error {
out := make([]Entry, 0, len(s.entries))
for _, id := range s.order {
if e, ok := s.entries[id]; ok {
out = append(out, e)
}
}
body, err := json.MarshalIndent(fileShape{Processes: out}, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, body, 0o600); err != nil {
return fmt.Errorf("persist: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("persist: rename %s: %w", s.path, err)
}
return nil
}

View File

@@ -0,0 +1,94 @@
package persist
import (
"os"
"reflect"
"testing"
)
func TestSaveAndReloadEntry(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dir)
s1, err := Open("projkey")
if err != nil {
t.Fatalf("open: %v", err)
}
if got := s1.List(); len(got) != 0 {
t.Fatalf("fresh store should be empty, got %v", got)
}
want := Entry{
ID: "p_abc123",
Name: "bun-dev",
Argv: []string{"sh", "-lc", "bun run dev"},
WorkDir: "/tmp/proj",
PresetRef: "shell",
AutoRestart: true,
}
if err := s1.Save(want); err != nil {
t.Fatalf("save: %v", err)
}
s2, err := Open("projkey")
if err != nil {
t.Fatalf("reopen: %v", err)
}
got := s2.List()
if len(got) != 1 || !reflect.DeepEqual(got[0], want) {
t.Fatalf("reload mismatch: got %v want [%v]", got, want)
}
if _, err := os.Stat(s2.Path()); err != nil {
t.Fatalf("stat processes.json: %v", err)
}
}
func TestRemoveEntry(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dir)
s, err := Open("projkey")
if err != nil {
t.Fatalf("open: %v", err)
}
if err := s.Save(Entry{ID: "a", Name: "a", Argv: []string{"a"}}); err != nil {
t.Fatalf("save a: %v", err)
}
if err := s.Save(Entry{ID: "b", Name: "b", Argv: []string{"b"}}); err != nil {
t.Fatalf("save b: %v", err)
}
if err := s.Remove("a"); err != nil {
t.Fatalf("remove a: %v", err)
}
got := s.List()
if len(got) != 1 || got[0].ID != "b" {
t.Fatalf("after remove a, got %v", got)
}
// Removing a non-existent entry is a no-op.
if err := s.Remove("missing"); err != nil {
t.Fatalf("remove missing: %v", err)
}
}
func TestSaveUpdatesExistingEntry(t *testing.T) {
dir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dir)
s, err := Open("projkey")
if err != nil {
t.Fatalf("open: %v", err)
}
if err := s.Save(Entry{ID: "a", Name: "old"}); err != nil {
t.Fatalf("save: %v", err)
}
if err := s.Save(Entry{ID: "a", Name: "new", AutoRestart: true}); err != nil {
t.Fatalf("update: %v", err)
}
got := s.List()
if len(got) != 1 || got[0].Name != "new" || !got[0].AutoRestart {
t.Fatalf("update mismatch: %v", got)
}
}
func TestOpenRequiresProjectKey(t *testing.T) {
if _, err := Open(""); err == nil {
t.Fatalf("open with empty project key should fail")
}
}

View File

@@ -40,9 +40,42 @@ type Preset struct {
Shell bool `json:"shell,omitempty"`
// Agent-only. SPEC §10.
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
MCPInjection *MCPInjection `json:"mcp_injection,omitempty"`
ReadySignal *ReadySignal `json:"ready_signal,omitempty"`
ChromeTrimHints []string `json:"chrome_trim_hints,omitempty"`
IdleDetection *IdleDetection `json:"idle_detection,omitempty"`
}
// IdleDetection configures steady-state idle classification for an
// agent preset. Independent of ReadySignal (which is startup-only).
// All fields are optional; when the whole block is nil the runtime
// falls back to output_activity with a 2s threshold.
//
// Strategy selects the primary signal:
// - "output_activity": ms since last PTY output (Claude, OpenCode).
// - "osc_title_stability": ms since last OSC 0/2 title change
// (Codex, Amp — title changes mean activity).
// - "osc_title_status": substring-match the current title against
// TitleStatusMap (Gemini — title carries a status word).
//
// Promoter patterns are applied on top of the strategy. They run
// against the recent ring-buffer tail; the first match wins in
// error > permission > thinking precedence and promotes the state
// over whatever the strategy returned.
type IdleDetection struct {
Strategy string `json:"strategy,omitempty"`
IdleThresholdMS int `json:"idle_threshold_ms,omitempty"`
// TitleStatusMap maps a (case-insensitive) substring of the OSC
// title to a state. Only meaningful for "osc_title_status".
// Allowed values: "idle", "working", "thinking", "permission", "error".
TitleStatusMap map[string]string `json:"title_status_map,omitempty"`
// Output regex promoters. Compiled at load time; bad patterns are
// surfaced as warnings and skipped.
PermissionPatterns []string `json:"permission_patterns,omitempty"`
ThinkingPatterns []string `json:"thinking_patterns,omitempty"`
ErrorPatterns []string `json:"error_patterns,omitempty"`
}
// MCPInjection covers the strategies SPEC §10 enumerates plus
@@ -196,6 +229,15 @@ func ensureDefaults(base string) error {
"argv": ["claude"],
"mcp_injection": { "kind": "flag", "flag": "--mcp-config" },
"ready_signal": { "idle_ms": 1000 },
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 2000,
"permission_patterns": [
"Do you want to proceed\\?",
" 1\\. Yes",
"1\\. Yes, and don't ask"
]
},
"chrome_trim_hints": [
"^Welcome to Claude Code",
"^/help for help",
@@ -220,6 +262,10 @@ func ensureDefaults(base string) error {
"format": "toml"
},
"ready_signal": { "idle_ms": 1000 },
"idle_detection": {
"strategy": "osc_title_stability",
"idle_threshold_ms": 2000
},
"chrome_trim_hints": [
"^OpenAI Codex",
"^\\s*model:",
@@ -243,6 +289,10 @@ func ensureDefaults(base string) error {
"var": "OPENCODE_CONFIG_CONTENT"
},
"ready_signal": { "idle_ms": 1000 },
"idle_detection": {
"strategy": "output_activity",
"idle_threshold_ms": 2000
},
"chrome_trim_hints": [
"^\\s*█",
"^\\s*opencode v",

View File

@@ -148,6 +148,52 @@ func (s *Store) Append(name, content string) error {
return err
}
// Delete removes the scratchpad file. Missing files are reported as
// errors; callers that want "delete if exists" can ignore os.ErrNotExist.
func (s *Store) Delete(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
p, err := s.safePath(name)
if err != nil {
return err
}
return os.Remove(p)
}
// Rename moves a scratchpad file to a new name within the same project
// directory. Returns os.ErrExist if newName already exists; the caller
// is expected to surface that to the user rather than clobber.
func (s *Store) Rename(oldName, newName string) error {
s.mu.Lock()
defer s.mu.Unlock()
src, err := s.safePath(oldName)
if err != nil {
return err
}
dst, err := s.safePath(newName)
if err != nil {
return err
}
if src == dst {
return nil
}
if _, err := os.Stat(dst); err == nil {
return fmt.Errorf("scratchpad: %q already exists", newName)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return os.Rename(src, dst)
}
// Path returns the absolute path of a scratchpad file. The file does
// not need to exist; callers like "Edit scratchpad" rely on this to
// hand the path to an external editor.
func (s *Store) Path(name string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.safePath(name)
}
func (s *Store) safePath(name string) (string, error) {
if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
return "", errors.New("scratchpad: invalid name")

View File

@@ -57,6 +57,20 @@ type Emulator interface {
// ActiveScreen reports whether we are on the primary or alternate buffer.
ActiveScreen() (Screen, error)
// Title returns the most recently set window title (OSC 0/2). Returns
// an empty string if no title has been set. Used by idle detection
// for the osc_title_stability and osc_title_status strategies.
Title() (string, error)
// ScrollViewportTop moves the viewport to the top of the scrollback.
ScrollViewportTop() error
// ScrollViewportBottom moves the viewport back to the active area.
ScrollViewportBottom() error
// ScrollViewportDelta moves the viewport by `delta` rows (negative = up).
ScrollViewportDelta(delta int) error
// OnWritePTY registers a callback that fires when the emulator wants
// to write bytes back to the PTY master (e.g. responses to DA / DSR
// queries). The callback runs synchronously inside Write and must not

View File

@@ -81,6 +81,27 @@ static GhosttyResult patterm_set_userdata(GhosttyTerminal t, uintptr_t ud) {
(const void *)ud);
}
static void patterm_scroll_viewport_top(GhosttyTerminal t) {
GhosttyTerminalScrollViewport beh;
beh.tag = GHOSTTY_SCROLL_VIEWPORT_TOP;
beh.value.delta = 0;
ghostty_terminal_scroll_viewport(t, beh);
}
static void patterm_scroll_viewport_bottom(GhosttyTerminal t) {
GhosttyTerminalScrollViewport beh;
beh.tag = GHOSTTY_SCROLL_VIEWPORT_BOTTOM;
beh.value.delta = 0;
ghostty_terminal_scroll_viewport(t, beh);
}
static void patterm_scroll_viewport_delta(GhosttyTerminal t, intptr_t d) {
GhosttyTerminalScrollViewport beh;
beh.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA;
beh.value.delta = d;
ghostty_terminal_scroll_viewport(t, beh);
}
static GhosttyFormatterTerminalOptions patterm_plain_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
@@ -161,7 +182,7 @@ func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
opts := C.GhosttyTerminalOptions{
cols: C.uint16_t(cols),
rows: C.uint16_t(rows),
max_scrollback: 0,
max_scrollback: 5000,
}
if rc := C.ghostty_terminal_new(nil, &e.term, opts); rc != C.GHOSTTY_SUCCESS {
@@ -523,6 +544,27 @@ func (e *GhosttyEmulator) Cursor() (CursorState, error) {
return CursorState{Col: uint16(col), Row: uint16(row), Visible: bool(visible)}, nil
}
// Title returns the most recent window title set by OSC 0/2 escape
// sequences. The libghostty-vt API hands back a borrowed pointer that
// stays valid only until the next vt_write/reset, so we copy out to a
// Go string under the same mutex that gates writes. An empty string
// (len=0) means no title has been set.
func (e *GhosttyEmulator) Title() (string, error) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return "", errors.New("vt: emulator closed")
}
var s C.GhosttyString
if rc := C.ghostty_terminal_get(e.term, C.GHOSTTY_TERMINAL_DATA_TITLE, unsafe.Pointer(&s)); rc != C.GHOSTTY_SUCCESS {
return "", fmt.Errorf("vt: get title failed: %s", ghosttyResultStr(rc))
}
if s.ptr == nil || s.len == 0 {
return "", nil
}
return C.GoStringN((*C.char)(unsafe.Pointer(s.ptr)), C.int(s.len)), nil
}
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
e.mu.Lock()
defer e.mu.Unlock()
@@ -539,6 +581,39 @@ func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
return ScreenPrimary, nil
}
// ScrollViewportTop scrolls the viewport to the top of the scrollback.
func (e *GhosttyEmulator) ScrollViewportTop() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("vt: emulator closed")
}
C.patterm_scroll_viewport_top(e.term)
return nil
}
// ScrollViewportBottom scrolls the viewport to the bottom (active area).
func (e *GhosttyEmulator) ScrollViewportBottom() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("vt: emulator closed")
}
C.patterm_scroll_viewport_bottom(e.term)
return nil
}
// ScrollViewportDelta scrolls the viewport by `delta` rows. Negative is up.
func (e *GhosttyEmulator) ScrollViewportDelta(delta int) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("vt: emulator closed")
}
C.patterm_scroll_viewport_delta(e.term, C.intptr_t(delta))
return nil
}
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {
if fn == nil {
e.onWrite.Store(nil)

View File

@@ -24,6 +24,10 @@ func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub
func (e *GhosttyEmulator) StyledScreenVT() ([]byte, error) { return nil, errStub }
func (e *GhosttyEmulator) Cursor() (CursorState, error) { return CursorState{}, errStub }
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub }
func (e *GhosttyEmulator) Title() (string, error) { return "", errStub }
func (e *GhosttyEmulator) ScrollViewportTop() error { return errStub }
func (e *GhosttyEmulator) ScrollViewportBottom() error { return errStub }
func (e *GhosttyEmulator) ScrollViewportDelta(int) error { return errStub }
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {}
func (e *GhosttyEmulator) Close() error { return nil }