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

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

View File

@@ -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 {

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) {
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)

View File

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

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) {
// We rely on the host terminal performing the scroll inside the
// 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" }
]
}