Go 445 lines
package tui
import (
"context"
"strings"
"github.com/ajhahnde/eeco/internal/config"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// model is the control-center state. The interactive surface is a hybrid
// command box: a sticky multi-line composer with a live status digest
// above it, rendered inline in the terminal scrollback (no alt-screen
// takeover). It only orchestrates engine operations that already exist;
// it adds no write path and obeys the same exit-code contract.
type model struct {
cfg *config.Config
version string
tip string
styles styles
ta textarea.Model
sp spinner.Model
history []string
histPos int // index into history; len(history) means the live draft
draft string // the in-progress line saved while browsing history
overlay bool // the ? shortcut overlay is open
width int
// pal holds the slash-command palette cursor. Whether the palette is
// open is derived from the input (paletteOpen), so the cursor is the
// only stored palette state.
pal palette
// homePrinted is set once the home block (logo + version + tip + hint)
// has been emitted to scrollback. The block prints on the first
// WindowSizeMsg so it lands centred for the known terminal width;
// from then on View renders only the sticky input + footer, and the
// home block scrolls off naturally as content fills.
homePrinted bool
// gen invalidates the result of an interrupted background op: a
// result whose generation no longer matches is discarded.
gen int
running bool
cancel context.CancelFunc
lastRun string
quitting bool
}
// inputMaxRows caps how tall the composer grows before it scrolls
// internally. The box starts at one row and grows with the draft up to
// this many rows (reflowHeight), so a short request keeps a single-line
// prompt and a long paste stays bounded.
const inputMaxRows = 8
// asyncResultMsg carries the output of a background operation back to
// the UI goroutine. A result whose gen mismatches the model's current
// gen was interrupted (Esc) and is dropped.
type asyncResultMsg struct {
gen int
lines []string
isRun bool
summary string
}
func newModel(cfg *config.Config, version string) model {
st := newStyles(colorEnabled())
ta := textarea.New()
ta.Placeholder = "type / for commands"
ta.ShowLineNumbers = false
// The textarea defaults to a six-row box; a prompt should idle at one
// row and grow with the draft (reflowHeight).
ta.SetHeight(1)
// One prompt glyph on line 0, aligned blanks on continuation lines, so a
// multi-line draft reads as one composer rather than a stack of prompts.
ta.SetPromptFunc(2, func(i int) string {
if i == 0 {
return "» "
}
return " "
})
ta.FocusedStyle.Prompt = st.prompt
ta.BlurredStyle.Prompt = st.prompt
// Bubbles' default placeholder style is dim grey (240) — readable on a
// pure-black terminal but invisible on a translucent / image-backed
// background. Pin to `dim` (250) so the affordance survives common
// terminal themes.
ta.FocusedStyle.Placeholder = st.dim
ta.BlurredStyle.Placeholder = st.dim
// Drop the default cursor-line background bar: a highlighted active row
// reads as a code editor, not a prompt.
ta.FocusedStyle.CursorLine = lipgloss.NewStyle()
ta.BlurredStyle.CursorLine = lipgloss.NewStyle()
// Enter submits (unchanged muscle memory, matches palette accept). A
// newline is Alt+Enter or Ctrl+J; Ctrl+J (literal LF) is the reliable
// fallback where a terminal swallows Alt+Enter. Never ctrl+m — it is
// Enter.
ta.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("alt+enter", "ctrl+j"))
ta.Focus()
sp := spinner.New()
sp.Spinner = spinner.MiniDot
sp.Style = st.brand
return model{
cfg: cfg,
version: version,
tip: pickTip(),
styles: st,
ta: ta,
sp: sp,
}
}
func (m model) Init() tea.Cmd { return textarea.Blink }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
// The textarea needs an explicit width to wrap (textinput auto-sized);
// set it on every size message, including a resize.
m.ta.SetWidth(msg.Width)
if !m.homePrinted {
m.homePrinted = true
return m, tea.Println(renderHome(m.width, m.styles, m.version, m.tip))
}
return m, nil
case asyncResultMsg:
if msg.gen != m.gen {
return m, nil // interrupted; discard stale output
}
m.running = false
m.cancel = nil
if msg.isRun {
m.lastRun = msg.summary
}
return m, printLines(msg.lines)
case spinner.TickMsg:
// Advance the spinner only while work is in flight; once running
// clears, returning nil lets the tick loop die.
if !m.running {
return m, nil
}
var cmd tea.Cmd
m.sp, cmd = m.sp.Update(msg)
return m, cmd
case tea.KeyMsg:
return m.onKey(msg)
}
var cmd tea.Cmd
m.ta, cmd = m.ta.Update(msg)
return m, cmd
}
func (m model) onKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// Ctrl-C always quits and restores the terminal.
if k.Type == tea.KeyCtrlC {
m.quitting = true
return m, tea.Quit
}
// The overlay swallows the next key (any key dismisses it).
if m.overlay {
m.overlay = false
return m, nil
}
// Slash-command palette: while open ("/" with no space yet) four keys
// drive the dropdown. Every other key falls through, so typing/deleting
// filters live and Esc still clears. Up/Down move the highlight here
// instead of browsing history; Tab/Enter accept the selection (Enter
// does not submit — the next Enter does).
if m.paletteOpen() {
items := m.paletteItems()
switch k.Type {
case tea.KeyUp:
m.pal.cursor--
m.clampPaletteCursor(len(items))
return m, nil
case tea.KeyDown:
m.pal.cursor++
m.clampPaletteCursor(len(items))
return m, nil
case tea.KeyTab:
return m.acceptPalette(items), nil
case tea.KeyEnter:
// Plain Enter accepts the highlighted row; Alt+Enter is a
// newline (handled in the main switch below), never a palette
// accept — consistent with Ctrl+J, which already falls through.
if !k.Alt {
return m.acceptPalette(items), nil
}
}
}
switch k.Type {
case tea.KeyEsc:
if m.running {
if m.cancel != nil {
m.cancel()
}
m.gen++ // invalidate the in-flight result
m.running = false
m.cancel = nil
return m, tea.Println(m.styles.dim.Render("interrupted"))
}
m.ta.SetValue("")
m.reflowHeight()
return m, nil
case tea.KeyUp:
// History only at the top of the draft; otherwise move the cursor up
// within a multi-line composer (fall through to ta.Update). A
// single-line draft has Line()==0, so today's history behaviour holds.
if m.ta.Line() == 0 {
m.historyPrev()
return m, nil
}
case tea.KeyDown:
// History only at the bottom of the draft; otherwise move the cursor
// down. A single-line draft has Line()==LineCount()-1==0.
if m.ta.Line() == m.ta.LineCount()-1 {
m.historyNext()
return m, nil
}
case tea.KeyTab:
newVal, candidates := complete(m.ta.Value(), runNames(m.cfg))
m.ta.SetValue(newVal)
m.ta.CursorEnd()
m.reflowHeight()
if len(candidates) > 0 {
return m, tea.Println(m.styles.dim.Render(" " + strings.Join(candidates, " ")))
}
return m, nil
case tea.KeyEnter:
// Plain Enter submits; Alt+Enter falls through to the textarea, which
// inserts a newline via the rebound InsertNewline binding.
if k.Alt {
break
}
return m.submit()
case tea.KeyRunes:
// `?` on an empty line opens the shortcut overlay; otherwise it
// is an ordinary character.
if string(k.Runes) == "?" && m.ta.Value() == "" {
m.overlay = true
return m, nil
}
// `q` on an empty line quits (a REPL convention); when there is
// text to edit, `q` is a literal character.
if string(k.Runes) == "q" && m.ta.Value() == "" && !m.running {
m.quitting = true
return m, tea.Quit
}
}
var cmd tea.Cmd
m.ta, cmd = m.ta.Update(k)
m.reflowHeight()
// A filter change (typing or backspace) while the palette is open snaps
// the highlight back to the top match, matching the Claude Code palette.
if m.paletteOpen() {
m.pal.cursor = 0
}
return m, cmd
}
func (m model) submit() (tea.Model, tea.Cmd) {
raw := m.ta.Value()
line := strings.TrimSpace(raw)
m.ta.SetValue("")
m.reflowHeight()
if line == "" {
return m, nil
}
if len(m.history) == 0 || m.history[len(m.history)-1] != line {
m.history = append(m.history, line)
}
m.histPos = len(m.history)
m.draft = ""
echo := tea.Println(m.styles.prompt.Render("» ") + line)
res := dispatch(m.cfg, m.styles, m.width, parseInput(line))
if res.quit {
m.quitting = true
return m, tea.Sequence(echo, tea.Quit)
}
if res.async != "" {
m.gen++
m.running = true
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
// Kick the spinner alongside the work so the footer animates while
// the request is in flight.
return m, tea.Batch(
tea.Sequence(echo, m.startAsync(ctx, m.gen, res)),
m.sp.Tick,
)
}
if len(res.lines) > 0 {
return m, tea.Sequence(echo, printLines(res.lines))
}
return m, echo
}
// startAsync runs a long operation off the UI goroutine so Esc can
// interrupt it. A `/run --ai` is genuinely cancellable through ctx; a
// native builtin run completes quickly, and Esc still detaches its (now
// stale) result via the generation token.
func (m model) startAsync(ctx context.Context, gen int, res dispatchResult) tea.Cmd {
cfg := m.cfg
st := m.styles
width := m.width
return func() tea.Msg {
switch res.async {
case "gc":
return asyncResultMsg{gen: gen, lines: opGC(cfg, st, width)}
case "run":
summary, lines, _ := opRun(cfg, st, width, res.asyncS, res.asyncAI)
return asyncResultMsg{gen: gen, lines: lines, isRun: true, summary: summary}
}
return asyncResultMsg{gen: gen}
}
}
func (m *model) historyPrev() {
if len(m.history) == 0 || m.histPos == 0 {
return
}
if m.histPos == len(m.history) {
m.draft = m.ta.Value()
}
m.histPos--
m.ta.SetValue(m.history[m.histPos])
m.ta.CursorEnd()
m.reflowHeight()
}
func (m *model) historyNext() {
if m.histPos >= len(m.history) {
return
}
m.histPos++
if m.histPos == len(m.history) {
m.ta.SetValue(m.draft)
} else {
m.ta.SetValue(m.history[m.histPos])
}
m.ta.CursorEnd()
m.reflowHeight()
}
// reflowHeight resizes the composer to fit the current draft, from one row
// up to inputMaxRows; past the cap the textarea scrolls internally. Called
// after any value change so the box grows and shrinks with the content.
func (m *model) reflowHeight() {
h := m.ta.LineCount()
if h < 1 {
h = 1
}
if h > inputMaxRows {
h = inputMaxRows
}
m.ta.SetHeight(h)
}
func (m model) View() string {
if m.quitting {
return ""
}
if m.overlay {
var b strings.Builder
for _, ln := range opHelp(m.styles, m.width) {
b.WriteString(ln)
b.WriteByte('\n')
}
b.WriteString(m.styles.key.Render("(press any key to close)"))
b.WriteByte('\n')
return b.String()
}
bar := barLine(m.cfg, m.version, m.lastRun)
if m.width > 0 {
bar = truncate(bar, m.width)
}
footer := m.styles.dimmer.Render(bar)
if m.running {
footer += " " + m.sp.View() + m.styles.dimmer.Render(" working (Esc to interrupt)")
}
// The live View is a constant-height region (input + footer) for the
// whole session. The home block (logo + version + tip + hint) is
// printed once via tea.Println on the first WindowSizeMsg, so it
// lives in scrollback and scrolls off the top naturally as content
// fills — the Claude Code TUI behaviour the operator targeted.
//
// When the slash-command palette is open the dropdown renders between
// the input and the footer; the region grows while open and shrinks
// when it closes (safe under inline, no-alt-screen Bubble Tea).
if m.paletteOpen() {
block := renderPalette(m.paletteItems(), m.pal.cursor, m.width, m.styles)
return m.ta.View() + "\n" + block + "\n" + footer + "\n"
}
return m.ta.View() + "\n" + footer + "\n"
}
// printLines emits content into the terminal scrollback above the live
// input region. Bubble Tea's Println keeps this output in the terminal
// after the program exits (no alt-screen), which is the inline-history
// behaviour PLAN.md §TUI requires.
func printLines(lines []string) tea.Cmd {
if len(lines) == 0 {
return nil
}
return tea.Println(strings.Join(lines, "\n"))
}
// truncate clips s to w display columns, appending an ellipsis when it
// had to cut. It operates on runes; the digest carries no escape codes.
func truncate(s string, w int) string {
if w <= 0 {
return ""
}
r := []rune(s)
if len(r) <= w {
return s
}
if w <= 1 {
return string(r[:w])
}
return string(r[:w-1]) + "…"
}