Bundles the in-flight work into the first tagged release. See CHANGELOG.md `[0.0.1] - 2026-05-14` for the full per-change list. Highlights: - Sidebar / chrome stability: clamp absolute cursor positioning and printable bytes to the viewport so long-running TUIs (claude, codex) can't spray into the right rail; bound tab bar's row clear to the viewport width so the rail isn't wiped on every tab redraw; flag scroll escapes (RI/IND/NEL/SU/SD/IL/DL) and clamp `CSI 0/1/2 J`/`K` to viewport columns. - Palette: "Spawn process…" form, macros (`sw `, `k `, `sp `), kill entries mark the focused tab, dead agents drop out of the switch list. - Sidebar: split into Processes (session-wide) + Agent Tree (per-active-agent) sections; relaunch indicator; Ctrl+W/S walks the combined list, Ctrl+A/D steps tabs. - MCP: protocol handshake (`initialize`, `tools/list`, `tools/call`, `ping`), `mcp_injection.kind = cli_override / config_env` so codex and opencode pick up the server with no file writes, `lifecycle` help topic and tool-description cleanup-duty pointers. - Lifecycle: orchestrator-spawned children cascade-killed when the parent dies; orchestrator-injected prompts end with CR + delayed Enter so claude submits cleanly.
This commit is contained in:
48
CHANGELOG.md
48
CHANGELOG.md
@@ -6,7 +6,23 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.1] - 2026-05-14
|
||||
|
||||
### Fixed
|
||||
- Tab bar redraw used `\x1b[2K` to clear rows 1 and 2 before painting
|
||||
labels, which wiped the sidebar columns on those rows too. When the
|
||||
sidebar cache was still warm the rail never repainted, leaving a
|
||||
gap where the sidebar's top border and "Processes" header should be.
|
||||
The clear is now bounded to the viewport width.
|
||||
- Long-running TUIs (claude / codex) whose internal column state
|
||||
drifted past the patterm viewport could spray text into the sidebar
|
||||
columns — overwriting the session-tree and scratchpad rail until the
|
||||
user opened/closed the palette to force a full repaint. The viewport
|
||||
renderer now clamps absolute cursor positioning (CUP / HVP / CHA /
|
||||
HPA) to the viewport's right edge and drops printable bytes (ASCII
|
||||
and full UTF-8 glyphs) that would otherwise land past it. Covered by
|
||||
a unit suite and a new `sidebar_survives_wide_writes` harness
|
||||
scenario.
|
||||
- Sub-agent panes spawned while another diff-based TUI (claude/codex/
|
||||
opencode) held focus could come up corrupted because the new child's
|
||||
first incremental updates targeted cells the host viewport hadn't
|
||||
@@ -15,7 +31,31 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
styled emulator grid — the same path that fixed the symptom when the
|
||||
user manually cycled focus with Ctrl+W / Ctrl+S.
|
||||
|
||||
### Changed
|
||||
- Command palette `Kill …` entries now mark the focused tab with the
|
||||
same "• … (current)" marker the `Switch to …` entries use, so the
|
||||
user can tell at a glance which tab a kill action targets.
|
||||
- Status line now advertises the navigation chords (`Ctrl-A/D · tabs`,
|
||||
`Ctrl-W/S · tree`) alongside `Ctrl-K · palette`. Hints decay
|
||||
shortest-first when the terminal is too narrow to fit all three.
|
||||
|
||||
### Added
|
||||
- "Spawn process…" entry in the command palette opens a two-field form
|
||||
for typing an arbitrary command line and ticking "Relaunch on exit".
|
||||
The command runs through `sh -lc` so multi-word lines like
|
||||
`bun run dev` resolve binaries the way an interactive shell would.
|
||||
When the relaunch flag is set, patterm Starts the process again after
|
||||
it exits (1s backoff). Killing the process from the palette clears
|
||||
the flag so it does not come back.
|
||||
- Dedicated "Processes" section in the sidebar above the agent tree,
|
||||
listing every top-level command/terminal process. It is global to
|
||||
the patterm session — switching between agent tabs no longer changes
|
||||
which processes are visible. The relaunch-on-exit indicator (`⟳`)
|
||||
shows next to processes the user opted into auto-restart for.
|
||||
- Ctrl+W / Ctrl+S now traverse the combined Processes section and the
|
||||
active agent tree as one flat list, so the user can step out of the
|
||||
agent tree into the Processes pane and back without leaving the
|
||||
keyboard.
|
||||
- New `lifecycle` help topic spelling out that the caller owns the
|
||||
processes it spawns and should call `close_process` when a sub-agent
|
||||
or spawned process is no longer needed. The `spawn_agent` and
|
||||
@@ -55,6 +95,14 @@ loosely follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
available macros.
|
||||
|
||||
### Changed
|
||||
- The sidebar's session-tree section is now labeled "Agent Tree" and
|
||||
shows only agent sessions (and any sub-agents they spawn). Top-level
|
||||
command and terminal processes live in the new "Processes" section
|
||||
above it.
|
||||
- Tab bar tabs now correspond to agent sessions only. Command/terminal
|
||||
processes that previously claimed a top-level tab now appear in the
|
||||
Processes sidebar section, so the tab strip is reserved for agent
|
||||
context.
|
||||
- Focus, lifecycle, and repaint paths now capture terminal layout before
|
||||
taking UI state locks, reducing resize-time deadlock risk without
|
||||
changing visible behavior.
|
||||
|
||||
2
TODO.md
2
TODO.md
@@ -1,4 +1,4 @@
|
||||
- [ ] There's a unicode <?> being displayed in opencode
|
||||
- [ ] There's a unicode <?> being displayed in opencode [ON HOLD]
|
||||
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
|
||||
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
|
||||
most likely the *host* terminal's font fallback for opencode's
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
cpty "github.com/creack/pty"
|
||||
"golang.org/x/term"
|
||||
@@ -213,6 +214,11 @@ type uiState struct {
|
||||
palette *paletteState
|
||||
focusedID string
|
||||
focusedName string
|
||||
// activeAgentID tracks which top-level agent tab "owns" the agent
|
||||
// tree section of the sidebar. It only updates when focus lands on
|
||||
// an agent (or one of its sub-agents), so the agent tree stays
|
||||
// visible even when the user steps into the Processes pane.
|
||||
activeAgentID string
|
||||
// renderer confines focused-child live output to the main viewport.
|
||||
// A fresh renderer is allocated per focused child so partial-escape
|
||||
// state cannot bleed between panes.
|
||||
@@ -283,6 +289,7 @@ func (st *uiState) focusProcess(processID string) {
|
||||
st.mu.Lock()
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.updateActiveAgentLocked(c)
|
||||
st.renderer = newViewportRenderer(layout)
|
||||
st.mu.Unlock()
|
||||
st.repaintFocused()
|
||||
@@ -291,6 +298,33 @@ func (st *uiState) focusProcess(processID string) {
|
||||
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
|
||||
// active agent intact, so the user can hop between the Processes pane
|
||||
// and the agent tree without losing context. Caller holds st.mu.
|
||||
func (st *uiState) updateActiveAgentLocked(c *Child) {
|
||||
if c.Kind != KindAgent {
|
||||
return
|
||||
}
|
||||
if c.ParentID == "" {
|
||||
st.activeAgentID = c.ID
|
||||
return
|
||||
}
|
||||
// Walk up to the top-level agent.
|
||||
root := c
|
||||
for root.ParentID != "" {
|
||||
parent := st.sess.FindChild(root.ParentID)
|
||||
if parent == nil {
|
||||
break
|
||||
}
|
||||
root = parent
|
||||
}
|
||||
if root.Kind == KindAgent && root.ParentID == "" {
|
||||
st.activeAgentID = root.ID
|
||||
}
|
||||
}
|
||||
|
||||
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
||||
// surface a one-line toast in the status row and remember the most
|
||||
// recent ask so the status line keeps showing it. The sidebar-blink is
|
||||
@@ -321,6 +355,7 @@ func (st *uiState) OnChildSpawned(c *Child) {
|
||||
st.mu.Lock()
|
||||
st.focusedID = c.ID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.updateActiveAgentLocked(c)
|
||||
renderer := newViewportRenderer(layout)
|
||||
st.renderer = renderer
|
||||
palOpen := st.palette != nil
|
||||
@@ -377,9 +412,15 @@ func (st *uiState) OnChildExited(c *Child) {
|
||||
} else {
|
||||
st.focusedID = next.ID
|
||||
st.focusedName = next.DisplayName()
|
||||
st.updateActiveAgentLocked(next)
|
||||
st.renderer = newViewportRenderer(layout)
|
||||
}
|
||||
}
|
||||
if c.ID == st.activeAgentID {
|
||||
// The active agent died; pin the agent tree to whatever agent
|
||||
// root is still running, or clear it if none remain.
|
||||
st.activeAgentID = firstRunningAgentID(st.sess.Children())
|
||||
}
|
||||
if st.palette != nil {
|
||||
st.palette.children = st.sess.Children()
|
||||
st.palette.focused = st.focusedID
|
||||
@@ -397,6 +438,41 @@ func (st *uiState) OnChildExited(c *Child) {
|
||||
st.drawTabBar()
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
|
||||
// Auto-restart kicks in for command entries the user marked "relaunch
|
||||
// on exit". A short backoff (1s) avoids hot-spinning on processes
|
||||
// that fail immediately. The user can clear the flag by killing the
|
||||
// process from the palette.
|
||||
if c.Kind == KindCommand && c.AutoRestart() {
|
||||
go st.scheduleAutoRestart(c)
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleAutoRestart re-Starts a command entry after a brief backoff.
|
||||
// Bails out if the user cleared the flag, closed the process, or the
|
||||
// entry came back to life through some other path while we were
|
||||
// waiting. Called as a goroutine from OnChildExited.
|
||||
func (st *uiState) scheduleAutoRestart(c *Child) {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !c.AutoRestart() {
|
||||
return
|
||||
}
|
||||
if st.sess.FindChild(c.ID) == nil {
|
||||
return
|
||||
}
|
||||
if c.IsLive() {
|
||||
return
|
||||
}
|
||||
l := st.layoutSnapshot()
|
||||
if err := st.sess.Start(c.ID, l.childCols(), l.childRows()); err != nil {
|
||||
st.dbgf("auto-restart %s: %v", c.ID, err)
|
||||
return
|
||||
}
|
||||
// Start doesn't fire emitSpawn, so we have to nudge the chrome
|
||||
// ourselves — the status flipped from exited back to running and
|
||||
// the sidebar's cached frame still shows the exited glyph.
|
||||
st.drawSidebar()
|
||||
st.drawStatusLine()
|
||||
}
|
||||
|
||||
// OnPTYOut writes live output for the focused child when the palette is
|
||||
@@ -563,7 +639,18 @@ func (st *uiState) drawStatusLine() {
|
||||
if trustMsg != "" {
|
||||
left = "[trust] " + trustMsg
|
||||
}
|
||||
right := "Ctrl-K · palette"
|
||||
// Hints decay shortest-first when the host is narrow so the focused
|
||||
// child name + ownership note on the left side never get clipped.
|
||||
hints := []string{
|
||||
"Ctrl-A/D · tabs",
|
||||
"Ctrl-W/S · tree",
|
||||
"Ctrl-K · palette",
|
||||
}
|
||||
right := strings.Join(hints, " · ")
|
||||
for len(hints) > 1 && int(cols)-len(left)-len(right) < 1 {
|
||||
hints = hints[1:]
|
||||
right = strings.Join(hints, " · ")
|
||||
}
|
||||
|
||||
pad := int(cols) - len(left) - len(right)
|
||||
if pad < 1 {
|
||||
@@ -831,13 +918,13 @@ func (st *uiState) processStdin(chunk []byte) {
|
||||
}
|
||||
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
|
||||
flushForward()
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, -1)
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, -1)
|
||||
i += adv
|
||||
break
|
||||
}
|
||||
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
|
||||
flushForward()
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, +1)
|
||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, +1)
|
||||
i += adv
|
||||
break
|
||||
}
|
||||
@@ -916,6 +1003,36 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
||||
}
|
||||
|
||||
case "spawn-process-submit":
|
||||
if action.command == "" {
|
||||
st.repaintFocused()
|
||||
return
|
||||
}
|
||||
l := st.layoutSnapshot()
|
||||
st.launcher.SetSize(l.childCols(), l.childRows())
|
||||
display := action.command
|
||||
if len(display) > 32 {
|
||||
display = display[:31] + "…"
|
||||
}
|
||||
// shell=true so multi-word commands like "bun run dev" pass
|
||||
// through `sh -lc` and the user's PATH resolves binaries the
|
||||
// way they expect from an interactive shell.
|
||||
c, err := st.launcher.LaunchCommandArgv([]string{action.command}, display, "", "", nil, true)
|
||||
if err != nil {
|
||||
st.flashError(fmt.Sprintf("spawn: %v", err))
|
||||
return
|
||||
}
|
||||
c.SetAutoRestart(action.relaunch)
|
||||
// LaunchCommandArgv fires OnChildSpawned synchronously, which
|
||||
// drew the sidebar before AutoRestart was set. Invalidate so the
|
||||
// ⟳ marker shows up on the next paint.
|
||||
if action.relaunch {
|
||||
st.chromeCacheMu.Lock()
|
||||
st.sidebarCache = ""
|
||||
st.chromeCacheMu.Unlock()
|
||||
st.drawSidebar()
|
||||
}
|
||||
|
||||
case "switch":
|
||||
c := st.sess.FindChild(action.childID)
|
||||
if c == nil || c.Status() != StatusRunning {
|
||||
@@ -926,6 +1043,7 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
st.mu.Lock()
|
||||
st.focusedID = action.childID
|
||||
st.focusedName = c.DisplayName()
|
||||
st.updateActiveAgentLocked(c)
|
||||
st.renderer = newViewportRenderer(layout)
|
||||
st.mu.Unlock()
|
||||
st.repaintFocused()
|
||||
@@ -934,6 +1052,11 @@ func (st *uiState) closePalette(action paletteAction) {
|
||||
st.drawStatusLine()
|
||||
|
||||
case "kill":
|
||||
// User-initiated kill cancels any pending auto-restart so the
|
||||
// process doesn't immediately come back.
|
||||
if c := st.sess.FindChild(action.childID); c != nil {
|
||||
c.SetAutoRestart(false)
|
||||
}
|
||||
_ = st.sess.Kill(action.childID, syscall.SIGTERM)
|
||||
st.repaintFocused()
|
||||
st.drawTabBar()
|
||||
|
||||
@@ -121,8 +121,17 @@ type Child struct {
|
||||
cleanupMu sync.Mutex
|
||||
cleanupPaths []string
|
||||
restarting atomic.Bool
|
||||
|
||||
// autoRestart is set when the user spawned this command process with
|
||||
// "relaunch on exit". The session listener consults it after the PTY
|
||||
// exits and calls Start to bring the entry back up. Cleared when the
|
||||
// user explicitly kills the process from the palette.
|
||||
autoRestart atomic.Bool
|
||||
}
|
||||
|
||||
func (c *Child) SetAutoRestart(v bool) { c.autoRestart.Store(v) }
|
||||
func (c *Child) AutoRestart() bool { return c.autoRestart.Load() }
|
||||
|
||||
// PortSighting is one entry returned by get_process_ports.
|
||||
type PortSighting struct {
|
||||
Port int `json:"port"`
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
type cursorShifter struct {
|
||||
rowOffset int
|
||||
childRows int // viewport height in child rows; used for DECSTBM resets
|
||||
childCols int // viewport width in child cols; used to clamp CUP/HVP/CHA/HPA columns
|
||||
|
||||
state shifterState
|
||||
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
|
||||
@@ -45,13 +46,25 @@ const (
|
||||
stSOSPMAPCEsc
|
||||
)
|
||||
|
||||
func newCursorShifter(rowOffset, childRows int) *cursorShifter {
|
||||
return &cursorShifter{rowOffset: rowOffset, childRows: childRows}
|
||||
func newCursorShifter(rowOffset, childRows, childCols int) *cursorShifter {
|
||||
return &cursorShifter{rowOffset: rowOffset, childRows: childRows, childCols: childCols}
|
||||
}
|
||||
|
||||
func (cs *cursorShifter) SetGeometry(rowOffset, childRows int) {
|
||||
func (cs *cursorShifter) SetGeometry(rowOffset, childRows, childCols int) {
|
||||
cs.rowOffset = rowOffset
|
||||
cs.childRows = childRows
|
||||
cs.childCols = childCols
|
||||
}
|
||||
|
||||
// clampCol returns col clamped to the viewport's rightmost column, so a
|
||||
// child that drifted into believing it has more horizontal space than
|
||||
// patterm assigned it can't reach into the sidebar. childCols == 0 (an
|
||||
// uninitialised shifter, only seen in tests) disables clamping.
|
||||
func (cs *cursorShifter) clampCol(col int) int {
|
||||
if cs.childCols > 0 && col > cs.childCols {
|
||||
return cs.childCols
|
||||
}
|
||||
return col
|
||||
}
|
||||
|
||||
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
|
||||
@@ -194,11 +207,24 @@ func (cs *cursorShifter) emitCSI() {
|
||||
return
|
||||
}
|
||||
r += cs.rowOffset
|
||||
c = cs.clampCol(c)
|
||||
cs.pending.WriteString("\x1b[")
|
||||
cs.pending.WriteString(strconv.Itoa(r))
|
||||
cs.pending.WriteByte(';')
|
||||
cs.pending.WriteString(strconv.Itoa(c))
|
||||
cs.pending.WriteByte(final)
|
||||
case 'G', '`':
|
||||
// CHA / HPA: absolute column. Clamp to the viewport so a stale
|
||||
// child width can't reach into the sidebar.
|
||||
c, ok := parseOneParam(paramsRaw, 1)
|
||||
if !ok {
|
||||
cs.pending.Write(cs.buf)
|
||||
return
|
||||
}
|
||||
c = cs.clampCol(c)
|
||||
cs.pending.WriteString("\x1b[")
|
||||
cs.pending.WriteString(strconv.Itoa(c))
|
||||
cs.pending.WriteByte(final)
|
||||
case 'd':
|
||||
// VPA: row.
|
||||
r, ok := parseOneParam(paramsRaw, 1)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
func TestCursorShifterCUP(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36)
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
got := cs.Shift([]byte("\x1b[H"))
|
||||
want := []byte("\x1b[2;1H")
|
||||
if !bytes.Equal(got, want) {
|
||||
@@ -15,7 +15,7 @@ func TestCursorShifterCUP(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCursorShifterCUPRowCol(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36)
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
got := cs.Shift([]byte("\x1b[10;5H"))
|
||||
if string(got) != "\x1b[11;5H" {
|
||||
t.Fatalf("CUP 10;5: got %q", got)
|
||||
@@ -23,7 +23,7 @@ func TestCursorShifterCUPRowCol(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCursorShifterVPA(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36)
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
got := cs.Shift([]byte("\x1b[7d"))
|
||||
if string(got) != "\x1b[8d" {
|
||||
t.Fatalf("VPA 7: got %q", got)
|
||||
@@ -31,7 +31,7 @@ func TestCursorShifterVPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCursorShifterDECSTBM(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36)
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
got := cs.Shift([]byte("\x1b[2;20r"))
|
||||
if string(got) != "\x1b[3;21r" {
|
||||
t.Fatalf("DECSTBM: got %q", got)
|
||||
@@ -43,7 +43,7 @@ func TestCursorShifterDECSTBM(t *testing.T) {
|
||||
// region — that's what was scrolling claude's content up after a
|
||||
// focus switch from codex.
|
||||
func TestCursorShifterDECSTBMEmptyResetsToViewport(t *testing.T) {
|
||||
cs := newCursorShifter(3, 36) // mainTop=4, childRows=36
|
||||
cs := newCursorShifter(3, 36, 80) // mainTop=4, childRows=36
|
||||
got := cs.Shift([]byte("\x1b[r"))
|
||||
if string(got) != "\x1b[4;39r" {
|
||||
t.Fatalf("empty DECSTBM reset: got %q want \\x1b[4;39r", got)
|
||||
@@ -51,7 +51,7 @@ func TestCursorShifterDECSTBMEmptyResetsToViewport(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36)
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
// Alt-screen toggle — private CSI.
|
||||
got := cs.Shift([]byte("\x1b[?1049h"))
|
||||
if string(got) != "\x1b[?1049h" {
|
||||
@@ -60,7 +60,7 @@ func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCursorShifterSGRPassthrough(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36)
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
|
||||
if string(got) != "\x1b[1;31mhello\x1b[0m" {
|
||||
t.Fatalf("SGR: got %q", got)
|
||||
@@ -68,7 +68,7 @@ func TestCursorShifterSGRPassthrough(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCursorShifterStraddleChunks(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36)
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
a := cs.Shift([]byte("\x1b["))
|
||||
b := cs.Shift([]byte("5;3H"))
|
||||
got := string(a) + string(b)
|
||||
@@ -78,7 +78,7 @@ func TestCursorShifterStraddleChunks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCursorShifterOSCNotRewritten(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36)
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
// OSC body containing what looks like a CSI cursor move — should
|
||||
// NOT be rewritten.
|
||||
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")
|
||||
@@ -87,3 +87,27 @@ func TestCursorShifterOSCNotRewritten(t *testing.T) {
|
||||
t.Fatalf("OSC: got %q want %q", got, in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorShifterClampsCUPColumn(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
got := cs.Shift([]byte("\x1b[5;120H"))
|
||||
if string(got) != "\x1b[6;80H" {
|
||||
t.Fatalf("CUP col 120 should clamp to childCols=80: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorShifterClampsCHAColumn(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36, 80)
|
||||
got := cs.Shift([]byte("\x1b[120G"))
|
||||
if string(got) != "\x1b[80G" {
|
||||
t.Fatalf("CHA col 120 should clamp to childCols=80: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorShifterCUPNoClampWhenChildColsZero(t *testing.T) {
|
||||
cs := newCursorShifter(1, 36, 0)
|
||||
got := cs.Shift([]byte("\x1b[5;120H"))
|
||||
if string(got) != "\x1b[6;120H" {
|
||||
t.Fatalf("childCols=0 should disable col clamping: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,20 @@ import (
|
||||
|
||||
// paletteAction is what the palette returns when the user picks an item.
|
||||
type paletteAction struct {
|
||||
// kind: "spawn-agent" | "spawn-process" | "switch" | "kill" | "quit" | "cancel"
|
||||
// kind: "spawn-agent" | "spawn-process" | "spawn-process-form" |
|
||||
// "spawn-process-submit" | "switch" | "kill" | "quit" | "cancel"
|
||||
kind string
|
||||
|
||||
// For spawn-*, the preset to launch.
|
||||
// For spawn-agent / spawn-process, the preset to launch.
|
||||
preset *preset.Preset
|
||||
|
||||
// For "switch" and "kill", the target child id.
|
||||
childID string
|
||||
|
||||
// For "spawn-process-submit": the freeform command line the user
|
||||
// typed and the relaunch-on-exit flag they ticked.
|
||||
command string
|
||||
relaunch bool
|
||||
}
|
||||
|
||||
type paletteItem struct {
|
||||
@@ -26,6 +32,26 @@ type paletteItem struct {
|
||||
action paletteAction
|
||||
}
|
||||
|
||||
// paletteMode toggles the palette between its fuzzy-picker UI and the
|
||||
// freeform "spawn process" form. The form lives inside the palette so
|
||||
// it shares the same modal-input contract (every byte intercepted; no
|
||||
// PTY forwarding) without needing a second overlay.
|
||||
type paletteMode int
|
||||
|
||||
const (
|
||||
paletteModePicker paletteMode = iota
|
||||
paletteModeSpawnForm
|
||||
)
|
||||
|
||||
// spawnProcessForm is the state for the "Spawn process…" two-field
|
||||
// form: a command line plus a "relaunch on exit" toggle. Tab cycles
|
||||
// focus; space toggles the checkbox when it owns focus; Enter submits.
|
||||
type spawnProcessForm struct {
|
||||
cmd []rune
|
||||
relaunch bool
|
||||
field int // 0 = command, 1 = relaunch checkbox
|
||||
}
|
||||
|
||||
// paletteState is the in-memory model for the overlay. SPEC §4: a
|
||||
// single fuzzy-searchable list of commands scoped to the current focus.
|
||||
type paletteState struct {
|
||||
@@ -36,6 +62,9 @@ type paletteState struct {
|
||||
presets preset.Set
|
||||
|
||||
items []paletteItem
|
||||
|
||||
mode paletteMode
|
||||
form *spawnProcessForm
|
||||
}
|
||||
|
||||
// macroPrefixes maps the palette macro prefix (without trailing space)
|
||||
@@ -147,13 +176,31 @@ func (p *paletteState) allItems() []paletteItem {
|
||||
})
|
||||
}
|
||||
|
||||
// Kill entries last among the action rows, before Quit.
|
||||
// Freeform "Spawn process…" entry. Opens a sub-form for typing an
|
||||
// arbitrary command line and ticking "relaunch on exit". The action
|
||||
// kind is intercepted by acceptOrEnterForm so accept switches the
|
||||
// palette into form mode instead of closing it. Placed after the
|
||||
// preset entries so quick-spawn flows keep the same ordering as
|
||||
// before this feature landed.
|
||||
out = append(out, paletteItem{
|
||||
label: "Spawn process…",
|
||||
hint: "freeform command · optional relaunch on exit",
|
||||
action: paletteAction{kind: "spawn-process-form"},
|
||||
})
|
||||
|
||||
// Kill entries last among the action rows, before Quit. Mirror the
|
||||
// "(current)" marker from switch entries so the focused tab is
|
||||
// obvious when scanning the kill list.
|
||||
for _, c := range p.children {
|
||||
if c.Status() != StatusRunning {
|
||||
continue
|
||||
}
|
||||
label := "Kill " + c.DisplayName()
|
||||
if c.ID == p.focused {
|
||||
label = "• " + label + " (current)"
|
||||
}
|
||||
out = append(out, paletteItem{
|
||||
label: "Kill " + c.DisplayName(),
|
||||
label: label,
|
||||
hint: "SIGTERM " + strings.Join(c.Argv, " "),
|
||||
action: paletteAction{kind: "kill", childID: c.ID},
|
||||
})
|
||||
@@ -233,6 +280,9 @@ func peekArrowEvent(chunk []byte, i int) (nav byte, advance int) {
|
||||
// are consumed silently so they don't fall through to the ESC branch
|
||||
// and accidentally cancel the palette.
|
||||
func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, done bool, advance int) {
|
||||
if p.mode == paletteModeSpawnForm {
|
||||
return p.handleFormInput(chunk, i)
|
||||
}
|
||||
b := chunk[i]
|
||||
if b == 0x1b {
|
||||
if n := csiLen(chunk, i); n > 0 {
|
||||
@@ -243,7 +293,7 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
|
||||
}
|
||||
switch b {
|
||||
case '\r', '\n':
|
||||
return p.accept(), true, 1
|
||||
return p.acceptOrEnterForm(1)
|
||||
case 0x7f, 0x08:
|
||||
p.backspace()
|
||||
case 0x15: // Ctrl-U
|
||||
@@ -263,6 +313,20 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
|
||||
return paletteAction{}, false, 1
|
||||
}
|
||||
|
||||
// acceptOrEnterForm wraps accept(): if the chosen item opens the
|
||||
// spawn-process form, transition into form mode instead of returning
|
||||
// done=true. The advance count is what the caller already consumed for
|
||||
// the Enter keystroke.
|
||||
func (p *paletteState) acceptOrEnterForm(adv int) (paletteAction, bool, int) {
|
||||
a := p.accept()
|
||||
if a.kind == "spawn-process-form" {
|
||||
p.mode = paletteModeSpawnForm
|
||||
p.form = &spawnProcessForm{}
|
||||
return paletteAction{}, false, adv
|
||||
}
|
||||
return a, true, adv
|
||||
}
|
||||
|
||||
func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
|
||||
switch final {
|
||||
case 'A':
|
||||
@@ -279,7 +343,7 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio
|
||||
}
|
||||
switch k.key {
|
||||
case 13: // Enter
|
||||
return p.accept(), true, n
|
||||
return p.acceptOrEnterForm(n)
|
||||
case 27: // Escape
|
||||
return paletteAction{kind: "cancel"}, true, n
|
||||
case 127, 8: // Backspace
|
||||
@@ -314,6 +378,98 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio
|
||||
return paletteAction{}, false, n
|
||||
}
|
||||
|
||||
// handleFormInput drives the spawn-process form. Tab cycles fields,
|
||||
// space toggles the relaunch checkbox when it has focus, Enter submits,
|
||||
// Esc cancels. The form supports both legacy and kitty key encodings to
|
||||
// match handleInput; bare ESC cancels the entire palette (consistent
|
||||
// with the picker).
|
||||
func (p *paletteState) handleFormInput(chunk []byte, i int) (paletteAction, bool, int) {
|
||||
b := chunk[i]
|
||||
if b == 0x1b {
|
||||
if n := csiLen(chunk, i); n > 0 {
|
||||
return p.handleFormCSI(chunk[i+2:i+n-1], chunk[i+n-1], n)
|
||||
}
|
||||
return paletteAction{kind: "cancel"}, true, 1
|
||||
}
|
||||
switch b {
|
||||
case '\r', '\n':
|
||||
return p.submitForm(), true, 1
|
||||
case '\t':
|
||||
p.cycleFormField()
|
||||
case 0x7f, 0x08:
|
||||
p.formBackspace()
|
||||
case ' ':
|
||||
if p.form.field == 1 {
|
||||
p.form.relaunch = !p.form.relaunch
|
||||
} else if b >= 0x20 && b < 0x7f {
|
||||
p.form.cmd = append(p.form.cmd, rune(b))
|
||||
}
|
||||
default:
|
||||
if b >= 0x20 && b < 0x7f && p.form.field == 0 {
|
||||
p.form.cmd = append(p.form.cmd, rune(b))
|
||||
}
|
||||
}
|
||||
return paletteAction{}, false, 1
|
||||
}
|
||||
|
||||
func (p *paletteState) handleFormCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
|
||||
switch final {
|
||||
case 'A', 'B':
|
||||
// Arrow up/down cycles field.
|
||||
p.cycleFormField()
|
||||
return paletteAction{}, false, n
|
||||
case 'u':
|
||||
k, ok := decodeCSIu(string(params))
|
||||
if !ok || k.event != 1 {
|
||||
return paletteAction{}, false, n
|
||||
}
|
||||
switch k.key {
|
||||
case 13:
|
||||
return p.submitForm(), true, n
|
||||
case 27:
|
||||
return paletteAction{kind: "cancel"}, true, n
|
||||
case 9:
|
||||
p.cycleFormField()
|
||||
case 127, 8:
|
||||
p.formBackspace()
|
||||
case ' ':
|
||||
if p.form.field == 1 {
|
||||
p.form.relaunch = !p.form.relaunch
|
||||
}
|
||||
default:
|
||||
if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f && p.form.field == 0 {
|
||||
p.form.cmd = append(p.form.cmd, rune(k.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
return paletteAction{}, false, n
|
||||
}
|
||||
|
||||
func (p *paletteState) cycleFormField() {
|
||||
p.form.field++
|
||||
if p.form.field > 1 {
|
||||
p.form.field = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p *paletteState) formBackspace() {
|
||||
if p.form.field == 0 && len(p.form.cmd) > 0 {
|
||||
p.form.cmd = p.form.cmd[:len(p.form.cmd)-1]
|
||||
}
|
||||
}
|
||||
|
||||
func (p *paletteState) submitForm() paletteAction {
|
||||
cmd := strings.TrimSpace(string(p.form.cmd))
|
||||
if cmd == "" {
|
||||
return paletteAction{kind: "cancel"}
|
||||
}
|
||||
return paletteAction{
|
||||
kind: "spawn-process-submit",
|
||||
command: cmd,
|
||||
relaunch: p.form.relaunch,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *paletteState) accept() paletteAction {
|
||||
if p.cursor >= 0 && p.cursor < len(p.items) {
|
||||
return p.items[p.cursor].action
|
||||
@@ -352,6 +508,10 @@ func (p *paletteState) cursorDown() {
|
||||
// The caller is responsible for the screen clear before the first
|
||||
// render.
|
||||
func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||||
if p.mode == paletteModeSpawnForm {
|
||||
p.renderForm(out, cols, rows)
|
||||
return
|
||||
}
|
||||
if cols < 32 {
|
||||
cols = 32
|
||||
}
|
||||
@@ -517,6 +677,123 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||||
_ = out.Flush()
|
||||
}
|
||||
|
||||
// renderForm paints the "Spawn process…" two-field form. Layout
|
||||
// mirrors the picker (centered rounded box) so the user feels like
|
||||
// they're still inside the palette. Cursor parks at the active field
|
||||
// so it blinks where the next byte will land.
|
||||
func (p *paletteState) renderForm(out writeFlusher, cols, rows int) {
|
||||
if p.form == nil {
|
||||
p.form = &spawnProcessForm{}
|
||||
}
|
||||
if cols < 32 {
|
||||
cols = 32
|
||||
}
|
||||
if rows < 10 {
|
||||
rows = 10
|
||||
}
|
||||
width := cols - 8
|
||||
if width > 72 {
|
||||
width = 72
|
||||
}
|
||||
if width < 40 {
|
||||
width = cols - 2
|
||||
}
|
||||
if width < 32 {
|
||||
width = 32
|
||||
}
|
||||
leftPad := (cols - width) / 2
|
||||
if leftPad < 1 {
|
||||
leftPad = 1
|
||||
}
|
||||
content := width - 4
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
|
||||
|
||||
row := 2
|
||||
title := "Spawn process"
|
||||
hint := "esc cancel"
|
||||
dashes := width - 3 - len(title) - 1 - 1 - len(hint) - 3
|
||||
if dashes < 2 {
|
||||
dashes = 2
|
||||
}
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "╭─ " + styleActive + title + styleReset + styleBorder + " " +
|
||||
strings.Repeat("─", dashes) + " " + styleHint + hint + styleReset + styleBorder + " ─╮" + styleReset)
|
||||
row++
|
||||
|
||||
cmdStr := string(p.form.cmd)
|
||||
cmdLen := utf8.RuneCountInString(cmdStr)
|
||||
pad := content - 2 - cmdLen
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
cmdStr = clipRunes(cmdStr, content-2)
|
||||
cmdLen = utf8.RuneCountInString(cmdStr)
|
||||
}
|
||||
prompt := "❯"
|
||||
if p.form.field == 0 {
|
||||
prompt = styleAccent + "❯" + styleReset
|
||||
} else {
|
||||
prompt = styleDim + "❯" + styleReset
|
||||
}
|
||||
cmdRow := row
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "│" + styleReset + " " + prompt + " " + cmdStr +
|
||||
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
|
||||
row++
|
||||
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||||
row++
|
||||
|
||||
box := "[ ]"
|
||||
if p.form.relaunch {
|
||||
box = "[x]"
|
||||
}
|
||||
check := " " + box + " Relaunch on exit"
|
||||
if p.form.field == 1 {
|
||||
check = styleAccent + "▎" + styleReset + " " + styleBold + box + styleReset + " Relaunch on exit"
|
||||
}
|
||||
checkLen := visibleLen(check)
|
||||
cpad := content - checkLen
|
||||
if cpad < 0 {
|
||||
cpad = 0
|
||||
}
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "│" + styleReset + " " + check +
|
||||
strings.Repeat(" ", cpad) + " " + styleBorder + "│" + styleReset)
|
||||
row++
|
||||
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||||
row++
|
||||
|
||||
footer := "↵ spawn · esc cancel · tab cycle · space toggle"
|
||||
fLen := utf8.RuneCountInString(footer)
|
||||
fpad := content - fLen
|
||||
if fpad < 0 {
|
||||
fpad = 0
|
||||
}
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "│" + styleReset + " " + styleHint + footer + styleReset +
|
||||
strings.Repeat(" ", fpad) + " " + styleBorder + "│" + styleReset)
|
||||
row++
|
||||
|
||||
moveTo(&b, row, leftPad)
|
||||
b.WriteString(styleBorder + "╰" + strings.Repeat("─", width-2) + "╯" + styleReset)
|
||||
|
||||
// Park the cursor on the command line if that field is focused.
|
||||
if p.form.field == 0 {
|
||||
moveTo(&b, cmdRow, leftPad+4+cmdLen)
|
||||
b.WriteString("\x1b[?25h")
|
||||
} else {
|
||||
b.WriteString("\x1b[?25l")
|
||||
}
|
||||
|
||||
_, _ = out.Write([]byte(b.String()))
|
||||
_ = out.Flush()
|
||||
}
|
||||
|
||||
func clipRunes(s string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
|
||||
@@ -107,6 +107,83 @@ func TestPaletteLegacyPrintableTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "Spawn process…" is intercepted on accept: it switches the palette
|
||||
// into the form mode instead of closing it. Subsequent Enter on a
|
||||
// non-empty command line emits the submit action with relaunch reflecting
|
||||
// the checkbox state.
|
||||
func TestPaletteSpawnProcessFormFlow(t *testing.T) {
|
||||
p := newPalette(nil, "", preset.Set{})
|
||||
// The "Spawn process…" entry is the only non-Quit item with an
|
||||
// empty preset list. Locate its index by scanning items.
|
||||
idx := -1
|
||||
for i, it := range p.items {
|
||||
if it.action.kind == "spawn-process-form" {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
t.Fatalf("no spawn-process-form item in palette items: %+v", p.items)
|
||||
}
|
||||
p.cursor = idx
|
||||
|
||||
// Enter on the entry opens the form (done=false, mode flips).
|
||||
action, done, _ := p.handleInput([]byte("\r"), 0)
|
||||
if done {
|
||||
t.Fatalf("spawn-process-form accept closed palette: action=%+v", action)
|
||||
}
|
||||
if p.mode != paletteModeSpawnForm || p.form == nil {
|
||||
t.Fatalf("palette did not switch to form mode: mode=%v form=%v", p.mode, p.form)
|
||||
}
|
||||
|
||||
// Type a command: "bun run dev".
|
||||
for _, b := range []byte("bun run dev") {
|
||||
_, _, _ = p.handleInput([]byte{b}, 0)
|
||||
}
|
||||
if string(p.form.cmd) != "bun run dev" {
|
||||
t.Fatalf("form cmd = %q", string(p.form.cmd))
|
||||
}
|
||||
|
||||
// Tab to the relaunch field, toggle with space.
|
||||
_, _, _ = p.handleInput([]byte{'\t'}, 0)
|
||||
if p.form.field != 1 {
|
||||
t.Fatalf("field after tab = %d, want 1", p.form.field)
|
||||
}
|
||||
_, _, _ = p.handleInput([]byte{' '}, 0)
|
||||
if !p.form.relaunch {
|
||||
t.Fatalf("relaunch toggle didn't stick")
|
||||
}
|
||||
|
||||
// Enter submits.
|
||||
action, done, _ = p.handleInput([]byte("\r"), 0)
|
||||
if !done || action.kind != "spawn-process-submit" {
|
||||
t.Fatalf("submit didn't fire: action=%+v done=%v", action, done)
|
||||
}
|
||||
if action.command != "bun run dev" || !action.relaunch {
|
||||
t.Fatalf("submit payload = %+v", action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteSpawnProcessFormEmptyCommandCancels(t *testing.T) {
|
||||
p := newPalette(nil, "", preset.Set{})
|
||||
p.mode = paletteModeSpawnForm
|
||||
p.form = &spawnProcessForm{}
|
||||
action, done, _ := p.handleInput([]byte("\r"), 0)
|
||||
if !done || action.kind != "cancel" {
|
||||
t.Fatalf("empty submit didn't cancel: action=%+v done=%v", action, done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaletteSpawnProcessFormEscCancels(t *testing.T) {
|
||||
p := newPalette(nil, "", preset.Set{})
|
||||
p.mode = paletteModeSpawnForm
|
||||
p.form = &spawnProcessForm{cmd: []rune("x")}
|
||||
action, done, _ := p.handleInput([]byte{0x1b}, 0)
|
||||
if !done || action.kind != "cancel" {
|
||||
t.Fatalf("ESC didn't cancel form: action=%+v done=%v", action, done)
|
||||
}
|
||||
}
|
||||
|
||||
// peekArrowEvent powers the chunk-level dedupe in processStdin. The
|
||||
// scenarios below cover the patterns we've actually seen terminals
|
||||
// emit for one physical Down press: a kitty press event, a legacy CSI
|
||||
|
||||
@@ -22,6 +22,7 @@ func (st *uiState) drawSidebar() {
|
||||
st.mu.Lock()
|
||||
palOpen := st.palette != nil
|
||||
focus := st.focusedID
|
||||
activeAgent := st.activeAgentID
|
||||
st.mu.Unlock()
|
||||
if palOpen {
|
||||
return
|
||||
@@ -70,12 +71,46 @@ func (st *uiState) drawSidebar() {
|
||||
return styleHint + "●" + styleReset
|
||||
}
|
||||
|
||||
writeHeader("Session tree")
|
||||
children := visibleSessionTree(st.sess.Children(), focus)
|
||||
if len(children) == 0 {
|
||||
// Processes section — top-level command/terminal processes,
|
||||
// session-wide (does not change when the user switches agent tabs).
|
||||
writeHeader("Processes")
|
||||
procs := processList(st.sess.Children())
|
||||
if len(procs) == 0 {
|
||||
write(" " + styleDim + "(none)" + styleReset)
|
||||
}
|
||||
for _, c := range procs {
|
||||
if row > maxRow {
|
||||
break
|
||||
}
|
||||
focused := c.ID == focus
|
||||
glyph := statusGlyph(c, focused)
|
||||
marker := ""
|
||||
if c.AutoRestart() {
|
||||
marker = " " + styleDim + "⟳" + styleReset
|
||||
}
|
||||
var line string
|
||||
if focused {
|
||||
line = " " + styleAccent + "▎" + styleReset + " " + glyph + " " +
|
||||
styleBold + c.DisplayName() + styleReset + marker
|
||||
} else {
|
||||
line = " " + glyph + " " + styleHint + c.DisplayName() + styleReset + marker
|
||||
}
|
||||
write(line)
|
||||
}
|
||||
|
||||
// Agent Tree section — formerly "Session tree". Shows the active
|
||||
// agent tab's root plus its sub-agents. The active agent is pinned
|
||||
// by activeAgentID, so the tree keeps showing the right tab even
|
||||
// when focus moves into the Processes section above.
|
||||
if row+2 <= maxRow {
|
||||
write("")
|
||||
}
|
||||
writeHeader("Agent Tree")
|
||||
agents := visibleAgentTree(st.sess.Children(), activeAgent)
|
||||
if len(agents) == 0 {
|
||||
write(" " + styleDim + "(empty)" + styleReset)
|
||||
}
|
||||
for _, c := range children {
|
||||
for _, c := range agents {
|
||||
if row > maxRow {
|
||||
break
|
||||
}
|
||||
|
||||
@@ -30,8 +30,14 @@ func (st *uiState) drawTabBar() {
|
||||
return
|
||||
}
|
||||
|
||||
// Tabs list top-level agent sessions only. Command/terminal
|
||||
// processes live in the Processes sidebar section and never own a
|
||||
// tab — they're global to the session, not per-tab.
|
||||
var sessions []*Child
|
||||
for _, c := range st.sess.Children() {
|
||||
if c.Kind != KindAgent {
|
||||
continue
|
||||
}
|
||||
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||
sessions = append(sessions, c)
|
||||
}
|
||||
@@ -125,9 +131,13 @@ func (st *uiState) drawTabBar() {
|
||||
|
||||
var b strings.Builder
|
||||
// Clear both rows so a stale label from the previous frame can't
|
||||
// bleed through.
|
||||
b.WriteString("\x1b[1;1H\x1b[2K")
|
||||
b.WriteString("\x1b[2;1H\x1b[2K")
|
||||
// bleed through. Use ECH clamped to `width` (= childCols) instead of
|
||||
// `\x1b[2K`: 2K wipes the entire line including the sidebar columns,
|
||||
// and if drawSidebar's chrome cache is fresh it won't repaint to
|
||||
// fill them back in — the user sees a gap where the sidebar border
|
||||
// and content should be.
|
||||
fmt.Fprintf(&b, "\x1b[1;1H\x1b[%dX", width)
|
||||
fmt.Fprintf(&b, "\x1b[2;1H\x1b[%dX", width)
|
||||
|
||||
for _, t := range tabs {
|
||||
// Row 1: centre-ish label inside the tab cell.
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
package app
|
||||
|
||||
// visibleAgentTree returns the running entries under the active agent
|
||||
// tab (root agent + its sub-agents). With the new Processes pane,
|
||||
// command processes live in their own section and never show up here —
|
||||
// the agent tree is for KindAgent (and KindTerminal sub-entries) only.
|
||||
func visibleAgentTree(children []*Child, activeAgentID string) []*Child {
|
||||
if activeAgentID == "" {
|
||||
return nil
|
||||
}
|
||||
out := make([]*Child, 0, len(children))
|
||||
for _, c := range children {
|
||||
if c.Status() != StatusRunning {
|
||||
continue
|
||||
}
|
||||
if c.Kind == KindCommand && c.ParentID == "" {
|
||||
continue
|
||||
}
|
||||
if c.ID == activeAgentID || c.ParentID == activeAgentID {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// visibleSessionTree is retained for the test suite and any pre-Processes
|
||||
// callers — it returns the active agent's tree given the focused id,
|
||||
// resolving the active agent root from focus the same way the previous
|
||||
// implementation did.
|
||||
func visibleSessionTree(children []*Child, focusID string) []*Child {
|
||||
rootID := activeRootID(children, focusID)
|
||||
if rootID == "" {
|
||||
@@ -17,12 +44,19 @@ func visibleSessionTree(children []*Child, focusID string) []*Child {
|
||||
return out
|
||||
}
|
||||
|
||||
// activeRootID resolves the agent root the user is "inside" right now.
|
||||
// If focus is on a sub-agent, it walks up. If focus is on a top-level
|
||||
// process (KindCommand), it falls through to the first running agent
|
||||
// root so the agent tree section keeps showing something coherent.
|
||||
func activeRootID(children []*Child, focusID string) string {
|
||||
if focusID != "" {
|
||||
for _, c := range children {
|
||||
if c.ID != focusID {
|
||||
continue
|
||||
}
|
||||
if c.Kind == KindCommand && c.ParentID == "" {
|
||||
break
|
||||
}
|
||||
if c.ParentID == "" {
|
||||
return c.ID
|
||||
}
|
||||
@@ -32,7 +66,14 @@ func activeRootID(children []*Child, focusID string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return firstRunningAgentID(children)
|
||||
}
|
||||
|
||||
func firstRunningAgentID(children []*Child) string {
|
||||
for _, c := range children {
|
||||
if c.Kind != KindAgent {
|
||||
continue
|
||||
}
|
||||
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||
return c.ID
|
||||
}
|
||||
@@ -40,6 +81,23 @@ func activeRootID(children []*Child, focusID string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// processList returns every top-level command/terminal entry in spawn
|
||||
// order, regardless of running state. The Processes sidebar section
|
||||
// keeps showing exited entries so the user can see what just died (and
|
||||
// because Session retains KindCommand entries for restart).
|
||||
func processList(children []*Child) []*Child {
|
||||
out := make([]*Child, 0, len(children))
|
||||
for _, c := range children {
|
||||
if c.ParentID != "" {
|
||||
continue
|
||||
}
|
||||
if c.Kind == KindCommand || c.Kind == KindTerminal {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findChildInSnapshot(children []*Child, id string) *Child {
|
||||
for _, c := range children {
|
||||
if c.ID == id {
|
||||
@@ -58,12 +116,16 @@ func firstRunningTopLevel(children []*Child) *Child {
|
||||
return nil
|
||||
}
|
||||
|
||||
// runningTopLevels lists every running top-level session in the order
|
||||
// they appear in the snapshot — the same order the tab bar uses, so
|
||||
// Ctrl+A/D navigation matches what the user sees on screen.
|
||||
// runningTopLevels lists every running top-level agent session in the
|
||||
// order they appear in the snapshot. Tabs only show agents — command
|
||||
// processes live in the Processes sidebar section — so Ctrl+A/D
|
||||
// navigation cycles through agent tabs exclusively.
|
||||
func runningTopLevels(children []*Child) []*Child {
|
||||
out := make([]*Child, 0, len(children))
|
||||
for _, c := range children {
|
||||
if c.Kind != KindAgent {
|
||||
continue
|
||||
}
|
||||
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||
out = append(out, c)
|
||||
}
|
||||
@@ -123,11 +185,29 @@ func currentTabFlat(children []*Child, focusID string) []*Child {
|
||||
return out
|
||||
}
|
||||
|
||||
// nextChildID returns the process id `step` positions away from the
|
||||
// current focus inside its tab, wrapping at both ends. Empty when
|
||||
// there's only one process in the tab.
|
||||
func nextChildID(children []*Child, focusID string, step int) string {
|
||||
flat := currentTabFlat(children, focusID)
|
||||
// sidebarNavList combines the Processes section and the active Agent
|
||||
// Tree into one flat list — top-to-bottom matching what the user sees
|
||||
// in the sidebar. Ctrl+W/S walks this list so the user can step out of
|
||||
// the agent tree, into the Processes section, and back.
|
||||
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) {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nextChildID returns the id `step` positions away from the current
|
||||
// focus in the combined Processes + active-agent-tree navigation list,
|
||||
// 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 {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -41,9 +41,9 @@ func childIDs(cs []*Child) []string {
|
||||
}
|
||||
|
||||
func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
|
||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
||||
r2 := testChild("c2", "root2", "", StatusRunning)
|
||||
r3 := testChild("c3", "root3", "", StatusRunning)
|
||||
r1 := testAgent("c1", "root1", "", StatusRunning)
|
||||
r2 := testAgent("c2", "root2", "", StatusRunning)
|
||||
r3 := testAgent("c3", "root3", "", StatusRunning)
|
||||
children := []*Child{r1, r2, r3}
|
||||
|
||||
if got := nextTabID(children, "c1", +1); got != "c2" {
|
||||
@@ -58,9 +58,9 @@ func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) {
|
||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
||||
r1Child := testChild("c2", "child1", "c1", StatusRunning)
|
||||
r2 := testChild("c3", "root2", "", StatusRunning)
|
||||
r1 := testAgent("c1", "root1", "", StatusRunning)
|
||||
r1Child := testAgent("c2", "child1", "c1", StatusRunning)
|
||||
r2 := testAgent("c3", "root2", "", StatusRunning)
|
||||
children := []*Child{r1, r1Child, r2}
|
||||
|
||||
// Focus is on a sub-agent of root1; Ctrl+D should jump to root2,
|
||||
@@ -71,29 +71,89 @@ func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNextChildIDCyclesWithinTab(t *testing.T) {
|
||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
||||
a := testChild("c2", "a", "c1", StatusRunning)
|
||||
b := testChild("c3", "b", "c1", StatusRunning)
|
||||
other := testChild("c4", "other-root", "", StatusRunning)
|
||||
r1 := testAgent("c1", "root1", "", StatusRunning)
|
||||
a := testAgent("c2", "a", "c1", StatusRunning)
|
||||
b := testAgent("c3", "b", "c1", StatusRunning)
|
||||
other := testAgent("c4", "other-root", "", StatusRunning)
|
||||
children := []*Child{r1, a, b, other}
|
||||
|
||||
if got := nextChildID(children, "c1", +1); got != "c2" {
|
||||
if got := nextChildID(children, "c1", "c1", +1); got != "c2" {
|
||||
t.Fatalf("root->first child: %q", got)
|
||||
}
|
||||
if got := nextChildID(children, "c2", +1); got != "c3" {
|
||||
if got := nextChildID(children, "c2", "c1", +1); got != "c3" {
|
||||
t.Fatalf("a->b: %q", got)
|
||||
}
|
||||
if got := nextChildID(children, "c3", +1); got != "c1" {
|
||||
if got := nextChildID(children, "c3", "c1", +1); got != "c1" {
|
||||
t.Fatalf("wrap b->root: %q", got)
|
||||
}
|
||||
if got := nextChildID(children, "c1", -1); got != "c3" {
|
||||
if got := nextChildID(children, "c1", "c1", -1); got != "c3" {
|
||||
t.Fatalf("wrap backward root->b: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextChildIDNoopWhenOnlyOneProcess(t *testing.T) {
|
||||
r := testChild("c1", "solo", "", StatusRunning)
|
||||
if got := nextChildID([]*Child{r}, "c1", +1); got != "" {
|
||||
r := testAgent("c1", "solo", "", StatusRunning)
|
||||
if got := nextChildID([]*Child{r}, "c1", "c1", +1); got != "" {
|
||||
t.Fatalf("expected empty when only one process in tab, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// testAgent is a testChild wrapper that sets KindAgent — the new
|
||||
// navigation/visibility helpers filter by kind, so tests need explicit
|
||||
// kinds to behave like real agents.
|
||||
func testAgent(id, name, parent string, status ChildStatus) *Child {
|
||||
c := testChild(id, name, parent, status)
|
||||
c.Kind = KindAgent
|
||||
return c
|
||||
}
|
||||
|
||||
func testProcess(id, name string, status ChildStatus) *Child {
|
||||
c := testChild(id, name, "", status)
|
||||
c.Kind = KindCommand
|
||||
return c
|
||||
}
|
||||
|
||||
func TestSidebarNavListIncludesProcessesAboveAgentTree(t *testing.T) {
|
||||
p1 := testProcess("p1", "bun", StatusRunning)
|
||||
p2 := testProcess("p2", "queue", StatusRunning)
|
||||
r := testAgent("a1", "claude", "", StatusRunning)
|
||||
sub := testAgent("a2", "sub", "a1", StatusRunning)
|
||||
flat := sidebarNavList([]*Child{p1, p2, r, sub}, "a1")
|
||||
if len(flat) != 4 || flat[0].ID != "p1" || flat[1].ID != "p2" ||
|
||||
flat[2].ID != "a1" || flat[3].ID != "a2" {
|
||||
t.Fatalf("flat = %v, want p1 p2 a1 a2", childIDs(flat))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextChildIDWalksProcessesThenAgentTree(t *testing.T) {
|
||||
p1 := testProcess("p1", "bun", StatusRunning)
|
||||
r := testAgent("a1", "claude", "", StatusRunning)
|
||||
sub := testAgent("a2", "sub", "a1", StatusRunning)
|
||||
children := []*Child{p1, r, sub}
|
||||
// From a process, Ctrl+S walks down into the agent tree.
|
||||
if got := nextChildID(children, "p1", "a1", +1); got != "a1" {
|
||||
t.Fatalf("p1 -> a1: %q", got)
|
||||
}
|
||||
// From the agent root, Ctrl+W walks back up into the process list.
|
||||
if got := nextChildID(children, "a1", "a1", -1); got != "p1" {
|
||||
t.Fatalf("a1 -> p1: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVisibleAgentTreeExcludesTopLevelCommands(t *testing.T) {
|
||||
p := testProcess("p1", "bun", StatusRunning)
|
||||
r := testAgent("a1", "claude", "", StatusRunning)
|
||||
got := visibleAgentTree([]*Child{p, r}, "a1")
|
||||
if len(got) != 1 || got[0].ID != "a1" {
|
||||
t.Fatalf("agent tree = %v, want only a1", childIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningTopLevelsSkipsCommands(t *testing.T) {
|
||||
p := testProcess("p1", "bun", StatusRunning)
|
||||
r := testAgent("a1", "claude", "", StatusRunning)
|
||||
got := runningTopLevels([]*Child{p, r})
|
||||
if len(got) != 1 || got[0].ID != "a1" {
|
||||
t.Fatalf("top-levels = %v, want only a1", childIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ type viewportRenderer struct {
|
||||
// OnPTYOut consumes the flag and invalidates the sidebar chrome
|
||||
// cache so the next drawSidebar repaints over the clobber.
|
||||
scrolled bool
|
||||
|
||||
// skipUTF8 is set when the current multi-byte UTF-8 character started
|
||||
// past the viewport's right edge. The starter byte was dropped, so
|
||||
// the remaining continuation bytes must be dropped too instead of
|
||||
// leaking into the sidebar columns.
|
||||
skipUTF8 bool
|
||||
}
|
||||
|
||||
type viewportState int
|
||||
@@ -45,7 +51,7 @@ const (
|
||||
|
||||
func newViewportRenderer(l terminalLayout) *viewportRenderer {
|
||||
return &viewportRenderer{
|
||||
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows())),
|
||||
shifter: newCursorShifter(int(l.mainTop)-1, int(l.childRows()), int(l.childCols())),
|
||||
layout: l,
|
||||
row: 1,
|
||||
col: 1,
|
||||
@@ -56,7 +62,7 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
|
||||
vr.mu.Lock()
|
||||
defer vr.mu.Unlock()
|
||||
vr.layout = l
|
||||
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()))
|
||||
vr.shifter.SetGeometry(int(l.mainTop)-1, int(l.childRows()), int(l.childCols()))
|
||||
}
|
||||
|
||||
func (vr *viewportRenderer) Render(in []byte) []byte {
|
||||
@@ -98,8 +104,7 @@ func (vr *viewportRenderer) feed(b byte) {
|
||||
vr.buf = append(vr.buf, b)
|
||||
return
|
||||
}
|
||||
vr.pending.WriteByte(b)
|
||||
vr.advancePrintable(b)
|
||||
vr.feedPrintable(b)
|
||||
case vpEsc:
|
||||
vr.buf = append(vr.buf, b)
|
||||
switch b {
|
||||
@@ -286,6 +291,9 @@ func (vr *viewportRenderer) clearViewportToCursor() string {
|
||||
if col < 1 {
|
||||
col = 1
|
||||
}
|
||||
if col > cols {
|
||||
col = cols
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\x1b7")
|
||||
for r := 1; r < row; r++ {
|
||||
@@ -318,6 +326,60 @@ func (vr *viewportRenderer) clearLine(n int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// right edge (childCols) are dropped so a child whose internal column
|
||||
// state drifted past the viewport can't spray into the sidebar columns.
|
||||
// UTF-8 continuation bytes follow the fate of their starter so a
|
||||
// multi-byte glyph drops as a unit.
|
||||
func (vr *viewportRenderer) feedPrintable(b byte) {
|
||||
// Control codes (CR, LF, BS, TAB, BEL, etc.) move the cursor or
|
||||
// signal state and must always be forwarded. They never produce
|
||||
// glyphs, so they can't clobber the sidebar themselves.
|
||||
if b < 0x20 || b == 0x7f {
|
||||
vr.pending.WriteByte(b)
|
||||
switch b {
|
||||
case '\r':
|
||||
vr.col = 1
|
||||
case '\n':
|
||||
vr.row++
|
||||
case '\b':
|
||||
if vr.col > 1 {
|
||||
vr.col--
|
||||
}
|
||||
case '\t':
|
||||
vr.col += 8 - ((vr.col - 1) % 8)
|
||||
}
|
||||
vr.skipUTF8 = false
|
||||
vr.clampCursor()
|
||||
return
|
||||
}
|
||||
// UTF-8 continuation byte (10xxxxxx) belongs to the current glyph.
|
||||
if b >= 0x80 && b < 0xC0 {
|
||||
if vr.skipUTF8 {
|
||||
return
|
||||
}
|
||||
vr.pending.WriteByte(b)
|
||||
return
|
||||
}
|
||||
// Glyph starter (ASCII 0x20..0x7E or UTF-8 leading byte 0xC0+). If
|
||||
// the cursor sits past the viewport we'd be spraying into the
|
||||
// sidebar columns — drop the glyph (and the continuation bytes that
|
||||
// follow, via skipUTF8).
|
||||
maxCol := int(vr.layout.childCols())
|
||||
if maxCol > 0 && vr.col > maxCol {
|
||||
vr.skipUTF8 = b >= 0xC0
|
||||
return
|
||||
}
|
||||
vr.skipUTF8 = false
|
||||
vr.pending.WriteByte(b)
|
||||
vr.col++
|
||||
vr.clampCursor()
|
||||
}
|
||||
|
||||
// advancePrintable is retained for tests that exercise cursor tracking
|
||||
// directly; the runtime path goes through feedPrintable.
|
||||
func (vr *viewportRenderer) advancePrintable(b byte) {
|
||||
switch b {
|
||||
case '\r':
|
||||
@@ -331,7 +393,7 @@ func (vr *viewportRenderer) advancePrintable(b byte) {
|
||||
case '\t':
|
||||
vr.col += 8 - ((vr.col - 1) % 8)
|
||||
default:
|
||||
if b >= 0x20 && b != 0x7f {
|
||||
if b >= 0x20 && b != 0x7f && (b < 0x80 || b >= 0xC0) {
|
||||
vr.col++
|
||||
}
|
||||
}
|
||||
@@ -389,7 +451,15 @@ func (vr *viewportRenderer) clampCursor() {
|
||||
if max := int(vr.layout.childRows()); vr.row > max {
|
||||
vr.row = max
|
||||
}
|
||||
if max := int(vr.layout.childCols()); vr.col > max {
|
||||
vr.col = max
|
||||
// Intentionally do NOT clamp vr.col to childCols here. feedPrintable
|
||||
// drops glyphs once vr.col exceeds childCols (so a child whose
|
||||
// internal column state drifted past the viewport can't spray bytes
|
||||
// into the sidebar). If we clamped col back to childCols on every
|
||||
// printable, every subsequent byte would look like it was still "at
|
||||
// the right margin" and would write again. We cap at childCols+1
|
||||
// instead so clear-line bookkeeping doesn't see arbitrarily large
|
||||
// numbers.
|
||||
if max := int(vr.layout.childCols()); vr.col > max+1 {
|
||||
vr.col = max + 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func bytesRepeat(b byte, n int) []byte {
|
||||
out := make([]byte, n)
|
||||
for i := range out {
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestViewportRendererShiftsCursor(t *testing.T) {
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
got := string(vr.Render([]byte("\x1b[H")))
|
||||
@@ -103,6 +111,65 @@ func TestViewportRendererTracksPrintableCursor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererClampsCUPColumn(t *testing.T) {
|
||||
// Layout: hostCols=120, sidebar present → childCols=91. A child that
|
||||
// thinks its viewport is the full host width could emit a CUP to col
|
||||
// 95 (inside the sidebar). The renderer must clamp the emitted CUP
|
||||
// column so the host cursor never lands in the sidebar.
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
got := string(vr.Render([]byte("\x1b[5;95H")))
|
||||
if !strings.Contains(got, "\x1b[7;91H") {
|
||||
t.Fatalf("CUP col 95 should clamp to 91 (childCols): got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererClampsCHAColumn(t *testing.T) {
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
got := string(vr.Render([]byte("\x1b[110G")))
|
||||
if !strings.Contains(got, "\x1b[91G") {
|
||||
t.Fatalf("CHA col 110 should clamp to 91 (childCols): got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererDropsPrintablesPastViewport(t *testing.T) {
|
||||
// A child whose internal column state drifted past the viewport
|
||||
// (childCols=91 here) might CUP to col 95 and stream text. The CUP
|
||||
// column is clamped to the viewport edge, but tracking still
|
||||
// considers the cursor "past" — so subsequent printables must drop
|
||||
// rather than walk into the sidebar columns.
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
got := string(vr.Render([]byte("\x1b[5;95HCLOBBER")))
|
||||
if strings.Contains(got, "CLOBBER") || strings.Contains(got, "LOBBER") {
|
||||
t.Fatalf("printables past childCols should be dropped: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererKeepsPrintablesUpToViewportEdge(t *testing.T) {
|
||||
// Writing exactly childCols glyphs from col 1 must reach the right
|
||||
// edge unchanged — the drop kicks in only after the cursor passes
|
||||
// the last viewport column.
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
in := append([]byte("\x1b[5;1H"), bytesRepeat('x', 91)...)
|
||||
got := string(vr.Render(in))
|
||||
if strings.Count(got, "x") != 91 {
|
||||
t.Fatalf("91 'x' glyphs from col 1 should all be emitted: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererDropsUTF8GlyphPastViewport(t *testing.T) {
|
||||
// A 3-byte UTF-8 glyph (U+2500 BOX DRAWINGS LIGHT HORIZONTAL) starting
|
||||
// past the viewport must be dropped as a unit — leaking even one
|
||||
// continuation byte would feed a malformed sequence to the host.
|
||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||
got := string(vr.Render([]byte("\x1b[5;95H─x")))
|
||||
if strings.Contains(got, "─") {
|
||||
t.Fatalf("UTF-8 glyph past viewport should be dropped: got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "x") {
|
||||
t.Fatalf("trailing ASCII past viewport should also be dropped: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewportRendererFlagsRIAsScrolling(t *testing.T) {
|
||||
// Reproduces the sidebar-gap bug: codex emits `\x1b[1;1H` followed
|
||||
// by 8× `\x1bM` (RI) on startup. RI at the top of the host scroll
|
||||
|
||||
@@ -30,6 +30,10 @@ func EncodeChord(name string) ([]byte, error) {
|
||||
return []byte{0x10}, nil
|
||||
case "ctrl-u":
|
||||
return []byte{0x15}, nil
|
||||
case "tab":
|
||||
return []byte{'\t'}, nil
|
||||
case "space":
|
||||
return []byte{' '}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown chord %q", name)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
},
|
||||
{ "type": "wait_text", "contains": "RIBURST READY", "timeout_ms": 5000 },
|
||||
{ "type": "wait_stable", "timeout_ms": 2000 },
|
||||
{ "type": "assert_contains", "contains": "Session tree" },
|
||||
{ "type": "assert_contains", "contains": "Processes" },
|
||||
{ "type": "assert_contains", "contains": "Agent Tree" },
|
||||
{ "type": "assert_contains", "contains": "● riburst" },
|
||||
{ "type": "assert_contains", "contains": "Scratchpads" },
|
||||
{
|
||||
"type": "assert_regex",
|
||||
"regex": "(?s)Session tree[^\\n]*\\n[^─\\n]*─[─]+[^\\n]*\\n[^●\\n]*● riburst",
|
||||
"regex": "(?s)Processes[^\\n]*\\n[^─\\n]*─[─]+[^\\n]*\\n[^●\\n]*● riburst",
|
||||
"timeout_ms": 2000
|
||||
}
|
||||
]
|
||||
|
||||
27
internal/harness/scenarios/sidebar_survives_wide_writes.json
Normal file
27
internal/harness/scenarios/sidebar_survives_wide_writes.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "sidebar_survives_wide_writes",
|
||||
"cols": 120,
|
||||
"rows": 40,
|
||||
"scripts": [
|
||||
{
|
||||
"name": "widewrite",
|
||||
"body": "#!/bin/sh\n# Reproduces a long-running TUI whose internal column state drifted\n# past the viewport width (the symptom seen in fucked-up-terminal.txt:\n# claude's input box drew a horizontal divider all the way to the host\n# edge, overwriting the sidebar). The widths here are: cols=120,\n# sidebarCols=28, so the viewport is 91 cols wide and the sidebar\n# border lives at col 92. Anything the child writes at col >= 92 lands\n# in the sidebar unless patterm defensively clamps it.\n#\n# After WIDE READY, emit 12 throw-away chunks to exhaust the focus\n# snapshot replay budget (8 chunks) so the wide-write clobber goes\n# through the *incremental* viewport renderer path. That's the path\n# that long-running sessions stay on — without clamping it can spray\n# bytes into the sidebar.\nprintf 'WIDE READY\\n'\ni=0\nwhile [ $i -lt 12 ]; do\n printf 'tick %d\\n' \"$i\"\n i=$((i + 1))\n sleep 0.05\ndone\nprintf 'PRE-CLOBBER\\n'\nsleep 0.2\nprintf '\\033[5;95HCLOBBER-CUP'\nsleep 0.1\nprintf '\\033[7;100HCLOBBER-CUP2'\nsleep 0.1\nprintf '\\033[9;1H'\nprintf '\\033[110GCLOBBER-CHA'\nprintf '\\nDONE\\n'\nsleep 5\n"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"type": "mcp_call",
|
||||
"method": "spawn_process",
|
||||
"params": { "kind": "command", "argv": ["widewrite"], "name": "widewrite" }
|
||||
},
|
||||
{ "type": "wait_text", "contains": "WIDE READY", "timeout_ms": 5000 },
|
||||
{ "type": "wait_text", "contains": "DONE", "timeout_ms": 5000 },
|
||||
{ "type": "wait_stable", "timeout_ms": 2000 },
|
||||
{ "type": "assert_contains", "contains": "Agent Tree" },
|
||||
{ "type": "assert_contains", "contains": "Processes" },
|
||||
{ "type": "assert_contains", "contains": "Scratchpads" },
|
||||
{ "type": "assert_contains", "contains": "● widewrite" },
|
||||
{ "type": "assert_not_contains", "contains": "CLOBBER-CUP" },
|
||||
{ "type": "assert_not_contains", "contains": "CLOBBER-CHA" }
|
||||
]
|
||||
}
|
||||
31
internal/harness/scenarios/spawn_process_form.json
Normal file
31
internal/harness/scenarios/spawn_process_form.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "spawn_process_form",
|
||||
"cols": 100,
|
||||
"rows": 30,
|
||||
"scripts": [
|
||||
{
|
||||
"name": "formfixture",
|
||||
"body": "#!/bin/sh\necho FORM-READY\nsleep 5\n"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{ "type": "wait_stable", "timeout_ms": 3000 },
|
||||
{ "type": "send_chord", "chord": "ctrl-k" },
|
||||
{ "type": "send_text", "text": "Spawn process" },
|
||||
{ "type": "send_chord", "chord": "enter" },
|
||||
{ "type": "wait_text", "contains": "Spawn process", "timeout_ms": 3000 },
|
||||
{ "type": "send_text", "text": "formfixture" },
|
||||
{ "type": "send_chord", "chord": "tab" },
|
||||
{ "type": "send_chord", "chord": "space" },
|
||||
{ "type": "send_chord", "chord": "enter" },
|
||||
{ "type": "wait_text", "contains": "FORM-READY", "timeout_ms": 5000 },
|
||||
{
|
||||
"type": "assert_mcp",
|
||||
"method": "list_processes",
|
||||
"path": "0.status",
|
||||
"equals": "running"
|
||||
},
|
||||
{ "type": "assert_contains", "contains": "Processes" },
|
||||
{ "type": "assert_contains", "contains": "⟳" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user