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