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

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