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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.0.1] - 2026-05-14
|
||||||
|
|
||||||
### Fixed
|
### 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/
|
- Sub-agent panes spawned while another diff-based TUI (claude/codex/
|
||||||
opencode) held focus could come up corrupted because the new child's
|
opencode) held focus could come up corrupted because the new child's
|
||||||
first incremental updates targeted cells the host viewport hadn't
|
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
|
styled emulator grid — the same path that fixed the symptom when the
|
||||||
user manually cycled focus with Ctrl+W / Ctrl+S.
|
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
|
### 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
|
- New `lifecycle` help topic spelling out that the caller owns the
|
||||||
processes it spawns and should call `close_process` when a sub-agent
|
processes it spawns and should call `close_process` when a sub-agent
|
||||||
or spawned process is no longer needed. The `spawn_agent` and
|
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.
|
available macros.
|
||||||
|
|
||||||
### Changed
|
### 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
|
- Focus, lifecycle, and repaint paths now capture terminal layout before
|
||||||
taking UI state locks, reducing resize-time deadlock risk without
|
taking UI state locks, reducing resize-time deadlock risk without
|
||||||
changing visible behavior.
|
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
|
- Investigated 2026-05-14: patterm passes ghostty grapheme codepoints
|
||||||
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
|
through unchanged (vt/ghostty.go:452-462), so the `<?>` glyph is
|
||||||
most likely the *host* terminal's font fallback for opencode's
|
most likely the *host* terminal's font fallback for opencode's
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
cpty "github.com/creack/pty"
|
cpty "github.com/creack/pty"
|
||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
@@ -213,6 +214,11 @@ type uiState struct {
|
|||||||
palette *paletteState
|
palette *paletteState
|
||||||
focusedID string
|
focusedID string
|
||||||
focusedName 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.
|
// renderer confines focused-child live output to the main viewport.
|
||||||
// A fresh renderer is allocated per focused child so partial-escape
|
// A fresh renderer is allocated per focused child so partial-escape
|
||||||
// state cannot bleed between panes.
|
// state cannot bleed between panes.
|
||||||
@@ -283,6 +289,7 @@ func (st *uiState) focusProcess(processID string) {
|
|||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
st.focusedID = c.ID
|
st.focusedID = c.ID
|
||||||
st.focusedName = c.DisplayName()
|
st.focusedName = c.DisplayName()
|
||||||
|
st.updateActiveAgentLocked(c)
|
||||||
st.renderer = newViewportRenderer(layout)
|
st.renderer = newViewportRenderer(layout)
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
st.repaintFocused()
|
st.repaintFocused()
|
||||||
@@ -291,6 +298,33 @@ func (st *uiState) focusProcess(processID string) {
|
|||||||
st.drawStatusLine()
|
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
|
// notifyAttention is the request_human_attention sink (SPEC §7). We
|
||||||
// surface a one-line toast in the status row and remember the most
|
// 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
|
// 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.mu.Lock()
|
||||||
st.focusedID = c.ID
|
st.focusedID = c.ID
|
||||||
st.focusedName = c.DisplayName()
|
st.focusedName = c.DisplayName()
|
||||||
|
st.updateActiveAgentLocked(c)
|
||||||
renderer := newViewportRenderer(layout)
|
renderer := newViewportRenderer(layout)
|
||||||
st.renderer = renderer
|
st.renderer = renderer
|
||||||
palOpen := st.palette != nil
|
palOpen := st.palette != nil
|
||||||
@@ -377,9 +412,15 @@ func (st *uiState) OnChildExited(c *Child) {
|
|||||||
} else {
|
} else {
|
||||||
st.focusedID = next.ID
|
st.focusedID = next.ID
|
||||||
st.focusedName = next.DisplayName()
|
st.focusedName = next.DisplayName()
|
||||||
|
st.updateActiveAgentLocked(next)
|
||||||
st.renderer = newViewportRenderer(layout)
|
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 {
|
if st.palette != nil {
|
||||||
st.palette.children = st.sess.Children()
|
st.palette.children = st.sess.Children()
|
||||||
st.palette.focused = st.focusedID
|
st.palette.focused = st.focusedID
|
||||||
@@ -397,6 +438,41 @@ func (st *uiState) OnChildExited(c *Child) {
|
|||||||
st.drawTabBar()
|
st.drawTabBar()
|
||||||
st.drawSidebar()
|
st.drawSidebar()
|
||||||
st.drawStatusLine()
|
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
|
// OnPTYOut writes live output for the focused child when the palette is
|
||||||
@@ -563,7 +639,18 @@ func (st *uiState) drawStatusLine() {
|
|||||||
if trustMsg != "" {
|
if trustMsg != "" {
|
||||||
left = "[trust] " + 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)
|
pad := int(cols) - len(left) - len(right)
|
||||||
if pad < 1 {
|
if pad < 1 {
|
||||||
@@ -831,13 +918,13 @@ func (st *uiState) processStdin(chunk []byte) {
|
|||||||
}
|
}
|
||||||
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
|
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
|
||||||
flushForward()
|
flushForward()
|
||||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, -1)
|
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, -1)
|
||||||
i += adv
|
i += adv
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
|
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
|
||||||
flushForward()
|
flushForward()
|
||||||
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, +1)
|
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, st.activeAgentID, +1)
|
||||||
i += adv
|
i += adv
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -916,6 +1003,36 @@ func (st *uiState) closePalette(action paletteAction) {
|
|||||||
st.flashError(fmt.Sprintf("spawn %s: %v", action.preset.Name, err))
|
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":
|
case "switch":
|
||||||
c := st.sess.FindChild(action.childID)
|
c := st.sess.FindChild(action.childID)
|
||||||
if c == nil || c.Status() != StatusRunning {
|
if c == nil || c.Status() != StatusRunning {
|
||||||
@@ -926,6 +1043,7 @@ func (st *uiState) closePalette(action paletteAction) {
|
|||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
st.focusedID = action.childID
|
st.focusedID = action.childID
|
||||||
st.focusedName = c.DisplayName()
|
st.focusedName = c.DisplayName()
|
||||||
|
st.updateActiveAgentLocked(c)
|
||||||
st.renderer = newViewportRenderer(layout)
|
st.renderer = newViewportRenderer(layout)
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
st.repaintFocused()
|
st.repaintFocused()
|
||||||
@@ -934,6 +1052,11 @@ func (st *uiState) closePalette(action paletteAction) {
|
|||||||
st.drawStatusLine()
|
st.drawStatusLine()
|
||||||
|
|
||||||
case "kill":
|
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.sess.Kill(action.childID, syscall.SIGTERM)
|
||||||
st.repaintFocused()
|
st.repaintFocused()
|
||||||
st.drawTabBar()
|
st.drawTabBar()
|
||||||
|
|||||||
@@ -121,8 +121,17 @@ type Child struct {
|
|||||||
cleanupMu sync.Mutex
|
cleanupMu sync.Mutex
|
||||||
cleanupPaths []string
|
cleanupPaths []string
|
||||||
restarting atomic.Bool
|
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.
|
// PortSighting is one entry returned by get_process_ports.
|
||||||
type PortSighting struct {
|
type PortSighting struct {
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
type cursorShifter struct {
|
type cursorShifter struct {
|
||||||
rowOffset int
|
rowOffset int
|
||||||
childRows int // viewport height in child rows; used for DECSTBM resets
|
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
|
state shifterState
|
||||||
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
|
buf []byte // bytes accumulated in current escape sequence (incl. introducer)
|
||||||
@@ -45,13 +46,25 @@ const (
|
|||||||
stSOSPMAPCEsc
|
stSOSPMAPCEsc
|
||||||
)
|
)
|
||||||
|
|
||||||
func newCursorShifter(rowOffset, childRows int) *cursorShifter {
|
func newCursorShifter(rowOffset, childRows, childCols int) *cursorShifter {
|
||||||
return &cursorShifter{rowOffset: rowOffset, childRows: childRows}
|
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.rowOffset = rowOffset
|
||||||
cs.childRows = childRows
|
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
|
// Shift consumes a chunk of PTY-master bytes, applies row offsets to
|
||||||
@@ -194,11 +207,24 @@ func (cs *cursorShifter) emitCSI() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
r += cs.rowOffset
|
r += cs.rowOffset
|
||||||
|
c = cs.clampCol(c)
|
||||||
cs.pending.WriteString("\x1b[")
|
cs.pending.WriteString("\x1b[")
|
||||||
cs.pending.WriteString(strconv.Itoa(r))
|
cs.pending.WriteString(strconv.Itoa(r))
|
||||||
cs.pending.WriteByte(';')
|
cs.pending.WriteByte(';')
|
||||||
cs.pending.WriteString(strconv.Itoa(c))
|
cs.pending.WriteString(strconv.Itoa(c))
|
||||||
cs.pending.WriteByte(final)
|
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':
|
case 'd':
|
||||||
// VPA: row.
|
// VPA: row.
|
||||||
r, ok := parseOneParam(paramsRaw, 1)
|
r, ok := parseOneParam(paramsRaw, 1)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestCursorShifterCUP(t *testing.T) {
|
func TestCursorShifterCUP(t *testing.T) {
|
||||||
cs := newCursorShifter(1, 36)
|
cs := newCursorShifter(1, 36, 80)
|
||||||
got := cs.Shift([]byte("\x1b[H"))
|
got := cs.Shift([]byte("\x1b[H"))
|
||||||
want := []byte("\x1b[2;1H")
|
want := []byte("\x1b[2;1H")
|
||||||
if !bytes.Equal(got, want) {
|
if !bytes.Equal(got, want) {
|
||||||
@@ -15,7 +15,7 @@ func TestCursorShifterCUP(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterCUPRowCol(t *testing.T) {
|
func TestCursorShifterCUPRowCol(t *testing.T) {
|
||||||
cs := newCursorShifter(1, 36)
|
cs := newCursorShifter(1, 36, 80)
|
||||||
got := cs.Shift([]byte("\x1b[10;5H"))
|
got := cs.Shift([]byte("\x1b[10;5H"))
|
||||||
if string(got) != "\x1b[11;5H" {
|
if string(got) != "\x1b[11;5H" {
|
||||||
t.Fatalf("CUP 10;5: got %q", got)
|
t.Fatalf("CUP 10;5: got %q", got)
|
||||||
@@ -23,7 +23,7 @@ func TestCursorShifterCUPRowCol(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterVPA(t *testing.T) {
|
func TestCursorShifterVPA(t *testing.T) {
|
||||||
cs := newCursorShifter(1, 36)
|
cs := newCursorShifter(1, 36, 80)
|
||||||
got := cs.Shift([]byte("\x1b[7d"))
|
got := cs.Shift([]byte("\x1b[7d"))
|
||||||
if string(got) != "\x1b[8d" {
|
if string(got) != "\x1b[8d" {
|
||||||
t.Fatalf("VPA 7: got %q", got)
|
t.Fatalf("VPA 7: got %q", got)
|
||||||
@@ -31,7 +31,7 @@ func TestCursorShifterVPA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterDECSTBM(t *testing.T) {
|
func TestCursorShifterDECSTBM(t *testing.T) {
|
||||||
cs := newCursorShifter(1, 36)
|
cs := newCursorShifter(1, 36, 80)
|
||||||
got := cs.Shift([]byte("\x1b[2;20r"))
|
got := cs.Shift([]byte("\x1b[2;20r"))
|
||||||
if string(got) != "\x1b[3;21r" {
|
if string(got) != "\x1b[3;21r" {
|
||||||
t.Fatalf("DECSTBM: got %q", got)
|
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
|
// region — that's what was scrolling claude's content up after a
|
||||||
// focus switch from codex.
|
// focus switch from codex.
|
||||||
func TestCursorShifterDECSTBMEmptyResetsToViewport(t *testing.T) {
|
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"))
|
got := cs.Shift([]byte("\x1b[r"))
|
||||||
if string(got) != "\x1b[4;39r" {
|
if string(got) != "\x1b[4;39r" {
|
||||||
t.Fatalf("empty DECSTBM reset: got %q want \\x1b[4;39r", got)
|
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) {
|
func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
|
||||||
cs := newCursorShifter(1, 36)
|
cs := newCursorShifter(1, 36, 80)
|
||||||
// Alt-screen toggle — private CSI.
|
// Alt-screen toggle — private CSI.
|
||||||
got := cs.Shift([]byte("\x1b[?1049h"))
|
got := cs.Shift([]byte("\x1b[?1049h"))
|
||||||
if string(got) != "\x1b[?1049h" {
|
if string(got) != "\x1b[?1049h" {
|
||||||
@@ -60,7 +60,7 @@ func TestCursorShifterPrivateCSIPassthrough(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterSGRPassthrough(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"))
|
got := cs.Shift([]byte("\x1b[1;31mhello\x1b[0m"))
|
||||||
if string(got) != "\x1b[1;31mhello\x1b[0m" {
|
if string(got) != "\x1b[1;31mhello\x1b[0m" {
|
||||||
t.Fatalf("SGR: got %q", got)
|
t.Fatalf("SGR: got %q", got)
|
||||||
@@ -68,7 +68,7 @@ func TestCursorShifterSGRPassthrough(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterStraddleChunks(t *testing.T) {
|
func TestCursorShifterStraddleChunks(t *testing.T) {
|
||||||
cs := newCursorShifter(1, 36)
|
cs := newCursorShifter(1, 36, 80)
|
||||||
a := cs.Shift([]byte("\x1b["))
|
a := cs.Shift([]byte("\x1b["))
|
||||||
b := cs.Shift([]byte("5;3H"))
|
b := cs.Shift([]byte("5;3H"))
|
||||||
got := string(a) + string(b)
|
got := string(a) + string(b)
|
||||||
@@ -78,7 +78,7 @@ func TestCursorShifterStraddleChunks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCursorShifterOSCNotRewritten(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
|
// OSC body containing what looks like a CSI cursor move — should
|
||||||
// NOT be rewritten.
|
// NOT be rewritten.
|
||||||
in := []byte("\x1b]0;\x1b[5;3Htitle\x07")
|
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)
|
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.
|
// paletteAction is what the palette returns when the user picks an item.
|
||||||
type paletteAction struct {
|
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
|
kind string
|
||||||
|
|
||||||
// For spawn-*, the preset to launch.
|
// For spawn-agent / spawn-process, the preset to launch.
|
||||||
preset *preset.Preset
|
preset *preset.Preset
|
||||||
|
|
||||||
// For "switch" and "kill", the target child id.
|
// For "switch" and "kill", the target child id.
|
||||||
childID string
|
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 {
|
type paletteItem struct {
|
||||||
@@ -26,6 +32,26 @@ type paletteItem struct {
|
|||||||
action paletteAction
|
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
|
// paletteState is the in-memory model for the overlay. SPEC §4: a
|
||||||
// single fuzzy-searchable list of commands scoped to the current focus.
|
// single fuzzy-searchable list of commands scoped to the current focus.
|
||||||
type paletteState struct {
|
type paletteState struct {
|
||||||
@@ -36,6 +62,9 @@ type paletteState struct {
|
|||||||
presets preset.Set
|
presets preset.Set
|
||||||
|
|
||||||
items []paletteItem
|
items []paletteItem
|
||||||
|
|
||||||
|
mode paletteMode
|
||||||
|
form *spawnProcessForm
|
||||||
}
|
}
|
||||||
|
|
||||||
// macroPrefixes maps the palette macro prefix (without trailing space)
|
// 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 {
|
for _, c := range p.children {
|
||||||
if c.Status() != StatusRunning {
|
if c.Status() != StatusRunning {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
label := "Kill " + c.DisplayName()
|
||||||
|
if c.ID == p.focused {
|
||||||
|
label = "• " + label + " (current)"
|
||||||
|
}
|
||||||
out = append(out, paletteItem{
|
out = append(out, paletteItem{
|
||||||
label: "Kill " + c.DisplayName(),
|
label: label,
|
||||||
hint: "SIGTERM " + strings.Join(c.Argv, " "),
|
hint: "SIGTERM " + strings.Join(c.Argv, " "),
|
||||||
action: paletteAction{kind: "kill", childID: c.ID},
|
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
|
// are consumed silently so they don't fall through to the ESC branch
|
||||||
// and accidentally cancel the palette.
|
// and accidentally cancel the palette.
|
||||||
func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, done bool, advance int) {
|
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]
|
b := chunk[i]
|
||||||
if b == 0x1b {
|
if b == 0x1b {
|
||||||
if n := csiLen(chunk, i); n > 0 {
|
if n := csiLen(chunk, i); n > 0 {
|
||||||
@@ -243,7 +293,7 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
|
|||||||
}
|
}
|
||||||
switch b {
|
switch b {
|
||||||
case '\r', '\n':
|
case '\r', '\n':
|
||||||
return p.accept(), true, 1
|
return p.acceptOrEnterForm(1)
|
||||||
case 0x7f, 0x08:
|
case 0x7f, 0x08:
|
||||||
p.backspace()
|
p.backspace()
|
||||||
case 0x15: // Ctrl-U
|
case 0x15: // Ctrl-U
|
||||||
@@ -263,6 +313,20 @@ func (p *paletteState) handleInput(chunk []byte, i int) (action paletteAction, d
|
|||||||
return paletteAction{}, false, 1
|
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) {
|
func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteAction, bool, int) {
|
||||||
switch final {
|
switch final {
|
||||||
case 'A':
|
case 'A':
|
||||||
@@ -279,7 +343,7 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio
|
|||||||
}
|
}
|
||||||
switch k.key {
|
switch k.key {
|
||||||
case 13: // Enter
|
case 13: // Enter
|
||||||
return p.accept(), true, n
|
return p.acceptOrEnterForm(n)
|
||||||
case 27: // Escape
|
case 27: // Escape
|
||||||
return paletteAction{kind: "cancel"}, true, n
|
return paletteAction{kind: "cancel"}, true, n
|
||||||
case 127, 8: // Backspace
|
case 127, 8: // Backspace
|
||||||
@@ -314,6 +378,98 @@ func (p *paletteState) handleCSI(params []byte, final byte, n int) (paletteActio
|
|||||||
return paletteAction{}, false, n
|
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 {
|
func (p *paletteState) accept() paletteAction {
|
||||||
if p.cursor >= 0 && p.cursor < len(p.items) {
|
if p.cursor >= 0 && p.cursor < len(p.items) {
|
||||||
return p.items[p.cursor].action
|
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
|
// The caller is responsible for the screen clear before the first
|
||||||
// render.
|
// render.
|
||||||
func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
||||||
|
if p.mode == paletteModeSpawnForm {
|
||||||
|
p.renderForm(out, cols, rows)
|
||||||
|
return
|
||||||
|
}
|
||||||
if cols < 32 {
|
if cols < 32 {
|
||||||
cols = 32
|
cols = 32
|
||||||
}
|
}
|
||||||
@@ -517,6 +677,123 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
|
|||||||
_ = out.Flush()
|
_ = 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 {
|
func clipRunes(s string, n int) string {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return ""
|
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
|
// peekArrowEvent powers the chunk-level dedupe in processStdin. The
|
||||||
// scenarios below cover the patterns we've actually seen terminals
|
// scenarios below cover the patterns we've actually seen terminals
|
||||||
// emit for one physical Down press: a kitty press event, a legacy CSI
|
// emit for one physical Down press: a kitty press event, a legacy CSI
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func (st *uiState) drawSidebar() {
|
|||||||
st.mu.Lock()
|
st.mu.Lock()
|
||||||
palOpen := st.palette != nil
|
palOpen := st.palette != nil
|
||||||
focus := st.focusedID
|
focus := st.focusedID
|
||||||
|
activeAgent := st.activeAgentID
|
||||||
st.mu.Unlock()
|
st.mu.Unlock()
|
||||||
if palOpen {
|
if palOpen {
|
||||||
return
|
return
|
||||||
@@ -70,12 +71,46 @@ func (st *uiState) drawSidebar() {
|
|||||||
return styleHint + "●" + styleReset
|
return styleHint + "●" + styleReset
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHeader("Session tree")
|
// Processes section — top-level command/terminal processes,
|
||||||
children := visibleSessionTree(st.sess.Children(), focus)
|
// session-wide (does not change when the user switches agent tabs).
|
||||||
if len(children) == 0 {
|
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)
|
write(" " + styleDim + "(empty)" + styleReset)
|
||||||
}
|
}
|
||||||
for _, c := range children {
|
for _, c := range agents {
|
||||||
if row > maxRow {
|
if row > maxRow {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,14 @@ func (st *uiState) drawTabBar() {
|
|||||||
return
|
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
|
var sessions []*Child
|
||||||
for _, c := range st.sess.Children() {
|
for _, c := range st.sess.Children() {
|
||||||
|
if c.Kind != KindAgent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if c.ParentID == "" && c.Status() == StatusRunning {
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||||
sessions = append(sessions, c)
|
sessions = append(sessions, c)
|
||||||
}
|
}
|
||||||
@@ -125,9 +131,13 @@ func (st *uiState) drawTabBar() {
|
|||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
// Clear both rows so a stale label from the previous frame can't
|
// Clear both rows so a stale label from the previous frame can't
|
||||||
// bleed through.
|
// bleed through. Use ECH clamped to `width` (= childCols) instead of
|
||||||
b.WriteString("\x1b[1;1H\x1b[2K")
|
// `\x1b[2K`: 2K wipes the entire line including the sidebar columns,
|
||||||
b.WriteString("\x1b[2;1H\x1b[2K")
|
// 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 {
|
for _, t := range tabs {
|
||||||
// Row 1: centre-ish label inside the tab cell.
|
// Row 1: centre-ish label inside the tab cell.
|
||||||
|
|||||||
@@ -1,5 +1,32 @@
|
|||||||
package app
|
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 {
|
func visibleSessionTree(children []*Child, focusID string) []*Child {
|
||||||
rootID := activeRootID(children, focusID)
|
rootID := activeRootID(children, focusID)
|
||||||
if rootID == "" {
|
if rootID == "" {
|
||||||
@@ -17,12 +44,19 @@ func visibleSessionTree(children []*Child, focusID string) []*Child {
|
|||||||
return out
|
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 {
|
func activeRootID(children []*Child, focusID string) string {
|
||||||
if focusID != "" {
|
if focusID != "" {
|
||||||
for _, c := range children {
|
for _, c := range children {
|
||||||
if c.ID != focusID {
|
if c.ID != focusID {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if c.Kind == KindCommand && c.ParentID == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
if c.ParentID == "" {
|
if c.ParentID == "" {
|
||||||
return c.ID
|
return c.ID
|
||||||
}
|
}
|
||||||
@@ -32,7 +66,14 @@ func activeRootID(children []*Child, focusID string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return firstRunningAgentID(children)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstRunningAgentID(children []*Child) string {
|
||||||
for _, c := range children {
|
for _, c := range children {
|
||||||
|
if c.Kind != KindAgent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if c.ParentID == "" && c.Status() == StatusRunning {
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||||
return c.ID
|
return c.ID
|
||||||
}
|
}
|
||||||
@@ -40,6 +81,23 @@ func activeRootID(children []*Child, focusID string) string {
|
|||||||
return ""
|
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 {
|
func findChildInSnapshot(children []*Child, id string) *Child {
|
||||||
for _, c := range children {
|
for _, c := range children {
|
||||||
if c.ID == id {
|
if c.ID == id {
|
||||||
@@ -58,12 +116,16 @@ func firstRunningTopLevel(children []*Child) *Child {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runningTopLevels lists every running top-level session in the order
|
// runningTopLevels lists every running top-level agent session in the
|
||||||
// they appear in the snapshot — the same order the tab bar uses, so
|
// order they appear in the snapshot. Tabs only show agents — command
|
||||||
// Ctrl+A/D navigation matches what the user sees on screen.
|
// processes live in the Processes sidebar section — so Ctrl+A/D
|
||||||
|
// navigation cycles through agent tabs exclusively.
|
||||||
func runningTopLevels(children []*Child) []*Child {
|
func runningTopLevels(children []*Child) []*Child {
|
||||||
out := make([]*Child, 0, len(children))
|
out := make([]*Child, 0, len(children))
|
||||||
for _, c := range children {
|
for _, c := range children {
|
||||||
|
if c.Kind != KindAgent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if c.ParentID == "" && c.Status() == StatusRunning {
|
if c.ParentID == "" && c.Status() == StatusRunning {
|
||||||
out = append(out, c)
|
out = append(out, c)
|
||||||
}
|
}
|
||||||
@@ -123,11 +185,29 @@ func currentTabFlat(children []*Child, focusID string) []*Child {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// nextChildID returns the process id `step` positions away from the
|
// sidebarNavList combines the Processes section and the active Agent
|
||||||
// current focus inside its tab, wrapping at both ends. Empty when
|
// Tree into one flat list — top-to-bottom matching what the user sees
|
||||||
// there's only one process in the tab.
|
// in the sidebar. Ctrl+W/S walks this list so the user can step out of
|
||||||
func nextChildID(children []*Child, focusID string, step int) string {
|
// the agent tree, into the Processes section, and back.
|
||||||
flat := currentTabFlat(children, focusID)
|
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 {
|
if len(flat) < 2 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ func childIDs(cs []*Child) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
|
func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
|
||||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
r1 := testAgent("c1", "root1", "", StatusRunning)
|
||||||
r2 := testChild("c2", "root2", "", StatusRunning)
|
r2 := testAgent("c2", "root2", "", StatusRunning)
|
||||||
r3 := testChild("c3", "root3", "", StatusRunning)
|
r3 := testAgent("c3", "root3", "", StatusRunning)
|
||||||
children := []*Child{r1, r2, r3}
|
children := []*Child{r1, r2, r3}
|
||||||
|
|
||||||
if got := nextTabID(children, "c1", +1); got != "c2" {
|
if got := nextTabID(children, "c1", +1); got != "c2" {
|
||||||
@@ -58,9 +58,9 @@ func TestNextTabIDWrapsAndSkipsCurrent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) {
|
func TestNextTabIDFromSubAgentJumpsByRoot(t *testing.T) {
|
||||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
r1 := testAgent("c1", "root1", "", StatusRunning)
|
||||||
r1Child := testChild("c2", "child1", "c1", StatusRunning)
|
r1Child := testAgent("c2", "child1", "c1", StatusRunning)
|
||||||
r2 := testChild("c3", "root2", "", StatusRunning)
|
r2 := testAgent("c3", "root2", "", StatusRunning)
|
||||||
children := []*Child{r1, r1Child, r2}
|
children := []*Child{r1, r1Child, r2}
|
||||||
|
|
||||||
// Focus is on a sub-agent of root1; Ctrl+D should jump to root2,
|
// 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) {
|
func TestNextChildIDCyclesWithinTab(t *testing.T) {
|
||||||
r1 := testChild("c1", "root1", "", StatusRunning)
|
r1 := testAgent("c1", "root1", "", StatusRunning)
|
||||||
a := testChild("c2", "a", "c1", StatusRunning)
|
a := testAgent("c2", "a", "c1", StatusRunning)
|
||||||
b := testChild("c3", "b", "c1", StatusRunning)
|
b := testAgent("c3", "b", "c1", StatusRunning)
|
||||||
other := testChild("c4", "other-root", "", StatusRunning)
|
other := testAgent("c4", "other-root", "", StatusRunning)
|
||||||
children := []*Child{r1, a, b, other}
|
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)
|
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)
|
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)
|
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)
|
t.Fatalf("wrap backward root->b: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNextChildIDNoopWhenOnlyOneProcess(t *testing.T) {
|
func TestNextChildIDNoopWhenOnlyOneProcess(t *testing.T) {
|
||||||
r := testChild("c1", "solo", "", StatusRunning)
|
r := testAgent("c1", "solo", "", StatusRunning)
|
||||||
if got := nextChildID([]*Child{r}, "c1", +1); got != "" {
|
if got := nextChildID([]*Child{r}, "c1", "c1", +1); got != "" {
|
||||||
t.Fatalf("expected empty when only one process in tab, got %q", 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
|
// 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
|
||||||
|
|
||||||
|
// 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
|
type viewportState int
|
||||||
@@ -45,7 +51,7 @@ const (
|
|||||||
|
|
||||||
func newViewportRenderer(l terminalLayout) *viewportRenderer {
|
func newViewportRenderer(l terminalLayout) *viewportRenderer {
|
||||||
return &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,
|
layout: l,
|
||||||
row: 1,
|
row: 1,
|
||||||
col: 1,
|
col: 1,
|
||||||
@@ -56,7 +62,7 @@ func (vr *viewportRenderer) SetLayout(l terminalLayout) {
|
|||||||
vr.mu.Lock()
|
vr.mu.Lock()
|
||||||
defer vr.mu.Unlock()
|
defer vr.mu.Unlock()
|
||||||
vr.layout = l
|
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 {
|
func (vr *viewportRenderer) Render(in []byte) []byte {
|
||||||
@@ -98,8 +104,7 @@ func (vr *viewportRenderer) feed(b byte) {
|
|||||||
vr.buf = append(vr.buf, b)
|
vr.buf = append(vr.buf, b)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
vr.pending.WriteByte(b)
|
vr.feedPrintable(b)
|
||||||
vr.advancePrintable(b)
|
|
||||||
case vpEsc:
|
case vpEsc:
|
||||||
vr.buf = append(vr.buf, b)
|
vr.buf = append(vr.buf, b)
|
||||||
switch b {
|
switch b {
|
||||||
@@ -286,6 +291,9 @@ func (vr *viewportRenderer) clearViewportToCursor() string {
|
|||||||
if col < 1 {
|
if col < 1 {
|
||||||
col = 1
|
col = 1
|
||||||
}
|
}
|
||||||
|
if col > cols {
|
||||||
|
col = cols
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("\x1b7")
|
b.WriteString("\x1b7")
|
||||||
for r := 1; r < row; r++ {
|
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) {
|
func (vr *viewportRenderer) advancePrintable(b byte) {
|
||||||
switch b {
|
switch b {
|
||||||
case '\r':
|
case '\r':
|
||||||
@@ -331,7 +393,7 @@ func (vr *viewportRenderer) advancePrintable(b byte) {
|
|||||||
case '\t':
|
case '\t':
|
||||||
vr.col += 8 - ((vr.col - 1) % 8)
|
vr.col += 8 - ((vr.col - 1) % 8)
|
||||||
default:
|
default:
|
||||||
if b >= 0x20 && b != 0x7f {
|
if b >= 0x20 && b != 0x7f && (b < 0x80 || b >= 0xC0) {
|
||||||
vr.col++
|
vr.col++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,7 +451,15 @@ func (vr *viewportRenderer) clampCursor() {
|
|||||||
if max := int(vr.layout.childRows()); vr.row > max {
|
if max := int(vr.layout.childRows()); vr.row > max {
|
||||||
vr.row = max
|
vr.row = max
|
||||||
}
|
}
|
||||||
if max := int(vr.layout.childCols()); vr.col > max {
|
// Intentionally do NOT clamp vr.col to childCols here. feedPrintable
|
||||||
vr.col = max
|
// 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"
|
"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) {
|
func TestViewportRendererShiftsCursor(t *testing.T) {
|
||||||
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
vr := newViewportRenderer(newTerminalLayout(120, 40))
|
||||||
got := string(vr.Render([]byte("\x1b[H")))
|
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) {
|
func TestViewportRendererFlagsRIAsScrolling(t *testing.T) {
|
||||||
// Reproduces the sidebar-gap bug: codex emits `\x1b[1;1H` followed
|
// 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
|
// 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
|
return []byte{0x10}, nil
|
||||||
case "ctrl-u":
|
case "ctrl-u":
|
||||||
return []byte{0x15}, nil
|
return []byte{0x15}, nil
|
||||||
|
case "tab":
|
||||||
|
return []byte{'\t'}, nil
|
||||||
|
case "space":
|
||||||
|
return []byte{' '}, nil
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("unknown chord %q", name)
|
return nil, fmt.Errorf("unknown chord %q", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,13 @@
|
|||||||
},
|
},
|
||||||
{ "type": "wait_text", "contains": "RIBURST READY", "timeout_ms": 5000 },
|
{ "type": "wait_text", "contains": "RIBURST READY", "timeout_ms": 5000 },
|
||||||
{ "type": "wait_stable", "timeout_ms": 2000 },
|
{ "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": "● riburst" },
|
||||||
{ "type": "assert_contains", "contains": "Scratchpads" },
|
{ "type": "assert_contains", "contains": "Scratchpads" },
|
||||||
{
|
{
|
||||||
"type": "assert_regex",
|
"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
|
"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