Files
2026-08-14 23:41:57 +08:00

1361 lines
51 KiB
Go

package tui
import (
"bytes"
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/smallnest/pigo/internal/agentcore"
"github.com/smallnest/pigo/internal/cli"
"github.com/smallnest/pigo/internal/cli/memstatus"
"github.com/smallnest/pigo/internal/cli/status"
"github.com/smallnest/pigo/internal/memory"
"github.com/smallnest/pigo/internal/runtime"
)
// Model is the root Bubble Tea model for the full-screen TUI. It composes a
// scrolling transcript (US-005) with the persistent status bar (#386) and a
// minimal input line, and owns the run lifecycle: on prompt submit it starts an
// agent run through the event bridge (bridge.go) and pumps the resulting tea.Msg
// stream into the transcript one message at a time. Downstream nodes grow the
// input into a full textarea (#390), render tool cards (#389), and wire the real
// session/run assembly (#392); the Init/Update/View contract and the alt-screen
// + quit-key handling stay stable.
type Model struct {
opts Options
theme Theme
// width and height track the terminal size reported by tea.WindowSizeMsg.
// They are zero until the first size message arrives; View degrades to a
// minimal render in that window.
width int
height int
// transcript is the scrolling message log (user / assistant / system turns).
transcript transcript
// input is the multi-line prompt editor (#390). It wraps a bubbles textarea
// so CJK / emoji are edited by rune (no dropped-byte bug), Enter submits and
// Shift+Enter inserts a newline. It is blurred while a run is in flight.
input input
// history holds previously submitted inputs (prompts and slash commands, in
// order), and histIdx is the browse cursor into it: len(history) means "not
// browsing — on the live draft", any smaller index points at a recalled entry.
// histDraft stashes the in-progress buffer when browsing begins so ↓ past the
// newest entry restores it. ↑/↓ walk history when the caret is on the first /
// last line of the composer, so multi-line editing is unaffected.
history []string
histIdx int
histDraft string
// running is true while an agent run is draining through runCh. Input submit
// is gated on it so a new run cannot start mid-run.
running bool
// runCh is the bridge channel for the in-flight run, or nil when idle. Update
// re-issues waitForEvent(runCh) after every bridged msg except runEndMsg.
runCh chan tea.Msg
// startRunFn launches an agent run for the submitted prompt, returning the
// bridge channel and the first waitForEvent Cmd (see bridge.startRun). It is
// bound to runSession.startRun by withSession (#392): the real binding
// constructs an AgentContext + RunConfig from opts and the live session. It is
// nil for a session-less model (the pure constructor / tests), in which case a
// submit records the prompt but starts no run.
startRunFn func(prompt string) (chan tea.Msg, tea.Cmd)
// session is the assembled run/persistence state (store, header, growing
// context, live config). It is nil for a session-less model; when set, the
// model persists the conversation to ~/.pigo/sessions after each turn ends.
session *runSession
// interruptFn cancels the in-flight run (the first stage of the two-stage
// interrupt, FR-14): pressing Esc / Ctrl+C while running signals the run to
// stop rather than quitting the program. It is a seam wired alongside
// startRunFn by session assembly (#392) — typically the run ctx's cancel
// func. Until then it may be nil, in which case an interrupt while running is
// a safe no-op (the pump keeps draining until it ends on its own).
interruptFn func()
// quitting is set when a quit key (Ctrl+C / Ctrl+D) is seen, so View can be a
// no-op on the final frame while the program tears down and restores the
// terminal.
quitting bool
// statusBar renders the persistent bottom line (#386, US-003). It is fed the
// terminal width, telemetry-derived context usage, and the async git probe
// result; View renders it just above the input line.
statusBar statusBar
// cwd is the launch directory, captured once at construction and reused for
// the git probe and the status bar's path display.
cwd string
// slash is the shared slash-command registry (#383) the TUI consults exactly
// as the REPL does: /model, /help, user templates, plugin commands and skills.
// It is bound to live so a /model switch mutates the same config the run loop
// reads. Built in NewModel (built-ins + disk templates) and rebuilt in
// withSession against the session's live config.
slash *runtime.SlashRegistry
// live is the mutable run configuration the /model command switches. In a
// session-bound model it is the SAME pointer the run loop reads (set by
// withSession), so a switch takes effect on the next turn.
live *cli.LiveConfig
// menu is the autocomplete popup shown while a "/name" is being typed (#391).
// It filters slash by the typed prefix; the model intercepts arrow/Tab/Enter
// keys to drive it before delegating to the textarea.
menu slashMenu
// toolCards indexes the rich tool-call cards (#389, US-006) by tool-call id so
// a toolEndMsg can locate the card started earlier and flip its state / attach
// the parsed response. Each card is also appended to the transcript as an
// ordered block (by pointer), so mutating one here re-renders it inline on the
// next reflow.
toolCards map[string]*toolCard
// lastToolCard points at the most recently started card; Ctrl+O toggles its
// expanded state and re-flows the transcript.
lastToolCard *toolCard
// draggingScrollbar is set while the left mouse button is held after pressing
// on the transcript scrollbar column, so subsequent motion events drag the
// thumb (and scroll the viewport) until the button is released.
draggingScrollbar bool
// sel is the current mouse text selection over the rendered shell (screen
// cells). A left-press off the scrollbar starts it, drag extends it, and it
// persists after release so Ctrl+C can copy the highlighted text.
sel selection
// spinner is the animated "working" indicator (verb + elapsed/token/effort
// stats) shown on the row above the input while a run is in flight.
spinner spinner
// subagents is the ordered set of live sub-agents dispatched by the `task`
// tool (SPEC 4.4, US-006). A toolStartMsg with name=="task" adds a row (and
// records its start time), subagentProgressMsg refreshes activity/tokens, and
// the task's toolEndMsg removes it. View renders it as a multi-line panel just
// above the spinner; it contributes zero rows when empty.
subagents subagentPanel
// pastes stores the full text of collapsed multi-line pastes, keyed by the id
// shown in the "[Pasted text #N +M lines]" placeholder left in the composer.
// submit expands the placeholders back to their content before sending, so a
// large paste never floods the editor (mirroring Claude Code).
pastes map[int]string
// pasteSeq is the monotonic counter behind the paste placeholder ids. It keeps
// climbing across submits so ids stay unique for the session.
pasteSeq int
// images maps the id shown in an "[Image #N]" placeholder to the temp PNG a
// Ctrl+V / Cmd+V image paste was saved to. submit expands the placeholder to an
// "@image:<path>" reference so BuildUserContent attaches the image as
// multimodal content (mirroring Claude Code's image paste).
images map[int]string
// imageSeq is the monotonic counter behind the image placeholder ids.
imageSeq int
}
// NewModel builds the root model from the assembled Options. It reads the
// current working directory (for the status bar's path display and git probe)
// and assembles the shared slash-command registry (#391), which reads the user
// prompt-template dirs (~/.pigo/{commands,prompts}) and the pre-loaded skills;
// missing dirs are not an error. The registry is bound here to a live config
// derived from Options; withSession rebinds it to the session's live config so a
// /model switch reaches the run loop.
func NewModel(opts Options) Model {
cwd, err := os.Getwd()
if err != nil {
cwd = ""
}
theme := DefaultTheme()
live := &cli.LiveConfig{
Model: opts.Model,
ProviderName: opts.ProviderName,
Provider: opts.Provider,
BaseURL: opts.BaseURL,
Protocol: opts.Protocol,
ThinkingLevel: opts.ThinkingLevel,
ContextWindow: cli.DefaultContextWindow,
}
return Model{
opts: opts,
theme: theme,
transcript: newTranscript(theme),
input: newInput(),
cwd: cwd,
statusBar: newStatusBar(theme, opts, cwd),
toolCards: make(map[string]*toolCard),
slash: newSlashRegistry(opts, live),
live: live,
menu: newSlashMenu(theme),
spinner: newSpinner(theme),
pastes: make(map[int]string),
images: make(map[int]string),
}
}
// withSession binds the assembled run session to the model: it wires the real
// run seam (startRunFn) and, for a resumed session, replays the prior history
// into the transcript so the user sees the conversation so far before entering
// interactive mode. Run calls it right after NewModel; the session-less
// constructor path (tests, pure construction) leaves startRunFn nil.
func (m Model) withSession(s *runSession, history []agentcore.Message) Model {
m.session = s
m.startRunFn = s.startRun
m.interruptFn = s.interrupt
// Rebind the registry to the session's own one (assembled against s.live, the
// very config the run loop reads via buildConfig) so /model mutates the live
// config, /trust reaches the session's trust manager (registered in
// newRunSessionWithStore), and /status can list skill/plugin/user commands.
m.live = s.live
m.slash = s.slash
m.transcript.addBanner(renderBanner(m.theme, m.opts, m.cwd))
seedTranscript(&m.transcript, history)
return m
}
// Init implements tea.Model. It kicks off the async git probe so the status bar
// can show the branch/dirty state as soon as it resolves; the alt-screen is
// requested declaratively via the AltScreen field on the View returned by View.
func (m Model) Init() tea.Cmd {
return tea.Batch(fetchGitCmd(m.cwd), m.input.Focus(), func() tea.Msg {
return tea.RequestBackgroundColor()
})
}
// Update implements tea.Model. It tracks the terminal size, drives the minimal
// input line, starts runs on submit, and pumps bridged run events into the
// transcript and status bar. It quits on the standard exit keys (Ctrl+C /
// Ctrl+D).
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.relayout()
return m, nil
case gitInfoMsg:
m.statusBar.SetGit(msg)
return m, nil
case tea.BackgroundColorMsg:
// Feed the terminal's real background to the Markdown renderer so glamour
// picks a matching light/dark palette WITHOUT issuing its own terminal
// query (which would leak its reply into the input — see SetMarkdownDark).
// Re-flow so any already-finalized assistant block re-renders in the right
// palette.
SetMarkdownDark(msg.IsDark())
m.transcript.reflow()
return m, nil
case tea.MouseWheelMsg:
// Mouse-wheel scrolling reaches the transcript viewport whether idle or
// running, so history stays scrollable with the wheel — not just PgUp/PgDn.
// The viewport (MouseWheelEnabled by default) turns the wheel event into a
// scroll; enabling MouseModeCellMotion in View is what makes the terminal
// deliver these events under the alt-screen at all.
cmd := m.transcript.update(msg)
m.sel = selection{}
return m, cmd
case tea.MouseClickMsg:
// A left press on the scrollbar column grabs the thumb (jump + drag). A left
// press anywhere else begins a text selection at that cell, replacing any
// prior one; a bare click (no drag) leaves it empty so it clears the old
// highlight without starting a copyable range.
if msg.Button == tea.MouseLeft {
if m.onScrollbar(msg.X, msg.Y) {
m.draggingScrollbar = true
m.transcript.scrollToRow(msg.Y)
return m, nil
}
m.sel = selection{active: true, anchor: point{msg.X, msg.Y}, cursor: point{msg.X, msg.Y}}
return m, nil
}
return m, nil
case tea.MouseMotionMsg:
// While the thumb is grabbed, vertical motion drags it regardless of the
// cursor's column. Otherwise, motion after a left press extends the text
// selection to the current cell.
if m.draggingScrollbar {
m.transcript.scrollToRow(msg.Y)
return m, nil
}
if m.sel.active {
m.sel.cursor = point{msg.X, msg.Y}
}
return m, nil
case tea.MouseReleaseMsg:
m.draggingScrollbar = false
if m.sel.active {
m.sel.cursor = point{msg.X, msg.Y}
}
return m, nil
case tea.PasteMsg:
// Bracketed paste (e.g. Cmd+V / right-click paste): the terminal delivers
// the whole clipboard payload as one message. A multi-line paste is
// collapsed to a compact placeholder (expanded at submit); a single-line
// paste is inserted verbatim. See handlePaste.
if !m.running {
return m.handlePaste(msg.Content)
}
return m, nil
case tea.ClipboardMsg:
// OSC52 clipboard read reply (from tea.ReadClipboard on Ctrl+V / Cmd+V).
// Route through the same collapse-or-insert path as bracketed paste.
if !m.running {
return m.handlePaste(msg.Content)
}
return m, nil
case clipboardImageMsg:
// Reply to a Ctrl+V / Cmd+V image-read attempt. With an image, drop an
// "[Image #N]" placeholder (expanded to an @image reference at submit); with
// none, fall back to a normal OSC52 text read so plain-text paste still works.
if !m.running {
if msg.ok {
return m.handleImagePaste(msg.path)
}
return m, tea.ReadClipboard
}
return m, nil
case tea.KeyPressMsg:
return m.handleKey(msg)
case spinnerTickMsg:
// Advance the working animation and schedule the next frame, but only while
// a run is in flight; once idle the tick is not re-issued so the spinner
// stops without a lingering goroutine.
if !m.running {
return m, nil
}
m.spinner.advance()
return m, m.tickSpinner()
case textDeltaMsg:
m.spinner.addTokens(msg.delta)
m.transcript.appendDelta(msg.delta)
m.remoteEcho(msg.delta)
return m, m.pumpNext()
case turnEndMsg:
m.transcript.finalizeTurn(msg.msg)
// Surface a failed or empty turn so a provider/API error is never silent.
// The loop delivers request failures (e.g. a 4xx from the endpoint) as a
// terminal assistant message with stopReason error/aborted via TurnEndEvent
// — not as the run's result error (runEndMsg.err) — so without this check
// the TUI would finalize an empty turn and return to the prompt with no
// output at all. Mirrors the headless driver and the line-based REPL.
switch msg.msg.StopReason {
case agentcore.StopReasonError:
reason := strings.TrimSpace(msg.msg.ErrorMessage)
if reason == "" {
reason = "the provider returned an error with no message"
}
m.transcript.addSystem("error: " + reason)
case agentcore.StopReasonAborted:
m.transcript.addSystem("error: aborted")
default:
// A turn that ends cleanly (end_turn) but produced no text, no thinking,
// and no tool calls means the endpoint accepted the request but sent back
// nothing usable (e.g. a 200 whose body was not in the wire format this
// protocol expects). Note it instead of showing nothing.
if len(msg.msg.Content) == 0 && len(msg.results) == 0 {
m.transcript.addSystem("note: empty response from the model (no content). " +
"Check that --model, --base-url and --protocol match the same provider.")
}
}
return m, m.pumpNext()
case toolStartMsg:
// Create a rich tool-call card, index it by id for the later end event, and
// append it as an ordered transcript block so it renders inline (#389).
card := &toolCard{id: msg.id, name: msg.name, input: msg.input, state: cardRunning}
m.toolCards[msg.id] = card
m.lastToolCard = card
m.transcript.addToolCard(card)
m.remoteEcho("\n· " + msg.name + "\n")
// A `task` tool call dispatches a sub-agent: open a status-panel row keyed by
// the tool-call id (matching the later progress/end events) and record its
// start so elapsed can be shown live (SPEC 4.4).
if msg.name == "task" {
m.subagents.add(msg.id, taskDescription(msg.input), time.Now())
m.relayout() // the new panel row shrinks the transcript to fit
}
return m, m.pumpNext()
case toolUpdateMsg:
// A `task` sub-agent forwards its text as incremental tool-update deltas;
// accumulate them onto the matching panel row so the expanded view can show
// the running output. appendOutput is a no-op for non-task ids (nothing to
// attach to), so ordinary tool updates are unaffected. Relayout only when the
// delta lands on the currently expanded row, whose growing output changes the
// panel height; other rows' output is buffered without touching the layout.
m.subagents.appendOutput(msg.id, msg.partial)
if m.subagents.expandedID() == msg.id {
m.relayout()
}
return m, m.pumpNext()
case subagentProgressMsg:
// A running sub-agent reported structured progress: refresh its panel row's
// activity/tokens. update adds the row if it is missing so a late/out-of-order
// progress (arriving before the task's start) is still shown (SPEC 5.4).
m.subagents.update(msg.id, msg.desc, msg.activity, msg.tokens, time.Now())
m.relayout() // a first-seen id adds a row; keep the transcript sized to it
return m, m.pumpNext()
case toolEndMsg:
// Flip the card's state and attach the parsed response tree. The card is
// held by pointer in the transcript, so a reflow re-renders it in place.
if card, ok := m.toolCards[msg.id]; ok {
if msg.ok {
card.state = cardSuccess
} else {
card.state = cardWarn
}
card.response = parseToolResult(msg.result)
m.transcript.reflow()
}
// Retire the sub-agent's status-panel row (a no-op for non-task tools whose id
// was never added), reclaiming its reserved height.
if _, wasSub := m.subagents.byID[msg.id]; wasSub {
m.subagents.remove(msg.id)
m.relayout()
}
return m, m.pumpNext()
case telemetryMsg:
// Feed the status bar's context-usage readout, and retain the event on the
// session's telemetry holder so /status can render the cumulative + last-run
// telemetry report (US-002, #292). Then keep the pump running.
m.statusBar.SetTelemetry(telemetryEventView{
util: msg.ev.ContextUtilization,
window: msg.ev.ContextWindow,
tokens: msg.ev.ContextTokens,
})
if m.session != nil && m.session.telemetry != nil {
m.session.telemetry.Fold(msg.ev)
}
return m, m.pumpNext()
case compactionStartMsg:
m.spinner.pin("Compacting conversation")
return m, m.pumpNext()
case compactionMsg:
m.spinner.unpin()
if m.session != nil {
m.session.compacted = true
}
m.transcript.addSystem("(context compacted)")
return m, m.pumpNext()
case rebuildDoneMsg:
// A manual /rebuild finished: clear the pinned "Preparing conversation
// context…" spinner (no run is pumping, so stop it and drop out of the
// running state) and report the outcome. rebuild() already applied the
// rebuilt messages and set session.compacted on success.
m.spinner.unpin()
m.spinner.stop()
m.running = false
if msg.err != nil {
m.transcript.addSystem("rebuild failed: " + msg.err.Error() + " (context left unchanged)")
} else {
m.transcript.addSystem(msg.summary)
}
m.relayout()
return m, nil
case runEndMsg:
m.running = false
m.runCh = nil
m.spinner.stop()
// The run is over: any still-open sub-agent rows are stale (their tasks ended
// with the run), so clear the panel to reclaim its height.
m.subagents = subagentPanel{}
m.relayout()
if msg.err != nil {
m.transcript.addSystem("Run ended: " + msg.err.Error())
}
// Persist the turn's new messages as a branch so the conversation survives
// exit and can be resumed (FR-16). This is race-free: the pump goroutine
// owns agentCtx.Messages during the run and only sends runEndMsg after
// DrainStream returns (loop done), so no goroutine is still writing the
// context when persist reads it here on the tea goroutine. A save failure
// is surfaced but non-fatal.
if m.session != nil {
if err := m.session.persist(); err != nil {
m.transcript.addSystem("Session save failed: " + err.Error())
}
}
// The editor was blurred at submit; re-enable it so the next prompt can be
// typed, and re-probe git since a run may have changed the working tree.
focus := m.input.Focus()
return m, tea.Batch(focus, fetchGitCmd(m.cwd))
case remoteInputMsg:
// A prompt arrived from the paired browser (remote-control). Always re-issue
// the listener so successive remote prompts keep arriving. While a run is in
// flight the prompt is refused with a note (mirroring the local single-run
// gate); when idle it is echoed as a user block and run — as a slash command
// if it starts with "/", else a normal prompt.
text := strings.TrimSpace(msg.text)
if m.running || text == "" {
if m.running && text != "" {
m.transcript.addSystem("(remote input ignored: a run is in progress)")
m.relayout()
}
return m, m.waitRemoteInput()
}
var cmd tea.Cmd
var next tea.Model = m
if strings.HasPrefix(text, "/") {
next, cmd = m.runSlash(text)
} else {
m.transcript.addUser(text)
m.remoteEcho("\n> " + text + "\n")
m.relayout()
next, cmd = m.startPrompt(text)
}
m = next.(Model)
return m, tea.Batch(cmd, m.waitRemoteInput())
}
return m, nil
}
// handleKey processes a key press. It resolves the keys the shell owns —
// two-stage interrupt/quit, prompt submit, transcript scrolling — and delegates
// everything else (character entry, in-buffer cursor movement, Shift+Enter
// newline) to the input editor while idle. Keys are matched via KeyPressMsg
// .String() so the mapping is terminal-independent.
func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
// While idle with the autocomplete popup open, the arrow / Tab / Esc keys
// drive the menu instead of the transcript or textarea (FR-15). Enter is left
// to the main switch below, which routes through submit → runSlash so the
// selected/typed command runs. These are matched via KeyPressMsg.String() so
// the mapping is terminal-independent.
if !m.running && m.menu.active {
switch msg.String() {
case "up":
m.menu.moveUp()
return m, nil
case "down":
m.menu.moveDown()
return m, nil
case "tab":
m = m.completeSlash()
m.relayout()
return m, nil
case "esc":
m.menu.close()
m.relayout()
return m, nil
case "enter":
return m.submitSlashSelected()
}
}
// While a sub-agent run is streaming, the composer is disabled (no typing until
// the run ends), so ↑/↓ drive a selection cursor over the live sub-agent status
// rows and Enter expands the selected row to show its accumulated output inline.
// Esc is the one-key escape back to the composer: with a row selected it drops
// the selection AND re-focuses the input box in a single press, so arrowing into
// the panel is never a trap. With no selection Esc falls through to its
// two-stage interrupt role below. The Value()=="" guard is a safety net for the
// rare case where text reached the buffer (e.g. a paste): then arrows edit the
// buffer rather than the panel.
if m.running && m.subagents.active() > 0 && m.input.Value() == "" {
switch msg.String() {
case "up":
m.subagents.selectUp()
m.relayout()
return m, nil
case "down":
m.subagents.selectDown()
m.relayout()
return m, nil
case "enter":
m.subagents.toggleExpand()
m.relayout()
return m, nil
case "esc":
if m.subagents.hasSelection() {
m.subagents.clearSelection()
focus := m.input.Focus()
m.relayout()
return m, focus
}
}
}
switch msg.String() {
case "ctrl+c":
// Ctrl+C copies the current mouse selection when there is one (over OSC52),
// clearing it afterward; with no selection it keeps its interrupt-or-quit
// role. Copying works even mid-run, so grabbing streamed output never
// interrupts the run.
if !m.sel.empty() {
text := m.selectedText()
m.sel = selection{}
if text != "" {
return m, tea.SetClipboard(text)
}
return m, nil
}
return m.interruptOrQuit()
case "super+c":
// Cmd+C on macOS is the platform-standard copy: copy the mouse selection
// when there is one (clearing it), else the whole input buffer. Unlike
// Ctrl+C it never interrupts/quits — Cmd+C means "copy" on macOS. Most
// terminals intercept Cmd+C for their own native copy and never deliver it
// here; this branch serves terminals that forward the Super modifier.
if !m.sel.empty() {
text := m.selectedText()
m.sel = selection{}
if text != "" {
return m, tea.SetClipboard(text)
}
return m, nil
}
if !m.running {
if v := m.input.Value(); v != "" {
return m, tea.SetClipboard(v)
}
}
return m, nil
case "esc":
return m.interruptOrQuit()
case "ctrl+o":
// Toggle the most-recent tool card between its capped preview and the full
// response tree, then re-flow so the change shows inline (#389).
if m.lastToolCard != nil {
m.lastToolCard.expanded = !m.lastToolCard.expanded
m.transcript.reflow()
}
return m, nil
case "ctrl+d":
// Ctrl+D quits only when idle; mid-run it is ignored so a run is never
// dropped by a stray EOF key.
if !m.running {
m.shutdownRemote()
m.quitting = true
return m, tea.Quit
}
return m, nil
case "enter":
// Enter submits the composed buffer (FR-13). Shift+Enter inserts a newline
// (rebound in newInput) so the editor is a true multi-line composer; when
// the slash menu is open, Enter runs the highlighted command (handled
// above), so this branch is only reached with the menu closed.
if !m.running {
return m.submit()
}
return m, nil
case "pgup", "pgdown":
// Page scrolling reaches the transcript viewport whether idle or running,
// so history stays readable while a run streams. Scrolling shifts the
// content under a screen-anchored selection, so drop the selection to avoid
// a stale highlight. Line-oriented keys (up / down / home / end) belong to
// the multi-line editor and are delegated below.
m.sel = selection{}
cmd := m.transcript.update(msg)
return m, cmd
case "ctrl+v":
// Explicit paste key: first try to pull an image off the clipboard (Claude
// Code-style image paste); the reply arrives as clipboardImageMsg and, when
// no image is present, falls back to an OSC52 text read (tea.ClipboardMsg).
// This is intercepted before textarea so its own Ctrl+V binding — which reads
// via an external process and returns an unexported message the model can't
// route — is bypassed. The common Cmd+V path does not reach here; it arrives
// as a bracketed tea.PasteMsg handled in Update.
if !m.running {
return m, readClipboardImage
}
return m, nil
case "super+v":
// Cmd+V on macOS is the platform-standard paste. Most terminals turn it
// into a bracketed paste (tea.PasteMsg, handled in Update); this branch
// covers terminals that instead forward the Super modifier as a key. Try an
// image read first, falling back to an OSC52 text read when none is present.
if !m.running {
return m, readClipboardImage
}
return m, nil
case "ctrl+y":
// Copy: the editor has no text selection, so this copies the whole buffer
// to the system clipboard over OSC52. A no-op on an empty buffer.
if !m.running {
if v := m.input.Value(); v != "" {
return m, tea.SetClipboard(v)
}
}
return m, nil
}
// Everything else is editing input; gated on idle so keystrokes never corrupt
// an in-flight prompt. textarea handles CJK / emoji by rune and Shift+Enter as
// a newline. After the buffer changes, refresh the autocomplete popup so it
// opens/filters/closes as the user types a "/name" prefix.
if !m.running {
// ↑/↓ walk the submitted-prompt history when the caret is at the top / bottom
// edge of the composer; otherwise they move the caret within a multi-line
// draft (handled by the textarea below).
switch msg.String() {
case "up":
return m.historyPrev(msg)
case "down":
return m.historyNext(msg)
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.menu.refresh(m.input.Value(), m.slash)
m.relayout()
return m, cmd
}
return m, nil
}
// submit starts a run for the current buffer: it appends the user block, clears
// and blurs the editor, flips to running, and — when a run starter is wired —
// returns the first pump Cmd. With no starter (pre-#392) it records the prompt
// and a system note without launching anything, and leaves the editor ready for
// the next line.
func (m Model) submit() (tea.Model, tea.Cmd) {
raw := strings.TrimSpace(m.input.Value())
prompt := strings.TrimSpace(m.expandImages(m.expandPastes(m.input.Value())))
if prompt == "" {
return m, nil
}
// Record the input (as typed) into the browse history, then exit browse mode.
m.recordHistory(raw)
// The placeholders have been expanded into the prompt, so the stored paste
// bodies and image paths are consumed; drop them (the id counters keep climbing).
m.pastes = make(map[int]string)
m.images = make(map[int]string)
// A "/name ..." line is a slash-command invocation, not a prompt: resolve it
// against the shared registry (same as the REPL) rather than sending it to the
// agent verbatim.
if strings.HasPrefix(prompt, "/") {
return m.runSlash(prompt)
}
m.transcript.addUser(prompt)
m.remoteEcho("\n> " + prompt + "\n")
m.input.Clear()
m.menu.close()
m.relayout()
return m.startPrompt(prompt)
}
// completeSlash fills the buffer with the highlighted candidate's "/name " so the
// user can go on to type arguments; the trailing space ends name-completion, so
// the refresh closes the popup. It is the Tab action while the menu is open.
func (m Model) completeSlash() Model {
if c, ok := m.menu.current(); ok {
m.input.SetValue("/" + c.Name + " ")
m.menu.refresh(m.input.Value(), m.slash)
}
return m
}
// submitSlashSelected runs the command the popup highlights (Enter while the
// menu is open). Navigating with the arrows then pressing Enter runs the
// selected command even if the typed prefix is shorter; with no selection it
// falls back to the raw buffer so a fully-typed "/name" still runs.
func (m Model) submitSlashSelected() (tea.Model, tea.Cmd) {
line := strings.TrimSpace(m.input.Value())
if c, ok := m.menu.current(); ok {
line = "/" + c.Name
}
m.recordHistory(line)
return m.runSlash(line)
}
// runSlash resolves a slash-command line against the shared registry and folds
// its outcome into the transcript, mirroring the REPL's dispatch: the invocation
// is echoed as a user block; an action command's status (e.g. /help, /model)
// renders as a system block; a prompt/skill command's expanded text starts a
// run; a hybrid (plugin) command shows its notifications then runs its prompt.
// An unknown command surfaces the resolver error as a system block.
func (m Model) runSlash(line string) (tea.Model, tea.Cmd) {
// /exit and /quit terminate the TUI, mirroring the REPL loop which intercepts
// them before slash resolution. They register only as no-op /help builtins, so
// without this the registry would resolve them to an empty action.
if line == "/exit" || line == "/quit" {
m.shutdownRemote()
m.quitting = true
return m, tea.Quit
}
// /memory is intercepted before registry resolution (like /rebuild): it
// prints the persistent-memory + infinite-context report, reading the live
// memory store, memory root, session id, and messages that a slash Action
// closure (string→string) cannot reach.
if line == "/memory" || strings.HasPrefix(line, "/memory ") {
m.transcript.addUser(line)
m.input.Clear()
m.menu.close()
var buf bytes.Buffer
var store *memory.Store
var memoryRoot, sessionID string
var msgs agentcore.MessageList
window := m.live.ContextWindow
if m.session != nil {
store = m.session.memstore
memoryRoot = m.session.memoryRoot
sessionID = m.session.header.ID
msgs = m.session.agentCtx.Messages
}
memstatus.RunMemory(&buf, store, memoryRoot, sessionID, msgs, window)
m.transcript.addSystem(strings.TrimRight(buf.String(), "\n"))
m.relayout()
return m, nil
}
// /status is intercepted before registry resolution (like /memory): it prints
// the shared runtime/context/project/credentials/telemetry report, which reads
// the session's live collaborators (live config, trust manager, telemetry
// holder, slash registry) that a slash Action closure (string→string) cannot
// reach. The rendering lives in the shared status package so the TUI and the
// REPL produce byte-identical output.
if line == "/status" || strings.HasPrefix(line, "/status ") {
m.transcript.addUser(line)
m.input.Clear()
m.menu.close()
if m.session == nil {
m.transcript.addSystem("(status unavailable: no active session)")
m.relayout()
return m, nil
}
var buf bytes.Buffer
status.RunStatus(&buf, m.session)
m.transcript.addSystem(strings.TrimRight(buf.String(), "\n"))
m.relayout()
return m, nil
}
// /session is intercepted before registry resolution (like /memory): it prints
// the conversation summary (session id, message count, estimated tokens, model/
// provider, created time, compaction count) from the live context — state a
// slash Action closure cannot reach. The rendering is shared with the REPL.
if line == "/session" {
m.transcript.addUser(line)
m.input.Clear()
m.menu.close()
if m.session == nil {
m.transcript.addSystem("(session unavailable: no active session)")
m.relayout()
return m, nil
}
var buf bytes.Buffer
m.session.renderSession(&buf)
m.transcript.addSystem(strings.TrimRight(buf.String(), "\n"))
m.relayout()
return m, nil
}
// /rebuild is intercepted before registry resolution (like /exit): it
// reconstructs the shared context from a persisted checkpoint (or falls back
// to compaction) and replaces the message list in place — work a slash Action
// closure cannot do. It reuses the compacting-indicator: the spinner is armed
// and pinned to "Preparing conversation context…" while the rebuild runs off
// the tea loop, and rebuildDoneMsg clears it and reports the result.
if line == "/rebuild" {
m.transcript.addUser(line)
m.input.Clear()
m.menu.close()
if m.session == nil {
m.transcript.addSystem("(rebuild unavailable: no active session)")
m.relayout()
return m, nil
}
m.spinner.begin(time.Now(), m.thinkingLabel())
m.spinner.pin("Preparing conversation context")
m.running = true
m.relayout()
return m, tea.Batch(m.session.rebuildCmd(), m.tickSpinner())
}
// /remote-control is intercepted before registry resolution (like /rebuild):
// it starts/stops the LAN mirror server, which owns state (server, bridge,
// listener Cmd) a string→string slash Action cannot hold.
if line == "/remote-control" || strings.HasPrefix(line, "/remote-control ") {
return m.runRemoteControl(line)
}
m.transcript.addUser(line)
m.input.Clear()
m.menu.close()
m.relayout()
if m.slash == nil {
m.transcript.addSystem("Slash commands unavailable")
return m, nil
}
outcome, err := m.slash.ResolveOutcome(line)
if err != nil {
m.transcript.addSystem(err.Error())
return m, nil
}
if outcome.Message != "" {
m.transcript.addSystem(outcome.Message)
}
// A live-state command (/model, /think) may have mutated m.live; sync the
// status bar so the model/thinking segments reflect the switch immediately.
if m.live != nil {
m.statusBar.SetModel(m.live.Model)
m.statusBar.SetThinking(string(m.live.ThinkingLevel))
}
// An action command is complete once its status is shown; a hybrid with no
// prompt (notifications only) likewise starts no run.
if outcome.Kind == runtime.SlashAction || outcome.Prompt == "" {
return m, nil
}
return m.startPrompt(outcome.Prompt)
}
// recordHistory appends an submitted input to the browse history (skipping a
// consecutive duplicate, like a shell) and resets the browse cursor to the live
// draft, so the next ↑ starts from the most recent entry and any stashed draft is
// dropped. A blank entry is never stored.
func (m *Model) recordHistory(entry string) {
entry = strings.TrimSpace(entry)
if entry != "" && (len(m.history) == 0 || m.history[len(m.history)-1] != entry) {
m.history = append(m.history, entry)
}
m.histIdx = len(m.history)
m.histDraft = ""
}
// historyPrev recalls the previous submitted input into the composer, but only
// when the caret is on the first line — otherwise ↑ moves the caret within a
// multi-line draft. The first recall stashes the live draft so historyNext can
// restore it, and the cursor lands past the newest entry (len(history)) initially.
func (m Model) historyPrev(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if len(m.history) == 0 || m.input.Line() != 0 {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.menu.refresh(m.input.Value(), m.slash)
m.relayout()
return m, cmd
}
if m.histIdx == len(m.history) {
m.histDraft = m.input.Value()
}
if m.histIdx > 0 {
m.histIdx--
}
m.input.SetValue(m.history[m.histIdx])
m.menu.refresh(m.input.Value(), m.slash)
m.relayout()
return m, nil
}
// historyNext walks forward toward more recent inputs — restoring the stashed
// draft once it steps past the newest entry — but only while browsing and with
// the caret on the last line; otherwise ↓ moves the caret within a multi-line
// draft.
func (m Model) historyNext(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if m.histIdx >= len(m.history) || m.input.Line() != m.input.LineCount()-1 {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.menu.refresh(m.input.Value(), m.slash)
m.relayout()
return m, cmd
}
m.histIdx++
if m.histIdx == len(m.history) {
m.input.SetValue(m.histDraft)
} else {
m.input.SetValue(m.history[m.histIdx])
}
m.menu.refresh(m.input.Value(), m.slash)
m.relayout()
return m, nil
}
// startPrompt launches an agent run for prompt, blurring the editor and flipping
// to running when a run starter is wired. With no starter (pre-session model /
// tests) it records the pre-#392 system note and stays idle. It is shared by a
// plain submit and by a slash prompt/skill command.
func (m Model) startPrompt(prompt string) (tea.Model, tea.Cmd) {
if m.startRunFn == nil {
m.transcript.addSystem("(run not wired up: see session assembly in #392)")
return m, nil
}
m.input.Blur()
ch, cmd := m.startRunFn(prompt)
m.runCh = ch
m.running = true
m.spinner.begin(time.Now(), m.thinkingLabel())
m.relayout()
return m, tea.Batch(cmd, m.tickSpinner())
}
// thinkingLabel returns the current thinking-effort label for the spinner stats
// (e.g. "medium"), or "" when no thinking level is configured so the stat is
// omitted. It reads the live config the /model command mutates, falling back to
// the launch Options.
func (m Model) thinkingLabel() string {
if m.live != nil && m.live.ThinkingLevel != "" {
return string(m.live.ThinkingLevel)
}
return string(m.opts.ThinkingLevel)
}
// taskDescription pulls the human-readable "description" out of a `task` tool
// call's decoded arguments for the sub-agent panel's row label. It returns ""
// when absent or non-string (the description field is optional in the schema),
// in which case the panel row leads with the activity instead.
func taskDescription(input map[string]any) string {
if s, ok := input["description"].(string); ok {
return s
}
return ""
}
// tickSpinner schedules the next spinner animation frame. The model re-issues it
// on each spinnerTickMsg while running, so the animation self-sustains until the
// run ends (the tick is simply not re-issued once idle).
func (m Model) tickSpinner() tea.Cmd {
return tea.Tick(spinnerInterval, func(t time.Time) tea.Msg {
return spinnerTickMsg(t)
})
}
// interruptOrQuit is the shared Esc / bare-Ctrl+C action: a two-stage interrupt
// (FR-14) that stops an in-flight run on the first press and stays in the
// program, or quits when idle.
func (m Model) interruptOrQuit() (tea.Model, tea.Cmd) {
if m.running {
if m.interruptFn != nil {
m.interruptFn()
}
m.transcript.addSystem("(interrupting the current run…)")
return m, nil
}
m.shutdownRemote()
m.quitting = true
return m, tea.Quit
}
// shutdownRemote stops the remote-control server on quit so the listener and
// WebSocket are released cleanly. A no-op when remote control is off or no
// session is bound.
func (m Model) shutdownRemote() {
if m.session != nil {
m.session.stopRemote()
}
}
// feedInput forwards a message (a paste payload) to the editor, then refreshes
// the slash menu and re-lays out because inserted text can add lines (growing
// the editor) or begin a "/name". It is the shared tail of the paste handlers.
func (m Model) feedInput(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.menu.refresh(m.input.Value(), m.slash)
m.relayout()
return m, cmd
}
// pastePlaceholderRe matches the "[Pasted text #N +M lines]" tokens handlePaste
// leaves in the composer, capturing the id so expandPastes can swap the stored
// body back in at submit.
var pastePlaceholderRe = regexp.MustCompile(`\[Pasted text #(\d+) \+\d+ lines\]`)
// handlePaste inserts a pasted payload into the editor. A multi-line paste is
// collapsed to a compact "[Pasted text #N +M lines]" placeholder (the full body
// stashed in m.pastes for expansion at submit), so a large paste does not flood
// the composer — mirroring Claude Code. A single-line paste is inserted verbatim.
func (m Model) handlePaste(content string) (tea.Model, tea.Cmd) {
if content == "" {
return m, nil
}
if strings.Contains(content, "\n") {
if m.pastes == nil {
m.pastes = make(map[int]string)
}
m.pasteSeq++
id := m.pasteSeq
m.pastes[id] = content
lines := strings.Count(content, "\n") + 1
placeholder := fmt.Sprintf("[Pasted text #%d +%d lines]", id, lines)
return m.feedInput(tea.PasteMsg{Content: placeholder})
}
return m.feedInput(tea.PasteMsg{Content: content})
}
// expandPastes replaces every paste placeholder in s with its stored body, so
// the submitted prompt carries the real pasted text rather than the compact
// token the user saw in the composer. An unknown id (e.g. the user edited the
// token) is left as-is. It returns s unchanged when no pastes are stashed.
func (m Model) expandPastes(s string) string {
if len(m.pastes) == 0 {
return s
}
return pastePlaceholderRe.ReplaceAllStringFunc(s, func(tok string) string {
sm := pastePlaceholderRe.FindStringSubmatch(tok)
id, err := strconv.Atoi(sm[1])
if err != nil {
return tok
}
if body, ok := m.pastes[id]; ok {
return body
}
return tok
})
}
// handleImagePaste stashes a pasted image (already saved to a temp PNG at path)
// and drops a compact "[Image #N]" placeholder into the composer, mirroring the
// text-paste placeholder. submit expands it into an "@image:<path>" reference so
// BuildUserContent attaches the image as multimodal content. An empty path falls
// back to a plain text read.
func (m Model) handleImagePaste(path string) (tea.Model, tea.Cmd) {
if path == "" {
return m, tea.ReadClipboard
}
if m.images == nil {
m.images = make(map[int]string)
}
m.imageSeq++
id := m.imageSeq
m.images[id] = path
placeholder := fmt.Sprintf("[Image #%d]", id)
return m.feedInput(tea.PasteMsg{Content: placeholder})
}
// imagePlaceholderRe matches the "[Image #N]" tokens handleImagePaste leaves in
// the composer, capturing the id so expandImages can swap the stored temp path
// back in as an "@image:<path>" reference at submit.
var imagePlaceholderRe = regexp.MustCompile(`\[Image #(\d+)\]`)
// expandImages replaces every image placeholder in s with an "@image:<path>"
// reference so BuildUserContent reads and attaches the pasted image. An unknown id
// (e.g. the user edited the token) is left as-is. It returns s unchanged when no
// images are stashed.
func (m Model) expandImages(s string) string {
if len(m.images) == 0 {
return s
}
return imagePlaceholderRe.ReplaceAllStringFunc(s, func(tok string) string {
sm := imagePlaceholderRe.FindStringSubmatch(tok)
id, err := strconv.Atoi(sm[1])
if err != nil {
return tok
}
if p, ok := m.images[id]; ok {
return "@image:" + p
}
return tok
})
}
// pumpNext re-issues waitForEvent for the in-flight run so the next bridged msg
// is pulled. It returns nil once the run has ended (runCh cleared), stopping the
// pump.
func (m Model) pumpNext() tea.Cmd {
if m.running && m.runCh != nil {
return waitForEvent(m.runCh)
}
return nil
}
// View implements tea.Model. It renders the shell on the alt-screen: the
// scrolling transcript filling the top rows, then the autocomplete popup (when
// open) and the multi-line input editor, and finally the persistent status bar
// (#386) on the very bottom row — below the input, per the layout fix. Setting
// AltScreen on the returned View is how Bubble Tea v2 enters/leaves the alternate
// screen buffer, so the user's scrollback is restored on quit.
func (m Model) View() tea.View {
if m.quitting {
return tea.View{AltScreen: true}
}
content := m.applySelection(m.renderContent())
// MouseModeCellMotion enables click/release/wheel events. Without it the
// alt-screen swallows the wheel (no native scrollback), so history could only
// be reached via PgUp/PgDn; enabling it lets the wheel scroll the transcript
// and drives both scrollbar drag and mouse text selection.
return tea.View{Content: content, AltScreen: true, MouseMode: tea.MouseModeCellMotion}
}
// renderContent builds the full-screen shell string (transcript, autocomplete
// popup, input editor, status bar) without any selection overlay. View wraps it
// with applySelection for display, and selectedText reuses it to extract the
// copied text from the exact rows the user sees.
func (m Model) renderContent() string {
width := m.width
if width <= 0 {
width = 80
}
height := m.height
if height <= 0 {
height = 24
}
status := m.statusBar.Render(width)
// The input editor renders its own prompt column and cursor across as many
// rows as the buffer currently spans (up to maxInputRows).
input := m.input.View()
// Fallback transcript rows before the first size message; once sized the
// viewport is pre-sized by relayout and pads its own content.
rows := transcriptHeight(height)
sized := m.width > 0 && m.height > 0
var b strings.Builder
if sized {
// The viewport pads its content to exactly the rows relayout reserved.
b.WriteString(m.transcript.view())
b.WriteByte('\n')
} else {
for i := 0; i < rows; i++ {
b.WriteByte('\n')
}
}
// The working spinner sits on its own row just above the input while a run is
// in flight (relayout reserves the row so the transcript shrinks to fit). The
// sub-agent status panel, when any `task` sub-agents are live, renders on the
// rows just ABOVE the spinner: one line each, elapsed refreshed every tick.
if m.running {
if panel := m.subagents.view(m.theme, width, time.Now()); panel != "" {
b.WriteString(panel)
b.WriteByte('\n')
}
if line := m.spinner.view(width); line != "" {
b.WriteString(line)
b.WriteByte('\n')
}
}
// The autocomplete popup, when open, renders just above the input line as an
// overlay (it contributes no rows while idle, so the empty-shell layout is
// unchanged).
if menu := m.menu.view(width); menu != "" {
b.WriteString(menu)
b.WriteByte('\n')
}
b.WriteString(input)
b.WriteByte('\n')
// The status bar is the final line, pinned to the very bottom of the shell
// below the input editor.
b.WriteString(status)
return b.String()
}
// applySelection overlays the mouse selection highlight onto the rendered
// content, inverting the selected cells like a terminal's own selection. Only
// rows the selection intersects are rewritten (as plain text with the span
// inverted); untouched rows keep their original coloring. It is a no-op when the
// selection is empty.
func (m Model) applySelection(content string) string {
if m.sel.empty() {
return content
}
start, end := m.sel.ordered()
hi := lipgloss.NewStyle().Reverse(true)
rows := strings.Split(content, "\n")
for y := start.y; y <= end.y && y < len(rows); y++ {
if y < 0 {
continue
}
c0, c1, ok := rowRange(start, end, y)
if !ok {
continue
}
rows[y], _ = selectRow(rows[y], c0, c1, hi)
}
return strings.Join(rows, "\n")
}
// selectedText extracts the plain text under the current selection from the rows
// the user sees, joining rows with newlines and trimming each row's trailing
// padding so copied text has no ragged whitespace tail. It returns "" when the
// selection is empty.
func (m Model) selectedText() string {
if m.sel.empty() {
return ""
}
start, end := m.sel.ordered()
rows := strings.Split(m.renderContent(), "\n")
var b strings.Builder
wrote := false
for y := start.y; y <= end.y && y < len(rows); y++ {
if y < 0 {
continue
}
c0, c1, ok := rowRange(start, end, y)
if !ok {
continue
}
_, text := selectRow(rows[y], c0, c1, lipgloss.Style{})
if wrote {
b.WriteByte('\n')
}
b.WriteString(strings.TrimRight(text, " "))
wrote = true
}
return b.String()
}
// relayout re-sizes the transcript to the rows left after reserving the status
// bar (1 row), the current input editor height, and any open autocomplete popup.
// It hands the transcript the full width; the transcript itself spends one column
// on the scrollbar only while its content overflows (see transcript.reflow), so a
// short conversation uses the whole width and shows no bar, while a scrolling one
// reserves the gutter — and that decision re-runs on every streamed line, not just
// on resize. It is called on every resize and after any edit that changes the
// input height or menu row count.
func (m *Model) relayout() {
if m.width <= 0 || m.height <= 0 {
return
}
rows := m.height - 1 - m.input.Height() - m.menu.rows()
if m.running {
rows-- // the working spinner occupies the row just above the input
// The sub-agent panel reserves one status row per live sub-agent, plus the
// wrapped output lines of the expanded row (if any); an empty panel reserves
// nothing so the single-run layout is unchanged.
rows -= m.subagents.lineCount(m.width)
}
if rows < 0 {
rows = 0
}
m.transcript.setSize(m.width, rows)
m.input.SetWidth(m.width)
}
// onScrollbar reports whether the terminal cell (x, y) is the transcript's
// scrollbar: the rightmost column (relayout reserves m.width-1 for content, so
// the bar sits at column m.width-1) within the transcript's visible rows, which
// start at the top of the screen (row 0). It gates click-to-drag so presses in
// the body or on other chrome are left alone. When the content fits there is no
// bar (relayout reclaims the column), so it always returns false.
func (m Model) onScrollbar(x, y int) bool {
if m.width <= 0 || !m.transcript.overflowing() {
return false
}
h := m.transcript.viewportHeight()
return x == m.width-1 && y >= 0 && y < h
}
// transcriptHeight returns the fallback number of rows for the transcript before
// the first size message arrives: the total minus the status bar and a single
// input row, floored at zero so tiny terminals never produce a negative extent.
func transcriptHeight(total int) int {
h := total - 2
if h < 0 {
h = 0
}
return h
}