diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c3c2b..bb9ce0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/TODO.md b/TODO.md index 90fd808..061c429 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index 6807452..5042b9a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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() diff --git a/internal/app/child.go b/internal/app/child.go index d1f5a32..f014fa7 100644 --- a/internal/app/child.go +++ b/internal/app/child.go @@ -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"` diff --git a/internal/app/cursorshift.go b/internal/app/cursorshift.go index 86120d8..5dd633d 100644 --- a/internal/app/cursorshift.go +++ b/internal/app/cursorshift.go @@ -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) diff --git a/internal/app/cursorshift_test.go b/internal/app/cursorshift_test.go index 759db37..f19432b 100644 --- a/internal/app/cursorshift_test.go +++ b/internal/app/cursorshift_test.go @@ -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) + } +} diff --git a/internal/app/palette.go b/internal/app/palette.go index f257885..025c71f 100644 --- a/internal/app/palette.go +++ b/internal/app/palette.go @@ -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 "" diff --git a/internal/app/palette_input_test.go b/internal/app/palette_input_test.go index 8204007..8958cbf 100644 --- a/internal/app/palette_input_test.go +++ b/internal/app/palette_input_test.go @@ -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 diff --git a/internal/app/sidebar.go b/internal/app/sidebar.go index e7113fe..474a7d5 100644 --- a/internal/app/sidebar.go +++ b/internal/app/sidebar.go @@ -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 } diff --git a/internal/app/tabbar.go b/internal/app/tabbar.go index f68acf4..946f340 100644 --- a/internal/app/tabbar.go +++ b/internal/app/tabbar.go @@ -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. diff --git a/internal/app/tree.go b/internal/app/tree.go index 1db5d63..4d716cd 100644 --- a/internal/app/tree.go +++ b/internal/app/tree.go @@ -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 "" } diff --git a/internal/app/tree_test.go b/internal/app/tree_test.go index f4141ca..c5018ee 100644 --- a/internal/app/tree_test.go +++ b/internal/app/tree_test.go @@ -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)) + } +} diff --git a/internal/app/viewport_renderer.go b/internal/app/viewport_renderer.go index 00c3e82..587ed69 100644 --- a/internal/app/viewport_renderer.go +++ b/internal/app/viewport_renderer.go @@ -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 } } diff --git a/internal/app/viewport_renderer_test.go b/internal/app/viewport_renderer_test.go index 111470b..70b684e 100644 --- a/internal/app/viewport_renderer_test.go +++ b/internal/app/viewport_renderer_test.go @@ -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 diff --git a/internal/harness/input.go b/internal/harness/input.go index 279740f..b0f390f 100644 --- a/internal/harness/input.go +++ b/internal/harness/input.go @@ -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) } diff --git a/internal/harness/scenarios/sidebar_survives_ri_scroll.json b/internal/harness/scenarios/sidebar_survives_ri_scroll.json index 0d2ee4c..9102ffb 100644 --- a/internal/harness/scenarios/sidebar_survives_ri_scroll.json +++ b/internal/harness/scenarios/sidebar_survives_ri_scroll.json @@ -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 } ] diff --git a/internal/harness/scenarios/sidebar_survives_wide_writes.json b/internal/harness/scenarios/sidebar_survives_wide_writes.json new file mode 100644 index 0000000..28dc9f7 --- /dev/null +++ b/internal/harness/scenarios/sidebar_survives_wide_writes.json @@ -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" } + ] +} diff --git a/internal/harness/scenarios/spawn_process_form.json b/internal/harness/scenarios/spawn_process_form.json new file mode 100644 index 0000000..4d5cef3 --- /dev/null +++ b/internal/harness/scenarios/spawn_process_form.json @@ -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": "⟳" } + ] +}