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]) + "…" }