Fix sidebar repaint and command restart navigation

This commit is contained in:
2026-05-14 22:41:24 +01:00
parent 83eb4f6b2d
commit 2f969fa215
9 changed files with 247 additions and 29 deletions

View File

@@ -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). from `git describe`; the release workflow injects the pushed tag).
Commit and date come from the Go toolchain's embedded VCS info, so Commit and date come from the Go toolchain's embedded VCS info, so
nothing has to be bumped by hand. 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 ### Changed
- CLI flag parsing switched from Go's stdlib `flag` to `spf13/pflag`. - 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 — single-hyphen long flags like `-project` are rejected. Help output
renders the canonical `--flag` form. 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 ## [0.0.1] - 2026-05-14
### Fixed ### Fixed

View File

@@ -5,3 +5,4 @@
Nerd Font private-use codepoints, not a patterm substitution. Nerd Font private-use codepoints, not a patterm substitution.
Need a concrete reproduction (which codepoint, which host Need a concrete reproduction (which codepoint, which host
terminal/font) before changing rendering. 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]

View File

@@ -298,6 +298,35 @@ func (st *uiState) focusProcess(processID string) {
st.drawStatusLine() 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 // updateActiveAgentLocked records the active agent root for the agent
// tree section whenever focus lands on an agent or one of its // tree section whenever focus lands on an agent or one of its
// sub-agents. Focusing a top-level command process leaves the previous // 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(out)
_, _ = os.Stdout.Write([]byte("\x1b[?7h")) _, _ = os.Stdout.Write([]byte("\x1b[?7h"))
st.outMu.Unlock() st.outMu.Unlock()
// RI / IND / NEL / SU / SD / IL / DL scroll content within the host's // RI / IND / NEL / SU / SD / IL / DL and bottom-margin LF / VT / FF
// scroll region, which spans every column — so any of them drags the // scroll content within the host's scroll region, which spans every
// right-hand sidebar's session-tree entries downward along with the // column — so any of them drags the right-hand sidebar's session-tree
// main pane. (Codex emits an 8× RI burst on startup, which produced // entries along with the main pane. The viewport renderer flags any
// the original report.) The viewport renderer flags any chunk that // chunk that scrolls; when set, drop the sidebar cache so the next
// contained one of those escapes; when set, drop the sidebar cache // drawSidebar repaints over the clobber instead of hitting the cache
// so the next drawSidebar repaints over the clobber instead of // and leaving the gap visible.
// hitting the cache and leaving the gap visible.
scrolled := renderer.TookScrollAction() scrolled := renderer.TookScrollAction()
if scrolled { if scrolled {
st.chromeCacheMu.Lock() st.chromeCacheMu.Lock()
@@ -639,13 +667,17 @@ func (st *uiState) drawStatusLine() {
if trustMsg != "" { if trustMsg != "" {
left = "[trust] " + 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. // child name + ownership note on the left side never get clipped.
// Context-specific hints are appended so they survive longest.
hints := []string{ hints := []string{
"Ctrl-A/D · tabs", "Ctrl-A/D · tabs",
"Ctrl-W/S · tree", "Ctrl-W/S · tree",
"Ctrl-K · palette", "Ctrl-K · palette",
} }
if c := st.sess.FindChild(focusID); c != nil && c.Kind == KindCommand {
hints = append(hints, "Ctrl-R · restart")
}
right := strings.Join(hints, " · ") right := strings.Join(hints, " · ")
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 { for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
hints = hints[1:] hints = hints[1:]
@@ -833,6 +865,7 @@ func (st *uiState) processStdin(chunk []byte) {
var pendingAction *paletteAction var pendingAction *paletteAction
var pendingNavID string var pendingNavID string
var pendingRestartID string
// Tracks the last arrow direction and the byte offset immediately // Tracks the last arrow direction and the byte offset immediately
// after its CSI sequence. Some terminals emit a duplicate adjacent // after its CSI sequence. Some terminals emit a duplicate adjacent
@@ -928,6 +961,14 @@ func (st *uiState) processStdin(chunk []byte) {
i += adv i += adv
break 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) forward = append(forward, b)
i++ i++
@@ -941,6 +982,9 @@ func (st *uiState) processStdin(chunk []byte) {
if pendingNavID != "" { if pendingNavID != "" {
st.focusProcess(pendingNavID) st.focusProcess(pendingNavID)
} }
if pendingRestartID != "" {
st.restartFocusedCommand(pendingRestartID)
}
} }
func (st *uiState) openPaletteLocked() { func (st *uiState) openPaletteLocked() {
@@ -1035,7 +1079,7 @@ func (st *uiState) closePalette(action paletteAction) {
case "switch": case "switch":
c := st.sess.FindChild(action.childID) c := st.sess.FindChild(action.childID)
if c == nil || c.Status() != StatusRunning { if c == nil || (c.Kind == KindAgent && c.Status() != StatusRunning) {
st.repaintFocused() st.repaintFocused()
return return
} }

View File

@@ -192,9 +192,6 @@ func currentTabFlat(children []*Child, focusID string) []*Child {
func sidebarNavList(children []*Child, activeAgentID string) []*Child { func sidebarNavList(children []*Child, activeAgentID string) []*Child {
out := make([]*Child, 0, 8) out := make([]*Child, 0, 8)
for _, c := range processList(children) { for _, c := range processList(children) {
if c.Status() != StatusRunning {
continue
}
out = append(out, c) out = append(out, c)
} }
for _, c := range visibleAgentTree(children, activeAgentID) { 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. // wrapping at both ends. Empty when there's nothing else to land on.
func nextChildID(children []*Child, focusID, activeAgentID string, step int) string { func nextChildID(children []*Child, focusID, activeAgentID string, step int) string {
flat := sidebarNavList(children, activeAgentID) flat := sidebarNavList(children, activeAgentID)
if len(flat) < 2 { if len(flat) == 0 {
return "" return ""
} }
if len(flat) == 1 {
if flat[0].ID == focusID {
return ""
}
return flat[0].ID
}
idx := -1 idx := -1
for i, c := range flat { for i, c := range flat {
if c.ID == focusID { if c.ID == focusID {

View File

@@ -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) { func TestNextChildIDWalksProcessesThenAgentTree(t *testing.T) {
p1 := testProcess("p1", "bun", StatusRunning) p1 := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", 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) { func TestVisibleAgentTreeExcludesTopLevelCommands(t *testing.T) {
p := testProcess("p1", "bun", StatusRunning) p := testProcess("p1", "bun", StatusRunning)
r := testAgent("a1", "claude", "", StatusRunning) r := testAgent("a1", "claude", "", StatusRunning)

View File

@@ -15,6 +15,8 @@ type viewportRenderer struct {
layout terminalLayout layout terminalLayout
row int row int
col int col int
scrollTop int
scrollBottom int
state viewportState state viewportState
buf []byte buf []byte
@@ -22,8 +24,9 @@ type viewportRenderer struct {
// scrolled is set when the chunk contained an escape that shifts // scrolled is set when the chunk contained an escape that shifts
// content row-wise within the host's scroll region — RI / IND / // content row-wise within the host's scroll region — RI / IND /
// NEL / SU / SD / IL / DL. DECSTBM constrains rows but not columns, // NEL / SU / SD / IL / DL, or LF / VT / FF at the bottom margin.
// so these scrolls drag the right-hand sidebar content with them. // 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 // OnPTYOut consumes the flag and invalidates the sidebar chrome
// cache so the next drawSidebar repaints over the clobber. // cache so the next drawSidebar repaints over the clobber.
scrolled bool scrolled bool
@@ -50,12 +53,14 @@ const (
) )
func newViewportRenderer(l terminalLayout) *viewportRenderer { func newViewportRenderer(l terminalLayout) *viewportRenderer {
return &viewportRenderer{ vr := &viewportRenderer{
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())), shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())),
layout: l, layout: l,
row: 1, row: 1,
col: 1, col: 1,
} }
vr.resetScrollRegion()
return vr
} }
func (vr *viewportRenderer) SetLayout(l terminalLayout) { func (vr *viewportRenderer) SetLayout(l terminalLayout) {
@@ -63,6 +68,7 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
defer vr.mu.Unlock() defer vr.mu.Unlock()
vr.layout = l vr.layout = l
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())) vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols()))
vr.resetScrollRegion()
} }
func (vr *viewportRenderer) Render(in []byte) []byte { 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 // TookScrollAction reports whether the most recent Render emitted (or
// forwarded) a scroll-triggering escape — RI / IND / NEL / SU / SD / // forwarded) a scroll action since the previous call. Callers use it
// IL / DL — since the previous call. The flag is reset on read. // to invalidate sidebar-cache state, because the host's scroll region
// Callers use it to invalidate sidebar-cache state, because the host's // spans the full row width and any scroll there drags the sidebar
// scroll region spans the full row width and any scroll there drags // content vertically.
// the sidebar content downward.
func (vr *viewportRenderer) TookScrollAction() bool { func (vr *viewportRenderer) TookScrollAction() bool {
vr.mu.Lock() vr.mu.Lock()
defer vr.mu.Unlock() 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 // feedPrintable handles one non-ESC byte in the vpNormal state. It both
// advances vr's cursor model and decides whether the byte should be // advances vr's cursor model and decides whether the byte should be
// forwarded to the host. Bytes that would land past the viewport's // forwarded to the host. Bytes that would land past the viewport's
@@ -342,8 +363,8 @@ func (vr *viewportRenderer) feedPrintable(b byte) {
switch b { switch b {
case '\r': case '\r':
vr.col = 1 vr.col = 1
case '\n': case '\n', '\v', '\f':
vr.row++ vr.lineFeed()
case '\b': case '\b':
if vr.col > 1 { if vr.col > 1 {
vr.col-- vr.col--
@@ -437,10 +458,38 @@ func (vr *viewportRenderer) trackCSI(final byte, params []byte) {
if ok { if ok {
vr.col -= n vr.col -= n
} }
case 'r':
vr.trackScrollRegion(params)
} }
vr.clampCursor() 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() { func (vr *viewportRenderer) clampCursor() {
if vr.row < 1 { if vr.row < 1 {
vr.row = 1 vr.row = 1

View File

@@ -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) { func TestViewportRendererForwardsRIVerbatim(t *testing.T) {
// We rely on the host terminal performing the scroll inside the // We rely on the host terminal performing the scroll inside the
// DECSTBM region; the renderer must not eat or transform RI. If a // DECSTBM region; the renderer must not eat or transform RI. If a

View File

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

View File

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