From 2f969fa215537a15b4668bb2c969cabb01bdffed Mon Sep 17 00:00:00 2001 From: Harry Bayliss Date: Thu, 14 May 2026 22:41:24 +0100 Subject: [PATCH] Fix sidebar repaint and command restart navigation --- CHANGELOG.md | 10 +++ TODO.md | 1 + internal/app/app.go | 64 ++++++++++++--- internal/app/tree.go | 11 ++- internal/app/tree_test.go | 16 ++++ internal/app/viewport_renderer.go | 79 +++++++++++++++---- internal/app/viewport_renderer_test.go | 28 +++++++ .../restart_exited_process_from_sidebar.json | 33 ++++++++ .../sidebar_survives_linefeed_scroll.json | 34 ++++++++ 9 files changed, 247 insertions(+), 29 deletions(-) create mode 100644 internal/harness/scenarios/restart_exited_process_from_sidebar.json create mode 100644 internal/harness/scenarios/sidebar_survives_linefeed_scroll.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ddbfdd9..bc20e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). from `git describe`; the release workflow injects the pushed tag). Commit and date come from the Go toolchain's embedded VCS info, so nothing has to be bumped by hand. +- Ctrl+R restarts the focused command process from the Processes + sidebar, including command entries that have already exited. ### Changed - CLI flag parsing switched from Go's stdlib `flag` to `spf13/pflag`. @@ -21,6 +23,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). — single-hyphen long flags like `-project` are rejected. Help output renders the canonical `--flag` form. +### Fixed +- Plain line-feed scrolling at the bottom of a child pane now invalidates + and repaints the sidebar, so long agent output can no longer drag the + sidebar border and labels out of view while the chrome cache stays warm. +- Exited command processes in the top Processes section are now reachable + with Ctrl+W/S navigation, so a dead shell entry can be focused and + restarted instead of becoming a visible but unreachable row. + ## [0.0.1] - 2026-05-14 ### Fixed diff --git a/TODO.md b/TODO.md index 061c429..7b98406 100644 --- a/TODO.md +++ b/TODO.md @@ -5,3 +5,4 @@ Nerd Font private-use codepoints, not a patterm substitution. Need a concrete reproduction (which codepoint, which host terminal/font) before changing rendering. +- [ ] After codex rips for like 15 minutes, the terminal becomes quite slow occasionally. Also resizing causes the terminal to go CRAZY with the scroll jumping around. [ON HOLD] diff --git a/internal/app/app.go b/internal/app/app.go index 5042b9a..ae657b5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -298,6 +298,35 @@ func (st *uiState) focusProcess(processID string) { st.drawStatusLine() } +func (st *uiState) restartFocusedCommand(processID string) { + c := st.sess.FindChild(processID) + if c == nil || c.Kind != KindCommand { + return + } + layout := st.layoutSnapshot() + renderer := newViewportRenderer(layout) + st.mu.Lock() + st.focusedID = c.ID + st.focusedName = c.DisplayName() + st.renderer = renderer + st.repaintNextPTY = c.ID + st.repaintNextPTYBudget = 8 + st.mu.Unlock() + + st.outMu.Lock() + _, _ = os.Stdout.Write(renderer.ClearViewport()) + st.outMu.Unlock() + + if err := st.sess.Restart(c.ID, syscall.SIGTERM, layout.childCols(), layout.childRows()); err != nil { + st.flashError(fmt.Sprintf("restart %s: %v", c.DisplayName(), err)) + return + } + st.moveToViewportOrigin() + st.drawTabBar() + st.drawSidebar() + 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 @@ -513,14 +542,13 @@ func (st *uiState) OnPTYOut(childID string, chunk []byte) { _, _ = os.Stdout.Write(out) _, _ = os.Stdout.Write([]byte("\x1b[?7h")) st.outMu.Unlock() - // RI / IND / NEL / SU / SD / IL / DL scroll content within the host's - // scroll region, which spans every column — so any of them drags the - // right-hand sidebar's session-tree entries downward along with the - // main pane. (Codex emits an 8× RI burst on startup, which produced - // the original report.) The viewport renderer flags any chunk that - // contained one of those escapes; when set, drop the sidebar cache - // so the next drawSidebar repaints over the clobber instead of - // hitting the cache and leaving the gap visible. + // RI / IND / NEL / SU / SD / IL / DL and bottom-margin LF / VT / FF + // scroll content within the host's scroll region, which spans every + // column — so any of them drags the right-hand sidebar's session-tree + // entries along with the main pane. The viewport renderer flags any + // chunk that scrolls; when set, drop the sidebar cache so the next + // drawSidebar repaints over the clobber instead of hitting the cache + // and leaving the gap visible. scrolled := renderer.TookScrollAction() if scrolled { st.chromeCacheMu.Lock() @@ -639,13 +667,17 @@ func (st *uiState) drawStatusLine() { if trustMsg != "" { left = "[trust] " + trustMsg } - // Hints decay shortest-first when the host is narrow so the focused + // Hints decay left-to-right when the host is narrow so the focused // child name + ownership note on the left side never get clipped. + // Context-specific hints are appended so they survive longest. hints := []string{ "Ctrl-A/D · tabs", "Ctrl-W/S · tree", "Ctrl-K · palette", } + if c := st.sess.FindChild(focusID); c != nil && c.Kind == KindCommand { + hints = append(hints, "Ctrl-R · restart") + } right := strings.Join(hints, " · ") for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 { hints = hints[1:] @@ -833,6 +865,7 @@ func (st *uiState) processStdin(chunk []byte) { var pendingAction *paletteAction var pendingNavID string + var pendingRestartID string // Tracks the last arrow direction and the byte offset immediately // after its CSI sequence. Some terminals emit a duplicate adjacent @@ -928,6 +961,14 @@ func (st *uiState) processStdin(chunk []byte) { i += adv break } + if hit, adv := matchCtrlChar(chunk, i, 'r'); hit { + if c := st.sess.FindChild(st.focusedID); c != nil && c.Kind == KindCommand { + flushForward() + pendingRestartID = c.ID + i += adv + break + } + } forward = append(forward, b) i++ @@ -941,6 +982,9 @@ func (st *uiState) processStdin(chunk []byte) { if pendingNavID != "" { st.focusProcess(pendingNavID) } + if pendingRestartID != "" { + st.restartFocusedCommand(pendingRestartID) + } } func (st *uiState) openPaletteLocked() { @@ -1035,7 +1079,7 @@ func (st *uiState) closePalette(action paletteAction) { case "switch": c := st.sess.FindChild(action.childID) - if c == nil || c.Status() != StatusRunning { + if c == nil || (c.Kind == KindAgent && c.Status() != StatusRunning) { st.repaintFocused() return } diff --git a/internal/app/tree.go b/internal/app/tree.go index 4d716cd..148c53b 100644 --- a/internal/app/tree.go +++ b/internal/app/tree.go @@ -192,9 +192,6 @@ func currentTabFlat(children []*Child, focusID string) []*Child { 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) { @@ -208,9 +205,15 @@ func sidebarNavList(children []*Child, activeAgentID string) []*Child { // 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 { + if len(flat) == 0 { return "" } + if len(flat) == 1 { + if flat[0].ID == focusID { + return "" + } + return flat[0].ID + } idx := -1 for i, c := range flat { if c.ID == focusID { diff --git a/internal/app/tree_test.go b/internal/app/tree_test.go index c5018ee..b5c0d8c 100644 --- a/internal/app/tree_test.go +++ b/internal/app/tree_test.go @@ -125,6 +125,15 @@ func TestSidebarNavListIncludesProcessesAboveAgentTree(t *testing.T) { } } +func TestSidebarNavListIncludesExitedProcesses(t *testing.T) { + p := testProcess("p1", "shell", StatusExited) + r := testAgent("a1", "claude", "", StatusRunning) + flat := sidebarNavList([]*Child{p, r}, "a1") + if len(flat) != 2 || flat[0].ID != "p1" || flat[1].ID != "a1" { + t.Fatalf("flat = %v, want exited process then active agent", childIDs(flat)) + } +} + func TestNextChildIDWalksProcessesThenAgentTree(t *testing.T) { p1 := testProcess("p1", "bun", StatusRunning) r := testAgent("a1", "claude", "", StatusRunning) @@ -140,6 +149,13 @@ func TestNextChildIDWalksProcessesThenAgentTree(t *testing.T) { } } +func TestNextChildIDCanEnterSingleExitedProcessFromNoFocus(t *testing.T) { + p := testProcess("p1", "shell", StatusExited) + if got := nextChildID([]*Child{p}, "", "", +1); got != "p1" { + t.Fatalf("empty focus -> exited process: %q want p1", got) + } +} + func TestVisibleAgentTreeExcludesTopLevelCommands(t *testing.T) { p := testProcess("p1", "bun", StatusRunning) r := testAgent("a1", "claude", "", StatusRunning) diff --git a/internal/app/viewport_renderer.go b/internal/app/viewport_renderer.go index 587ed69..6edba45 100644 --- a/internal/app/viewport_renderer.go +++ b/internal/app/viewport_renderer.go @@ -10,11 +10,13 @@ import ( // viewportRenderer rewrites child PTY output so it lands inside the // main viewport instead of controlling patterm's full host terminal. type viewportRenderer struct { - mu sync.Mutex - shifter *cursorShifter - layout terminalLayout - row int - col int + mu sync.Mutex + shifter *cursorShifter + layout terminalLayout + row int + col int + scrollTop int + scrollBottom int state viewportState buf []byte @@ -22,8 +24,9 @@ type viewportRenderer struct { // scrolled is set when the chunk contained an escape that shifts // content row-wise within the host's scroll region — RI / IND / - // NEL / SU / SD / IL / DL. DECSTBM constrains rows but not columns, - // so these scrolls drag the right-hand sidebar content with them. + // NEL / SU / SD / IL / DL, or LF / VT / FF at the bottom margin. + // DECSTBM constrains rows but not columns, so these scrolls drag the + // right-hand sidebar content with them. // OnPTYOut consumes the flag and invalidates the sidebar chrome // cache so the next drawSidebar repaints over the clobber. scrolled bool @@ -50,12 +53,14 @@ const ( ) func newViewportRenderer(l terminalLayout) *viewportRenderer { - return &viewportRenderer{ + vr := &viewportRenderer{ shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())), layout: l, row: 1, col: 1, } + vr.resetScrollRegion() + return vr } func (vr *viewportRenderer) SetLayout(l terminalLayout) { @@ -63,6 +68,7 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) { defer vr.mu.Unlock() vr.layout = l vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())) + vr.resetScrollRegion() } func (vr *viewportRenderer) Render(in []byte) []byte { @@ -82,11 +88,10 @@ func (vr *viewportRenderer) ClearViewport() []byte { } // TookScrollAction reports whether the most recent Render emitted (or -// forwarded) a scroll-triggering escape — RI / IND / NEL / SU / SD / -// IL / DL — since the previous call. The flag is reset on read. -// Callers use it to invalidate sidebar-cache state, because the host's -// scroll region spans the full row width and any scroll there drags -// the sidebar content downward. +// forwarded) a scroll action since the previous call. Callers use it +// to invalidate sidebar-cache state, because the host's scroll region +// spans the full row width and any scroll there drags the sidebar +// content vertically. func (vr *viewportRenderer) TookScrollAction() bool { vr.mu.Lock() defer vr.mu.Unlock() @@ -326,6 +331,22 @@ func (vr *viewportRenderer) clearLine(n int) string { } } +func (vr *viewportRenderer) resetScrollRegion() { + vr.scrollTop = 1 + vr.scrollBottom = int(vr.layout.childRows()) + if vr.scrollBottom < 1 { + vr.scrollBottom = 1 + } +} + +func (vr *viewportRenderer) lineFeed() { + if vr.row >= vr.scrollTop && vr.row == vr.scrollBottom { + vr.scrolled = true + return + } + vr.row++ +} + // feedPrintable handles one non-ESC byte in the vpNormal state. It both // advances vr's cursor model and decides whether the byte should be // forwarded to the host. Bytes that would land past the viewport's @@ -342,8 +363,8 @@ func (vr *viewportRenderer) feedPrintable(b byte) { switch b { case '\r': vr.col = 1 - case '\n': - vr.row++ + case '\n', '\v', '\f': + vr.lineFeed() case '\b': if vr.col > 1 { vr.col-- @@ -437,10 +458,38 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) { if ok { vr.col -= n } + case 'r': + vr.trackScrollRegion(params) } vr.clampCursor() } +func (vr *viewportRenderer) trackScrollRegion(params []byte) { + if len(params) == 0 { + vr.resetScrollRegion() + return + } + top, bottom, ok := parseTwoParams(params) + if !ok { + return + } + maxRows := int(vr.layout.childRows()) + if maxRows < 1 { + maxRows = 1 + } + if top < 1 { + top = 1 + } + if bottom < 1 || bottom > maxRows { + bottom = maxRows + } + if top >= bottom { + return + } + vr.scrollTop = top + vr.scrollBottom = bottom +} + func (vr *viewportRenderer) clampCursor() { if vr.row < 1 { vr.row = 1 diff --git a/internal/app/viewport_renderer_test.go b/internal/app/viewport_renderer_test.go index 70b684e..badb23c 100644 --- a/internal/app/viewport_renderer_test.go +++ b/internal/app/viewport_renderer_test.go @@ -211,6 +211,34 @@ func TestViewportRendererFlagsScrollVerbs(t *testing.T) { } } +func TestViewportRendererFlagsLineFeedAtViewportBottomAsScrolling(t *testing.T) { + vr := newViewportRenderer(newTerminalLayout(120, 40)) + _ = vr.Render([]byte("\x1b[37;1H\n")) + if !vr.TookScrollAction() { + t.Fatalf("LF at viewport bottom should flag scroll") + } +} + +func TestViewportRendererDoesNotFlagLineFeedBeforeViewportBottom(t *testing.T) { + vr := newViewportRenderer(newTerminalLayout(120, 40)) + _ = vr.Render([]byte("\x1b[36;1H\n")) + if vr.TookScrollAction() { + t.Fatalf("LF before viewport bottom should not flag scroll") + } +} + +func TestViewportRendererFlagsLineFeedAtCustomScrollBottom(t *testing.T) { + vr := newViewportRenderer(newTerminalLayout(120, 40)) + _ = vr.Render([]byte("\x1b[5;10r\x1b[9;1H\n")) + if vr.TookScrollAction() { + t.Fatalf("LF before custom scroll bottom should not flag scroll") + } + _ = vr.Render([]byte("\n")) + if !vr.TookScrollAction() { + t.Fatalf("LF at custom scroll bottom should flag scroll") + } +} + func TestViewportRendererForwardsRIVerbatim(t *testing.T) { // We rely on the host terminal performing the scroll inside the // DECSTBM region; the renderer must not eat or transform RI. If a diff --git a/internal/harness/scenarios/restart_exited_process_from_sidebar.json b/internal/harness/scenarios/restart_exited_process_from_sidebar.json new file mode 100644 index 0000000..92eca58 --- /dev/null +++ b/internal/harness/scenarios/restart_exited_process_from_sidebar.json @@ -0,0 +1,33 @@ +{ + "name": "restart_exited_process_from_sidebar", + "cols": 120, + "rows": 40, + "scripts": [ + { + "name": "quick-shell", + "body": "#!/bin/sh\ncount_file=\"$XDG_RUNTIME_DIR/quick-shell-count\"\nif [ -f \"$count_file\" ]; then\n n=$(cat \"$count_file\")\nelse\n n=0\nfi\nn=$((n + 1))\nprintf '%s\\n' \"$n\" > \"$count_file\"\nprintf 'QUICK RUN %s\\n' \"$n\"\n" + } + ], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": { "kind": "command", "argv": ["quick-shell"], "name": "quick-shell" } + }, + { "type": "wait_text", "contains": "QUICK RUN 1", "timeout_ms": 5000 }, + { "type": "wait_stable", "timeout_ms": 2000 }, + { "type": "assert_contains", "contains": "○ quick-shell" }, + { "type": "send_text", "text": "\u0017" }, + { "type": "wait_stable", "timeout_ms": 2000 }, + { "type": "assert_contains", "contains": "quick-shell · you have control" }, + { "type": "mark_raw", "save_as": "before_restart" }, + { "type": "send_text", "text": "\u0012" }, + { "type": "wait_text", "contains": "QUICK RUN 2", "timeout_ms": 5000 }, + { + "type": "assert_raw_since_regex", + "from": "before_restart", + "regex": "QUICK RUN 2", + "timeout_ms": 2000 + } + ] +} diff --git a/internal/harness/scenarios/sidebar_survives_linefeed_scroll.json b/internal/harness/scenarios/sidebar_survives_linefeed_scroll.json new file mode 100644 index 0000000..32198c5 --- /dev/null +++ b/internal/harness/scenarios/sidebar_survives_linefeed_scroll.json @@ -0,0 +1,34 @@ +{ + "name": "sidebar_survives_linefeed_scroll", + "cols": 120, + "rows": 40, + "scripts": [ + { + "name": "linefeed-scroll", + "body": "#!/bin/sh\n# Plain LF at the bottom of the child viewport scrolls the host's\n# DECSTBM region. Because that region spans every column, enough LFs\n# drag the sidebar border and section labels out of the visible region\n# unless patterm invalidates and repaints the sidebar cache.\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'warmup %02d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'LINEFEED READY\\n'\nIFS= read -r _\nprintf '\\033[1;37r'\nprintf '\\033[37;1H'\ni=0\nwhile [ $i -lt 45 ]; do\n printf 'scroll line %02d\\n' \"$i\"\n i=$((i + 1))\ndone\nprintf 'LINEFEED DONE\\n'\nsleep 5\n" + } + ], + "steps": [ + { + "type": "mcp_call", + "method": "spawn_process", + "params": { "kind": "command", "argv": ["linefeed-scroll"], "name": "linefeed-scroll" } + }, + { "type": "wait_text", "contains": "LINEFEED READY", "timeout_ms": 5000 }, + { "type": "wait_stable", "timeout_ms": 2000 }, + { "type": "mark_raw", "save_as": "before_scroll" }, + { "type": "send_chord", "chord": "enter" }, + { "type": "wait_text", "contains": "LINEFEED DONE", "timeout_ms": 5000 }, + { + "type": "assert_raw_since_regex", + "from": "before_scroll", + "regex": "Agent Tree", + "timeout_ms": 2000 + }, + { "type": "wait_stable", "timeout_ms": 2000 }, + { "type": "assert_contains", "contains": "Processes" }, + { "type": "assert_contains", "contains": "Agent Tree" }, + { "type": "assert_contains", "contains": "Scratchpads" }, + { "type": "assert_contains", "contains": "● linefeed-scroll" } + ] +}