This commit is contained in:
2026-05-15 00:28:06 +01:00
parent 2f969fa215
commit 0d578d54f1
31 changed files with 3209 additions and 164 deletions

View File

@@ -7,6 +7,15 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [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.
- `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
@@ -15,6 +24,36 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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`.
@@ -24,12 +63,63 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
renders the canonical `--flag` form.
### Fixed
- 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

16
TODO.md
View File

@@ -5,4 +5,18 @@
Nerd Font private-use codepoints, not a patterm substitution.
Need a concrete reproduction (which codepoint, which host
terminal/font) before changing rendering.
- [ ] After codex rips for like 15 minutes, the terminal becomes quite slow occasionally. Also resizing causes the terminal to go CRAZY with the scroll jumping around. [ON HOLD]
- [ ] 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.
- [ ] Opening the command palette with a scratchpad open creates very buggy ui.
- Typing into the command palette doesn't work at all
- Hitting esc causes buggy chrome, the top border of the command palette is still visible
- This is only fixed by Ctrl + W, hitting esc again to close the palette, then re-opening it when over an agent view.
- [ ] Context aware command palette options
- Options for current scratchpad (delete, rename, edit) at the top when a scratchpad is selected.
- Options for current agent (rename [renames tab], close) at the top when an agent is selected.
- Options for current process (rename [renames list item], delete, stop, restart) at the top when a process is selected.

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

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"

View File

@@ -17,9 +17,11 @@ import (
"golang.org/x/term"
"github.com/hjbdev/patterm/internal/mcp"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/preset"
"github.com/hjbdev/patterm/internal/scratchpad"
"github.com/hjbdev/patterm/internal/trust"
"github.com/hjbdev/patterm/internal/vt"
)
// Options configures a patterm run.
@@ -55,6 +57,14 @@ func Run(ctx context.Context, opts Options) error {
return fmt.Errorf("app: trust init: %w", err)
}
// Per-project persisted-process store. Survives across patterm
// restarts so user-created top-level command processes come back
// after a relaunch.
persistStore, err := persist.Open(opts.ProjectKey)
if err != nil {
return fmt.Errorf("app: persist init: %w", err)
}
// In-process MCP server bound to the per-PID socket. Children that
// support MCP get pointed at `patterm mcp-stdio --socket=... --identity=...`.
// SPEC §10.
@@ -66,6 +76,15 @@ func Run(ctx context.Context, opts Options) error {
sess := NewSession(opts.ProjectDir, opts.ProjectKey)
defer sess.Shutdown()
// Snapshot persisted processes BEFORE attaching the store: Spawn
// mints fresh ids, so the old records would otherwise linger
// alongside the new ones. Drop them up front; the restore loop
// below re-saves each entry under its new id.
savedProcesses := persistStore.List()
for _, e := range savedProcesses {
_ = persistStore.Remove(e.ID)
}
sess.SetPersistStore(persistStore)
cols, rows := hostSize()
@@ -98,6 +117,7 @@ func Run(ctx context.Context, opts Options) error {
presets: presets,
launcher: launcher,
pads: pads,
chromeWake: make(chan struct{}, 1),
trust: trustStore,
hostCols: cols,
hostRows: rows,
@@ -122,23 +142,45 @@ func Run(ctx context.Context, opts Options) error {
launcher.SetSize(layout.childCols(), layout.childRows())
host.SetSize(layout.childCols(), layout.childRows())
// Replay persisted top-level command processes. Failures are
// logged and skipped so a stale entry (preset deleted, binary
// missing) doesn't block startup.
for _, e := range savedProcesses {
c, err := launcher.RestoreCommand(e, presets)
if err != nil {
st.dbgf("restore process %s (%s): %v", e.Name, e.ID, err)
continue
}
if e.AutoRestart {
c.SetAutoRestart(true)
}
}
var wg sync.WaitGroup
// SIGWINCH.
// SIGWINCH. The kernel emits one signal per kernel-side resize, and
// drag-resizes produce tens of them per second. The full
// resize-redraw pipeline (ResizeAll + clearScreen + repaintFocused +
// chrome) is expensive enough that running it per signal causes
// visible scroll-jumping in diff-based TUIs like codex. Coalesce:
// reset an ~80ms timer on every event, then run the pipeline once
// when the timer fires. Skip repaintFocused on this path — the
// child's own SIGWINCH-driven redraw fills the viewport; running
// our snapshot replay over a child that's mid-reflow is what
// produces the "crazy" scroll.
wg.Add(1)
winch := make(chan os.Signal, 1)
signal.Notify(winch, syscall.SIGWINCH)
go func() {
defer wg.Done()
defer signal.Stop(winch)
for {
select {
case <-ctx.Done():
return
case <-winch:
const debounce = 80 * time.Millisecond
var timer *time.Timer
var timerC <-chan time.Time
doResize := func() {
c, r := hostSize()
if c == 0 || r == 0 {
continue
return
}
st.dimsMu.Lock()
st.hostCols, st.hostRows = c, r
@@ -153,11 +195,57 @@ func Run(ctx context.Context, opts Options) error {
launcher.SetSize(l.childCols(), l.childRows())
host.SetSize(l.childCols(), l.childRows())
st.clearScreen()
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
for {
select {
case <-ctx.Done():
if timer != nil {
timer.Stop()
}
return
case <-winch:
if timer == nil {
timer = time.NewTimer(debounce)
timerC = timer.C
} else {
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(debounce)
}
case <-timerC:
timer = nil
timerC = nil
doResize()
}
}
}()
// Chrome ticker: drain the dirty flag at ~60 Hz so per-chunk PTY
// output doesn't pay tabbar/statusline rebuild cost on every chunk.
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(16 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-st.chromeWake:
case <-ticker.C:
}
if !st.chromeDirty.Swap(false) {
continue
}
st.drawTabBar()
st.drawStatusLine()
}
}()
@@ -214,6 +302,21 @@ type uiState struct {
palette *paletteState
focusedID string
focusedName string
// focusedPad names the scratchpad currently rendered in the main
// viewport. When non-empty, focusedID is "" and the host renders
// pad content instead of forwarding child PTY output. Mutually
// exclusive with focusedID.
focusedPad string
// padOffset is the index of the top-most rendered row in the
// markdown-formatted view of focusedPad. Reset when focus moves to
// a different pad; preserved across content changes for the same
// pad so writes from MCP don't snap the user's view back to the
// top.
padOffset int
// padOffsetName tracks which pad padOffset belongs to so a focus
// switch resets the offset cleanly.
padOffsetName string
// activeAgentID tracks which top-level agent tab "owns" the agent
// tree section of the sidebar. It only updates when focus lands on
// an agent (or one of its sub-agents), so the agent tree stays
@@ -253,6 +356,22 @@ type uiState struct {
sidebarCache string
statusLineCache string
// chromeDirty defers tab-bar and status-line repaints off the
// per-PTY-chunk hot path. OnPTYOut sets it; a ticker goroutine
// drains it at ~60 Hz and runs the actual draw calls. Latency-
// sensitive paths (owner flip, attention, trust, focus change)
// continue to call drawStatusLine / drawTabBar synchronously.
chromeDirty atomic.Bool
chromeWake chan struct{}
// padsCacheMu guards the cached scratchpad listing. The sidebar
// and palette/sidebar nav helpers read it on every chunk-driven
// repaint; the cache invalidates in scratchpadsChanged() which is
// the canonical "pads mutated" signal from MCP write/append. nil
// means "never read yet" — next caller refreshes.
padsCacheMu sync.Mutex
padsCache []scratchpad.Entry
lastExit atomic.Int32
}
@@ -287,17 +406,70 @@ func (st *uiState) focusProcess(processID string) {
}
layout := st.layoutSnapshot()
st.mu.Lock()
leavingPad := st.focusedPad != ""
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout)
st.mu.Unlock()
// Wipe whatever the previous focus (PTY child or pad view) left in
// the viewport before painting the new child's snapshot.
if leavingPad {
st.clearViewportArea()
}
st.repaintFocused()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// focusScratchpad shifts focus to a scratchpad. The main viewport
// renders the pad's text instead of any child PTY; PTY output for the
// previously focused child is dropped until focus moves back to a
// child. Empty name clears scratchpad focus.
func (st *uiState) focusScratchpad(name string) {
if name == "" {
return
}
st.mu.Lock()
if st.padOffsetName != name {
st.padOffset = 0
st.padOffsetName = name
}
st.focusedPad = name
st.focusedID = ""
st.focusedName = name
st.renderer = nil
st.mu.Unlock()
st.clearViewportArea()
st.repaintFocusedPad()
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// clearViewportArea wipes the rectangle the focused-child PTY (or pad
// view) paints into so the next paint starts on a clean canvas. Used
// when transitioning between pad and child focus.
func (st *uiState) clearViewportArea() {
layout := st.layoutSnapshot()
mainBottom := int(layout.statusRow) - statusRows
if mainBottom < int(layout.mainTop) {
return
}
var b strings.Builder
// ECH clears `mainCols` cells from each row in the viewport without
// touching the sidebar columns.
width := int(layout.childCols())
for r := int(layout.mainTop); r <= mainBottom; r++ {
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", r, int(layout.mainLeft), width)
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.WriteString(b.String())
}
func (st *uiState) restartFocusedCommand(processID string) {
c := st.sess.FindChild(processID)
if c == nil || c.Kind != KindCommand {
@@ -310,7 +482,7 @@ func (st *uiState) restartFocusedCommand(processID string) {
st.focusedName = c.DisplayName()
st.renderer = renderer
st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 8
st.repaintNextPTYBudget = 2
st.mu.Unlock()
st.outMu.Lock()
@@ -372,16 +544,26 @@ func (st *uiState) notifyAttention(childID, reason string) {
}
func (st *uiState) scratchpadsChanged() {
st.padsCacheMu.Lock()
st.padsCache = nil
st.padsCacheMu.Unlock()
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.drawSidebar()
st.mu.Lock()
focusedPad := st.focusedPad
st.mu.Unlock()
if focusedPad != "" {
st.repaintFocusedPad()
}
}
// OnChildSpawned auto-focuses the new child.
func (st *uiState) OnChildSpawned(c *Child) {
layout := st.layoutSnapshot()
st.mu.Lock()
st.focusedPad = ""
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
@@ -405,7 +587,7 @@ func (st *uiState) OnChildSpawned(c *Child) {
// emulator grid, so the host display tracks the emulator state
// without needing a manual focus cycle.
st.repaintNextPTY = c.ID
st.repaintNextPTYBudget = 8
st.repaintNextPTYBudget = 2
st.mu.Unlock()
// Wipe the viewport area so the previous focused child's PTY
@@ -537,10 +719,16 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
} else {
out = renderer.Render(chunk)
}
// One write covers the autowrap-disable prelude, the chunk, and the
// autowrap-restore postlude — three syscalls collapsed into one
// under outMu. The three sequences were already emitted atomically
// under the lock; coalescing just halves the syscall count.
wrapped := make([]byte, 0, len(out)+10)
wrapped = append(wrapped, "\x1b[?7l"...)
wrapped = append(wrapped, out...)
wrapped = append(wrapped, "\x1b[?7h"...)
st.outMu.Lock()
_, _ = os.Stdout.Write([]byte("\x1b[?7l"))
_, _ = os.Stdout.Write(out)
_, _ = os.Stdout.Write([]byte("\x1b[?7h"))
_, _ = os.Stdout.Write(wrapped)
st.outMu.Unlock()
// RI / IND / NEL / SU / SD / IL / DL and bottom-margin LF / VT / FF
// scroll content within the host's scroll region, which spans every
@@ -554,31 +742,71 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) {
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
}
st.drawTabBar()
if scrolled {
// Scrolled chunks can clobber the sidebar columns; repaint
// synchronously so the gap fills before the next chunk lands.
st.drawSidebar()
}
st.drawStatusLine()
// Defer the tab bar + status line repaint to the chrome ticker.
// The cached frame already short-circuits the wire write, but
// avoiding the string build, FindChild, and locking on every
// chunk pulls steady-state CPU off the hot path.
st.markChromeDirty()
}
func (st *uiState) enterScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h"))
// SGR mouse reporting (?1000h ?1006h) stays on the entire time patterm
// is on the alt screen so we always receive wheel events. The focused
// child's wheel handling in processStdin decides whether each event
// scrolls the viewport (primary screen) or forwards to the child
// (alt screen / pad / palette).
_, _ = os.Stdout.Write([]byte("\x1b[?1049h\x1b[H\x1b[2J\x1b[?25h\x1b[?1000h\x1b[?1006h"))
st.outMu.Unlock()
st.installHostScrollRegion()
}
func (st *uiState) leaveScreen() {
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[?1049l"))
// Tear down any mouse reporting patterm enabled before leaving the
// alt screen; otherwise the calling shell can be left with a host
// that still emits SGR mouse events. Reset DECSTBM so the calling
// shell isn't stuck with a constrained scroll region.
_, _ = os.Stdout.Write([]byte("\x1b[r\x1b[?6l\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"))
}
func (st *uiState) clearScreen() {
st.invalidateChromeCache()
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
st.outMu.Unlock()
// Re-arm the host scroll region so the post-clear paint inherits
// the viewport bounds. Without this, a SIGWINCH-driven clearScreen
// followed by a long burst of child output (no DECSTBM of its own)
// would scroll the host's full screen — chrome included — every
// time the cursor reached the bottom row.
st.installHostScrollRegion()
}
// installHostScrollRegion writes DECSTBM to bound the host's scroll
// region to mainTop..mainBottom, then disables origin mode and CUPs
// back to viewport-top. With this in place a child that emits LF / IND
// / NEL / RI / SU / SD / IL / DL at the bottom of the viewport scrolls
// only within the viewport rows — the tab bar and status row never see
// the scroll. renderFocusedSnapshot already emits the same prelude for
// snapshot replays; this method covers the windows in between (initial
// startup, post-SIGWINCH, post-clearScreen) when no snapshot fires.
func (st *uiState) installHostScrollRegion() {
layout := st.layoutSnapshot()
mainBottom := int(layout.statusRow) - statusRows
if mainBottom < int(layout.mainTop) {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
fmt.Fprintf(os.Stdout, "\x1b[?6l\x1b[%d;%dr\x1b[%d;%dH",
int(layout.mainTop), mainBottom,
int(layout.mainTop), int(layout.mainLeft))
}
// invalidateChromeCache forces the next drawTabBar / drawSidebar /
@@ -587,6 +815,39 @@ func (st *uiState) clearScreen() {
// change, full repaint) must call this — otherwise the chrome stays
// blank because the cached frame still matches the unchanged state
// even though the wire was cleared.
// padsList returns the cached scratchpad listing, refreshing from
// disk on the first call after invalidation. Callers must not mutate
// the returned slice — it is shared.
func (st *uiState) padsList() []scratchpad.Entry {
st.padsCacheMu.Lock()
if st.padsCache != nil {
out := st.padsCache
st.padsCacheMu.Unlock()
return out
}
st.padsCacheMu.Unlock()
entries, err := st.pads.List()
if err != nil {
return nil
}
st.padsCacheMu.Lock()
st.padsCache = entries
st.padsCacheMu.Unlock()
return entries
}
// markChromeDirty schedules a chrome (tab bar + status line) repaint
// on the next ticker frame. Cheap to call from the per-PTY-chunk hot
// path. Latency-sensitive sites (focus change, owner flip, attention,
// trust prompts) keep calling drawTabBar / drawStatusLine directly.
func (st *uiState) markChromeDirty() {
st.chromeDirty.Store(true)
select {
case st.chromeWake <- struct{}{}:
default:
}
}
func (st *uiState) invalidateChromeCache() {
st.chromeCacheMu.Lock()
st.tabBarCache = ""
@@ -635,17 +896,22 @@ func (st *uiState) drawStatusLine() {
if cols == 0 || rows == 0 {
return
}
owner := ""
// Resolve the focused child once — drawStatusLine fires on every
// PTY chunk and ticker tick, and FindChild takes the session
// mutex.
var focusedChild *Child
if focusID != "" {
if c := st.sess.FindChild(focusID); c != nil {
switch c.Owner() {
focusedChild = st.sess.FindChild(focusID)
}
owner := ""
if focusedChild != nil {
switch focusedChild.Owner() {
case OwnerOrchestrator:
owner = "orchestrator driving"
case OwnerUser:
owner = "you have control"
}
}
}
left := ""
if focusName != "" {
left = focusName
@@ -675,9 +941,12 @@ func (st *uiState) drawStatusLine() {
"Ctrl-W/S · tree",
"Ctrl-K · palette",
}
if c := st.sess.FindChild(focusID); c != nil && c.Kind == KindCommand {
if focusedChild != nil {
hints = append(hints, "Ctrl-B · scroll")
if focusedChild.Kind == KindCommand {
hints = append(hints, "Ctrl-R · restart")
}
}
right := strings.Join(hints, " · ")
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
hints = hints[1:]
@@ -864,8 +1133,28 @@ func (st *uiState) processStdin(chunk []byte) {
}
var pendingAction *paletteAction
var pendingNavID string
var pendingNav navEntry
var pendingRestartID string
var pendingViewportDelta int
var pendingViewportBottom bool
var pendingPadStep int
var pendingPadExit bool
// childOnPrimary captures whether the focused child is on its primary
// screen at the start of this chunk. Wheel events on the primary
// screen scroll the emulator viewport (inline scrollback); on the
// alternate screen they fall through to the child PTY so vim / less /
// codex can consume them.
childOnPrimary := false
if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil {
if em := c.Emulator(); em != nil {
if sc, err := em.ActiveScreen(); err == nil && sc == vt.ScreenPrimary {
childOnPrimary = true
}
}
}
}
// Tracks the last arrow direction and the byte offset immediately
// after its CSI sequence. Some terminals emit a duplicate adjacent
@@ -881,6 +1170,136 @@ func (st *uiState) processStdin(chunk []byte) {
for i < len(chunk) {
b := chunk[i]
// Scratchpad mode: pad has no PTY destination, so input is
// repurposed for scrolling the rendered markdown view.
// Scroll-wheel events are the primary control (we enable SGR
// mouse reporting in focusScratchpad); arrow keys / PgUp/PgDn /
// Home / End work for keyboard users. App-level chords (Ctrl-K
// palette, Ctrl-WASD focus, Ctrl-B scrollback) fall through to
// the handlers below; everything else is swallowed silently so
// typing into a pad view can't leak to a child PTY.
if st.focusedPad != "" {
if b == 0x1b { // ESC or CSI
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
params := chunk[i+2 : i+n-1]
// SGR mouse: `CSI < button ; col ; row M/m`. We
// enabled 1006 reporting on focus, so the host emits
// this form. Wheel-up = 64, wheel-down = 65; +shift
// adds 4 → 68/69; +ctrl adds 16 → 80/81. We treat
// any wheel button as a 3-row step.
if final == 'M' && len(params) > 0 && params[0] == '<' {
if step, ok := parseSGRMouseWheel(params[1:]); ok {
pendingPadStep += step
i += n
continue
}
// Non-wheel mouse event (click/drag/release):
// drop silently. Pads don't have a click model
// yet, and forwarding to a child would be
// confusing while the pad view is up.
i += n
continue
}
if final == 'm' && len(params) > 0 && params[0] == '<' {
// SGR release event — always drop.
i += n
continue
}
switch final {
case 'A':
pendingPadStep -= 1
i += n
continue
case 'B':
pendingPadStep += 1
i += n
continue
case '~':
pstr := string(params)
layout := st.layoutLocked()
page := int(layout.childRows()) - 2
if page < 1 {
page = 1
}
switch pstr {
case "5":
pendingPadStep -= page
i += n
continue
case "6":
pendingPadStep += page
i += n
continue
case "1", "7":
pendingPadStep -= 1 << 30
i += n
continue
case "4", "8":
pendingPadStep += 1 << 30
i += n
continue
}
case 'u':
if k, ok := decodeCSIu(string(params)); ok && k.event == 1 {
switch k.key {
case kittyKeyUp:
pendingPadStep -= 1
i += n
continue
case kittyKeyDown:
pendingPadStep += 1
i += n
continue
}
}
}
// Unhandled CSI: drop so the pad view stays stable
// instead of letting stray escapes hit the next
// handler block.
i += n
continue
}
// Legacy X10 mouse: `CSI M Cb Cx Cy`, three raw bytes
// after the M. csiLen consumed only up to 'M'; pick up
// the three trailing bytes here. Cb is button + 32;
// wheel = 64 → byte 96, wheel-down = 65 → byte 97.
if i+5 < len(chunk) && chunk[i+1] == '[' && chunk[i+2] == 'M' {
cb := chunk[i+3]
switch cb {
case 96, 100, 112: // 64, 68, 80 — wheel up variants
pendingPadStep -= 3
i += 6
continue
case 97, 101, 113: // 65, 69, 81 — wheel down variants
pendingPadStep += 3
i += 6
continue
}
// Non-wheel legacy mouse: drop the 6-byte event.
i += 6
continue
}
// Bare ESC exits the pad view.
pendingPadExit = true
i++
break
}
// Plain bytes (letters, control chars other than ESC) drop
// silently except for the app-level chords we explicitly
// allow through below.
if hit, _ := matchCtrlK(chunk, i); hit {
// fall through to the app-level handler
} else if hit, _ := matchCtrlChar(chunk, i, 'a'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'd'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 'w'); hit {
} else if hit, _ := matchCtrlChar(chunk, i, 's'); hit {
} else {
i++
continue
}
}
// Palette mode swallows all bytes.
if st.palette != nil {
if nav, navLen := peekArrowEvent(chunk, i); nav != 0 {
@@ -939,25 +1358,29 @@ func (st *uiState) processStdin(chunk []byte) {
// further forwarding ambiguous between old and new pane.
if hit, adv := matchCtrlChar(chunk, i, 'a'); hit {
flushForward()
pendingNavID = nextTabID(st.sess.Children(), st.focusedID, -1)
if id := nextTabID(st.sess.Children(), st.focusedID, -1); id != "" {
pendingNav = navEntry{childID: id}
}
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 'd'); hit {
flushForward()
pendingNavID = nextTabID(st.sess.Children(), st.focusedID, +1)
if id := nextTabID(st.sess.Children(), st.focusedID, +1); id != "" {
pendingNav = navEntry{childID: id}
}
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
flushForward()
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, -1)
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), -1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
flushForward()
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, +1)
pendingNav = nextNavEntry(st.sess.Children(), st.focusedID, st.focusedPad, st.activeAgentID, st.padsList(), +1)
i += adv
break
}
@@ -969,6 +1392,54 @@ func (st *uiState) processStdin(chunk []byte) {
break
}
}
// Ctrl-B snaps the focused child's emulator viewport back to the
// active area. Use this as the escape hatch from a scrolled-up
// state — wheel scrolls move the viewport into the libghostty
// scrollback history; Ctrl-B brings it back. The chord is
// intercepted before forwarding so the child shell doesn't see a
// stray Ctrl-B (readline backward-char).
if hit, adv := matchCtrlChar(chunk, i, 'b'); hit {
if st.focusedID != "" {
flushForward()
pendingViewportBottom = true
i += adv
continue
}
}
// Inline wheel scrollback for a focused child on the primary
// screen. The host always has SGR mouse reporting armed (see
// enterScreen), so wheel events arrive here even when the child
// shell never asked for mouse input. On the alternate screen we
// let the bytes fall through to forward so vim / less / codex
// receive the wheel event as input.
if childOnPrimary && b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
final := chunk[i+n-1]
params := chunk[i+2 : i+n-1]
if final == 'M' && len(params) > 0 && params[0] == '<' {
if step, ok := parseSGRMouseWheel(params[1:]); ok {
pendingViewportDelta += step
i += n
continue
}
}
}
// Legacy X10 mouse wheel: `CSI M Cb Cx Cy`.
if i+5 < len(chunk) && chunk[i+1] == '[' && chunk[i+2] == 'M' {
cb := chunk[i+3]
switch cb {
case 96, 100, 112:
pendingViewportDelta -= 3
i += 6
continue
case 97, 101, 113:
pendingViewportDelta += 3
i += 6
continue
}
}
}
forward = append(forward, b)
i++
@@ -979,12 +1450,78 @@ func (st *uiState) processStdin(chunk []byte) {
if pendingAction != nil {
st.closePalette(*pendingAction)
}
if pendingNavID != "" {
st.focusProcess(pendingNavID)
if !pendingNav.empty() {
switch {
case pendingNav.isPad():
st.focusScratchpad(pendingNav.pad)
case pendingNav.isChild():
st.focusProcess(pendingNav.childID)
}
}
if pendingRestartID != "" {
st.restartFocusedCommand(pendingRestartID)
}
if pendingViewportDelta != 0 {
st.scrollFocusedViewport(pendingViewportDelta)
}
if pendingViewportBottom {
st.scrollFocusedViewportToBottom()
}
if pendingPadStep != 0 {
st.padScroll(pendingPadStep)
}
if pendingPadExit {
st.exitPadView()
}
}
// scrollFocusedViewport scrolls the focused child's emulator viewport by
// `delta` rows (negative is up into scrollback history, positive is down
// towards the active area) and repaints the main pane against the new
// snapshot. No-op if no child is focused or the emulator isn't live yet.
func (st *uiState) scrollFocusedViewport(delta int) {
st.mu.Lock()
id := st.focusedID
st.mu.Unlock()
if id == "" {
return
}
c := st.sess.FindChild(id)
if c == nil {
return
}
em := c.Emulator()
if em == nil {
return
}
if err := em.ScrollViewportDelta(delta); err != nil {
return
}
st.repaintFocused()
}
// scrollFocusedViewportToBottom snaps the focused child's emulator
// viewport back to the active (live) area. Bound to Ctrl-B as the escape
// hatch from a scrolled-up state.
func (st *uiState) scrollFocusedViewportToBottom() {
st.mu.Lock()
id := st.focusedID
st.mu.Unlock()
if id == "" {
return
}
c := st.sess.FindChild(id)
if c == nil {
return
}
em := c.Emulator()
if em == nil {
return
}
if err := em.ScrollViewportBottom(); err != nil {
return
}
st.repaintFocused()
}
func (st *uiState) openPaletteLocked() {
@@ -1173,7 +1710,7 @@ func (st *uiState) repaintFocused() {
st.mu.Lock()
if st.focusedID == id {
st.repaintNextPTY = id
st.repaintNextPTYBudget = 8
st.repaintNextPTYBudget = 2
}
st.mu.Unlock()
st.outMu.Lock()
@@ -1181,6 +1718,149 @@ func (st *uiState) repaintFocused() {
_, _ = os.Stdout.Write(out)
}
// repaintFocusedPad paints the focused scratchpad's content into the
// main viewport, honouring the per-pad scroll offset and clamping it
// to the rendered body size so a shrunk pad doesn't leave the view
// scrolled past its last line.
func (st *uiState) repaintFocusedPad() {
st.mu.Lock()
name := st.focusedPad
st.mu.Unlock()
if name == "" {
return
}
layout := st.layoutSnapshot()
content, _, err := st.pads.Read(name)
if err != nil {
content = fmt.Sprintf("(scratchpad %q unreadable: %v)", name, err)
}
out := st.renderPadView(name, content, layout)
if len(out) == 0 {
return
}
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write(out)
}
// renderPadView builds the bytes that paint a scratchpad's content
// into the main viewport. Title row, divider, then a markdown-rendered
// body windowed by the per-pad scroll offset. Caller owns outMu and
// any prior clearViewportArea.
func (st *uiState) renderPadView(name, content string, layout terminalLayout) []byte {
mainBottom := int(layout.statusRow) - statusRows
width := int(layout.childCols())
if mainBottom < int(layout.mainTop) || width < 1 {
return nil
}
bodyCols := width - 1
if bodyCols < 1 {
bodyCols = 1
}
rendered := renderMarkdownLines(content, bodyCols)
bodyRows := mainBottom - int(layout.mainTop) + 1 - 2
if bodyRows < 1 {
bodyRows = 1
}
maxOffset := len(rendered) - bodyRows
if maxOffset < 0 {
maxOffset = 0
}
st.mu.Lock()
if st.padOffset > maxOffset {
st.padOffset = maxOffset
}
if st.padOffset < 0 {
st.padOffset = 0
}
offset := st.padOffset
st.mu.Unlock()
var b strings.Builder
fmt.Fprintf(&b, "\x1b[0m\x1b[?6l\x1b[%d;%dr\x1b[?25l\x1b[%d;%dH",
int(layout.mainTop), mainBottom,
int(layout.mainTop), int(layout.mainLeft))
row := int(layout.mainTop)
writeRow := func(prefix, body, style string) {
if row > mainBottom {
return
}
fmt.Fprintf(&b, "\x1b[%d;%dH\x1b[%dX", row, int(layout.mainLeft), width)
fmt.Fprintf(&b, "\x1b[%d;%dH%s", row, int(layout.mainLeft), style)
b.WriteString(prefix)
b.WriteString(body)
b.WriteString(styleReset)
row++
}
// Header tells the user which pad they're viewing and the scroll
// position so a partial view is obvious.
end := offset + bodyRows
if end > len(rendered) {
end = len(rendered)
}
title := fmt.Sprintf(" %s (%d-%d / %d · ↑/↓ PgUp/PgDn · Esc back)",
name, offset+1, end, len(rendered))
if len(rendered) == 0 {
title = fmt.Sprintf(" %s (empty · Esc back)", name)
}
writeRow("", title, styleActive+styleBold)
if width > 2 {
writeRow("", " "+strings.Repeat("─", width-2), styleBorder)
} else {
writeRow("", strings.Repeat("─", width), styleBorder)
}
for i := offset; i < end; i++ {
writeRow(" ", rendered[i], "")
}
for row <= mainBottom {
writeRow("", "", "")
}
return []byte(b.String())
}
// exitPadView leaves scratchpad focus and falls back to the first
// running top-level child, or an empty viewport if there is none. No-op
// when no pad is focused.
func (st *uiState) exitPadView() {
st.mu.Lock()
if st.focusedPad == "" {
st.mu.Unlock()
return
}
st.focusedPad = ""
st.focusedName = ""
st.mu.Unlock()
st.clearViewportArea()
if next := firstRunningTopLevel(st.sess.Children()); next != nil {
st.focusProcess(next.ID)
return
}
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
}
// padScroll moves the focused-pad viewport by delta rows (negative =
// up, positive = down). No-op if no pad is focused. Clamping is
// performed against the rendered row count inside renderPadView, so
// callers can pass arbitrarily large step values for "jump to end".
func (st *uiState) padScroll(delta int) {
st.mu.Lock()
if st.focusedPad == "" {
st.mu.Unlock()
return
}
st.padOffset += delta
if st.padOffset < 0 {
st.padOffset = 0
}
st.mu.Unlock()
st.repaintFocusedPad()
}
func (st *uiState) renderFocusedSnapshot(id string, renderer *viewportRenderer, layout terminalLayout) []byte {
text, cursor, err := st.sess.SnapshotChild(id)
if err != nil {

View File

@@ -1,6 +1,7 @@
package app
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
@@ -108,10 +109,14 @@ type Child struct {
// ringMu guards ring. The ring buffer carries the last `ringCap`
// bytes the PTY produced, used by SPEC §7 get_process_output stream
// mode and search_output scrollback.
// mode and search_output scrollback. The ring is a fixed-size byte
// array with a wrap-around write index — no per-chunk reslice or
// reallocation. StreamRead serves contiguous slices by copying out
// of the (possibly wrapped) ring into a fresh buffer.
ringMu sync.Mutex
ring []byte
ringStart int64 // absolute offset of ring[0]
ring []byte // length == ringCap once allocated
ringPos int // next byte to overwrite
ringFull bool // true once ringWrites ≥ ringCap
ringWrites int64 // cumulative bytes written
// portsMu guards ports. Best-effort port detection: regex on stream.
@@ -127,11 +132,37 @@ type Child struct {
// exits and calls Start to bring the entry back up. Cleared when the
// user explicitly kills the process from the palette.
autoRestart atomic.Bool
// persistFn is set by Session after Spawn registers the entry. The
// callback mirrors mutable bits (name, auto-restart) into the
// persist store so a restarted patterm can rebuild this entry. Nil
// when no persist store is attached (unit tests / non-command
// entries).
persistMu sync.Mutex
persistFn func(*Child)
}
func (c *Child) SetAutoRestart(v bool) { c.autoRestart.Store(v) }
func (c *Child) SetAutoRestart(v bool) {
c.autoRestart.Store(v)
c.firePersist()
}
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
func (c *Child) setPersistFn(fn func(*Child)) {
c.persistMu.Lock()
c.persistFn = fn
c.persistMu.Unlock()
}
func (c *Child) firePersist() {
c.persistMu.Lock()
fn := c.persistFn
c.persistMu.Unlock()
if fn != nil {
fn(c)
}
}
// PortSighting is one entry returned by get_process_ports.
type PortSighting struct {
Port int `json:"port"`
@@ -152,7 +183,7 @@ func newChildEntry(id, name string, kind ChildKind, argv, env []string, parentID
Kind: kind,
ParentID: parentID,
PresetRef: presetRef,
ring: make([]byte, 0, ringCap),
ring: make([]byte, ringCap),
}
st := StatusStopped
c.status.Store(&st)
@@ -254,6 +285,7 @@ func (c *Child) SetName(name string) {
c.nameMu.Lock()
c.Name = name
c.nameMu.Unlock()
c.firePersist()
}
// ScreenVersion returns the current emulator snapshot version, bumped
@@ -302,13 +334,22 @@ func (c *Child) recordWrite(chunk []byte) {
c.lastWriteNS.Store(time.Now().UnixNano())
c.screenVersion.Add(1)
c.ringMu.Lock()
c.ring = append(c.ring, chunk...)
c.ringWrites += int64(len(chunk))
if len(c.ring) > ringCap {
drop := len(c.ring) - ringCap
c.ring = c.ring[drop:]
c.ringStart += int64(drop)
// Chunks larger than ringCap are tail-truncated — only the last
// ringCap bytes of the chunk can survive.
src := chunk
if len(src) > ringCap {
src = src[len(src)-ringCap:]
}
for written := 0; written < len(src); {
n := copy(c.ring[c.ringPos:], src[written:])
c.ringPos += n
if c.ringPos >= ringCap {
c.ringPos = 0
c.ringFull = true
}
written += n
}
c.ringWrites += int64(len(chunk))
c.ringMu.Unlock()
c.scanPortsFromChunk(chunk)
}
@@ -316,6 +357,11 @@ func (c *Child) recordWrite(chunk []byte) {
// scanPortsFromChunk does best-effort port detection on a PTY chunk.
// SPEC §7 get_process_ports — no probing, just stream scanning.
func (c *Child) scanPortsFromChunk(chunk []byte) {
// Cheap prefix check: most chunks don't contain a URL. Bail before
// running the regex DFA over the whole chunk.
if !bytes.Contains(chunk, []byte("http")) {
return
}
matches := portRegex.FindAllSubmatch(chunk, -1)
if len(matches) == 0 {
return
@@ -364,16 +410,38 @@ func (c *Child) Ports() []PortSighting {
func (c *Child) StreamRead(since int64) ([]byte, int64) {
c.ringMu.Lock()
defer c.ringMu.Unlock()
if since < c.ringStart {
since = c.ringStart
end := c.ringWrites
var ringStart int64
if c.ringFull {
ringStart = end - int64(ringCap)
}
if since < ringStart {
since = ringStart
}
end := c.ringStart + int64(len(c.ring))
if since >= end {
return nil, end
}
start := int(since - c.ringStart)
out := make([]byte, end-since)
copy(out, c.ring[start:])
n := int(end - since)
out := make([]byte, n)
// Locate `since` in the ring. When the buffer hasn't wrapped yet,
// bytes 0..ringPos hold writes 0..ringPos. After wrap, ringPos
// points at the oldest byte, and the freshest byte is at
// (ringPos - 1) mod ringCap.
var pos int
if c.ringFull {
skip := int(since - ringStart) // bytes after the oldest
pos = (c.ringPos + skip) % ringCap
} else {
pos = int(since)
}
first := ringCap - pos
if first > n {
first = n
}
copy(out, c.ring[pos:pos+first])
if first < n {
copy(out[first:], c.ring[:n-first])
}
return out, end
}
@@ -395,19 +463,17 @@ func (c *Child) signal(sig syscall.Signal) error {
// NudgeRedraw asks the child to throw away any diff-based render state
// and emit a full frame on the next tick. Used after a focus switch so
// ratatui/ink TUIs re-render coherently against the snapshot we just
// replayed. We toggle the PTY size by one row so the kernel reliably
// emits SIGWINCH (TIOCSWINSZ skips the signal if the size didn't
// change), then send SIGWINCH explicitly for TUIs that miss or coalesce
// the size-toggled signal. The emulator is left alone — it already
// matches our intended size and the brief mismatch only affects what the
// child writes during the second redraw.
// replayed. Sends an explicit SIGWINCH; TIOCSWINSZ with the same size
// is a no-op in the kernel, so an explicit signal is what most TUIs
// actually act on anyway. Avoid resize-toggles here — under a drag-
// resize the kernel still emits intermediate SIGWINCHes against the
// host PTY and toggling our child's size on top produces inconsistent
// grid state.
func (c *Child) NudgeRedraw(cols, rows uint16) {
pty := c.PTY()
if pty == nil || rows < 2 {
return
}
_ = pty.Resize(cols, rows-1)
_ = pty.Resize(cols, rows)
_ = c.signal(syscall.SIGWINCH)
}

View File

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

View File

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

View File

@@ -378,7 +378,7 @@ func (h *toolHost) GetProcessOutput(callerID, processID, mode string, sinceOffse
return out, nil
case "stream":
b, end := c.StreamRead(sinceOffset)
out.Content = stripANSI(string(b))
out.Content = string(stripANSIBytes(nil, b))
out.NewOffset = end
return out, nil
default:
@@ -409,10 +409,10 @@ func (h *toolHost) SearchOutput(callerID, processID, pattern, kind string, limit
return mcp.SearchResult{}, mcp.Errorf(mcp.ErrorKindInvalidArgs, "regex: %v", err)
}
b, _ := c.StreamRead(0)
text := string(b)
if kind == "rendered" {
text = stripANSI(text)
b = stripANSIBytes(nil, b)
}
text := string(b)
lines := strings.Split(text, "\n")
matches := make([]mcp.SearchMatch, 0, limit)
truncated := false
@@ -440,10 +440,19 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
if scope == "" {
scope = "grid"
}
if scope != "grid" && scope != "scrollback" {
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
}
deadline := time.Now().Add(time.Duration(timeoutSeconds * float64(time.Second)))
tick := time.NewTicker(50 * time.Millisecond)
defer tick.Stop()
for {
// chunkWake fires on every PTY chunk for the target child. The
// fallback timer guarantees we still re-check on grid-only sweeps
// where the cursor position changed without a fresh chunk landing.
wake := newChunkNotifier(c.ID)
h.sess.Subscribe(wake)
defer h.sess.Unsubscribe(wake)
check := func() (bool, string) {
text := ""
switch scope {
case "grid":
@@ -454,23 +463,75 @@ func (h *toolHost) WaitForPattern(callerID, processID, pattern string, timeoutSe
}
case "scrollback":
b, _ := c.StreamRead(0)
text = stripANSI(string(b))
default:
return false, "", mcp.Errorf(mcp.ErrorKindInvalidArgs, "unknown scope %q (want grid|scrollback)", scope)
text = string(stripANSIBytes(nil, b))
}
if m := re.FindString(text); m != "" {
return true, m
}
return false, ""
}
if ok, m := check(); ok {
return true, m, nil
}
if time.Now().After(deadline) {
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "", nil
}
<-tick.C
// Long fallback tick — the chunk notifier wakes us promptly
// on fresh PTY output; the timer is only there for cases
// where grid state shifted without a new chunk.
wait := 500 * time.Millisecond
if remaining < wait {
wait = remaining
}
select {
case <-wake.fired:
case <-time.After(wait):
}
if ok, m := check(); ok {
return true, m, nil
}
if !c.IsLive() && c.Status() != StatusStopped {
return false, "", nil
}
}
}
// chunkNotifier is a one-shot-per-chunk wake channel listener.
// Registers via Session.Subscribe; emits a non-blocking signal on
// `fired` for every PTY chunk emitted by the target child. Used by
// WaitForPattern to avoid 50ms-tick polling of the entire ring/grid.
type chunkNotifier struct {
childID string
fired chan struct{}
}
func newChunkNotifier(childID string) *chunkNotifier {
return &chunkNotifier{childID: childID, fired: make(chan struct{}, 1)}
}
func (n *chunkNotifier) OnChildSpawned(*Child) {}
func (n *chunkNotifier) OnChildExited(c *Child) {
if c.ID != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
if id != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (h *toolHost) GetProcessPorts(callerID, processID string) ([]mcp.PortSighting, error) {
c := h.sess.FindChild(processID)
if c == nil {
@@ -887,6 +948,74 @@ func stripANSI(s string) string {
return ansiRegexp.ReplaceAllString(s, "")
}
// stripANSIBytes is the byte-slice form of stripANSI. Skips the
// string conversion and the regex DFA — useful when the caller will
// itself walk the result line-by-line (SearchOutput) or feed it to a
// pattern match (WaitForPattern scrollback). Recognises the same
// shapes the regex did:
// - `\x1b[ <params> <intermediate> <final-byte>` (CSI / SGR)
// - `\x1b<final-byte>` for `@..._` (one-byte escapes)
// - `\x07` (BEL)
//
// The dst slice is reused if cap is sufficient; the returned slice
// is what callers should use.
func stripANSIBytes(dst, src []byte) []byte {
if cap(dst) < len(src) {
dst = make([]byte, 0, len(src))
} else {
dst = dst[:0]
}
for i := 0; i < len(src); {
b := src[i]
if b == 0x07 {
i++
continue
}
if b != 0x1b {
dst = append(dst, b)
i++
continue
}
// ESC-led sequence.
if i+1 >= len(src) {
// Stranded ESC at end of buffer — drop it.
i++
continue
}
next := src[i+1]
if next != '[' {
// One-byte ESC sequence (`\x1b<final>` where final is
// `@..._` per the regex; we drop anything that follows).
if next >= 0x40 && next <= 0x5f {
i += 2
continue
}
// Anything else after ESC: drop the ESC, keep walking.
i++
continue
}
// CSI: parameters [0x30..0x3f]*, intermediate [0x20..0x2f]*,
// final [0x40..0x7e].
j := i + 2
for j < len(src) && src[j] >= 0x30 && src[j] <= 0x3f {
j++
}
for j < len(src) && src[j] >= 0x20 && src[j] <= 0x2f {
j++
}
if j < len(src) && src[j] >= 0x40 && src[j] <= 0x7e {
i = j + 1
continue
}
// Incomplete CSI — the regex form falls back to its
// `\x1b<final>` rule and matches `\x1b[` (`[` is 0x5b, inside
// 0x40..0x5f), consuming the two-byte prefix and leaving the
// pending params/intermediate bytes intact. Match that.
i += 2
}
return dst
}
// availableToolsForRole — SPEC §7 whoami exposes the list a caller can
// invoke from its current role. Sub-agents lose `spawn_agent` (§8
// two-level-tree rule).

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -12,9 +12,11 @@ import (
"fmt"
"os"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/hjbdev/patterm/internal/persist"
"github.com/hjbdev/patterm/internal/vt"
)
@@ -38,8 +40,25 @@ type Session struct {
// listeners is the set of UI listeners that want to hear about child
// lifecycle events (spawn/exit) — exactly one (the TUI) in v1.
// listeners is an atomic.Pointer to a frozen slice. Subscribe
// copy-on-writes the slice; emit* paths use a single atomic Load.
// This drops one mutex acquisition per PTY chunk on the hot path.
listenersMu sync.Mutex
listeners []ChildEventListener
listeners atomic.Pointer[[]ChildEventListener]
// persistStore records top-level command entries to a per-project
// JSON file so they can be re-spawned after patterm restarts.
// Optional; nil means "no persistence" (used by unit tests).
persistStore *persist.Store
}
// SetPersistStore attaches a process-persistence store. Future Spawn /
// Close / Rename / SetAutoRestart calls on top-level command entries
// will mirror the change into the store.
func (s *Session) SetPersistStore(p *persist.Store) {
s.mu.Lock()
s.persistStore = p
s.mu.Unlock()
}
// ChildEventListener is implemented by the TUI to react to lifecycle
@@ -65,32 +84,58 @@ func NewSession(projectDir, projectKey string) *Session {
func (s *Session) Subscribe(l ChildEventListener) {
s.listenersMu.Lock()
defer s.listenersMu.Unlock()
s.listeners = append(s.listeners, l)
prev := s.listenersSnapshot()
next := make([]ChildEventListener, 0, len(prev)+1)
next = append(next, prev...)
next = append(next, l)
s.listeners.Store(&next)
}
// Unsubscribe removes a previously-registered listener. Safe to call
// with a listener that wasn't registered (no-op).
func (s *Session) Unsubscribe(l ChildEventListener) {
s.listenersMu.Lock()
defer s.listenersMu.Unlock()
prev := s.listenersSnapshot()
if len(prev) == 0 {
return
}
next := make([]ChildEventListener, 0, len(prev))
for _, e := range prev {
if e != l {
next = append(next, e)
}
}
s.listeners.Store(&next)
}
// listenersSnapshot returns the frozen listener slice. Safe to call
// without the listeners mutex.
func (s *Session) listenersSnapshot() []ChildEventListener {
p := s.listeners.Load()
if p == nil {
return nil
}
return *p
}
func (s *Session) emitSpawn(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
for _, l := range s.listenersSnapshot() {
l.OnChildSpawned(c)
}
}
func (s *Session) emitExit(c *Child) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
for _, l := range s.listenersSnapshot() {
l.OnChildExited(c)
}
}
// emitPTYOut dispatches a fresh PTY chunk to every listener. Listeners
// MUST NOT retain `chunk` past return — the slice is owned by the
// pumpChild read buffer and is overwritten on the next read.
func (s *Session) emitPTYOut(id string, chunk []byte) {
s.listenersMu.Lock()
ls := append([]ChildEventListener(nil), s.listeners...)
s.listenersMu.Unlock()
for _, l := range ls {
for _, l := range s.listenersSnapshot() {
l.OnPTYOut(id, chunk)
}
}
@@ -162,14 +207,67 @@ func (s *Session) Spawn(spec SpawnSpec, cols, rows uint16) (*Child, error) {
s.mu.Lock()
s.children[id] = c
s.order = append(s.order, id)
store := s.persistStore
s.mu.Unlock()
// Wire persistence callback BEFORE registering so SetName /
// SetAutoRestart calls that race the listener still hit the store.
if store != nil {
c.setPersistFn(func(ch *Child) {
s.persistEntry(ch)
})
s.persistEntry(c)
}
s.emitSpawn(c)
go s.pumpChild(c, runID)
go s.reapChild(c, runID)
return c, nil
}
// persistEntry writes (or refreshes) the persist record for c if it
// qualifies — top-level command entries only. No-op when no store is
// attached.
func (s *Session) persistEntry(c *Child) {
s.mu.Lock()
store := s.persistStore
s.mu.Unlock()
if store == nil || !shouldPersist(c) {
return
}
e := persist.Entry{
ID: c.ID,
Name: c.DisplayName(),
Argv: append([]string(nil), c.Argv...),
WorkDir: c.WorkDir,
PresetRef: c.PresetRef,
AutoRestart: c.AutoRestart(),
}
if err := store.Save(e); err != nil {
logf("persist save %s: %v", c.ID, err)
}
}
func (s *Session) forgetPersisted(id string) {
s.mu.Lock()
store := s.persistStore
s.mu.Unlock()
if store == nil {
return
}
if err := store.Remove(id); err != nil {
logf("persist remove %s: %v", id, err)
}
}
// shouldPersist gates which Child entries get mirrored into the
// persist store. v1 only restores top-level command entries — agents
// and terminals are ephemeral by design, and sub-agent-spawned
// commands belong to their orchestrator's lifecycle.
func shouldPersist(c *Child) bool {
return c != nil && c.Kind == KindCommand && c.ParentID == ""
}
// Start (re)attaches a PTY to an entry that is currently stopped or
// exited. Errors if the entry is already live.
func (s *Session) Start(id string, cols, rows uint16) error {
@@ -238,6 +336,7 @@ func (s *Session) Close(id string, sig syscall.Signal) error {
}
}
s.mu.Unlock()
s.forgetPersisted(id)
return nil
}
@@ -257,6 +356,12 @@ func (s *Session) pumpChild(c *Child, runID uint64) {
if pty == nil {
return
}
// One PTY read buffer per pump goroutine. Consumers downstream
// (em.Write is synchronous through CGO; recordWrite append-copies
// into the ring; renderer.Render copies into its pending buffer)
// all complete or copy before returning, so the buffer can be
// reused without aliasing live data. See ChildEventListener.OnPTYOut
// docstring — listeners must not retain `chunk`.
buf := make([]byte, 64*1024)
for {
n, err := pty.Read(buf)
@@ -264,8 +369,7 @@ func (s *Session) pumpChild(c *Child, runID uint64) {
if !c.isCurrentRun(runID) {
return
}
chunk := make([]byte, n)
copy(chunk, buf[:n])
chunk := buf[:n]
if em := c.Emulator(); em != nil {
if _, werr := em.Write(chunk); werr != nil {
logf("emulator.Write(child %s): %v", c.ID, werr)

View File

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

View File

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

View File

@@ -17,6 +17,8 @@ type viewportRenderer struct {
col int
scrollTop int
scrollBottom int
originMode bool
lrMarginMode bool
state viewportState
buf []byte
@@ -75,8 +77,40 @@ func (vr *viewportRenderer) Render(in []byte) []byte {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.pending.Reset()
for _, b := range in {
vr.feed(b)
// Fast path: while we're in vpNormal and have a run of plain ASCII
// printables that fit the remaining column budget, copy en bloc
// instead of round-tripping each byte through the feed state
// machine. UTF-8 leaders and any control byte fall back to the
// per-byte path so the cursor/skipUTF8/clamp logic stays exact.
for i := 0; i < len(in); {
if vr.state == vpNormal {
maxCol := int(vr.layout.childCols())
if maxCol > 0 && vr.col >= 1 && vr.col <= maxCol {
budget := maxCol - vr.col + 1
j := i
for j < len(in) && budget > 0 {
b := in[j]
// Pure ASCII printables only — any control byte
// (0x1b ESC included), UTF-8 leader, or trailer
// kicks back to the state machine.
if b < 0x20 || b == 0x7f || b >= 0x80 {
break
}
j++
budget--
}
if j-i >= 4 {
vr.pending.Write(in[i:j])
vr.col += j - i
vr.skipUTF8 = false
vr.clampCursor()
i = j
continue
}
}
}
vr.feed(in[i])
i++
}
return []byte(vr.pending.String())
}
@@ -192,12 +226,53 @@ func (vr *viewportRenderer) emitCSI() {
params := vr.buf[2 : len(vr.buf)-1]
if final == 'h' || final == 'l' {
if isOriginMode(params) {
vr.setOriginMode(final == 'h')
vr.emitCursorPosition(vr.row, vr.col)
return
}
if isLeftRightMarginMode(params) {
vr.lrMarginMode = final == 'h'
return
}
if isAltScreenMode(params) {
return
}
if isMouseTrackingMode(params) {
// Patterm owns mouse reporting on the host so wheel events keep
// flowing for scroll-viewport. The child's own emulator still
// observes the mode set/reset (it processes the same bytes we
// hand to ghostty_terminal_vt_write), so we know whether the
// child wants mouse input — we just don't let it disarm our
// host listener.
return
}
}
if final == 's' && vr.lrMarginMode {
return
}
switch final {
case 'H', 'f':
r, c, ok := parseTwoParams(params)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
vr.row = vr.originRow(r)
vr.col = c
vr.emitCursorPosition(vr.row, c)
vr.clampCursor()
case 'd':
r, ok := parseOneParam(params, 1)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
vr.row = vr.originRow(r)
vr.pending.Write(vr.shifter.Shift([]byte(fmt.Sprintf("\x1b[%dd", vr.row))))
vr.clampCursor()
case 'J':
n, ok := parseOneParam(params, 0)
if !ok {
@@ -230,10 +305,85 @@ func (vr *viewportRenderer) emitCSI() {
// the sidebar is repainted afterwards.
vr.pending.Write(vr.shifter.Shift(vr.buf))
vr.scrolled = true
case 'r':
vr.pending.Write(vr.shifter.Shift(vr.buf))
if vr.trackScrollRegion(params) {
vr.emitHomeAfterScrollRegion()
}
case 'A', 'B', 'E', 'F':
// Relative cursor moves: CUU (A) / CUD (B) / CNL (E) / CPL (F).
// The cursor shifter only rewrites absolute positioning, so a
// child that asks the cursor to "go up 50" from viewport row 1
// would walk the host cursor into the tab bar (and the next
// printable would write there). Clamp the step using the
// renderer's tracked row so the host cursor stays inside the
// viewport. E / F additionally home the column to 1.
vr.emitRelativeRowMove(final, params)
return
default:
vr.pending.Write(vr.shifter.Shift(vr.buf))
}
if final != 'H' && final != 'f' && final != 'd' && final != 'r' {
vr.trackCSI(final, params)
}
}
// emitRelativeRowMove rewrites CSI A / B / E / F so the resulting host
// cursor stays within rows 1..childRows in viewport coordinates. The
// renderer already tracks vr.row for clear-line bookkeeping; reusing
// that here avoids a second cursor model. n is normalized — a step of
// 0 is treated as 1 to match xterm. After clamping, if the effective
// step is zero we drop the sequence (the cursor is already pinned to
// the boundary). E / F also move the cursor to column 1 even when no
// row step is emitted.
func (vr *viewportRenderer) emitRelativeRowMove(final byte, params []byte) {
n, ok := parseOneParam(params, 1)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
if n <= 0 {
n = 1
}
rows := int(vr.layout.childRows())
if rows < 1 {
rows = 1
}
row := vr.row
if row < 1 {
row = 1
}
if row > rows {
row = rows
}
up := final == 'A' || final == 'F'
var safe int
if up {
safe = row - 1
} else {
safe = rows - row
}
if safe < 0 {
safe = 0
}
if n > safe {
n = safe
}
if n > 0 {
if up {
vr.row -= n
} else {
vr.row += n
}
fmt.Fprintf(&vr.pending, "\x1b[%d%c", n, final)
}
if final == 'E' || final == 'F' {
// CNL / CPL anchor the column at 1 regardless of whether the
// row step was clamped to zero, matching xterm.
vr.col = 1
vr.pending.WriteByte('\r')
}
vr.clampCursor()
}
func isAltScreenMode(params []byte) bool {
@@ -250,6 +400,52 @@ func isAltScreenMode(params []byte) bool {
return false
}
func isOriginMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
if p == "6" {
return true
}
}
return false
}
func isLeftRightMarginMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
if p == "69" {
return true
}
}
return false
}
// isMouseTrackingMode reports whether any of the modes in a CSI ? … h/l
// is a mouse-tracking or mouse-encoding DEC private mode. The host runs
// with SGR mouse reporting permanently armed; we drop the child's set/
// reset for these modes from the host stream so wheel events keep
// reaching patterm.
func isMouseTrackingMode(params []byte) bool {
s := string(params)
if !strings.HasPrefix(s, "?") {
return false
}
for _, p := range strings.Split(strings.TrimPrefix(s, "?"), ";") {
switch p {
case "9", "1000", "1001", "1002", "1003", "1004",
"1005", "1006", "1007", "1015", "1016":
return true
}
}
return false
}
func (vr *viewportRenderer) clearViewport() string {
var b strings.Builder
b.WriteString("\x1b7")
@@ -339,6 +535,53 @@ func (vr *viewportRenderer) resetScrollRegion() {
}
}
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
@@ -426,7 +669,7 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
case 'H', 'f':
r, c, ok := parseTwoParams(params)
if ok {
vr.row, vr.col = r, c
vr.row, vr.col = vr.originRow(r), c
}
case 'G', '`':
c, ok := parseOneParam(params, 1)
@@ -436,7 +679,7 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
case 'd':
r, ok := parseOneParam(params, 1)
if ok {
vr.row = r
vr.row = vr.originRow(r)
}
case 'A':
n, ok := parseOneParam(params, 1)
@@ -459,19 +702,21 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
vr.col -= n
}
case 'r':
vr.trackScrollRegion(params)
if vr.trackScrollRegion(params) {
vr.homeAfterScrollRegion()
}
}
vr.clampCursor()
}
func (vr *viewportRenderer) trackScrollRegion(params []byte) {
func (vr *viewportRenderer) trackScrollRegion(params []byte) bool {
if len(params) == 0 {
vr.resetScrollRegion()
return
return true
}
top, bottom, ok := parseTwoParams(params)
if !ok {
return
return false
}
maxRows := int(vr.layout.childRows())
if maxRows < 1 {
@@ -484,10 +729,11 @@ func (vr *viewportRenderer) trackScrollRegion(params []byte) {
bottom = maxRows
}
if top >= bottom {
return
return false
}
vr.scrollTop = top
vr.scrollBottom = bottom
return true
}
func (vr *viewportRenderer) clampCursor() {

View File

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

View File

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

View File

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

View File

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

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

@@ -57,6 +57,15 @@ type Emulator interface {
// ActiveScreen reports whether we are on the primary or alternate buffer.
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
// to write bytes back to the PTY master (e.g. responses to DA / DSR
// queries). The callback runs synchronously inside Write and must not

View File

@@ -81,6 +81,27 @@ static GhosttyResult patterm_set_userdata(GhosttyTerminal t, uintptr_t ud) {
(const void *)ud);
}
static void patterm_scroll_viewport_top(GhosttyTerminal t) {
GhosttyTerminalScrollViewport beh;
beh.tag = GHOSTTY_SCROLL_VIEWPORT_TOP;
beh.value.delta = 0;
ghostty_terminal_scroll_viewport(t, beh);
}
static void patterm_scroll_viewport_bottom(GhosttyTerminal t) {
GhosttyTerminalScrollViewport beh;
beh.tag = GHOSTTY_SCROLL_VIEWPORT_BOTTOM;
beh.value.delta = 0;
ghostty_terminal_scroll_viewport(t, beh);
}
static void patterm_scroll_viewport_delta(GhosttyTerminal t, intptr_t d) {
GhosttyTerminalScrollViewport beh;
beh.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA;
beh.value.delta = d;
ghostty_terminal_scroll_viewport(t, beh);
}
static GhosttyFormatterTerminalOptions patterm_plain_fmt_opts(void) {
GhosttyFormatterTerminalOptions opts = GHOSTTY_INIT_SIZED(GhosttyFormatterTerminalOptions);
opts.emit = GHOSTTY_FORMATTER_FORMAT_PLAIN;
@@ -161,7 +182,7 @@ func NewGhosttyEmulator(cols, rows uint16) (*GhosttyEmulator, error) {
opts := C.GhosttyTerminalOptions{
cols: C.uint16_t(cols),
rows: C.uint16_t(rows),
max_scrollback: 0,
max_scrollback: 5000,
}
if rc := C.ghostty_terminal_new(nil, &e.term, opts); rc != C.GHOSTTY_SUCCESS {
@@ -539,6 +560,39 @@ func (e *GhosttyEmulator) ActiveScreen() (Screen, error) {
return ScreenPrimary, nil
}
// ScrollViewportTop scrolls the viewport to the top of the scrollback.
func (e *GhosttyEmulator) ScrollViewportTop() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("vt: emulator closed")
}
C.patterm_scroll_viewport_top(e.term)
return nil
}
// ScrollViewportBottom scrolls the viewport to the bottom (active area).
func (e *GhosttyEmulator) ScrollViewportBottom() error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("vt: emulator closed")
}
C.patterm_scroll_viewport_bottom(e.term)
return nil
}
// ScrollViewportDelta scrolls the viewport by `delta` rows. Negative is up.
func (e *GhosttyEmulator) ScrollViewportDelta(delta int) error {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed {
return errors.New("vt: emulator closed")
}
C.patterm_scroll_viewport_delta(e.term, C.intptr_t(delta))
return nil
}
func (e *GhosttyEmulator) OnWritePTY(fn func([]byte)) {
if fn == nil {
e.onWrite.Store(nil)

View File

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