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" | "pad-delete" | "pad-rename" | "pad-rename-form" | // "pad-rename-submit" | "pad-edit" | "agent-rename" | // "agent-rename-form" | "agent-rename-submit" | "agent-close" | // "proc-rename" | "proc-rename-form" | "proc-rename-submit" | // "proc-delete" | "proc-stop" | "proc-restart" 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 // For pad-* actions, the scratchpad name to operate on. padName string // For *-rename-submit actions, the user-typed new name. newName string } 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 paletteModeRenameForm ) // 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 } // renameForm is a one-field inline form used by the "Rename scratchpad / // agent / process" context palette entries. The submit action kind // determines what gets renamed; the target name (pad name or child id) // is carried alongside so closePalette knows what to apply the new // name to. type renameForm struct { name []rune subject string // "pad" | "agent" | "proc" target string // padName for "pad"; childID for "agent"/"proc" title string // e.g. "Rename scratchpad: notes.md" } // 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 focusedPad string presets preset.Set items []paletteItem mode paletteMode form *spawnProcessForm renameForm *renameForm } // 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 ` filters to switch entries only, // `k ` to kills, `sp ` 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 findChildByID(children []*Child, id string) *Child { if id == "" { return nil } for _, c := range children { if c.ID == id { return c } } return nil } func newPalette(children []*Child, focused, focusedPad string, presets preset.Set) *paletteState { p := &paletteState{children: children, focused: focused, focusedPad: focusedPad, 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 // Context-aware entries come first so the most relevant actions for // whatever is currently focused are one or two keystrokes away. // Order matters: a focused scratchpad shadows any focused child // (focus owns one or the other at a time). switch { case p.focusedPad != "": name := p.focusedPad out = append(out, paletteItem{ label: "Delete scratchpad: " + name, hint: "remove the file from disk", action: paletteAction{kind: "pad-delete", padName: name}, }) out = append(out, paletteItem{ label: "Rename scratchpad: " + name, hint: "inline rename · enter to commit", action: paletteAction{kind: "pad-rename-form", padName: name}, }) out = append(out, paletteItem{ label: "Edit scratchpad: " + name, hint: "open in external editor (zed)", action: paletteAction{kind: "pad-edit", padName: name}, }) case p.focused != "": if c := findChildByID(p.children, p.focused); c != nil { name := c.DisplayName() switch c.Kind { case KindAgent: out = append(out, paletteItem{ label: "Rename agent: " + name, hint: "inline rename · enter to commit", action: paletteAction{kind: "agent-rename-form", childID: c.ID}, }) out = append(out, paletteItem{ label: "Close agent: " + name, hint: "SIGTERM " + strings.Join(c.Argv, " "), action: paletteAction{kind: "agent-close", childID: c.ID}, }) default: out = append(out, paletteItem{ label: "Rename process: " + name, hint: "inline rename · enter to commit", action: paletteAction{kind: "proc-rename-form", childID: c.ID}, }) out = append(out, paletteItem{ label: "Delete process: " + name, hint: "remove entry; SIGKILL if alive", action: paletteAction{kind: "proc-delete", childID: c.ID}, }) out = append(out, paletteItem{ label: "Stop process: " + name, hint: "SIGTERM · keep entry for restart", action: paletteAction{kind: "proc-stop", childID: c.ID}, }) out = append(out, paletteItem{ label: "Restart process: " + name, hint: "SIGTERM then start with same argv", action: paletteAction{kind: "proc-restart", childID: c.ID}, }) } } } // 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) } if p.mode == paletteModeRenameForm { return p.handleRenameInput(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 or one of the rename forms, 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() switch a.kind { case "spawn-process-form": p.mode = paletteModeSpawnForm p.form = &spawnProcessForm{} return paletteAction{}, false, adv case "pad-rename-form": p.enterRenameForm("pad", a.padName, a.padName, "Rename scratchpad: "+a.padName) return paletteAction{}, false, adv case "agent-rename-form", "proc-rename-form": subject := "agent" title := "Rename agent: " if a.kind == "proc-rename-form" { subject = "proc" title = "Rename process: " } current := "" if c := findChildByID(p.children, a.childID); c != nil { current = c.DisplayName() } p.enterRenameForm(subject, a.childID, current, title+current) return paletteAction{}, false, adv } return a, true, adv } func (p *paletteState) enterRenameForm(subject, target, current, title string) { p.mode = paletteModeRenameForm p.renameForm = &renameForm{ name: []rune(current), subject: subject, target: target, title: title, } } 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 } // handleRenameInput drives the single-field rename form. Enter commits // the typed name, Esc cancels back out of the palette entirely (same // semantics as the spawn form so the user has one mental model). func (p *paletteState) handleRenameInput(chunk []byte, i int) (paletteAction, bool, int) { b := chunk[i] if b == 0x1b { if n := csiLen(chunk, i); n > 0 { return p.handleRenameCSI(chunk[i+2:i+n-1], chunk[i+n-1], n) } return paletteAction{kind: "cancel"}, true, 1 } switch b { case '\r', '\n': return p.submitRename(), true, 1 case 0x7f, 0x08: p.renameBackspace() case 0x15: // Ctrl-U if p.renameForm != nil { p.renameForm.name = p.renameForm.name[:0] } default: if b >= 0x20 && b < 0x7f && p.renameForm != nil { p.renameForm.name = append(p.renameForm.name, rune(b)) } } return paletteAction{}, false, 1 } func (p *paletteState) handleRenameCSI(params []byte, final byte, n int) (paletteAction, bool, int) { switch final { case 'u': k, ok := decodeCSIu(string(params)) if !ok || k.event != 1 { return paletteAction{}, false, n } switch k.key { case 13: return p.submitRename(), true, n case 27: return paletteAction{kind: "cancel"}, true, n case 127, 8: p.renameBackspace() default: if k.mods == 5 && k.key == 'u' { if p.renameForm != nil { p.renameForm.name = p.renameForm.name[:0] } return paletteAction{}, false, n } if k.mods == 1 && k.key >= 0x20 && k.key < 0x7f && p.renameForm != nil { p.renameForm.name = append(p.renameForm.name, rune(k.key)) } } } return paletteAction{}, false, n } func (p *paletteState) renameBackspace() { if p.renameForm != nil && len(p.renameForm.name) > 0 { p.renameForm.name = p.renameForm.name[:len(p.renameForm.name)-1] } } func (p *paletteState) submitRename() paletteAction { if p.renameForm == nil { return paletteAction{kind: "cancel"} } newName := strings.TrimSpace(string(p.renameForm.name)) if newName == "" { return paletteAction{kind: "cancel"} } var kind string switch p.renameForm.subject { case "pad": kind = "pad-rename-submit" return paletteAction{kind: kind, padName: p.renameForm.target, newName: newName} case "agent": kind = "agent-rename-submit" case "proc": kind = "proc-rename-submit" default: return paletteAction{kind: "cancel"} } return paletteAction{kind: kind, childID: p.renameForm.target, newName: newName} } 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 p.mode == paletteModeRenameForm { p.renderRename(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 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() } // renderRename paints the single-field rename form. Layout mirrors the // spawn form so the user keeps the same mental model. func (p *paletteState) renderRename(out writeFlusher, cols, rows int) { if p.renameForm == nil { p.renameForm = &renameForm{} } 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 := p.renameForm.title if title == "" { title = "Rename" } hint := "esc cancel" titleLen := utf8.RuneCountInString(title) if titleLen > width-12 { title = clipRunes(title, width-13) + "…" titleLen = utf8.RuneCountInString(title) } dashes := width - 3 - titleLen - 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++ nameStr := string(p.renameForm.name) nameLen := utf8.RuneCountInString(nameStr) pad := content - 2 - nameLen if pad < 0 { pad = 0 nameStr = clipRunes(nameStr, content-2) nameLen = utf8.RuneCountInString(nameStr) } nameRow := row moveTo(&b, row, leftPad) b.WriteString(styleBorder + "│" + styleReset + " " + styleAccent + "❯" + styleReset + " " + nameStr + strings.Repeat(" ", pad) + " " + styleBorder + "│" + styleReset) row++ moveTo(&b, row, leftPad) b.WriteString(styleBorder + "├" + strings.Repeat("─", width-2) + "┤" + styleReset) row++ footer := "↵ commit · esc cancel · ⌃u clear" 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) moveTo(&b, nameRow, leftPad+4+nameLen) b.WriteString("\x1b[?25h") _, _ = 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) }