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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user