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.
860 lines
22 KiB
Go
860 lines
22 KiB
Go
package app
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"github.com/hjbdev/patterm/internal/preset"
|
||
)
|
||
|
||
// paletteAction is what the palette returns when the user picks an item.
|
||
type paletteAction struct {
|
||
// kind: "spawn-agent" | "spawn-process" | "spawn-process-form" |
|
||
// "spawn-process-submit" | "switch" | "kill" | "quit" | "cancel"
|
||
kind string
|
||
|
||
// 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 {
|
||
label string
|
||
hint string
|
||
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 {
|
||
query []rune
|
||
cursor int
|
||
children []*Child
|
||
focused string
|
||
presets preset.Set
|
||
|
||
items []paletteItem
|
||
|
||
mode paletteMode
|
||
form *spawnProcessForm
|
||
}
|
||
|
||
// 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()
|
||
return p
|
||
}
|
||
|
||
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 {
|
||
p.items = p.items[:0]
|
||
for _, it := range all {
|
||
if fuzzyMatch(strings.ToLower(it.label+" "+it.hint), q) {
|
||
p.items = append(p.items, it)
|
||
}
|
||
}
|
||
}
|
||
if p.cursor >= len(p.items) {
|
||
p.cursor = len(p.items) - 1
|
||
}
|
||
if p.cursor < 0 {
|
||
p.cursor = 0
|
||
}
|
||
}
|
||
|
||
func (p *paletteState) allItems() []paletteItem {
|
||
var out []paletteItem
|
||
|
||
// 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.DisplayName()
|
||
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,
|
||
hint: strings.Join(pr.Argv, " "),
|
||
action: paletteAction{kind: "spawn-agent", preset: pr},
|
||
})
|
||
}
|
||
for _, pr := range p.presets.Processes {
|
||
out = append(out, paletteItem{
|
||
label: "Run process: " + pr.Name,
|
||
hint: strings.Join(pr.Argv, " "),
|
||
action: paletteAction{kind: "spawn-process", preset: pr},
|
||
})
|
||
}
|
||
|
||
// 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: label,
|
||
hint: "SIGTERM " + strings.Join(c.Argv, " "),
|
||
action: paletteAction{kind: "kill", childID: c.ID},
|
||
})
|
||
}
|
||
|
||
out = append(out, paletteItem{
|
||
label: "Quit",
|
||
hint: "exit patterm; SIGTERM every child",
|
||
action: paletteAction{kind: "quit"},
|
||
})
|
||
return out
|
||
}
|
||
|
||
func fuzzyMatch(hay, needle string) bool {
|
||
if needle == "" {
|
||
return true
|
||
}
|
||
hi := 0
|
||
for _, r := range needle {
|
||
idx := strings.IndexRune(hay[hi:], r)
|
||
if idx < 0 {
|
||
return false
|
||
}
|
||
hi += idx + utf8.RuneLen(r)
|
||
}
|
||
return true
|
||
}
|
||
|
||
// kitty functional keycodes for arrows.
|
||
const (
|
||
kittyKeyUp = 57352
|
||
kittyKeyDown = 57353
|
||
)
|
||
|
||
// peekArrowEvent classifies the CSI sequence at chunk[i:] as Up ('U'),
|
||
// Down ('D'), or none (0) and returns the byte length of that sequence.
|
||
// Used by the palette input loop to suppress duplicate adjacent
|
||
// arrow events some terminals emit for a single physical keypress
|
||
// (either two legacy `CSI B` in a row, or a legacy + kitty pair).
|
||
func peekArrowEvent(chunk []byte, i int) (nav byte, advance int) {
|
||
if i >= len(chunk) || chunk[i] != 0x1b {
|
||
return 0, 0
|
||
}
|
||
n := csiLen(chunk, i)
|
||
if n == 0 {
|
||
return 0, 0
|
||
}
|
||
final := chunk[i+n-1]
|
||
switch final {
|
||
case 'A':
|
||
return 'U', n
|
||
case 'B':
|
||
return 'D', n
|
||
case 'u':
|
||
k, ok := decodeCSIu(string(chunk[i+2 : i+n-1]))
|
||
if !ok || k.event != 1 {
|
||
return 0, n
|
||
}
|
||
switch k.key {
|
||
case kittyKeyUp:
|
||
return 'U', n
|
||
case kittyKeyDown:
|
||
return 'D', n
|
||
}
|
||
}
|
||
return 0, 0
|
||
}
|
||
|
||
// handleInput consumes one keystroke from chunk[i:] and updates palette
|
||
// state. advance is how many bytes the keystroke occupies (1 for legacy
|
||
// keys, longer for CSI sequences). Returning done=true tells the caller
|
||
// the palette is finished and action describes what to do next.
|
||
//
|
||
// Recognised input includes both legacy byte forms and the kitty
|
||
// keyboard CSI u encoding that codex/ratatui pushes onto the terminal.
|
||
// Unknown CSI sequences (including release events from kitty flag 2)
|
||
// 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 {
|
||
return p.handleCSI(chunk[i+2:i+n-1], chunk[i+n-1], n)
|
||
}
|
||
// Bare ESC (no CSI follow-up): cancel.
|
||
return paletteAction{kind: "cancel"}, true, 1
|
||
}
|
||
switch b {
|
||
case '\r', '\n':
|
||
return p.acceptOrEnterForm(1)
|
||
case 0x7f, 0x08:
|
||
p.backspace()
|
||
case 0x15: // Ctrl-U
|
||
p.clearQuery()
|
||
case 0x0e: // Ctrl-N
|
||
p.cursorDown()
|
||
case 0x10: // Ctrl-P
|
||
p.cursorUp()
|
||
case 0x0b: // Ctrl-K inside palette is a no-op (would re-open); ignore.
|
||
case 0x16: // Ctrl-V literal-paste — ignore in palette.
|
||
default:
|
||
if b >= 0x20 && b < 0x7f {
|
||
p.query = append(p.query, rune(b))
|
||
p.rebuild()
|
||
}
|
||
}
|
||
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':
|
||
p.cursorUp()
|
||
return paletteAction{}, false, n
|
||
case 'B':
|
||
p.cursorDown()
|
||
return paletteAction{}, false, n
|
||
case 'u':
|
||
k, ok := decodeCSIu(string(params))
|
||
if !ok || k.event != 1 {
|
||
// Repeat / release events, or malformed: ignore.
|
||
return paletteAction{}, false, n
|
||
}
|
||
switch k.key {
|
||
case 13: // Enter
|
||
return p.acceptOrEnterForm(n)
|
||
case 27: // Escape
|
||
return paletteAction{kind: "cancel"}, true, n
|
||
case 127, 8: // Backspace
|
||
p.backspace()
|
||
case kittyKeyUp:
|
||
p.cursorUp()
|
||
case kittyKeyDown:
|
||
p.cursorDown()
|
||
default:
|
||
// Ctrl-modified character keys.
|
||
if k.mods == 5 {
|
||
switch k.key {
|
||
case 'u':
|
||
p.clearQuery()
|
||
case 'n':
|
||
p.cursorDown()
|
||
case 'p':
|
||
p.cursorUp()
|
||
}
|
||
return paletteAction{}, false, n
|
||
}
|
||
// Unmodified printable ASCII typed via CSI u (flag 8): treat
|
||
// as a query keystroke.
|
||
if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f {
|
||
p.query = append(p.query, rune(k.key))
|
||
p.rebuild()
|
||
}
|
||
}
|
||
return paletteAction{}, false, n
|
||
}
|
||
// Anything else (~, function keys, etc.): consume silently.
|
||
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
|
||
}
|
||
return paletteAction{kind: "cancel"}
|
||
}
|
||
|
||
func (p *paletteState) backspace() {
|
||
if len(p.query) > 0 {
|
||
p.query = p.query[:len(p.query)-1]
|
||
p.rebuild()
|
||
}
|
||
}
|
||
|
||
func (p *paletteState) clearQuery() {
|
||
p.query = p.query[:0]
|
||
p.rebuild()
|
||
}
|
||
|
||
func (p *paletteState) cursorUp() {
|
||
p.cursor--
|
||
if p.cursor < 0 {
|
||
p.cursor = 0
|
||
}
|
||
}
|
||
|
||
func (p *paletteState) cursorDown() {
|
||
p.cursor++
|
||
if p.cursor >= len(p.items) {
|
||
p.cursor = len(p.items) - 1
|
||
}
|
||
}
|
||
|
||
// render draws the palette onto out. Layout is a rounded box with a
|
||
// title bar, query line, divider, item list, divider, and footer.
|
||
// 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
|
||
}
|
||
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 // visible cells between the " " padding on each side
|
||
|
||
var b strings.Builder
|
||
b.WriteString("\x1b[?25l\x1b[H\x1b[2J\x1b[3J")
|
||
|
||
row := 2
|
||
titleText := "patterm"
|
||
keyHint := "Ctrl-K"
|
||
// ╭─ patterm ─...─ Ctrl-K ─╮ uses: 3 + len(title) + 1 + dashes + 1 + len(hint) + 3
|
||
dashes := width - 3 - len(titleText) - 1 - 1 - len(keyHint) - 3
|
||
if dashes < 2 {
|
||
dashes = 2
|
||
}
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "╭─ " + styleActive + titleText + styleReset + styleBorder + " " +
|
||
strings.Repeat("─", dashes) + " " + styleHint + keyHint + styleReset + styleBorder + " ─╮" + styleReset)
|
||
row++
|
||
|
||
queryStr := string(p.query)
|
||
queryRow := row
|
||
qLen := utf8.RuneCountInString(queryStr)
|
||
qPad := content - 2 - qLen
|
||
if qPad < 0 {
|
||
qPad = 0
|
||
}
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "❯" + styleReset + " " + queryStr +
|
||
strings.Repeat(" ", qPad) + " " + styleBorder + "│" + styleReset)
|
||
row++
|
||
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||
row++
|
||
|
||
maxItems := rows - 6
|
||
if maxItems > 12 {
|
||
maxItems = 12
|
||
}
|
||
if maxItems < 1 {
|
||
maxItems = 1
|
||
}
|
||
start := 0
|
||
if p.cursor >= maxItems {
|
||
start = p.cursor - maxItems + 1
|
||
}
|
||
end := start + maxItems
|
||
if end > len(p.items) {
|
||
end = len(p.items)
|
||
}
|
||
|
||
for i := 0; i < maxItems; i++ {
|
||
moveTo(&b, row, leftPad)
|
||
if start+i >= end {
|
||
if len(p.items) == 0 && i == 0 {
|
||
msg := styleDim + "no matches" + styleReset
|
||
pad := content - 2 - 10
|
||
if pad < 0 {
|
||
pad = 0
|
||
}
|
||
b.WriteString(styleBorder + "│" + styleReset + " " + msg +
|
||
strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset)
|
||
} else {
|
||
b.WriteString(styleBorder + "│" + styleReset + strings.Repeat(" ", width-2) +
|
||
styleBorder + "│" + styleReset)
|
||
}
|
||
row++
|
||
continue
|
||
}
|
||
|
||
it := p.items[start+i]
|
||
isSel := (start + i) == p.cursor
|
||
avail := content - 2 // 2 cells reserved for the selection indicator
|
||
|
||
label := it.label
|
||
hint := it.hint
|
||
labelLen := utf8.RuneCountInString(label)
|
||
hintLen := utf8.RuneCountInString(hint)
|
||
|
||
if labelLen > avail {
|
||
label = clipRunes(label, avail-1) + "…"
|
||
labelLen = utf8.RuneCountInString(label)
|
||
hint = ""
|
||
hintLen = 0
|
||
} else if hintLen > 0 {
|
||
gap := avail - labelLen - hintLen
|
||
if gap < 3 {
|
||
budget := avail - labelLen - 3
|
||
if budget > 1 {
|
||
hint = clipRunes(hint, budget-1) + "…"
|
||
hintLen = utf8.RuneCountInString(hint)
|
||
} else {
|
||
hint = ""
|
||
hintLen = 0
|
||
}
|
||
}
|
||
}
|
||
gap := avail - labelLen - hintLen
|
||
if gap < 0 {
|
||
gap = 0
|
||
}
|
||
|
||
var indicator, labelStr, hintStr string
|
||
if isSel {
|
||
indicator = styleAccent + "▎" + styleReset + " "
|
||
labelStr = styleBold + label + styleReset
|
||
} else {
|
||
indicator = " "
|
||
labelStr = label
|
||
}
|
||
if hint != "" {
|
||
hintStr = styleHint + hint + styleReset
|
||
}
|
||
|
||
b.WriteString(styleBorder + "│" + styleReset + " " + indicator + labelStr +
|
||
strings.Repeat(" ", gap) + hintStr + " " + styleBorder + "│" + styleReset)
|
||
row++
|
||
}
|
||
|
||
moveTo(&b, row, leftPad)
|
||
b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset)
|
||
row++
|
||
|
||
footer := "↵ run · esc close · ↑↓ navigate · sw/k/sp <q> filter"
|
||
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 real terminal cursor at the end of the query so it
|
||
// blinks naturally in place of the old underscore stub.
|
||
moveTo(&b, queryRow, leftPad+4+qLen)
|
||
b.WriteString("\x1b[?25h")
|
||
|
||
_, _ = out.Write([]byte(b.String()))
|
||
_ = 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 ""
|
||
}
|
||
count := 0
|
||
for i := range s {
|
||
if count == n {
|
||
return s[:i]
|
||
}
|
||
count++
|
||
}
|
||
return s
|
||
}
|
||
|
||
type writeFlusher interface {
|
||
Write(p []byte) (int, error)
|
||
Flush() error
|
||
}
|
||
|
||
type writeFlusherBase interface {
|
||
Write(p []byte) (int, error)
|
||
}
|
||
|
||
type nopFlusher struct{ io writeFlusherBase }
|
||
|
||
func wrapWriter(w writeFlusherBase) writeFlusher { return nopFlusher{io: w} }
|
||
func (n nopFlusher) Write(p []byte) (int, error) { return n.io.Write(p) }
|
||
func (n nopFlusher) Flush() error { return nil }
|
||
|
||
func moveTo(b *strings.Builder, row, col int) {
|
||
fmt.Fprintf(b, "\x1b[%d;%dH", row, col)
|
||
}
|
||
|
||
func padRight(s string, width int) string {
|
||
w := width - visibleLen(s)
|
||
if w <= 0 {
|
||
return s
|
||
}
|
||
return s + strings.Repeat(" ", w)
|
||
}
|
||
|
||
func visibleLen(s string) int {
|
||
n := 0
|
||
in := false
|
||
for _, r := range s {
|
||
if r == 0x1b {
|
||
in = true
|
||
continue
|
||
}
|
||
if in {
|
||
if r == 'm' || r == 'H' {
|
||
in = false
|
||
}
|
||
continue
|
||
}
|
||
n++
|
||
}
|
||
return n
|
||
}
|
||
|
||
func countAnsi(s string) int {
|
||
return len(s) - visibleLen(s)
|
||
}
|