Release v0.0.1
Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s

Bundles the in-flight work into the first tagged release. See
CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list.
Highlights:

- Sidebar / chrome stability: clamp absolute cursor positioning and
  printable bytes to the viewport so long-running TUIs (claude, codex)
  can't spray into the right rail; bound tab bar's row clear to the
  viewport width so the rail isn't wiped on every tab redraw; flag
  scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K`
  to viewport columns.
- Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill
  entries mark the focused tab, dead agents drop out of the switch
  list.
- Sidebar: split into Processes (session-wide) + Agent Tree
  (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the
  combined list, Ctrl+A/D steps tabs.
- MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`,
  `ping`), `mcp_injection.kind = cli_override / config_env` so codex
  and opencode pick up the server with no file writes, `lifecycle`
  help topic and tool-description cleanup-duty pointers.
- Lifecycle: orchestrator-spawned children cascade-killed when the
  parent dies; orchestrator-injected prompts end with CR + delayed
  Enter so claude submits cleanly.
This commit is contained in:
2026-05-14 22:04:32 +01:00
parent 63f0ddcb38
commit 52e06c914e
18 changed files with 1031 additions and 62 deletions

View File

@@ -6,7 +6,23 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.0.1] - 2026-05-14
### Fixed
- Tab bar redraw used `\x1b[2K` to clear rows 1 and 2 before painting
labels, which wiped the sidebar columns on those rows too. When the
sidebar cache was still warm the rail never repainted, leaving a
gap where the sidebar's top border and "Processes" header should be.
The clear is now bounded to the viewport width.
- Long-running TUIs (claude / codex) whose internal column state
drifted past the patterm viewport could spray text into the sidebar
columns — overwriting the session-tree and scratchpad rail until the
user opened/closed the palette to force a full repaint. The viewport
renderer now clamps absolute cursor positioning (CUP / HVP / CHA /
HPA) to the viewport's right edge and drops printable bytes (ASCII
and full UTF-8 glyphs) that would otherwise land past it. Covered by
a unit suite and a new `sidebar_survives_wide_writes` harness
scenario.
- Sub-agent panes spawned while another diff-based TUI (claude/codex/
opencode) held focus could come up corrupted because the new child's
first incremental updates targeted cells the host viewport hadn't
@@ -15,7 +31,31 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
styled emulator grid — the same path that fixed the symptom when the
user manually cycled focus with Ctrl+W / Ctrl+S.
### Changed
- Command palette `Kill …` entries now mark the focused tab with the
same "• … (current)" marker the `Switch to …` entries use, so the
user can tell at a glance which tab a kill action targets.
- Status line now advertises the navigation chords (`Ctrl-A/D · tabs`,
`Ctrl-W/S · tree`) alongside `Ctrl-K · palette`. Hints decay
shortest-first when the terminal is too narrow to fit all three.
### Added
- "Spawn process…" entry in the command palette opens a two-field form
for typing an arbitrary command line and ticking "Relaunch on exit".
The command runs through `sh -lc` so multi-word lines like
`bun run dev` resolve binaries the way an interactive shell would.
When the relaunch flag is set, patterm Starts the process again after
it exits (1s backoff). Killing the process from the palette clears
the flag so it does not come back.
- Dedicated "Processes" section in the sidebar above the agent tree,
listing every top-level command/terminal process. It is global to
the patterm session — switching between agent tabs no longer changes
which processes are visible. The relaunch-on-exit indicator (`⟳`)
shows next to processes the user opted into auto-restart for.
- Ctrl+W / Ctrl+S now traverse the combined Processes section and the
active agent tree as one flat list, so the user can step out of the
agent tree into the Processes pane and back without leaving the
keyboard.
- New `lifecycle` help topic spelling out that the caller owns the
processes it spawns and should call `close_process` when a sub-agent
or spawned process is no longer needed. The `spawn_agent` and
@@ -55,6 +95,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
available macros.
### Changed
- The sidebar's session-tree section is now labeled "Agent Tree" and
shows only agent sessions (and any sub-agents they spawn). Top-level
command and terminal processes live in the new "Processes" section
above it.
- Tab bar tabs now correspond to agent sessions only. Command/terminal
processes that previously claimed a top-level tab now appear in the
Processes sidebar section, so the tab strip is reserved for agent
context.
- Focus, lifecycle, and repaint paths now capture terminal layout before
taking UI state locks, reducing resize-time deadlock risk without
changing visible behavior.

View File

@@ -1,4 +1,4 @@
- [ ] There's a unicode <?> being displayed in opencode
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD]
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
most likely the *host* terminal's font fallback for opencode's

View File

@@ -11,6 +11,7 @@ import (
"sync"
"sync/atomic"
"syscall"
"time"
cpty "github.com/creack/pty"
"golang.org/x/term"
@@ -213,6 +214,11 @@ type uiState struct {
palette *paletteState
focusedID string
focusedName 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
// visible even when the user steps into the Processes pane.
activeAgentID string
// renderer confines focused-child live output to the main viewport.
// A fresh renderer is allocated per focused child so partial-escape
// state cannot bleed between panes.
@@ -283,6 +289,7 @@ func (st *uiState) focusProcess(processID string) {
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout)
st.mu.Unlock()
st.repaintFocused()
@@ -291,6 +298,33 @@ func (st *uiState) focusProcess(processID string) {
st.drawStatusLine()
}
// updateActiveAgentLocked records the active agent root for the agent
// tree section whenever focus lands on an agent or one of its
// sub-agents. Focusing a top-level command process leaves the previous
// active agent intact, so the user can hop between the Processes pane
// and the agent tree without losing context. Caller holds st.mu.
func (st *uiState) updateActiveAgentLocked(c *Child) {
if c.Kind != KindAgent {
return
}
if c.ParentID == "" {
st.activeAgentID = c.ID
return
}
// Walk up to the top-level agent.
root := c
for root.ParentID != "" {
parent := st.sess.FindChild(root.ParentID)
if parent == nil {
break
}
root = parent
}
if root.Kind == KindAgent && root.ParentID == "" {
st.activeAgentID = root.ID
}
}
// notifyAttention is the request_human_attention sink (SPEC §7). We
// surface a one-line toast in the status row and remember the most
// recent ask so the status line keeps showing it. The sidebar-blink is
@@ -321,6 +355,7 @@ func (st *uiState) OnChildSpawned(c *Child) {
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
renderer := newViewportRenderer(layout)
st.renderer = renderer
palOpen := st.palette != nil
@@ -377,9 +412,15 @@ func (st *uiState) OnChildExited(c *Child) {
} else {
st.focusedID = next.ID
st.focusedName = next.DisplayName()
st.updateActiveAgentLocked(next)
st.renderer = newViewportRenderer(layout)
}
}
if c.ID == st.activeAgentID {
// The active agent died; pin the agent tree to whatever agent
// root is still running, or clear it if none remain.
st.activeAgentID = firstRunningAgentID(st.sess.Children())
}
if st.palette != nil {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
@@ -397,6 +438,41 @@ func (st *uiState) OnChildExited(c *Child) {
st.drawTabBar()
st.drawSidebar()
st.drawStatusLine()
// Auto-restart kicks in for command entries the user marked "relaunch
// on exit". A short backoff (1s) avoids hot-spinning on processes
// that fail immediately. The user can clear the flag by killing the
// process from the palette.
if c.Kind == KindCommand && c.AutoRestart() {
go st.scheduleAutoRestart(c)
}
}
// scheduleAutoRestart re-Starts a command entry after a brief backoff.
// Bails out if the user cleared the flag, closed the process, or the
// entry came back to life through some other path while we were
// waiting. Called as a goroutine from OnChildExited.
func (st *uiState) scheduleAutoRestart(c *Child) {
time.Sleep(1 * time.Second)
if !c.AutoRestart() {
return
}
if st.sess.FindChild(c.ID) == nil {
return
}
if c.IsLive() {
return
}
l := st.layoutSnapshot()
if err := st.sess.Start(c.ID, l.childCols(), l.childRows()); err != nil {
st.dbgf("auto-restart %s: %v", c.ID, err)
return
}
// Start doesn't fire emitSpawn, so we have to nudge the chrome
// ourselves — the status flipped from exited back to running and
// the sidebar's cached frame still shows the exited glyph.
st.drawSidebar()
st.drawStatusLine()
}
// OnPTYOut writes live output for the focused child when the palette is
@@ -563,7 +639,18 @@ func (st *uiState) drawStatusLine() {
if trustMsg != "" {
left = "[trust] " + trustMsg
}
right := "Ctrl-K · palette"
// Hints decay shortest-first when the host is narrow so the focused
// child name + ownership note on the left side never get clipped.
hints := []string{
"Ctrl-A/D · tabs",
"Ctrl-W/S · tree",
"Ctrl-K · palette",
}
right := strings.Join(hints, " · ")
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
hints = hints[1:]
right = strings.Join(hints, " · ")
}
pad := int(cols) - len(left) - len(right)
if pad < 1 {
@@ -831,13 +918,13 @@ func (st *uiState) processStdin(chunk []byte) {
}
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
flushForward()
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, -1)
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, -1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
flushForward()
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, +1)
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, +1)
i += adv
break
}
@@ -916,6 +1003,36 @@ func (st *uiState) closePalette(action paletteAction) {
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
}
case "spawn-process-submit":
if action.command == "" {
st.repaintFocused()
return
}
l := st.layoutSnapshot()
st.launcher.SetSize(l.childCols(), l.childRows())
display := action.command
if len(display) > 32 {
display = display[:31] + "…"
}
// shell=true so multi-word commands like "bun run dev" pass
// through `sh -lc` and the user's PATH resolves binaries the
// way they expect from an interactive shell.
c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, "", "", nil, true)
if err != nil {
st.flashError(fmt.Sprintf("spawn: %v", err))
return
}
c.SetAutoRestart(action.relaunch)
// LaunchCommandArgv fires OnChildSpawned synchronously, which
// drew the sidebar before AutoRestart was set. Invalidate so the
// ⟳ marker shows up on the next paint.
if action.relaunch {
st.chromeCacheMu.Lock()
st.sidebarCache = ""
st.chromeCacheMu.Unlock()
st.drawSidebar()
}
case "switch":
c := st.sess.FindChild(action.childID)
if c == nil || c.Status() != StatusRunning {
@@ -926,6 +1043,7 @@ func (st *uiState) closePalette(action paletteAction) {
st.mu.Lock()
st.focusedID = action.childID
st.focusedName = c.DisplayName()
st.updateActiveAgentLocked(c)
st.renderer = newViewportRenderer(layout)
st.mu.Unlock()
st.repaintFocused()
@@ -934,6 +1052,11 @@ func (st *uiState) closePalette(action paletteAction) {
st.drawStatusLine()
case "kill":
// User-initiated kill cancels any pending auto-restart so the
// process doesn't immediately come back.
if c := st.sess.FindChild(action.childID); c != nil {
c.SetAutoRestart(false)
}
_ = st.sess.Kill(action.childID, syscall.SIGTERM)
st.repaintFocused()
st.drawTabBar()

View File

@@ -121,8 +121,17 @@ type Child struct {
cleanupMu sync.Mutex
cleanupPaths []string
restarting atomic.Bool
// autoRestart is set when the user spawned this command process with
// "relaunch on exit". The session listener consults it after the PTY
// exits and calls Start to bring the entry back up. Cleared when the
// user explicitly kills the process from the palette.
autoRestart atomic.Bool
}
func (c *Child) SetAutoRestart(v bool) { c.autoRestart.Store(v) }
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
// PortSighting is one entry returned by get_process_ports.
type PortSighting struct {
Port int `json:"port"`

View File

@@ -23,6 +23,7 @@ import (
type cursorShifter struct {
rowOffset int
childRows int // viewport height in child rows; used for DECSTBM resets
childCols int // viewport width in child cols; used to clamp CUP/HVP/CHA/HPA columns
state shifterState
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
@@ -45,13 +46,25 @@ const (
stSOSPMAPCEsc
)
func newCursorShifter(rowOffset, childRows int) *cursorShifter {
return &cursorShifter{rowOffset: rowOffset, childRows: childRows}
func newCursorShifter(rowOffset, childRows, childCols int) *cursorShifter {
return &cursorShifter{rowOffset: rowOffset, childRows: childRows, childCols: childCols}
}
func (cs *cursorShifter) SetGeometry(rowOffset, childRows int) {
func (cs *cursorShifter) SetGeometry(rowOffset, childRows, childCols int) {
cs.rowOffset = rowOffset
cs.childRows = childRows
cs.childCols = childCols
}
// clampCol returns col clamped to the viewport's rightmost column, so a
// child that drifted into believing it has more horizontal space than
// patterm assigned it can't reach into the sidebar. childCols == 0 (an
// uninitialised shifter, only seen in tests) disables clamping.
func (cs *cursorShifter) clampCol(col int) int {
if cs.childCols > 0 && col > cs.childCols {
return cs.childCols
}
return col
}
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
@@ -194,11 +207,24 @@ func (cs *cursorShifter) emitCSI() {
return
}
r += cs.rowOffset
c = cs.clampCol(c)
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(r))
cs.pending.WriteByte(';')
cs.pending.WriteString(strconv.Itoa(c))
cs.pending.WriteByte(final)
case 'G', '`':
// CHA / HPA: absolute column. Clamp to the viewport so a stale
// child width can't reach into the sidebar.
c, ok := parseOneParam(paramsRaw, 1)
if !ok {
cs.pending.Write(cs.buf)
return
}
c = cs.clampCol(c)
cs.pending.WriteString("\x1b[")
cs.pending.WriteString(strconv.Itoa(c))
cs.pending.WriteByte(final)
case 'd':
// VPA: row.
r, ok := parseOneParam(paramsRaw, 1)

View File

@@ -6,7 +6,7 @@ import (
)
func TestCursorShifterCUP(t *testing.T) {
cs := newCursorShifter(1, 36)
cs := newCursorShifter(1, 36, 80)
got := cs.Shift([]byte("\x1b[H"))
want := []byte("\x1b[2;1H")
if !bytes.Equal(got, want) {
@@ -15,7 +15,7 @@ func TestCursorShifterCUP(t *testing.T) {
}
func TestCursorShifterCUPRowCol(t *testing.T) {
cs := newCursorShifter(1, 36)
cs := newCursorShifter(1, 36, 80)
got := cs.Shift([]byte("\x1b[10;5H"))
if string(got) != "\x1b[11;5H" {
t.Fatalf("CUP 10;5: got %q", got)
@@ -23,7 +23,7 @@ func TestCursorShifterCUPRowCol(t *testing.T) {
}
func TestCursorShifterVPA(t *testing.T) {
cs := newCursorShifter(1, 36)
cs := newCursorShifter(1, 36, 80)
got := cs.Shift([]byte("\x1b[7d"))
if string(got) != "\x1b[8d" {
t.Fatalf("VPA 7: got %q", got)
@@ -31,7 +31,7 @@ func TestCursorShifterVPA(t *testing.T) {
}
func TestCursorShifterDECSTBM(t *testing.T) {
cs := newCursorShifter(1, 36)
cs := newCursorShifter(1, 36, 80)
got := cs.Shift([]byte("\x1b[2;20r"))
if string(got) != "\x1b[3;21r" {
t.Fatalf("DECSTBM: got %q", got)
@@ -43,7 +43,7 @@ func TestCursorShifterDECSTBM(t *testing.T) {
// region — that's what was scrolling claude's content up after a
// focus switch from codex.
func TestCursorShifterDECSTBMEmptyResetsToViewport(t *testing.T) {
cs := newCursorShifter(3, 36) // mainTop=4, childRows=36
cs := newCursorShifter(3, 36, 80) // mainTop=4, childRows=36
got := cs.Shift([]byte("\x1b[r"))
if string(got) != "\x1b[4;39r" {
t.Fatalf("empty DECSTBM reset: got %q want \\x1b[4;39r", got)
@@ -51,7 +51,7 @@ func TestCursorShifterDECSTBMEmptyResetsToViewport(t *testing.T) {
}
func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
cs := newCursorShifter(1, 36)
cs := newCursorShifter(1, 36, 80)
// Alt-screen toggle — private CSI.
got := cs.Shift([]byte("\x1b[?1049h"))
if string(got) != "\x1b[?1049h" {
@@ -60,7 +60,7 @@ func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
}
func TestCursorShifterSGRPassthrough(t *testing.T) {
cs := newCursorShifter(1, 36)
cs := newCursorShifter(1, 36, 80)
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
if string(got) != "\x1b[1;31mhello\x1b[0m" {
t.Fatalf("SGR: got %q", got)
@@ -68,7 +68,7 @@ func TestCursorShifterSGRPassthrough(t *testing.T) {
}
func TestCursorShifterStraddleChunks(t *testing.T) {
cs := newCursorShifter(1, 36)
cs := newCursorShifter(1, 36, 80)
a := cs.Shift([]byte("\x1b["))
b := cs.Shift([]byte("5;3H"))
got := string(a) + string(b)
@@ -78,7 +78,7 @@ func TestCursorShifterStraddleChunks(t *testing.T) {
}
func TestCursorShifterOSCNotRewritten(t *testing.T) {
cs := newCursorShifter(1, 36)
cs := newCursorShifter(1, 36, 80)
// OSC body containing what looks like a CSI cursor move — should
// NOT be rewritten.
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")
@@ -87,3 +87,27 @@ func TestCursorShifterOSCNotRewritten(t *testing.T) {
t.Fatalf("OSC: got %q want %q", got, in)
}
}
func TestCursorShifterClampsCUPColumn(t *testing.T) {
cs := newCursorShifter(1, 36, 80)
got := cs.Shift([]byte("\x1b[5;120H"))
if string(got) != "\x1b[6;80H" {
t.Fatalf("CUP col 120 should clamp to childCols=80: got %q", got)
}
}
func TestCursorShifterClampsCHAColumn(t *testing.T) {
cs := newCursorShifter(1, 36, 80)
got := cs.Shift([]byte("\x1b[120G"))
if string(got) != "\x1b[80G" {
t.Fatalf("CHA col 120 should clamp to childCols=80: got %q", got)
}
}
func TestCursorShifterCUPNoClampWhenChildColsZero(t *testing.T) {
cs := newCursorShifter(1, 36, 0)
got := cs.Shift([]byte("\x1b[5;120H"))
if string(got) != "\x1b[6;120H" {
t.Fatalf("childCols=0 should disable col clamping: got %q", got)
}
}

View File

@@ -10,14 +10,20 @@ import (
// paletteAction is what the palette returns when the user picks an item.
type paletteAction struct {
// kind: "spawn-agent" | "spawn-process" | "switch" | "kill" | "quit" | "cancel"
// kind: "spawn-agent" | "spawn-process" | "spawn-process-form" |
// "spawn-process-submit" | "switch" | "kill" | "quit" | "cancel"
kind string
// For spawn-*, the preset to launch.
// For spawn-agent / spawn-process, the preset to launch.
preset *preset.Preset
// For "switch" and "kill", the target child id.
childID string
// For "spawn-process-submit": the freeform command line the user
// typed and the relaunch-on-exit flag they ticked.
command string
relaunch bool
}
type paletteItem struct {
@@ -26,6 +32,26 @@ type paletteItem struct {
action paletteAction
}
// paletteMode toggles the palette between its fuzzy-picker UI and the
// freeform "spawn process" form. The form lives inside the palette so
// it shares the same modal-input contract (every byte intercepted; no
// PTY forwarding) without needing a second overlay.
type paletteMode int
const (
paletteModePicker paletteMode = iota
paletteModeSpawnForm
)
// spawnProcessForm is the state for the "Spawn process…" two-field
// form: a command line plus a "relaunch on exit" toggle. Tab cycles
// focus; space toggles the checkbox when it owns focus; Enter submits.
type spawnProcessForm struct {
cmd []rune
relaunch bool
field int // 0 = command, 1 = relaunch checkbox
}
// paletteState is the in-memory model for the overlay. SPEC §4: a
// single fuzzy-searchable list of commands scoped to the current focus.
type paletteState struct {
@@ -36,6 +62,9 @@ type paletteState struct {
presets preset.Set
items []paletteItem
mode paletteMode
form *spawnProcessForm
}
// macroPrefixes maps the palette macro prefix (without trailing space)
@@ -147,13 +176,31 @@ func (p *paletteState) allItems() []paletteItem {
})
}
// Kill entries last among the action rows, before Quit.
// Freeform "Spawn process…" entry. Opens a sub-form for typing an
// arbitrary command line and ticking "relaunch on exit". The action
// kind is intercepted by acceptOrEnterForm so accept switches the
// palette into form mode instead of closing it. Placed after the
// preset entries so quick-spawn flows keep the same ordering as
// before this feature landed.
out = append(out, paletteItem{
label: "Spawn process…",
hint: "freeform command · optional relaunch on exit",
action: paletteAction{kind: "spawn-process-form"},
})
// Kill entries last among the action rows, before Quit. Mirror the
// "(current)" marker from switch entries so the focused tab is
// obvious when scanning the kill list.
for _, c := range p.children {
if c.Status() != StatusRunning {
continue
}
label := "Kill " + c.DisplayName()
if c.ID == p.focused {
label = "• " + label + " (current)"
}
out = append(out, paletteItem{
label: "Kill " + c.DisplayName(),
label: label,
hint: "SIGTERM " + strings.Join(c.Argv, " "),
action: paletteAction{kind: "kill", childID: c.ID},
})
@@ -233,6 +280,9 @@ func peekArrowEvent(chunk []byte, i int) (nav byte, advance int) {
// are consumed silently so they don't fall through to the ESC branch
// and accidentally cancel the palette.
func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, done bool, advance int) {
if p.mode == paletteModeSpawnForm {
return p.handleFormInput(chunk, i)
}
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
@@ -243,7 +293,7 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
}
switch b {
case '\r', '\n':
return p.accept(), true, 1
return p.acceptOrEnterForm(1)
case 0x7f, 0x08:
p.backspace()
case 0x15: // Ctrl-U
@@ -263,6 +313,20 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
return paletteAction{}, false, 1
}
// acceptOrEnterForm wraps accept(): if the chosen item opens the
// spawn-process form, transition into form mode instead of returning
// done=true. The advance count is what the caller already consumed for
// the Enter keystroke.
func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
a := p.accept()
if a.kind == "spawn-process-form" {
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
return paletteAction{}, false, adv
}
return a, true, adv
}
func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
switch final {
case 'A':
@@ -279,7 +343,7 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio
}
switch k.key {
case 13: // Enter
return p.accept(), true, n
return p.acceptOrEnterForm(n)
case 27: // Escape
return paletteAction{kind: "cancel"}, true, n
case 127, 8: // Backspace
@@ -314,6 +378,98 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio
return paletteAction{}, false, n
}
// handleFormInput drives the spawn-process form. Tab cycles fields,
// space toggles the relaunch checkbox when it has focus, Enter submits,
// Esc cancels. The form supports both legacy and kitty key encodings to
// match handleInput; bare ESC cancels the entire palette (consistent
// with the picker).
func (p *paletteState) handleFormInput(chunk []byte, i int) (paletteAction, bool, int) {
b := chunk[i]
if b == 0x1b {
if n := csiLen(chunk, i); n > 0 {
return p.handleFormCSI(chunk[i+2:i+n-1], chunk[i+n-1], n)
}
return paletteAction{kind: "cancel"}, true, 1
}
switch b {
case '\r', '\n':
return p.submitForm(), true, 1
case '\t':
p.cycleFormField()
case 0x7f, 0x08:
p.formBackspace()
case ' ':
if p.form.field == 1 {
p.form.relaunch = !p.form.relaunch
} else if b >= 0x20 && b < 0x7f {
p.form.cmd = append(p.form.cmd, rune(b))
}
default:
if b >= 0x20 && b < 0x7f && p.form.field == 0 {
p.form.cmd = append(p.form.cmd, rune(b))
}
}
return paletteAction{}, false, 1
}
func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
switch final {
case 'A', 'B':
// Arrow up/down cycles field.
p.cycleFormField()
return paletteAction{}, false, n
case 'u':
k, ok := decodeCSIu(string(params))
if !ok || k.event != 1 {
return paletteAction{}, false, n
}
switch k.key {
case 13:
return p.submitForm(), true, n
case 27:
return paletteAction{kind: "cancel"}, true, n
case 9:
p.cycleFormField()
case 127, 8:
p.formBackspace()
case ' ':
if p.form.field == 1 {
p.form.relaunch = !p.form.relaunch
}
default:
if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f && p.form.field == 0 {
p.form.cmd = append(p.form.cmd, rune(k.key))
}
}
}
return paletteAction{}, false, n
}
func (p *paletteState) cycleFormField() {
p.form.field++
if p.form.field > 1 {
p.form.field = 0
}
}
func (p *paletteState) formBackspace() {
if p.form.field == 0 && len(p.form.cmd) > 0 {
p.form.cmd = p.form.cmd[:len(p.form.cmd)-1]
}
}
func (p *paletteState) submitForm() paletteAction {
cmd := strings.TrimSpace(string(p.form.cmd))
if cmd == "" {
return paletteAction{kind: "cancel"}
}
return paletteAction{
kind: "spawn-process-submit",
command: cmd,
relaunch: p.form.relaunch,
}
}
func (p *paletteState) accept() paletteAction {
if p.cursor >= 0 && p.cursor < len(p.items) {
return p.items[p.cursor].action
@@ -352,6 +508,10 @@ func (p *paletteState) cursorDown() {
// The caller is responsible for the screen clear before the first
// render.
func (p *paletteState) render(out writeFlusher, cols, rows int) {
if p.mode == paletteModeSpawnForm {
p.renderForm(out, cols, rows)
return
}
if cols < 32 {
cols = 32
}
@@ -517,6 +677,123 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
_ = out.Flush()
}
// renderForm paints the "Spawn process…" two-field form. Layout
// mirrors the picker (centered rounded box) so the user feels like
// they're still inside the palette. Cursor parks at the active field
// so it blinks where the next byte will land.
func (p *paletteState) renderForm(out writeFlusher, cols, rows int) {
if p.form == nil {
p.form = &spawnProcessForm{}
}
if cols < 32 {
cols = 32
}
if rows < 10 {
rows = 10
}
width := cols - 8
if width > 72 {
width = 72
}
if width < 40 {
width = cols - 2
}
if width < 32 {
width = 32
}
leftPad := (cols - width) / 2
if leftPad < 1 {
leftPad = 1
}
content := width - 4
var b strings.Builder
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
row := 2
title := "Spawn process"
hint := "esc cancel"
dashes := width - 3 - len(title) - 1 - 1 - len(hint) - 3
if dashes < 2 {
dashes = 2
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " +
strings.Repeat("─", dashes) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
row++
cmdStr := string(p.form.cmd)
cmdLen := utf8.RuneCountInString(cmdStr)
pad := content - 2 - cmdLen
if pad < 0 {
pad = 0
cmdStr = clipRunes(cmdStr, content-2)
cmdLen = utf8.RuneCountInString(cmdStr)
}
prompt := ""
if p.form.field == 0 {
prompt = styleAccent + "" + styleReset
} else {
prompt = styleDim + "" + styleReset
}
cmdRow := row
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + prompt + " " + cmdStr +
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
box := "[ ]"
if p.form.relaunch {
box = "[x]"
}
check := " " + box + " Relaunch on exit"
if p.form.field == 1 {
check = styleAccent + "▎" + styleReset + " " + styleBold + box + styleReset + " Relaunch on exit"
}
checkLen := visibleLen(check)
cpad := content - checkLen
if cpad < 0 {
cpad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + check +
strings.Repeat(" ", cpad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := "↵ spawn · esc cancel · tab cycle · space toggle"
fLen := utf8.RuneCountInString(footer)
fpad := content - fLen
if fpad < 0 {
fpad = 0
}
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + footer + styleReset +
strings.Repeat(" ", fpad) + " " + styleBorder + "│" + styleReset)
row++
moveTo(&b, row, leftPad)
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
// Park the cursor on the command line if that field is focused.
if p.form.field == 0 {
moveTo(&b, cmdRow, leftPad+4+cmdLen)
b.WriteString("\x1b[?25h")
} else {
b.WriteString("\x1b[?25l")
}
_, _ = out.Write([]byte(b.String()))
_ = out.Flush()
}
func clipRunes(s string, n int) string {
if n <= 0 {
return ""

View File

@@ -107,6 +107,83 @@ func TestPaletteLegacyPrintableTypes(t *testing.T) {
}
}
// "Spawn process…" is intercepted on accept: it switches the palette
// into the form mode instead of closing it. Subsequent Enter on a
// non-empty command line emits the submit action with relaunch reflecting
// the checkbox state.
func TestPaletteSpawnProcessFormFlow(t *testing.T) {
p := newPalette(nil, "", preset.Set{})
// The "Spawn process…" entry is the only non-Quit item with an
// empty preset list. Locate its index by scanning items.
idx := -1
for i, it := range p.items {
if it.action.kind == "spawn-process-form" {
idx = i
break
}
}
if idx < 0 {
t.Fatalf("no spawn-process-form item in palette items: %+v", p.items)
}
p.cursor = idx
// Enter on the entry opens the form (done=false, mode flips).
action, done, _ := p.handleInput([]byte("\r"), 0)
if done {
t.Fatalf("spawn-process-form accept closed palette: action=%+v", action)
}
if p.mode != paletteModeSpawnForm || p.form == nil {
t.Fatalf("palette did not switch to form mode: mode=%v form=%v", p.mode, p.form)
}
// Type a command: "bun run dev".
for _, b := range []byte("bun run dev") {
_, _, _ = p.handleInput([]byte{b}, 0)
}
if string(p.form.cmd) != "bun run dev" {
t.Fatalf("form cmd = %q", string(p.form.cmd))
}
// Tab to the relaunch field, toggle with space.
_, _, _ = p.handleInput([]byte{'\t'}, 0)
if p.form.field != 1 {
t.Fatalf("field after tab = %d, want 1", p.form.field)
}
_, _, _ = p.handleInput([]byte{' '}, 0)
if !p.form.relaunch {
t.Fatalf("relaunch toggle didn't stick")
}
// Enter submits.
action, done, _ = p.handleInput([]byte("\r"), 0)
if !done || action.kind != "spawn-process-submit" {
t.Fatalf("submit didn't fire: action=%+v done=%v", action, done)
}
if action.command != "bun run dev" || !action.relaunch {
t.Fatalf("submit payload = %+v", action)
}
}
func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) {
p := newPalette(nil, "", preset.Set{})
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{}
action, done, _ := p.handleInput([]byte("\r"), 0)
if !done || action.kind != "cancel" {
t.Fatalf("empty submit didn't cancel: action=%+v done=%v", action, done)
}
}
func TestPaletteSpawnProcessFormEscCancels(t *testing.T) {
p := newPalette(nil, "", preset.Set{})
p.mode = paletteModeSpawnForm
p.form = &spawnProcessForm{cmd: []rune("x")}
action, done, _ := p.handleInput([]byte{0x1b}, 0)
if !done || action.kind != "cancel" {
t.Fatalf("ESC didn't cancel form: action=%+v done=%v", action, done)
}
}
// peekArrowEvent powers the chunk-level dedupe in processStdin. The
// scenarios below cover the patterns we've actually seen terminals
// emit for one physical Down press: a kitty press event, a legacy CSI

View File

@@ -22,6 +22,7 @@ func (st *uiState) drawSidebar() {
st.mu.Lock()
palOpen := st.palette != nil
focus := st.focusedID
activeAgent := st.activeAgentID
st.mu.Unlock()
if palOpen {
return
@@ -70,12 +71,46 @@ func (st *uiState) drawSidebar() {
return styleHint + "●" + styleReset
}
writeHeader("Session tree")
children := visibleSessionTree(st.sess.Children(), focus)
if len(children) == 0 {
// Processes section — top-level command/terminal processes,
// session-wide (does not change when the user switches agent tabs).
writeHeader("Processes")
procs := processList(st.sess.Children())
if len(procs) == 0 {
write(" " + styleDim + "(none)" + styleReset)
}
for _, c := range procs {
if row > maxRow {
break
}
focused := c.ID == focus
glyph := statusGlyph(c, focused)
marker := ""
if c.AutoRestart() {
marker = " " + styleDim + "⟳" + styleReset
}
var line string
if focused {
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " +
styleBold + c.DisplayName() + styleReset + marker
} else {
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker
}
write(line)
}
// Agent Tree section — formerly "Session tree". Shows the active
// agent tab's root plus its sub-agents. The active agent is pinned
// by activeAgentID, so the tree keeps showing the right tab even
// when focus moves into the Processes section above.
if row+2 <= maxRow {
write("")
}
writeHeader("Agent Tree")
agents := visibleAgentTree(st.sess.Children(), activeAgent)
if len(agents) == 0 {
write(" " + styleDim + "(empty)" + styleReset)
}
for _, c := range children {
for _, c := range agents {
if row > maxRow {
break
}

View File

@@ -30,8 +30,14 @@ func (st *uiState) drawTabBar() {
return
}
// Tabs list top-level agent sessions only. Command/terminal
// processes live in the Processes sidebar section and never own a
// tab — they're global to the session, not per-tab.
var sessions []*Child
for _, c := range st.sess.Children() {
if c.Kind != KindAgent {
continue
}
if c.ParentID == "" && c.Status() == StatusRunning {
sessions = append(sessions, c)
}
@@ -125,9 +131,13 @@ func (st *uiState) drawTabBar() {
var b strings.Builder
// Clear both rows so a stale label from the previous frame can't
// bleed through.
b.WriteString("\x1b[1;1H\x1b[2K")
b.WriteString("\x1b[2;1H\x1b[2K")
// bleed through. Use ECH clamped to `width` (= childCols) instead of
// `\x1b[2K`: 2K wipes the entire line including the sidebar columns,
// and if drawSidebar's chrome cache is fresh it won't repaint to
// fill them back in — the user sees a gap where the sidebar border
// and content should be.
fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX", width)
fmt.Fprintf(&b, "\x1b[2;1H\x1b[%dX", width)
for _, t := range tabs {
// Row 1: centre-ish label inside the tab cell.

View File

@@ -1,5 +1,32 @@
package app
// 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 —
// the agent tree is for KindAgent (and KindTerminal sub-entries) only.
func visibleAgentTree(children []*Child, activeAgentID string) []*Child {
if activeAgentID == "" {
return nil
}
out := make([]*Child, 0, len(children))
for _, c := range children {
if c.Status() != StatusRunning {
continue
}
if c.Kind == KindCommand && c.ParentID == "" {
continue
}
if c.ID == activeAgentID || c.ParentID == activeAgentID {
out = append(out, c)
}
}
return out
}
// visibleSessionTree is retained for the test suite and any pre-Processes
// callers — it returns the active agent's tree given the focused id,
// resolving the active agent root from focus the same way the previous
// implementation did.
func visibleSessionTree(children []*Child, focusID string) []*Child {
rootID := activeRootID(children, focusID)
if rootID == "" {
@@ -17,12 +44,19 @@ func visibleSessionTree(children []*Child, focusID string) []*Child {
return out
}
// activeRootID resolves the agent root the user is "inside" right now.
// If focus is on a sub-agent, it walks up. If focus is on a top-level
// process (KindCommand), it falls through to the first running agent
// root so the agent tree section keeps showing something coherent.
func activeRootID(children []*Child, focusID string) string {
if focusID != "" {
for _, c := range children {
if c.ID != focusID {
continue
}
if c.Kind == KindCommand && c.ParentID == "" {
break
}
if c.ParentID == "" {
return c.ID
}
@@ -32,7 +66,14 @@ func activeRootID(children []*Child, focusID string) string {
return ""
}
}
return firstRunningAgentID(children)
}
func firstRunningAgentID(children []*Child) string {
for _, c := range children {
if c.Kind != KindAgent {
continue
}
if c.ParentID == "" && c.Status() == StatusRunning {
return c.ID
}
@@ -40,6 +81,23 @@ func activeRootID(children []*Child, focusID string) string {
return ""
}
// processList returns every top-level command/terminal entry in spawn
// order, regardless of running state. The Processes sidebar section
// keeps showing exited entries so the user can see what just died (and
// because Session retains KindCommand entries for restart).
func processList(children []*Child) []*Child {
out := make([]*Child, 0, len(children))
for _, c := range children {
if c.ParentID != "" {
continue
}
if c.Kind == KindCommand || c.Kind == KindTerminal {
out = append(out, c)
}
}
return out
}
func findChildInSnapshot(children []*Child, id string) *Child {
for _, c := range children {
if c.ID == id {
@@ -58,12 +116,16 @@ func firstRunningTopLevel(children []*Child) *Child {
return nil
}
// runningTopLevels lists every running top-level session in the order
// they appear in the snapshot — the same order the tab bar uses, so
// Ctrl+A/D navigation matches what the user sees on screen.
// runningTopLevels lists every running top-level agent session in the
// order they appear in the snapshot. Tabs only show agents — command
// processes live in the Processes sidebar section — so Ctrl+A/D
// navigation cycles through agent tabs exclusively.
func runningTopLevels(children []*Child) []*Child {
out := make([]*Child, 0, len(children))
for _, c := range children {
if c.Kind != KindAgent {
continue
}
if c.ParentID == "" && c.Status() == StatusRunning {
out = append(out, c)
}
@@ -123,11 +185,29 @@ func currentTabFlat(children []*Child, focusID string) []*Child {
return out
}
// nextChildID returns the process id `step` positions away from the
// current focus inside its tab, wrapping at both ends. Empty when
// there's only one process in the tab.
func nextChildID(children []*Child, focusID string, step int) string {
flat := currentTabFlat(children, focusID)
// sidebarNavList combines the Processes section and the active Agent
// Tree into one flat list — top-to-bottom matching what the user sees
// in the sidebar. Ctrl+W/S walks this list so the user can step out of
// the agent tree, into the Processes section, and back.
func sidebarNavList(children []*Child, activeAgentID string) []*Child {
out := make([]*Child, 0, 8)
for _, c := range processList(children) {
if c.Status() != StatusRunning {
continue
}
out = append(out, c)
}
for _, c := range visibleAgentTree(children, activeAgentID) {
out = append(out, c)
}
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.
func nextChildID(children []*Child, focusID, activeAgentID string, step int) string {
flat := sidebarNavList(children, activeAgentID)
if len(flat) < 2 {
return ""
}

View File

@@ -41,9 +41,9 @@ func childIDs(cs []*Child) []string {
}
func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
r1 := testChild("c1", "root1", "", StatusRunning)
r2 := testChild("c2", "root2", "", StatusRunning)
r3 := testChild("c3", "root3", "", StatusRunning)
r1 := testAgent("c1", "root1", "", StatusRunning)
r2 := testAgent("c2", "root2", "", StatusRunning)
r3 := testAgent("c3", "root3", "", StatusRunning)
children := []*Child{r1, r2, r3}
if got := nextTabID(children, "c1", +1); got != "c2" {
@@ -58,9 +58,9 @@ func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
}
func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) {
r1 := testChild("c1", "root1", "", StatusRunning)
r1Child := testChild("c2", "child1", "c1", StatusRunning)
r2 := testChild("c3", "root2", "", StatusRunning)
r1 := testAgent("c1", "root1", "", StatusRunning)
r1Child := testAgent("c2", "child1", "c1", StatusRunning)
r2 := testAgent("c3", "root2", "", StatusRunning)
children := []*Child{r1, r1Child, r2}
// Focus is on a sub-agent of root1; Ctrl+D should jump to root2,
@@ -71,29 +71,89 @@ func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) {
}
func TestNextChildIDCyclesWithinTab(t *testing.T) {
r1 := testChild("c1", "root1", "", StatusRunning)
a := testChild("c2", "a", "c1", StatusRunning)
b := testChild("c3", "b", "c1", StatusRunning)
other := testChild("c4", "other-root", "", StatusRunning)
r1 := testAgent("c1", "root1", "", StatusRunning)
a := testAgent("c2", "a", "c1", StatusRunning)
b := testAgent("c3", "b", "c1", StatusRunning)
other := testAgent("c4", "other-root", "", StatusRunning)
children := []*Child{r1, a, b, other}
if got := nextChildID(children, "c1", +1); got != "c2" {
if got := nextChildID(children, "c1", "c1", +1); got != "c2" {
t.Fatalf("root->first child: %q", got)
}
if got := nextChildID(children, "c2", +1); got != "c3" {
if got := nextChildID(children, "c2", "c1", +1); got != "c3" {
t.Fatalf("a->b: %q", got)
}
if got := nextChildID(children, "c3", +1); got != "c1" {
if got := nextChildID(children, "c3", "c1", +1); got != "c1" {
t.Fatalf("wrap b->root: %q", got)
}
if got := nextChildID(children, "c1", -1); got != "c3" {
if got := nextChildID(children, "c1", "c1", -1); got != "c3" {
t.Fatalf("wrap backward root->b: %q", got)
}
}
func TestNextChildIDNoopWhenOnlyOneProcess(t *testing.T) {
r := testChild("c1", "solo", "", StatusRunning)
if got := nextChildID([]*Child{r}, "c1", +1); got != "" {
r := testAgent("c1", "solo", "", StatusRunning)
if got := nextChildID([]*Child{r}, "c1", "c1", +1); got != "" {
t.Fatalf("expected empty when only one process in tab, got %q", got)
}
}
// testAgent is a testChild wrapper that sets KindAgent — the new
// navigation/visibility helpers filter by kind, so tests need explicit
// kinds to behave like real agents.
func testAgent(id, name, parent string, status ChildStatus) *Child {
c := testChild(id, name, parent, status)
c.Kind = KindAgent
return c
}
func testProcess(id, name string, status ChildStatus) *Child {
c := testChild(id, name, "", status)
c.Kind = KindCommand
return c
}
func TestSidebarNavListIncludesProcessesAboveAgentTree(t *testing.T) {
p1 := testProcess("p1", "bun", StatusRunning)
p2 := testProcess("p2", "queue", StatusRunning)
r := testAgent("a1", "claude", "", StatusRunning)
sub := testAgent("a2", "sub", "a1", StatusRunning)
flat := sidebarNavList([]*Child{p1, p2, r, sub}, "a1")
if len(flat) != 4 || flat[0].ID != "p1" || flat[1].ID != "p2" ||
flat[2].ID != "a1" || flat[3].ID != "a2" {
t.Fatalf("flat = %v, want p1 p2 a1 a2", childIDs(flat))
}
}
func TestNextChildIDWalksProcessesThenAgentTree(t *testing.T) {
p1 := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", StatusRunning)
sub := testAgent("a2", "sub", "a1", StatusRunning)
children := []*Child{p1, r, sub}
// From a process, Ctrl+S walks down into the agent tree.
if got := nextChildID(children, "p1", "a1", +1); got != "a1" {
t.Fatalf("p1 -> a1: %q", got)
}
// From the agent root, Ctrl+W walks back up into the process list.
if got := nextChildID(children, "a1", "a1", -1); got != "p1" {
t.Fatalf("a1 -> p1: %q", got)
}
}
func TestVisibleAgentTreeExcludesTopLevelCommands(t *testing.T) {
p := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", StatusRunning)
got := visibleAgentTree([]*Child{p, r}, "a1")
if len(got) != 1 || got[0].ID != "a1" {
t.Fatalf("agent tree = %v, want only a1", childIDs(got))
}
}
func TestRunningTopLevelsSkipsCommands(t *testing.T) {
p := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", StatusRunning)
got := runningTopLevels([]*Child{p, r})
if len(got) != 1 || got[0].ID != "a1" {
t.Fatalf("top-levels = %v, want only a1", childIDs(got))
}
}

View File

@@ -27,6 +27,12 @@ type viewportRenderer struct {
// OnPTYOut consumes the flag and invalidates the sidebar chrome
// cache so the next drawSidebar repaints over the clobber.
scrolled bool
// skipUTF8 is set when the current multi-byte UTF-8 character started
// past the viewport's right edge. The starter byte was dropped, so
// the remaining continuation bytes must be dropped too instead of
// leaking into the sidebar columns.
skipUTF8 bool
}
type viewportState int
@@ -45,7 +51,7 @@ const (
func newViewportRenderer(l terminalLayout) *viewportRenderer {
return &viewportRenderer{
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows())),
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())),
layout: l,
row: 1,
col: 1,
@@ -56,7 +62,7 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
vr.mu.Lock()
defer vr.mu.Unlock()
vr.layout = l
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()))
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols()))
}
func (vr *viewportRenderer) Render(in []byte) []byte {
@@ -98,8 +104,7 @@ func (vr *viewportRenderer) feed(b byte) {
vr.buf = append(vr.buf, b)
return
}
vr.pending.WriteByte(b)
vr.advancePrintable(b)
vr.feedPrintable(b)
case vpEsc:
vr.buf = append(vr.buf, b)
switch b {
@@ -286,6 +291,9 @@ func (vr *viewportRenderer) clearViewportToCursor() string {
if col < 1 {
col = 1
}
if col > cols {
col = cols
}
var b strings.Builder
b.WriteString("\x1b7")
for r := 1; r < row; r++ {
@@ -318,6 +326,60 @@ func (vr *viewportRenderer) clearLine(n int) string {
}
}
// feedPrintable handles one non-ESC byte in the vpNormal state. It both
// advances vr's cursor model and decides whether the byte should be
// forwarded to the host. Bytes that would land past the viewport's
// right edge (childCols) are dropped so a child whose internal column
// state drifted past the viewport can't spray into the sidebar columns.
// UTF-8 continuation bytes follow the fate of their starter so a
// multi-byte glyph drops as a unit.
func (vr *viewportRenderer) feedPrintable(b byte) {
// Control codes (CR, LF, BS, TAB, BEL, etc.) move the cursor or
// signal state and must always be forwarded. They never produce
// glyphs, so they can't clobber the sidebar themselves.
if b < 0x20 || b == 0x7f {
vr.pending.WriteByte(b)
switch b {
case '\r':
vr.col = 1
case '\n':
vr.row++
case '\b':
if vr.col > 1 {
vr.col--
}
case '\t':
vr.col += 8 - ((vr.col - 1) % 8)
}
vr.skipUTF8 = false
vr.clampCursor()
return
}
// UTF-8 continuation byte (10xxxxxx) belongs to the current glyph.
if b >= 0x80 && b < 0xC0 {
if vr.skipUTF8 {
return
}
vr.pending.WriteByte(b)
return
}
// Glyph starter (ASCII 0x20..0x7E or UTF-8 leading byte 0xC0+). If
// the cursor sits past the viewport we'd be spraying into the
// sidebar columns — drop the glyph (and the continuation bytes that
// follow, via skipUTF8).
maxCol := int(vr.layout.childCols())
if maxCol > 0 && vr.col > maxCol {
vr.skipUTF8 = b >= 0xC0
return
}
vr.skipUTF8 = false
vr.pending.WriteByte(b)
vr.col++
vr.clampCursor()
}
// advancePrintable is retained for tests that exercise cursor tracking
// directly; the runtime path goes through feedPrintable.
func (vr *viewportRenderer) advancePrintable(b byte) {
switch b {
case '\r':
@@ -331,7 +393,7 @@ func (vr *viewportRenderer) advancePrintable(b byte) {
case '\t':
vr.col += 8 - ((vr.col - 1) % 8)
default:
if b >= 0x20 && b != 0x7f {
if b >= 0x20 && b != 0x7f && (b < 0x80 || b >= 0xC0) {
vr.col++
}
}
@@ -389,7 +451,15 @@ func (vr *viewportRenderer) clampCursor() {
if max := int(vr.layout.childRows()); vr.row > max {
vr.row = max
}
if max := int(vr.layout.childCols()); vr.col > max {
vr.col = max
// Intentionally do NOT clamp vr.col to childCols here. feedPrintable
// drops glyphs once vr.col exceeds childCols (so a child whose
// internal column state drifted past the viewport can't spray bytes
// into the sidebar). If we clamped col back to childCols on every
// printable, every subsequent byte would look like it was still "at
// the right margin" and would write again. We cap at childCols+1
// instead so clear-line bookkeeping doesn't see arbitrarily large
// numbers.
if max := int(vr.layout.childCols()); vr.col > max+1 {
vr.col = max + 1
}
}

View File

@@ -5,6 +5,14 @@ import (
"testing"
)
func bytesRepeat(b byte, n int) []byte {
out := make([]byte, n)
for i := range out {
out[i] = b
}
return out
}
func TestViewportRendererShiftsCursor(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[H")))
@@ -103,6 +111,65 @@ func TestViewportRendererTracksPrintableCursor(t *testing.T) {
}
}
func TestViewportRendererClampsCUPColumn(t *testing.T) {
// Layout: hostCols=120, sidebar present → childCols=91. A child that
// thinks its viewport is the full host width could emit a CUP to col
// 95 (inside the sidebar). The renderer must clamp the emitted CUP
// column so the host cursor never lands in the sidebar.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95H")))
if !strings.Contains(got, "\x1b[7;91H") {
t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got)
}
}
func TestViewportRendererClampsCHAColumn(t *testing.T) {
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[110G")))
if !strings.Contains(got, "\x1b[91G") {
t.Fatalf("CHA col 110 should clamp to 91 (childCols): got %q", got)
}
}
func TestViewportRendererDropsPrintablesPastViewport(t *testing.T) {
// A child whose internal column state drifted past the viewport
// (childCols=91 here) might CUP to col 95 and stream text. The CUP
// column is clamped to the viewport edge, but tracking still
// considers the cursor "past" — so subsequent printables must drop
// rather than walk into the sidebar columns.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95HCLOBBER")))
if strings.Contains(got, "CLOBBER") || strings.Contains(got, "LOBBER") {
t.Fatalf("printables past childCols should be dropped: got %q", got)
}
}
func TestViewportRendererKeepsPrintablesUpToViewportEdge(t *testing.T) {
// Writing exactly childCols glyphs from col 1 must reach the right
// edge unchanged — the drop kicks in only after the cursor passes
// the last viewport column.
vr := newViewportRenderer(newTerminalLayout(120, 40))
in := append([]byte("\x1b[5;1H"), bytesRepeat('x', 91)...)
got := string(vr.Render(in))
if strings.Count(got, "x") != 91 {
t.Fatalf("91 'x' glyphs from col 1 should all be emitted: got %q", got)
}
}
func TestViewportRendererDropsUTF8GlyphPastViewport(t *testing.T) {
// A 3-byte UTF-8 glyph (U+2500 BOX DRAWINGS LIGHT HORIZONTAL) starting
// past the viewport must be dropped as a unit — leaking even one
// continuation byte would feed a malformed sequence to the host.
vr := newViewportRenderer(newTerminalLayout(120, 40))
got := string(vr.Render([]byte("\x1b[5;95H─x")))
if strings.Contains(got, "─") {
t.Fatalf("UTF-8 glyph past viewport should be dropped: got %q", got)
}
if strings.Contains(got, "x") {
t.Fatalf("trailing ASCII past viewport should also be dropped: got %q", got)
}
}
func TestViewportRendererFlagsRIAsScrolling(t *testing.T) {
// Reproduces the sidebar-gap bug: codex emits `\x1b[1;1H` followed
// by 8× `\x1bM` (RI) on startup. RI at the top of the host scroll

View File

@@ -30,6 +30,10 @@ func EncodeChord(name string) ([]byte, error) {
return []byte{0x10}, nil
case "ctrl-u":
return []byte{0x15}, nil
case "tab":
return []byte{'\t'}, nil
case "space":
return []byte{' '}, nil
}
return nil, fmt.Errorf("unknown chord %q", name)
}

View File

@@ -16,12 +16,13 @@
},
{ "type": "wait_text", "contains": "RIBURST READY", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Session tree" },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "Agent Tree" },
{ "type": "assert_contains", "contains": "● riburst" },
{ "type": "assert_contains", "contains": "Scratchpads" },
{
"type": "assert_regex",
"regex": "(?s)Session tree[^\\n]*\\n[^─\\n]*─[─]+[^\\n]*\\n[^●\\n]*● riburst",
"regex": "(?s)Processes[^\\n]*\\n[^─\\n]*─[─]+[^\\n]*\\n[^●\\n]*● riburst",
"timeout_ms": 2000
}
]

View File

@@ -0,0 +1,27 @@
{
"name": "sidebar_survives_wide_writes",
"cols": 120,
"rows": 40,
"scripts": [
{
"name": "widewrite",
"body": "#!/bin/sh\n# Reproduces a long-running TUI whose internal column state drifted\n# past the viewport width (the symptom seen in fucked-up-terminal.txt:\n# claude's input box drew a horizontal divider all the way to the host\n# edge, overwriting the sidebar). The widths here are: cols=120,\n# sidebarCols=28, so the viewport is 91 cols wide and the sidebar\n# border lives at col 92. Anything the child writes at col >= 92 lands\n# in the sidebar unless patterm defensively clamps it.\n#\n# After WIDE READY, emit 12 throw-away chunks to exhaust the focus\n# snapshot replay budget (8 chunks) so the wide-write clobber goes\n# through the *incremental* viewport renderer path. That's the path\n# that long-running sessions stay on — without clamping it can spray\n# bytes into the sidebar.\nprintf 'WIDE READY\\n'\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'tick %d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'PRE-CLOBBER\\n'\nsleep 0.2\nprintf '\\033[5;95HCLOBBER-CUP'\nsleep 0.1\nprintf '\\033[7;100HCLOBBER-CUP2'\nsleep 0.1\nprintf '\\033[9;1H'\nprintf '\\033[110GCLOBBER-CHA'\nprintf '\\nDONE\\n'\nsleep 5\n"
}
],
"steps": [
{
"type": "mcp_call",
"method": "spawn_process",
"params": { "kind": "command", "argv": ["widewrite"], "name": "widewrite" }
},
{ "type": "wait_text", "contains": "WIDE READY", "timeout_ms": 5000 },
{ "type": "wait_text", "contains": "DONE", "timeout_ms": 5000 },
{ "type": "wait_stable", "timeout_ms": 2000 },
{ "type": "assert_contains", "contains": "Agent Tree" },
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "Scratchpads" },
{ "type": "assert_contains", "contains": "● widewrite" },
{ "type": "assert_not_contains", "contains": "CLOBBER-CUP" },
{ "type": "assert_not_contains", "contains": "CLOBBER-CHA" }
]
}

View File

@@ -0,0 +1,31 @@
{
"name": "spawn_process_form",
"cols": 100,
"rows": 30,
"scripts": [
{
"name": "formfixture",
"body": "#!/bin/sh\necho FORM-READY\nsleep 5\n"
}
],
"steps": [
{ "type": "wait_stable", "timeout_ms": 3000 },
{ "type": "send_chord", "chord": "ctrl-k" },
{ "type": "send_text", "text": "Spawn process" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "Spawn process", "timeout_ms": 3000 },
{ "type": "send_text", "text": "formfixture" },
{ "type": "send_chord", "chord": "tab" },
{ "type": "send_chord", "chord": "space" },
{ "type": "send_chord", "chord": "enter" },
{ "type": "wait_text", "contains": "FORM-READY", "timeout_ms": 5000 },
{
"type": "assert_mcp",
"method": "list_processes",
"path": "0.status",
"equals": "running"
},
{ "type": "assert_contains", "contains": "Processes" },
{ "type": "assert_contains", "contains": "⟳" }
]
}