Land staged session/MCP/chrome work + sidebar clear-J fix

This batches the in-flight [Unreleased] block from CHANGELOG.md into a
single commit. Highlights:

- Real MCP protocol layer (initialize / tools/list / tools/call) so
  vendor MCP clients can complete the handshake against the per-PID
  socket. Legacy direct-dispatch preserved for the harness.
- New mcp_injection kinds — cli_override for codex, config_env for
  opencode — joining the existing env-var and config_file paths so
  patterm can slot into more agents without touching their real
  config or auth.
- Ctrl+A/D and Ctrl+W/S focus navigation across tabs and intra-tab
  process lists, recognised in legacy / kitty CSI u / xterm
  modifyOtherKeys encodings.
- Palette macros (sw / k / sp ) and reordering so open sessions
  surface above spawn-new entries.
- Two-row tab bar, sidebar/tabbar/status chrome cache, viewport-wipe
  on agent spawn, CR-terminated orchestrator injections, and split-
  Enter PTY writes so paste-detecting TUIs see Enter as a key event.

Also fixes the bug logged in TODO: claude's Ctrl+O tool-call expansion
emits CSI 0 J, which the viewport renderer was forwarding verbatim —
wiping the sidebar to the right of the cursor and leaving the chrome
cache convinced nothing had changed. CSI 0 J and CSI 1 J are now
translated into per-row ECH sequences clamped to the viewport, same
as CSI 2 J and CSI K already were.

Agent guides (CLAUDE.md / AGENTS.md) now spell out the
TODO->CHANGELOG workflow so completed items land in the changelog
rather than as ticked entries left behind in TODO.
This commit is contained in:
2026-05-14 19:09:35 +01:00
parent 7649587f9a
commit 3622c41fd0
25 changed files with 1951 additions and 163 deletions

View File

@@ -235,6 +235,17 @@ type uiState struct {
hostCols, hostRows uint16
stdinTTY bool
// chromeCacheMu guards the last-rendered byte cache for each chrome
// element. The tab bar, sidebar, and status line all repaint on
// many state changes and on every PTY chunk, but their content
// usually doesn't change between calls — caching the rendered
// output and skipping a write when it matches eliminates the
// flicker (especially in the sidebar's session tree).
chromeCacheMu sync.Mutex
tabBarCache string
sidebarCache string
statusLineCache string
lastExit atomic.Int32
}
@@ -300,14 +311,28 @@ func (st *uiState) OnChildSpawned(c *Child) {
st.mu.Lock()
st.focusedID = c.ID
st.focusedName = c.Name
st.renderer = newViewportRenderer(st.layoutSnapshot())
if st.palette != nil {
renderer := newViewportRenderer(st.layoutSnapshot())
st.renderer = renderer
palOpen := st.palette != nil
if palOpen {
st.palette.children = st.sess.Children()
st.palette.focused = st.focusedID
st.palette.rebuild()
st.renderPaletteLocked()
}
st.mu.Unlock()
// Wipe the viewport area so the previous focused child's PTY
// output doesn't bleed through beneath the new pane. The palette
// branch is skipped because the palette overlay covers the whole
// screen and is about to take focus back to OnChildSpawned's
// caller path.
if !palOpen {
st.outMu.Lock()
_, _ = os.Stdout.Write(renderer.ClearViewport())
st.outMu.Unlock()
}
st.moveToViewportOrigin()
st.drawTabBar()
st.drawSidebar()
@@ -402,11 +427,26 @@ func (st *uiState) leaveScreen() {
}
func (st *uiState) clearScreen() {
st.invalidateChromeCache()
st.outMu.Lock()
defer st.outMu.Unlock()
_, _ = os.Stdout.Write([]byte("\x1b[?25h\x1b[H\x1b[2J"))
}
// invalidateChromeCache forces the next drawTabBar / drawSidebar /
// drawStatusLine call to actually emit bytes, regardless of cached
// content. Anything that clears or repaints the screen (resize, focus
// change, full repaint) must call this — otherwise the chrome stays
// blank because the cached frame still matches the unchanged state
// even though the wire was cleared.
func (st *uiState) invalidateChromeCache() {
st.chromeCacheMu.Lock()
st.tabBarCache = ""
st.sidebarCache = ""
st.statusLineCache = ""
st.chromeCacheMu.Unlock()
}
func (st *uiState) moveToViewportOrigin() {
layout := st.layoutSnapshot()
st.outMu.Lock()
@@ -489,6 +529,14 @@ func (st *uiState) drawStatusLine() {
if len(line) > int(cols) {
line = line[:int(cols)]
}
st.chromeCacheMu.Lock()
if line == st.statusLineCache {
st.chromeCacheMu.Unlock()
return
}
st.statusLineCache = line
st.chromeCacheMu.Unlock()
st.outMu.Lock()
defer st.outMu.Unlock()
// Save cursor, move to last row col 1, write, restore.
@@ -535,6 +583,36 @@ func (st *uiState) layoutLocked() terminalLayout {
return newTerminalLayout(st.hostCols, st.hostRows)
}
// splitOnEnter walks input and returns each Enter byte (CR or LF) as
// its own slice, with the surrounding non-Enter bytes batched between.
// Empty pieces are dropped. The result preserves byte order, so
// "hello\rworld\n" yields ["hello", "\r", "world", "\n"]. Callers use
// this to keep Enter keystrokes from getting bundled into the same
// PTY write as the text that preceded them — TUI agents' paste
// detection (claude/codex/opencode) otherwise swallows the CR as
// literal content instead of treating it as a key event.
func splitOnEnter(in []byte) [][]byte {
if len(in) == 0 {
return nil
}
var out [][]byte
start := 0
for i, b := range in {
if b != '\r' && b != '\n' {
continue
}
if i > start {
out = append(out, in[start:i])
}
out = append(out, in[i:i+1])
start = i + 1
}
if start < len(in) {
out = append(out, in[start:])
}
return out
}
func (st *uiState) stdinLoop() error {
buf := make([]byte, 4096)
for {
@@ -616,6 +694,9 @@ func (st *uiState) processStdin(chunk []byte) {
if st.focusedID != "" {
if c := st.sess.FindChild(st.focusedID); c != nil && c.Status() == StatusRunning {
prev := c.Owner()
// InjectAsUser splits Enter bytes onto their own
// writes so claude / codex / opencode don't treat a
// "text\r" batch as a paste.
_ = c.InjectAsUser(forward)
if prev != OwnerUser {
go st.drawStatusLine()
@@ -626,6 +707,7 @@ func (st *uiState) processStdin(chunk []byte) {
}
var pendingAction *paletteAction
var pendingNavID string
// Tracks the last arrow direction and the byte offset immediately
// after its CSI sequence. Some terminals emit a duplicate adjacent
@@ -691,6 +773,37 @@ func (st *uiState) processStdin(chunk []byte) {
continue
}
// Ctrl+WASD: directional focus navigation, matching the four
// arrow keys you'd expect in a tiling layout. A/D step between
// top-level tabs; W/S step through the current tab's process
// list (root first, then sub-agents). Bytes after the chord
// in the same chunk are dropped — the focus change makes
// further forwarding ambiguous between old and new pane.
if hit, adv := matchCtrlChar(chunk, i, 'a'); hit {
flushForward()
pendingNavID = nextTabID(st.sess.Children(), st.focusedID, -1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 'd'); hit {
flushForward()
pendingNavID = nextTabID(st.sess.Children(), st.focusedID, +1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 'w'); hit {
flushForward()
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, -1)
i += adv
break
}
if hit, adv := matchCtrlChar(chunk, i, 's'); hit {
flushForward()
pendingNavID = nextChildID(st.sess.Children(), st.focusedID, +1)
i += adv
break
}
forward = append(forward, b)
i++
}
@@ -700,6 +813,9 @@ func (st *uiState) processStdin(chunk []byte) {
if pendingAction != nil {
st.closePalette(*pendingAction)
}
if pendingNavID != "" {
st.focusProcess(pendingNavID)
}
}
func (st *uiState) openPaletteLocked() {