Fix sidebar repaint and command restart navigation
This commit is contained in:
10
CHANGELOG.md
10
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
|
||||
|
||||
1
TODO.md
1
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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user