ajhahn.de
← eeco
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]) + "…"
}