Files
patterm/internal/app/palette.go
Harry Bayliss 52e06c914e
Some checks failed
release / build-linux-amd64 (push) Failing after 10m52s
Release v0.0.1
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.
2026-05-14 22:04:32 +01:00

860 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}