1361 lines
51 KiB
Go
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
|
|
}
|