6 Commits

Author SHA1 Message Date
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
46 changed files with 4407 additions and 216 deletions

View File

@@ -30,7 +30,8 @@ jobs:
CGO_ENABLED: 1 CGO_ENABLED: 1
run: | run: |
mkdir -p dist 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 \ -o dist/patterm-${{ github.ref_name }}-linux-amd64 \
./cmd/patterm ./cmd/patterm

View File

@@ -6,6 +6,140 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
### Added
- 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
- 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
- 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 ## [0.0.1] - 2026-05-14
### Fixed ### Fixed

View File

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

View File

@@ -5,3 +5,10 @@
Nerd Font private-use codepoints, not a patterm substitution. Nerd Font private-use codepoints, not a patterm substitution.
Need a concrete reproduction (which codepoint, which host Need a concrete reproduction (which codepoint, which host
terminal/font) before changing rendering. 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 ( import (
"encoding/json" "encoding/json"
"flag"
"fmt" "fmt"
"os" "os"
flag "github.com/spf13/pflag"
"github.com/hjbdev/patterm/internal/harness" "github.com/hjbdev/patterm/internal/harness"
) )

View File

@@ -4,6 +4,7 @@
// //
// patterm run in $PWD // patterm run in $PWD
// patterm --project <dir> run in <dir> // patterm --project <dir> run in <dir>
// patterm --version print version and exit
// patterm mcp-stdio --socket S --identity I // patterm mcp-stdio --socket S --identity I
// internal: stdio MCP proxy spawned for // internal: stdio MCP proxy spawned for
// children, forwards JSON-RPC over S // children, forwards JSON-RPC over S
@@ -13,15 +14,22 @@ package main
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"os" "os"
"runtime/debug"
"time"
flag "github.com/spf13/pflag"
"github.com/hjbdev/patterm/internal/app" "github.com/hjbdev/patterm/internal/app"
"github.com/hjbdev/patterm/internal/mcp" "github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/projectkey" "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() { func main() {
// The mcp-stdio subcommand is a separate top-level mode: when an // The mcp-stdio subcommand is a separate top-level mode: when an
// agent CLI launches `patterm mcp-stdio --socket ...`, the same // agent CLI launches `patterm mcp-stdio --socket ...`, the same
@@ -38,9 +46,17 @@ func main() {
return 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() flag.Parse()
if *showVersion {
fmt.Println(versionString())
return
}
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
die("getwd: %v", err) 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) { func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, "patterm: "+format+"\n", args...) fmt.Fprintf(os.Stderr, "patterm: "+format+"\n", args...)
os.Exit(1) 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 ( require (
github.com/creack/pty v1.1.24 github.com/creack/pty v1.1.24
github.com/spf13/pflag v1.0.10
golang.org/x/term v0.43.0 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 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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= 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 package app
import ( import (
"bytes"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"errors" "errors"
@@ -108,10 +109,14 @@ type Child struct {
// ringMu guards ring. The ring buffer carries the last `ringCap` // ringMu guards ring. The ring buffer carries the last `ringCap`
// bytes the PTY produced, used by SPEC §7 get_process_output stream // 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 ringMu sync.Mutex
ring []byte ring []byte // length == ringCap once allocated
ringStart int64 // absolute offset of ring[0] ringPos int // next byte to overwrite
ringFull bool // true once ringWrites ≥ ringCap
ringWrites int64 // cumulative bytes written ringWrites int64 // cumulative bytes written
// portsMu guards ports. Best-effort port detection: regex on stream. // portsMu guards ports. Best-effort port detection: regex on stream.
@@ -127,11 +132,37 @@ type Child struct {
// exits and calls Start to bring the entry back up. Cleared when the // exits and calls Start to bring the entry back up. Cleared when the
// user explicitly kills the process from the palette. // user explicitly kills the process from the palette.
autoRestart atomic.Bool 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) SetAutoRestart(v bool) {
c.autoRestart.Store(v)
c.firePersist()
}
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() } 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. // PortSighting is one entry returned by get_process_ports.
type PortSighting struct { type PortSighting struct {
Port int `json:"port"` Port int `json:"port"`
@@ -152,7 +183,7 @@ func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID
Kind: kind, Kind: kind,
ParentID: parentID, ParentID: parentID,
PresetRef: presetRef, PresetRef: presetRef,
ring: make([]byte, 0, ringCap), ring: make([]byte, ringCap),
} }
st := StatusStopped st := StatusStopped
c.status.Store(&st) c.status.Store(&st)
@@ -254,6 +285,7 @@ func (c *Child) SetName(name string) {
c.nameMu.Lock() c.nameMu.Lock()
c.Name = name c.Name = name
c.nameMu.Unlock() c.nameMu.Unlock()
c.firePersist()
} }
// ScreenVersion returns the current emulator snapshot version, bumped // ScreenVersion returns the current emulator snapshot version, bumped
@@ -302,13 +334,22 @@ func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano()) c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1) c.screenVersion.Add(1)
c.ringMu.Lock() c.ringMu.Lock()
c.ring = append(c.ring, chunk...) // Chunks larger than ringCap are tail-truncated — only the last
c.ringWrites += int64(len(chunk)) // ringCap bytes of the chunk can survive.
if len(c.ring) > ringCap { src := chunk
drop := len(c.ring) - ringCap if len(src) > ringCap {
c.ring = c.ring[drop:] src = src[len(src)-ringCap:]
c.ringStart += int64(drop)
} }
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.ringMu.Unlock()
c.scanPortsFromChunk(chunk) c.scanPortsFromChunk(chunk)
} }
@@ -316,6 +357,11 @@ func (c *Child) recordWrite(chunk []byte) {
// scanPortsFromChunk does best-effort port detection on a PTY chunk. // scanPortsFromChunk does best-effort port detection on a PTY chunk.
// SPEC §7 get_process_ports — no probing, just stream scanning. // SPEC §7 get_process_ports — no probing, just stream scanning.
func (c *Child) scanPortsFromChunk(chunk []byte) { 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) matches := portRegex.FindAllSubmatch(chunk, -1)
if len(matches) == 0 { if len(matches) == 0 {
return return
@@ -364,16 +410,38 @@ func (c *Child) Ports() []PortSighting {
func (c *Child) StreamRead(since int64) ([]byte, int64) { func (c *Child) StreamRead(since int64) ([]byte, int64) {
c.ringMu.Lock() c.ringMu.Lock()
defer c.ringMu.Unlock() defer c.ringMu.Unlock()
if since < c.ringStart { end := c.ringWrites
since = c.ringStart 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 { if since >= end {
return nil, end return nil, end
} }
start := int(since - c.ringStart) n := int(end - since)
out := make([]byte, end-since) out := make([]byte, n)
copy(out, c.ring[start:]) // 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 return out, end
} }
@@ -395,19 +463,17 @@ func (c *Child) signal(sig syscall.Signal) error {
// NudgeRedraw asks the child to throw away any diff-based render state // 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 // 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 // ratatui/ink TUIs re-render coherently against the snapshot we just
// replayed. We toggle the PTY size by one row so the kernel reliably // replayed. Sends an explicit SIGWINCH; TIOCSWINSZ with the same size
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't // is a no-op in the kernel, so an explicit signal is what most TUIs
// change), then send SIGWINCH explicitly for TUIs that miss or coalesce // actually act on anyway. Avoid resize-toggles here — under a drag-
// the size-toggled signal. The emulator is left alone — it already // resize the kernel still emits intermediate SIGWINCHes against the
// matches our intended size and the brief mismatch only affects what the // host PTY and toggling our child's size on top produces inconsistent
// child writes during the second redraw. // grid state.
func (c *Child) NudgeRedraw(cols, rows uint16) { func (c *Child) NudgeRedraw(cols, rows uint16) {
pty := c.PTY() pty := c.PTY()
if pty == nil || rows < 2 { if pty == nil || rows < 2 {
return return
} }
_ = pty.Resize(cols, rows-1)
_ = pty.Resize(cols, rows)
_ = c.signal(syscall.SIGWINCH) _ = c.signal(syscall.SIGWINCH)
} }

View File

@@ -67,6 +67,29 @@ func (cs *cursorShifter) clampCol(col int) int {
return col 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 // Shift consumes a chunk of PTY-master bytes, applies row offsets to
// any complete CUP/HVP/VPA/DECSTBM sequences, and returns the rewritten // any complete CUP/HVP/VPA/DECSTBM sequences, and returns the rewritten
// bytes. Partial sequences are buffered across calls so a CSI that // bytes. Partial sequences are buffered across calls so a CSI that
@@ -206,7 +229,7 @@ func (cs *cursorShifter) emitCSI() {
cs.pending.Write(cs.buf) cs.pending.Write(cs.buf)
return return
} }
r += cs.rowOffset r = cs.clampHostRow(r + cs.rowOffset)
c = cs.clampCol(c) c = cs.clampCol(c)
cs.pending.WriteString("\x1b[") cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r)) cs.pending.WriteString(strconv.Itoa(r))
@@ -226,13 +249,14 @@ func (cs *cursorShifter) emitCSI() {
cs.pending.WriteString(strconv.Itoa(c)) cs.pending.WriteString(strconv.Itoa(c))
cs.pending.WriteByte(final) cs.pending.WriteByte(final)
case 'd': 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) r, ok := parseOneParam(paramsRaw, 1)
if !ok { if !ok {
cs.pending.Write(cs.buf) cs.pending.Write(cs.buf)
return return
} }
r += cs.rowOffset r = cs.clampHostRow(r + cs.rowOffset)
cs.pending.WriteString("\x1b[") cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r)) cs.pending.WriteString(strconv.Itoa(r))
cs.pending.WriteByte(final) 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) 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

@@ -378,7 +378,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
return out, nil return out, nil
case "stream": case "stream":
b, end := c.StreamRead(sinceOffset) b, end := c.StreamRead(sinceOffset)
out.Content = stripANSI(string(b)) out.Content = string(stripANSIBytes(nil, b))
out.NewOffset = end out.NewOffset = end
return out, nil return out, nil
default: default:
@@ -409,10 +409,10 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err) return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
} }
b, _ := c.StreamRead(0) b, _ := c.StreamRead(0)
text := string(b)
if kind == "rendered" { if kind == "rendered" {
text = stripANSI(text) b = stripANSIBytes(nil, b)
} }
text := string(b)
lines := strings.Split(text, "\n") lines := strings.Split(text, "\n")
matches := make([]mcp.SearchMatch, 0, limit) matches := make([]mcp.SearchMatch, 0, limit)
truncated := false truncated := false
@@ -440,10 +440,19 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
if scope == "" { if scope == "" {
scope = "grid" 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))) deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop() // chunkWake fires on every PTY chunk for the target child. The
for { // 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 := "" text := ""
switch scope { switch scope {
case "grid": case "grid":
@@ -454,23 +463,75 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
} }
case "scrollback": case "scrollback":
b, _ := c.StreamRead(0) b, _ := c.StreamRead(0)
text = stripANSI(string(b)) text = string(stripANSIBytes(nil, b))
default:
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
} }
if m := re.FindString(text); m != "" { if m := re.FindString(text); m != "" {
return true, m
}
return false, ""
}
if ok, m := check(); ok {
return true, m, nil return true, m, nil
} }
if time.Now().After(deadline) { for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "", nil 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 { if !c.IsLive() && c.Status() != StatusStopped {
return false, "", nil 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 (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) { func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID) c := h.sess.FindChild(processID)
if c == nil { if c == nil {
@@ -887,6 +948,74 @@ func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "") 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 // availableToolsForRole — SPEC §7 whoami exposes the list a caller can
// invoke from its current role. Sub-agents lose `spawn_agent` (§8 // invoke from its current role. Sub-agents lose `spawn_agent` (§8
// two-level-tree rule). // two-level-tree rule).

View File

@@ -40,6 +40,36 @@ type csiuKey struct {
event int 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. // decodeCSIu parses the parameter string of a `CSI ... u` sequence.
// The kitty shape is: // 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) { func TestMatchCtrlKConsecutive(t *testing.T) {
// Two kitty Ctrl-K sequences back to back, the chord case. // Two kitty Ctrl-K sequences back to back, the chord case.
chunk := []byte("\x1b[107;5u\x1b[107;5u") chunk := []byte("\x1b[107;5u\x1b[107;5u")

View File

@@ -9,6 +9,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset" "github.com/hjbdev/patterm/internal/preset"
) )
@@ -202,6 +203,33 @@ func (l *Launcher) LaunchCommandArgv(argv []string, displayName, parentID, workD
}, cols, rows) }, 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. // LaunchTerminal spawns a bare interactive shell. SPEC §7 kind=terminal.
// argv defaults to $SHELL -i when empty. // argv defaults to $SHELL -i when empty.
func (l *Launcher) LaunchTerminal(argv []string, displayName, parentID, workDir string, env []string) (*Child, error) { 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. // paletteAction is what the palette returns when the user picks an item.
type paletteAction struct { type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "spawn-process-form" | // 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 kind string
// For spawn-agent / spawn-process, the preset to launch. // 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. // typed and the relaunch-on-exit flag they ticked.
command string command string
relaunch bool 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 { type paletteItem struct {
@@ -41,6 +52,7 @@ type paletteMode int
const ( const (
paletteModePicker paletteMode = iota paletteModePicker paletteMode = iota
paletteModeSpawnForm paletteModeSpawnForm
paletteModeRenameForm
) )
// spawnProcessForm is the state for the "Spawn process…" two-field // spawnProcessForm is the state for the "Spawn process…" two-field
@@ -52,6 +64,18 @@ type spawnProcessForm struct {
field int // 0 = command, 1 = relaunch checkbox 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 // paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus. // single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct { type paletteState struct {
@@ -59,12 +83,14 @@ type paletteState struct {
cursor int cursor int
children []*Child children []*Child
focused string focused string
focusedPad string
presets preset.Set presets preset.Set
items []paletteItem items []paletteItem
mode paletteMode mode paletteMode
form *spawnProcessForm form *spawnProcessForm
renameForm *renameForm
} }
// macroPrefixes maps the palette macro prefix (without trailing space) // macroPrefixes maps the palette macro prefix (without trailing space)
@@ -90,8 +116,20 @@ func detectMacro(q string) (macro, rest string) {
return "", q return "", q
} }
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState { func findChildByID(children []*Child, id string) *Child {
p := &paletteState{children: children, focused: focused, presets: presets} 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() p.rebuild()
return p return p
} }
@@ -135,6 +173,68 @@ func (p *paletteState) rebuild() {
func (p *paletteState) allItems() []paletteItem { func (p *paletteState) allItems() []paletteItem {
var out []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 // Switch entries first — existing open agents/processes should
// surface above options to spawn new ones. Hide non-running agents // surface above options to spawn new ones. Hide non-running agents
// (e.g. killed ones) so the list doesn't accumulate corpses. Command // (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 { if p.mode == paletteModeSpawnForm {
return p.handleFormInput(chunk, i) return p.handleFormInput(chunk, i)
} }
if p.mode == paletteModeRenameForm {
return p.handleRenameInput(chunk, i)
}
b := chunk[i] b := chunk[i]
if b == 0x1b { if b == 0x1b {
if n := csiLen(chunk, i); n > 0 { 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 // acceptOrEnterForm wraps accept(): if the chosen item opens the
// spawn-process form, transition into form mode instead of returning // spawn-process form or one of the rename forms, transition into form
// done=true. The advance count is what the caller already consumed for // mode instead of returning done=true. The advance count is what the
// the Enter keystroke. // caller already consumed for the Enter keystroke.
func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) { func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
a := p.accept() a := p.accept()
if a.kind == "spawn-process-form" { switch a.kind {
case "spawn-process-form":
p.mode = paletteModeSpawnForm p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{} p.form = &spawnProcessForm{}
return paletteAction{}, false, adv 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 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) { func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
switch final { switch final {
case 'A': case 'A':
@@ -445,6 +575,92 @@ func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteA
return paletteAction{}, false, n 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() { func (p *paletteState) cycleFormField() {
p.form.field++ p.form.field++
if p.form.field > 1 { if p.form.field > 1 {
@@ -512,6 +728,10 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
p.renderForm(out, cols, rows) p.renderForm(out, cols, rows)
return return
} }
if p.mode == paletteModeRenameForm {
p.renderRename(out, cols, rows)
return
}
if cols < 32 { if cols < 32 {
cols = 32 cols = 32
} }
@@ -794,6 +1014,96 @@ func (p *paletteState) renderForm(out writeFlusher, cols, rows int) {
_ = out.Flush() _ = 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 { func clipRunes(s string, n int) string {
if n <= 0 { if n <= 0 {
return "" 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 { func newTestPalette() *paletteState {
return newPalette(nil, "", preset.Set{}) return newPalette(nil, "", "", preset.Set{})
} }
func TestPaletteIgnoresKittyReleaseEvent(t *testing.T) { func TestPaletteIgnoresKittyReleaseEvent(t *testing.T) {
@@ -49,7 +49,7 @@ func TestPaletteBareEscCancels(t *testing.T) {
func TestPaletteKittyArrowsNavigate(t *testing.T) { func TestPaletteKittyArrowsNavigate(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}} pr := []*preset.Preset{{Name: "a"}, {Name: "b"}, {Name: "c"}}
p := newPalette(nil, "", preset.Set{Agents: pr}) p := newPalette(nil, "", "", preset.Set{Agents: pr})
if p.cursor != 0 { if p.cursor != 0 {
t.Fatalf("initial cursor %d", p.cursor) t.Fatalf("initial cursor %d", p.cursor)
} }
@@ -70,7 +70,7 @@ func TestPaletteKittyArrowsNavigate(t *testing.T) {
func TestPaletteLegacyArrowsStillWork(t *testing.T) { func TestPaletteLegacyArrowsStillWork(t *testing.T) {
pr := []*preset.Preset{{Name: "a"}, {Name: "b"}} pr := []*preset.Preset{{Name: "a"}, {Name: "b"}}
p := newPalette(nil, "", preset.Set{Agents: pr}) p := newPalette(nil, "", "", preset.Set{Agents: pr})
_, _, adv := p.handleInput([]byte("\x1b[B"), 0) _, _, adv := p.handleInput([]byte("\x1b[B"), 0)
if adv != 3 { if adv != 3 {
t.Fatalf("advance %d", adv) t.Fatalf("advance %d", adv)
@@ -82,7 +82,7 @@ func TestPaletteLegacyArrowsStillWork(t *testing.T) {
func TestPaletteKittyEnterAccepts(t *testing.T) { func TestPaletteKittyEnterAccepts(t *testing.T) {
pr := []*preset.Preset{{Name: "x"}} 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) action, done, _ := p.handleInput([]byte("\x1b[13u"), 0)
if !done || action.kind != "spawn-agent" { if !done || action.kind != "spawn-agent" {
t.Fatalf("Enter via CSI u didn't accept: action=%+v done=%v", action, done) 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 // non-empty command line emits the submit action with relaunch reflecting
// the checkbox state. // the checkbox state.
func TestPaletteSpawnProcessFormFlow(t *testing.T) { 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 // The "Spawn process…" entry is the only non-Quit item with an
// empty preset list. Locate its index by scanning items. // empty preset list. Locate its index by scanning items.
idx := -1 idx := -1
@@ -165,7 +165,7 @@ func TestPaletteSpawnProcessFormFlow(t *testing.T) {
} }
func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) { func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) {
p := newPalette(nil, "", preset.Set{}) p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{} p.form = &spawnProcessForm{}
action, done, _ := p.handleInput([]byte("\r"), 0) action, done, _ := p.handleInput([]byte("\r"), 0)
@@ -175,7 +175,7 @@ func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) {
} }
func TestPaletteSpawnProcessFormEscCancels(t *testing.T) { func TestPaletteSpawnProcessFormEscCancels(t *testing.T) {
p := newPalette(nil, "", preset.Set{}) p := newPalette(nil, "", "", preset.Set{})
p.mode = paletteModeSpawnForm p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{cmd: []rune("x")} p.form = &spawnProcessForm{cmd: []rune("x")}
action, done, _ := p.handleInput([]byte{0x1b}, 0) 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" "fmt"
"os" "os"
"sync" "sync"
"sync/atomic"
"syscall" "syscall"
"time" "time"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/vt" "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 // listeners is the set of UI listeners that want to hear about child
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1. // 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 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 // ChildEventListener is implemented by the TUI to react to lifecycle
@@ -65,32 +84,58 @@ func NewSession(projectDir, projectKey string) *Session {
func (s *Session) Subscribe(l ChildEventListener) { func (s *Session) Subscribe(l ChildEventListener) {
s.listenersMu.Lock() s.listenersMu.Lock()
defer s.listenersMu.Unlock() 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) { func (s *Session) emitSpawn(c *Child) {
s.listenersMu.Lock() for _, l := range s.listenersSnapshot() {
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnChildSpawned(c) l.OnChildSpawned(c)
} }
} }
func (s *Session) emitExit(c *Child) { func (s *Session) emitExit(c *Child) {
s.listenersMu.Lock() for _, l := range s.listenersSnapshot() {
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnChildExited(c) 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) { func (s *Session) emitPTYOut(id string, chunk []byte) {
s.listenersMu.Lock() for _, l := range s.listenersSnapshot() {
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
l.OnPTYOut(id, chunk) l.OnPTYOut(id, chunk)
} }
} }
@@ -162,14 +207,67 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
s.mu.Lock() s.mu.Lock()
s.children[id] = c s.children[id] = c
s.order = append(s.order, id) s.order = append(s.order, id)
store := s.persistStore
s.mu.Unlock() 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) s.emitSpawn(c)
go s.pumpChild(c, runID) go s.pumpChild(c, runID)
go s.reapChild(c, runID) go s.reapChild(c, runID)
return c, nil 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 // Start (re)attaches a PTY to an entry that is currently stopped or
// exited. Errors if the entry is already live. // exited. Errors if the entry is already live.
func (s *Session) Start(id string, cols, rows uint16) error { func (s *Session) Start(id string, cols, rows uint16) error {
@@ -238,6 +336,7 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
} }
} }
s.mu.Unlock() s.mu.Unlock()
s.forgetPersisted(id)
return nil return nil
} }
@@ -257,6 +356,12 @@ func (s *Session) pumpChild(c *Child, runID uint64) {
if pty == nil { if pty == nil {
return 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) buf := make([]byte, 64*1024)
for { for {
n, err := pty.Read(buf) n, err := pty.Read(buf)
@@ -264,8 +369,7 @@ func (s *Session) pumpChild(c *Child, runID uint64) {
if !c.isCurrentRun(runID) { if !c.isCurrentRun(runID) {
return return
} }
chunk := make([]byte, n) chunk := buf[:n]
copy(chunk, buf[:n])
if em := c.Emulator(); em != nil { if em := c.Emulator(); em != nil {
if _, werr := em.Write(chunk); werr != nil { if _, werr := em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr) logf("emulator.Write(child %s): %v", c.ID, werr)

View File

@@ -22,6 +22,7 @@ func (st *uiState) drawSidebar() {
st.mu.Lock() st.mu.Lock()
palOpen := st.palette != nil palOpen := st.palette != nil
focus := st.focusedID focus := st.focusedID
focusPad := st.focusedPad
activeAgent := st.activeAgentID activeAgent := st.activeAgentID
st.mu.Unlock() st.mu.Unlock()
if palOpen { if palOpen {
@@ -130,30 +131,24 @@ func (st *uiState) drawSidebar() {
write(line) write(line)
} }
// Scratchpads list — pick the most-recently-modified one as the // Scratchpads list — names only. The preview pane used to live
// preview target. SPEC §4. // here and clobbered the main viewport when content overflowed the
var previewName string // 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 { if row+2 <= maxRow {
write("") write("")
writeHeader("Scratchpads") writeHeader("Scratchpads")
entries, err := st.pads.List() entries := st.padsList()
if err == nil { if entries != nil {
if len(entries) == 0 { if len(entries) == 0 {
write(" " + styleDim + "(none)" + styleReset) write(" " + styleDim + "(none)" + styleReset)
} else { } else {
var newestTS string
for _, e := range entries {
if e.ModifiedAt > newestTS {
newestTS = e.ModifiedAt
previewName = e.Name
}
}
for _, e := range entries { for _, e := range entries {
if row > maxRow { if row > maxRow {
break break
} }
var line string var line string
if e.Name == previewName { if e.Name == focusPad {
line = " " + styleAccent + "▎" + styleReset + " " + line = " " + styleAccent + "▎" + styleReset + " " +
styleBold + e.Name + styleReset styleBold + e.Name + styleReset
} else { } else {
@@ -165,22 +160,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 // Blank-fill any rows the rail content didn't cover so stale
// content from a previous redraw doesn't linger. // content from a previous redraw doesn't linger.
for row <= maxRow { for row <= maxRow {

View File

@@ -1,5 +1,19 @@
package app 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 // visibleAgentTree returns the running entries under the active agent
// tab (root agent + its sub-agents). With the new Processes pane, // tab (root agent + its sub-agents). With the new Processes pane,
// command processes live in their own section and never show up here — // 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 { func sidebarNavList(children []*Child, activeAgentID string) []*Child {
out := make([]*Child, 0, 8) out := make([]*Child, 0, 8)
for _, c := range processList(children) { for _, c := range processList(children) {
if c.Status() != StatusRunning {
continue
}
out = append(out, c) out = append(out, c)
} }
for _, c := range visibleAgentTree(children, activeAgentID) { for _, c := range visibleAgentTree(children, activeAgentID) {
@@ -203,14 +214,77 @@ func sidebarNavList(children []*Child, activeAgentID string) []*Child {
return out return out
} }
// nextChildID returns the id `step` positions away from the current // sidebarNav returns the combined Processes + Agent Tree + Scratchpads
// focus in the combined Processes + active-agent-tree navigation list, // navigation list. Scratchpads always appear after children so the
// wrapping at both ends. Empty when there's nothing else to land on. // 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 { func nextChildID(children []*Child, focusID, activeAgentID string, step int) string {
flat := sidebarNavList(children, activeAgentID) flat := sidebarNavList(children, activeAgentID)
if len(flat) < 2 { if len(flat) == 0 {
return "" return ""
} }
if len(flat) == 1 {
if flat[0].ID == focusID {
return ""
}
return flat[0].ID
}
idx := -1 idx := -1
for i, c := range flat { for i, c := range flat {
if c.ID == focusID { 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) { func TestNextChildIDWalksProcessesThenAgentTree(t *testing.T) {
p1 := testProcess("p1", "bun", StatusRunning) p1 := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", 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) { func TestVisibleAgentTreeExcludesTopLevelCommands(t *testing.T) {
p := testProcess("p1", "bun", StatusRunning) p := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", StatusRunning) r := testAgent("a1", "claude", "", StatusRunning)

View File

@@ -15,6 +15,10 @@ type viewportRenderer struct {
layout terminalLayout layout terminalLayout
row int row int
col int col int
scrollTop int
scrollBottom int
originMode bool
lrMarginMode bool
state viewportState state viewportState
buf []byte buf []byte
@@ -22,8 +26,9 @@ type viewportRenderer struct {
// scrolled is set when the chunk contained an escape that shifts // scrolled is set when the chunk contained an escape that shifts
// content row-wise within the host's scroll region — RI / IND / // content row-wise within the host's scroll region — RI / IND /
// NEL / SU / SD / IL / DL. DECSTBM constrains rows but not columns, // NEL / SU / SD / IL / DL, or LF / VT / FF at the bottom margin.
// so these scrolls drag the right-hand sidebar content with them. // 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 // OnPTYOut consumes the flag and invalidates the sidebar chrome
// cache so the next drawSidebar repaints over the clobber. // cache so the next drawSidebar repaints over the clobber.
scrolled bool scrolled bool
@@ -50,12 +55,14 @@ const (
) )
func newViewportRenderer(l terminalLayout) *viewportRenderer { func newViewportRenderer(l terminalLayout) *viewportRenderer {
return &viewportRenderer{ vr := &viewportRenderer{
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())), shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())),
layout: l, layout: l,
row: 1, row: 1,
col: 1, col: 1,
} }
vr.resetScrollRegion()
return vr
} }
func (vr *viewportRenderer) SetLayout(l terminalLayout) { func (vr *viewportRenderer) SetLayout(l terminalLayout) {
@@ -63,14 +70,47 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
defer vr.mu.Unlock() defer vr.mu.Unlock()
vr.layout = l vr.layout = l
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())) vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols()))
vr.resetScrollRegion()
} }
func (vr *viewportRenderer) Render(in []byte) []byte { func (vr *viewportRenderer) Render(in []byte) []byte {
vr.mu.Lock() vr.mu.Lock()
defer vr.mu.Unlock() defer vr.mu.Unlock()
vr.pending.Reset() vr.pending.Reset()
for _, b := range in { // Fast path: while we're in vpNormal and have a run of plain ASCII
vr.feed(b) // 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()) return []byte(vr.pending.String())
} }
@@ -82,11 +122,10 @@ func (vr *viewportRenderer) ClearViewport() []byte {
} }
// TookScrollAction reports whether the most recent Render emitted (or // TookScrollAction reports whether the most recent Render emitted (or
// forwarded) a scroll-triggering escape — RI / IND / NEL / SU / SD / // forwarded) a scroll action since the previous call. Callers use it
// IL / DL — since the previous call. The flag is reset on read. // to invalidate sidebar-cache state, because the host's scroll region
// Callers use it to invalidate sidebar-cache state, because the host's // spans the full row width and any scroll there drags the sidebar
// scroll region spans the full row width and any scroll there drags // content vertically.
// the sidebar content downward.
func (vr *viewportRenderer) TookScrollAction() bool { func (vr *viewportRenderer) TookScrollAction() bool {
vr.mu.Lock() vr.mu.Lock()
defer vr.mu.Unlock() defer vr.mu.Unlock()
@@ -187,12 +226,53 @@ func (vr *viewportRenderer) emitCSI() {
params := vr.buf[2 : len(vr.buf)-1] params := vr.buf[2 : len(vr.buf)-1]
if final == 'h' || final == 'l' { 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) { if isAltScreenMode(params) {
return 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 { 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': case 'J':
n, ok := parseOneParam(params, 0) n, ok := parseOneParam(params, 0)
if !ok { if !ok {
@@ -225,10 +305,85 @@ func (vr *viewportRenderer) emitCSI() {
// the sidebar is repainted afterwards. // the sidebar is repainted afterwards.
vr.pending.Write(vr.shifter.Shift(vr.buf)) vr.pending.Write(vr.shifter.Shift(vr.buf))
vr.scrolled = true 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: default:
vr.pending.Write(vr.shifter.Shift(vr.buf)) vr.pending.Write(vr.shifter.Shift(vr.buf))
} }
if final != 'H' && final != 'f' && final != 'd' && final != 'r' {
vr.trackCSI(final, params) 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 { func isAltScreenMode(params []byte) bool {
@@ -245,6 +400,52 @@ func isAltScreenMode(params []byte) bool {
return false 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 { func (vr *viewportRenderer) clearViewport() string {
var b strings.Builder var b strings.Builder
b.WriteString("\x1b7") 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 // feedPrintable handles one non-ESC byte in the vpNormal state. It both
// advances vr's cursor model and decides whether the byte should be // advances vr's cursor model and decides whether the byte should be
// forwarded to the host. Bytes that would land past the viewport's // forwarded to the host. Bytes that would land past the viewport's
@@ -342,8 +606,8 @@ func (vr *viewportRenderer) feedPrintable(b byte) {
switch b { switch b {
case '\r': case '\r':
vr.col = 1 vr.col = 1
case '\n': case '\n', '\v', '\f':
vr.row++ vr.lineFeed()
case '\b': case '\b':
if vr.col > 1 { if vr.col > 1 {
vr.col-- vr.col--
@@ -405,7 +669,7 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
case 'H', 'f': case 'H', 'f':
r, c, ok := parseTwoParams(params) r, c, ok := parseTwoParams(params)
if ok { if ok {
vr.row, vr.col = r, c vr.row, vr.col = vr.originRow(r), c
} }
case 'G', '`': case 'G', '`':
c, ok := parseOneParam(params, 1) c, ok := parseOneParam(params, 1)
@@ -415,7 +679,7 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
case 'd': case 'd':
r, ok := parseOneParam(params, 1) r, ok := parseOneParam(params, 1)
if ok { if ok {
vr.row = r vr.row = vr.originRow(r)
} }
case 'A': case 'A':
n, ok := parseOneParam(params, 1) n, ok := parseOneParam(params, 1)
@@ -437,10 +701,41 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
if ok { if ok {
vr.col -= n vr.col -= n
} }
case 'r':
if vr.trackScrollRegion(params) {
vr.homeAfterScrollRegion()
}
} }
vr.clampCursor() 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() { func (vr *viewportRenderer) clampCursor() {
if vr.row < 1 { if vr.row < 1 {
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) { func TestViewportRendererClearScreenIsViewportOnly(t *testing.T) {
// hostRows=7 leaves four viewport rows after the 2-row tab bar and // hostRows=7 leaves four viewport rows after the 2-row tab bar and
// 1-row status reservation. // 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) { func TestViewportRendererForwardsRIVerbatim(t *testing.T) {
// We rely on the host terminal performing the scroll inside the // We rely on the host terminal performing the scroll inside the
// DECSTBM region; the renderer must not eat or transform RI. If a // 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 return []byte{0x10}, nil
case "ctrl-u": case "ctrl-u":
return []byte{0x15}, nil 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": case "tab":
return []byte{'\t'}, nil return []byte{'\t'}, nil
case "space": case "space":
return []byte{' '}, nil 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) 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

@@ -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,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" }
]
}

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

@@ -148,6 +148,52 @@ func (s *Store) Append(name, content string) error {
return err 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) { func (s *Store) safePath(name string) (string, error) {
if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." { if name == "" || strings.ContainsAny(name, "/\\") || name == "." || name == ".." {
return "", errors.New("scratchpad: invalid name") return "", errors.New("scratchpad: invalid name")

View File

@@ -57,6 +57,15 @@ type Emulator interface {
// ActiveScreen reports whether we are on the primary or alternate buffer. // ActiveScreen reports whether we are on the primary or alternate buffer.
ActiveScreen() (Screen, error) ActiveScreen() (Screen, 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 // OnWritePTY registers a callback that fires when the emulator wants
// to write bytes back to the PTY master (e.g. responses to DA / DSR // to write bytes back to the PTY master (e.g. responses to DA / DSR
// queries). The callback runs synchronously inside Write and must not // 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); (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) { static GhosttyFormatterTerminalOptions patterm_plain_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions); GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN; opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
@@ -161,7 +182,7 @@ func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
opts := C.GhosttyTerminalOptions{ opts := C.GhosttyTerminalOptions{
cols: C.uint16_t(cols), cols: C.uint16_t(cols),
rows: C.uint16_t(rows), 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 { if rc := C.ghostty_terminal_new(nil, &e.term, opts); rc != C.GHOSTTY_SUCCESS {
@@ -539,6 +560,39 @@ func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
return ScreenPrimary, nil 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)) { func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {
if fn == nil { if fn == nil {
e.onWrite.Store(nil) e.onWrite.Store(nil)

View File

@@ -24,6 +24,9 @@ func (e *GhosttyEmulator) SerializeVT() ([]byte, error) { return nil, errStub
func (e *GhosttyEmulator) StyledScreenVT() ([]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) Cursor() (CursorState, error) { return CursorState{}, errStub }
func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, errStub } func (e *GhosttyEmulator) ActiveScreen() (Screen, error) { return 0, 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) OnWritePTY(fn func([]byte)) {}
func (e *GhosttyEmulator) Close() error { return nil } func (e *GhosttyEmulator) Close() error { return nil }