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).
|
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
|
||||||
|
|||||||
1
TODO.md
1
TODO.md
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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