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

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
package app
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
@@ -108,11 +109,15 @@ type Child struct {
// ringMu guards ring. The ring buffer carries the last `ringCap`
// bytes the PTY produced, used by SPEC §7 get_process_output stream
// mode and search_output scrollback.
// mode and search_output scrollback. The ring is a fixed-size byte
// array with a wrap-around write index — no per-chunk reslice or
// reallocation. StreamRead serves contiguous slices by copying out
// of the (possibly wrapped) ring into a fresh buffer.
ringMu sync.Mutex
ring []byte
ringStart int64 // absolute offset of ring[0]
ringWrites int64 // cumulative bytes written
ring []byte // length == ringCap once allocated
ringPos int // next byte to overwrite
ringFull bool // true once ringWrites ≥ ringCap
ringWrites int64 // cumulative bytes written
// portsMu guards ports. Best-effort port detection: regex on stream.
portsMu sync.Mutex
@@ -127,10 +132,36 @@ type Child struct {
// exits and calls Start to bring the entry back up. Cleared when the
// user explicitly kills the process from the palette.
autoRestart atomic.Bool
// persistFn is set by Session after Spawn registers the entry. The
// callback mirrors mutable bits (name, auto-restart) into the
// persist store so a restarted patterm can rebuild this entry. Nil
// when no persist store is attached (unit tests / non-command
// entries).
persistMu sync.Mutex
persistFn func(*Child)
}
func (c *Child) SetAutoRestart(v bool) { c.autoRestart.Store(v) }
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
func (c *Child) SetAutoRestart(v bool) {
c.autoRestart.Store(v)
c.firePersist()
}
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
func (c *Child) setPersistFn(fn func(*Child)) {
c.persistMu.Lock()
c.persistFn = fn
c.persistMu.Unlock()
}
func (c *Child) firePersist() {
c.persistMu.Lock()
fn := c.persistFn
c.persistMu.Unlock()
if fn != nil {
fn(c)
}
}
// PortSighting is one entry returned by get_process_ports.
type PortSighting struct {
@@ -152,7 +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, nil
return true, m
}
if time.Now().After(deadline) {
return false, ""
}
if ok, m := check(); ok {
return true, m, nil
}
for {
remaining := time.Until(deadline)
if remaining <= 0 {
return false, "", nil
}
<-tick.C
// Long fallback tick — the chunk notifier wakes us promptly
// on fresh PTY output; the timer is only there for cases
// where grid state shifted without a new chunk.
wait := 500 * time.Millisecond
if remaining < wait {
wait = remaining
}
select {
case <-wake.fired:
case <-time.After(wait):
}
if ok, m := check(); ok {
return true, m, nil
}
if !c.IsLive() && c.Status() != StatusStopped {
return false, "", nil
}
}
}
// chunkNotifier is a one-shot-per-chunk wake channel listener.
// Registers via Session.Subscribe; emits a non-blocking signal on
// `fired` for every PTY chunk emitted by the target child. Used by
// WaitForPattern to avoid 50ms-tick polling of the entire ring/grid.
type chunkNotifier struct {
childID string
fired chan struct{}
}
func newChunkNotifier(childID string) *chunkNotifier {
return &chunkNotifier{childID: childID, fired: make(chan struct{}, 1)}
}
func (n *chunkNotifier) OnChildSpawned(*Child) {}
func (n *chunkNotifier) OnChildExited(c *Child) {
if c.ID != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (n *chunkNotifier) OnPTYOut(id string, chunk []byte) {
if id != n.childID {
return
}
select {
case n.fired <- struct{}{}:
default:
}
}
func (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))
}
vr.trackCSI(final, params)
if final != 'H' && final != 'f' && final != 'd' && final != 'r' {
vr.trackCSI(final, params)
}
}
// emitRelativeRowMove rewrites CSI A / B / E / F so the resulting host
// cursor stays within rows 1..childRows in viewport coordinates. The
// renderer already tracks vr.row for clear-line bookkeeping; reusing
// that here avoids a second cursor model. n is normalized — a step of
// 0 is treated as 1 to match xterm. After clamping, if the effective
// step is zero we drop the sequence (the cursor is already pinned to
// the boundary). E / F also move the cursor to column 1 even when no
// row step is emitted.
func (vr *viewportRenderer) emitRelativeRowMove(final byte, params []byte) {
n, ok := parseOneParam(params, 1)
if !ok {
vr.pending.Write(vr.shifter.Shift(vr.buf))
return
}
if n <= 0 {
n = 1
}
rows := int(vr.layout.childRows())
if rows < 1 {
rows = 1
}
row := vr.row
if row < 1 {
row = 1
}
if row > rows {
row = rows
}
up := final == 'A' || final == 'F'
var safe int
if up {
safe = row - 1
} else {
safe = rows - row
}
if safe < 0 {
safe = 0
}
if n > safe {
n = safe
}
if n > 0 {
if up {
vr.row -= n
} else {
vr.row += n
}
fmt.Fprintf(&vr.pending, "\x1b[%d%c", n, final)
}
if final == 'E' || final == 'F' {
// CNL / CPL anchor the column at 1 regardless of whether the
// row step was clamped to zero, matching xterm.
vr.col = 1
vr.pending.WriteByte('\r')
}
vr.clampCursor()
}
func isAltScreenMode(params []byte) bool {
@@ -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