Release v0.0.1
Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s

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:
2026-05-14 22:04:32 +01:00
parent 63f0ddcb38
commit 52e06c914e
18 changed files with 1031 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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