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

@@ -38,6 +38,29 @@ type paletteState struct {
items []paletteItem
}
// macroPrefixes maps the palette macro prefix (without trailing space)
// to the paletteAction.kind values that should be retained when that
// macro is active. Typing `sw <query>` filters to switch entries only,
// `k <query>` to kills, `sp <query>` to spawn entries (agents +
// processes).
var macroPrefixes = map[string][]string{
"sw": {"switch"},
"k": {"kill"},
"sp": {"spawn-agent", "spawn-process"},
}
// detectMacro returns the macro keyword and the remaining query, or
// ("", original) if no macro is active. A macro is active when the
// query starts with one of the known prefixes followed by a space.
func detectMacro(q string) (macro, rest string) {
for k := range macroPrefixes {
if len(q) > len(k) && q[:len(k)] == k && q[len(k)] == ' ' {
return k, q[len(k)+1:]
}
}
return "", q
}
func newPalette(children []*Child, focused string, presets preset.Set) *paletteState {
p := &paletteState{children: children, focused: focused, presets: presets}
p.rebuild()
@@ -47,6 +70,21 @@ func newPalette(children []*Child, focused string, presets preset.Set) *paletteS
func (p *paletteState) rebuild() {
all := p.allItems()
q := strings.ToLower(string(p.query))
macro, rest := detectMacro(q)
if macro != "" {
kinds := macroPrefixes[macro]
filtered := all[:0:0]
for _, it := range all {
for _, k := range kinds {
if it.action.kind == k {
filtered = append(filtered, it)
break
}
}
}
all = filtered
q = rest
}
if q == "" {
p.items = all
} else {
@@ -68,8 +106,32 @@ func (p *paletteState) rebuild() {
func (p *paletteState) allItems() []paletteItem {
var out []paletteItem
// Preset commands first — SPEC §4 calls these out as the primary
// way to spawn anything. One entry per file under presets/.
// Switch entries first — existing open agents/processes should
// surface above options to spawn new ones. Hide non-running agents
// (e.g. killed ones) so the list doesn't accumulate corpses. Command
// processes are session-persistent, so they remain visible after
// exit to keep restart_process in reach.
for _, c := range p.children {
if c.Kind == KindAgent && c.Status() != StatusRunning {
continue
}
label := "Switch to " + c.Name
hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "• " + label + " (current)"
}
if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]"
}
out = append(out, paletteItem{
label: label,
hint: hint,
action: paletteAction{kind: "switch", childID: c.ID},
})
}
// Preset commands — SPEC §4 calls these out as the primary way to
// spawn anything. One entry per file under presets/.
for _, pr := range p.presets.Agents {
out = append(out, paletteItem{
label: "Spawn agent: " + pr.Name,
@@ -85,22 +147,7 @@ func (p *paletteState) allItems() []paletteItem {
})
}
// Switch / Kill entries — one per existing child.
for _, c := range p.children {
label := "Switch to " + c.Name
hint := strings.Join(c.Argv, " ")
if c.ID == p.focused {
label = "• " + label + " (current)"
}
if c.Status() != StatusRunning {
label = label + " [" + string(c.Status()) + "]"
}
out = append(out, paletteItem{
label: label,
hint: hint,
action: paletteAction{kind: "switch", childID: c.ID},
})
}
// Kill entries last among the action rows, before Quit.
for _, c := range p.children {
if c.Status() != StatusRunning {
continue
@@ -447,7 +494,7 @@ func (p *paletteState) render(out writeFlusher, cols, rows int) {
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
row++
footer := "↵ run · esc close · ↑↓ navigate"
footer := "↵ run · esc close · ↑↓ navigate · sw/k/sp <q> filter"
fLen := utf8.RuneCountInString(footer)
fPad := content - fLen
if fPad < 0 {