Files
2026-07-13 13:00:08 +08:00

4202 lines
140 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"unicode/utf8"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"reasonix/internal/agent"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/hook"
"reasonix/internal/i18n"
"reasonix/internal/memory"
"reasonix/internal/migration"
"reasonix/internal/outputstyle"
"reasonix/internal/permission"
"reasonix/internal/plugin"
"reasonix/internal/provider"
"reasonix/internal/sandbox"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
// chatTUI is a bubbletea Model that normally owns the terminal with an
// alt-screen transcript viewport. Termux is the exception: it stays in the
// normal buffer and commits finalized output to native scrollback via
// tea.Println so taps can still focus the soft keyboard.
type chatTUI struct {
ctrl control.SessionAPI
label string
missing string // missing-key warning surfaced once in the banner, "" when ready
width int
height int
// nativeScrollback keeps Termux out of alt-screen mode so taps still focus
// the textarea and raise the soft keyboard.
nativeScrollback bool
// mouseCaptureOff releases mouse ownership back to the terminal (View() sets
// tea.MouseModeNone instead of MouseModeCellMotion) so its native
// click-drag selection and right-click context menu work again. Toggled by
// "/mouse" or REASONIX_DISABLE_MOUSE at startup; trades away in-app
// drag-select, the transcript scrollbar, and wheel-scroll while it's on,
// since the terminal no longer forwards those events to Reasonix.
mouseCaptureOff bool
input textarea.Model
spinner spinner.Model
submittedInputs []string
submittedInputCursor int
submittedInputDraft string
pastedBlocks []pastedBlock
nextPasteID int
state tuiState
runStart time.Time
elapsed int
// retryAttempt/retryMax drive the transient "retrying (n/m)" indicator while
// the provider re-attempts the connection; cleared by the next stream event.
retryAttempt int
retryMax int
// turnTokens accumulates this turn's output tokens (summed from per-step Usage
// events) for the live "↓N" readout in the running status line.
turnTokens int
// balance is the last-fetched wallet-balance readout (e.g. "¥110.00"), "" when
// the provider declares no balance_url or a fetch failed. Refreshed async on
// startup and after each turn so the status line stays roughly current without
// blocking the event loop.
balance string
// todoArgs is the latest todo_write call's raw args; it drives the task list
// pinned just above the input (see renderTodoPanel). "" when there's no list.
// Persists across turns until the work completes or a new session starts.
todoArgs string
// planMode mirrors the agent's read-only gate (Shift+Tab toggles it). The
// marker rides in outgoing user messages so the cache-stable prompt prefix is
// left untouched.
planMode bool
// sessionSwitch is set by replayActiveBranch to suppress the ClearScreen
// flicker when the viewport content is completely rebuilt during a session
// switch (#5441). Cleared after one Update cycle.
sessionSwitch bool
// yoloRestoreToolApprovalMode remembers the Ask/Auto base mode that Ctrl+Y
// should restore after a desktop-style YOLO toggle.
yoloRestoreToolApprovalMode string
// pendingInterject queues input typed while a turn runs; each TurnDone
// dequeues the front and submits it as the next turn.
pendingInterject []string
// queueEditCursor tracks which queued message the user is currently
// browsing/editing via ↑/↓ during tuiRunning. -1 means "not browsing".
queueEditCursor int
// queueEditDraft saves the in-progress input text when the user first
// presses ↑ to browse the queue, so it can be restored when the cursor
// moves past the end.
queueEditDraft string
// history is a resumed session's messages, committed to scrollback once on
// the first WindowSizeMsg so a reopened chat shows its prior transcript.
history []provider.Message
// reasoning accumulates the in-progress thinking stream (dim); pending
// accumulates the in-progress answer (raw markdown). They are committed to
// scrollback (reasoning collapsed by default, answer markdown-rendered) when they
// finalize — at a tool/usage boundary or turn end — not previewed live, so
// the bottom region stays a stable height. pendingCommit queues finalized
// lines so a single Update emits exactly one ordered tea.Println.
reasoning *strings.Builder
pending *strings.Builder
pendingCommit *[]string
renderer *mdRenderer
showReasoning bool // Ctrl+O / /verbose: show raw thinking text in the CLI
cfg *config.Config
// reasoningLineIdx is the transcript index of the live "▎ thinking…" marker
// while a reasoning block streams; it's rewritten to "▎ thought for Ns" when
// the block closes. -1 when no block is open. transcriptDirty forces a
// viewport re-feed after that in-place rewrite (length is unchanged).
reasoningLineIdx int
// reasoningTextIdx is the transcript index of the live reasoning text block
// (the block right after the marker), streamed in as the model thinks and
// removed when the block collapses (kept only in verbose mode). -1 when none.
reasoningTextIdx int
// reasoningView is a bounded trailing window (≤ reasoningViewMax bytes) of the
// streaming thought, rendered live; the full text stays in reasoning for verbose.
reasoningView []byte
// reasoningNative is the Termux/native-scrollback path: reasoning is buffered
// without a live transcript block, then appended once as a final summary.
reasoningNative bool
thinkStart time.Time
// answerIdx is the transcript index of the streaming answer block (rewritten in
// place as completed paragraphs arrive); -1 when none is open. answerFlushed is
// how many bytes of pending have already been rendered into it, so a Text packet
// that doesn't close a new paragraph re-renders nothing.
answerIdx int
answerFlushed int
// toolStreamIdx is the transcript index of a running tool's live-output block
// (streamed via ToolProgress under the tool card); -1 when none. toolStreamID
// is the call ID it belongs to. Only a bounded tail is kept — the last few
// complete lines (toolTail) plus the in-progress one (toolPartial) — so a
// high-output command can't balloon memory or cost O(n²) re-splitting;
// toolLineCount feeds the collapse summary.
toolStreamIdx int
toolStreamID string
toolTail []string
toolPartial string
toolLineCount int
// shellOutputs stores the full accumulated output of each shell command
// (tool IDs with "shell-" prefix), so the first 10 lines can be shown after
// collapse and Ctrl+B can toggle the complete output.
shellOutputs map[string]string
shellExpanded map[string]bool
// shellTranscriptIdx maps a shell tool ID to the transcript index of its
// collapsed output block, so Ctrl+B can rewrite it in place.
shellTranscriptIdx map[string]int
// toolLineCountByID keeps a switched-away tool's last line count so a late
// ToolResult can still render "⎿ N lines" (shellOutputs only tracks "shell-" ids).
toolLineCountByID map[string]int
// toolStreamStart / toolStreamFrame drive the "⎿ working · Ns" line shown
// under a dispatched tool that hasn't produced output yet, so a slow tool
// reads as making progress rather than frozen.
toolStreamStart time.Time
toolStreamFrame int
transcriptDirty bool
// forceGotoBottom is set by replayActiveBranch and resetFreshContextView to
// pin the viewport to the bottom after a session / branch / clear switch
// regardless of the previous wasAtBottom state (#4584).
forceGotoBottom bool
eventCh chan event.Event
started bool // banner + resumed history committed once
// transcript holds every finalized line commitLine emits; the viewport
// renders a scrollable window of it (alt-screen owns the grid, so there's no
// native terminal scrollback). sel is the live left-drag text selection.
transcript []string
wrappedLines []string // transcript wrapped to viewport width (rendered each frame)
viewport viewport.Model
sel selection
// autoScroll drives edge-drag scrolling: -1 up, +1 down, 0 off. dragX is the
// column the drag is held at, so the ticker can extend the selection head.
autoScroll int
dragX int
// scrollbarDrag owns left-button drags that start on the transcript scrollbar
// column. It is separate from text selection so the visual thumb is not a
// dead target and dragging it never leaves a transcript selection behind.
scrollbarDrag bool
scrollbarGrabOffset int
// copyNoticeText is a transient "copied to clipboard" hint shown on the status
// line after a mouse-drag, right-click, or Ctrl+C selection copy; "" when none
// is showing. copyNoticeSeq guards its expiry tick so an older copy's timer
// can't clear a newer notice — each copy bumps the sequence and only a tick
// carrying the current sequence clears the text.
copyNoticeText string
copyNoticeSeq int
// The user bubble is echoed to scrollback immediately on Enter (bubbleStartIdx
// marks where in the transcript it landed). It stays "un-sendable" until the
// first response packet arrives: pressing Esc/Ctrl+C before then pops those
// lines back off the transcript and restores the text to the input box, leaving
// no trace. bubblePending is true from startTurn until the first packet confirms
// the send or it's un-sent; turnDiscarded then swallows the turn's
// already-buffered events until its TurnDone settles.
pendingRestore string
pendingPastes []string
bubbleStartIdx int
bubblePending bool
turnDiscarded bool
// pendingApproval holds the tool-call approval currently shown in the banner
// (nil when none). While set, the controller's run goroutine is blocked
// awaiting ctrl.Approve and key input is captured to answer it.
pendingApproval *event.Approval
// chooser holds the `ask` tool's question card (nil when none). While set, the
// run goroutine is blocked awaiting ctrl.AnswerQuestion and keys drive the card.
chooser *chooser
// rewind holds the Esc-Esc / "/rewind" picker (nil when closed); while set,
// keys drive it and it renders as an overlay. lastEsc times the double-Esc
// gesture that opens it on an empty composer.
rewind *rewindPicker
// resumePick is the interactive "/resume" session picker overlay. Non-nil
// while the user browses saved sessions with ↑/↓ and confirms with Enter.
resumePick *resumePicker
copyPick *copyPicker
lastEsc time.Time
// mcp is the interactive "/mcp" manager overlay. mcpDisabled tracks servers
// turned off only for this chat session, matching the desktop connector
// toggle's non-persistent semantics.
mcp *mcpManager
mcpDisabled map[string]bool
// clearConfirm is the destructive "/clear" confirmation overlay. It is separate
// from /new because /clear discards the current transcript instead of saving it.
clearConfirm *clearConfirm
// lastCtrlCAt records when Ctrl+C was pressed while idle on an empty
// composer, enabling a "press again to quit" confirmation pattern (1.5s
// window). Reset when Ctrl+C clears non-empty input instead.
lastCtrlCAt time.Time
// mcpImport holds the interactive cc-switch MCP import picker (nil when
// closed). It writes selected servers to config and hot-connects the ones that
// can start successfully.
mcpImport *mcpImportPicker
// host is the running MCP servers (nil when no plugins). The TUI reads
// prompts (slash commands), resources (@-references), and server status
// (/mcp) from it.
host *plugin.Host
// commands are custom slash commands loaded from .reasonix/commands; each renders
// its template with the typed args and sends the result as a turn.
commands []command.Command
// skills are the discoverable skills (built-in + user/project); each is offered
// in the slash menu as "/<name>" and managed via /skills.
skills []skill.Skill
// skillPick is the interactive skill picker overlay for /skills. nil when closed.
skillPick *skillPicker
// buildController builds a fresh controller for a model/profile pair, carrying prior
// history across and pinning auto-save to resumePath so the continued
// conversation stays in one file (set by chatREPL; it must NOT touch this
// model — the swap happens on the running copy). nil disables runtime
// rebuild commands. modelRef is the active "provider/model" ref, marked
// current in the picker. runtimeProfile stores boot's normalized token mode:
// full (displayed as balanced), economy, or delivery.
buildController func(spec controllerBuildSpec, carry []provider.Message, resumePath string) (*control.Controller, error)
modelRef string
runtimeProfile string
effortLevel string // "" when the current provider/model has no configurable effort
// leases owns the session lease guarding the TUI's active session file (set
// by chatREPL; nil in tests and when persistence is disabled). Every in-TUI
// operation that rebinds the controller to another session file must move
// the lease first — see rebindSessionLease / followSessionLease.
leases *control.SessionLeaseKeeper
// outputStyle is the active output-style name (config agent.output_style),
// shown as the current entry in the /output-style listing. "" = default.
outputStyle string
// diffMaxLines controls the max lines shown in a diff view. 0 = show all;
// non-zero = fold at that many lines. Toggled by /diff-fold.
diffMaxLines int
// statuslineCmd is the user's custom status-line command (config
// [statusline].command); "" disables it. statuslineOut caches its latest
// one-line stdout, refreshed at startup and after each turn and rendered in
// place of the built-in data row.
statuslineCmd string
statuslineOut string
gitStatus gitStatus
// statusLineCount is the number of terminal rows the status block occupies
// (wrapped working line + wrapped status line + wrapped data line). Updated
// each frame via computeStatusLineCount so bottomRows can reserve the correct
// height; starts at 2 (unwrapped) until first render.
statusLineCount int
// modelSwitchPending is true while any async controller rebuild is in flight.
modelSwitchPending bool
// pendingModelSwitch holds the tea.Cmd that triggers the async build. The
// historical field name is retained because model, effort, skill refresh,
// and work-mode changes all share the same atomic swap path.
pendingModelSwitch tea.Cmd
// oldControllers accumulates controllers retired by runtime switches.
// They cannot be closed during the switch (Close runs SessionEnd hooks
// and kills plugin subprocesses, both of which corrupt the terminal's
// raw mode). Instead they are closed at process exit when the terminal
// is already being restored.
oldControllers []control.SessionAPI
// completion is the live autocomplete menu (slash commands; @-refs later).
completion completion
// fileSearchCache memoizes fileref.Search by query so the bounded walk runs
// once per @token fragment, not on every keystroke that re-renders the menu.
fileSearchCache map[string][]string
}
type tuiState int
const (
tuiIdle tuiState = iota
tuiRunning
)
type controllerBuildSpec struct {
ModelRef string
RuntimeProfile string
ToolApprovalMode string
PlanMode bool
}
// agentEventMsg is one typed event from the agent's run loop.
type agentEventMsg event.Event
// maxEventDrain caps how many buffered events one Update coalesces before
// yielding to render, so a sustained output flood still shows live progress.
const maxEventDrain = 512
const resetMouseTracking = ansi.ResetModeMouseX10 +
ansi.ResetModeMouseNormal +
ansi.ResetModeMouseHighlight +
ansi.ResetModeMouseButtonEvent +
ansi.ResetModeMouseAnyEvent +
ansi.ResetModeMouseExtSgr +
ansi.ResetModeMouseExtUtf8 +
ansi.ResetModeMouseExtUrxvt +
ansi.ResetModeMouseExtSgrPixel
// compactDoneMsg reports that an async /compact pass returned. The card was
// already drawn from the CompactionDone event; this only surfaces a failure and
// snapshots on success.
type compactDoneMsg struct{ err error }
// tuiShutdownMsg asks the live TUI model to persist its current controller and
// quit. It is injected from the signal handler so shutdown does not snapshot a
// stale controller captured before an in-TUI rebuild.
type tuiShutdownMsg struct{}
// elapsedTickMsg fires once a second while a turn runs, driving the "thinking
// Ns" counter in the status line.
type elapsedTickMsg struct{}
// balanceMsg carries the result of an async wallet-balance fetch; text is the
// formatted readout ("" when none/failed).
type balanceMsg struct{ text string }
// statuslineMsg carries the latest custom status-line output (one line, ""
// when none/failed).
type statuslineMsg struct{ out string }
// gitStatusMsg carries the latest lightweight git readout for the built-in
// status line. Empty means "not a git worktree" or "git unavailable".
type gitStatusMsg struct{ status gitStatus }
// runStatusline runs the user's custom status-line command off the event loop,
// feeding it a small JSON context on stdin and returning its first stdout line.
// A no-op (nil) when no command is configured. Tight timeout so a slow script
// can't stall the UI; failures collapse to an empty line rather than an error.
func (m chatTUI) runStatusline() tea.Cmd {
cmd := m.statuslineCmd
if cmd == "" {
return nil
}
used, window := m.ctrl.ContextSnapshot()
cwd, _ := os.Getwd()
payload, _ := json.Marshal(map[string]any{
"model": m.label,
"contextUsed": used,
"contextWindow": window,
"cwd": cwd,
})
return func() tea.Msg { return statuslineMsg{out: runStatuslineCmd(cmd, string(payload))} }
}
const statuslineCommandTimeout = 2 * time.Second
// runStatuslineCmd runs a status-line command with the JSON context on stdin and
// returns its first stdout line (status lines are a single row). A tight timeout
// keeps a slow script from stalling the UI; any failure collapses to "".
func runStatuslineCmd(cmd, stdinPayload string) string {
return runStatuslineCmdWithTimeout(cmd, stdinPayload, statuslineCommandTimeout)
}
func runStatuslineCmdWithTimeout(cmd, stdinPayload string, timeout time.Duration) string {
res := hook.DefaultSpawner(context.Background(), hook.SpawnInput{
Command: cmd,
Stdin: stdinPayload + "\n",
Timeout: timeout,
})
out := strings.TrimSpace(res.Stdout)
if i := strings.IndexByte(out, '\n'); i >= 0 {
out = strings.TrimSpace(out[:i])
}
return out
}
func (m chatTUI) refreshGitStatus() tea.Cmd {
if m.statuslineCmd != "" {
return nil
}
return fetchGitStatus()
}
// modelSwitchMsg carries the result of an async /model switch. A nil err means
// the new controller is ready in ctrl; label/commands/skills/host mirror the
// fields that runModelSubcommand used to set synchronously. oldCtrl is the
// previous controller that must be closed after the switch — its cleanup
// (SessionEnd hooks, plugin subprocess kill) is deferred to a tea.Cmd so it
// runs after the render completes, avoiding corruption of the terminal's raw
// mode that would occur if Close() were called from the build goroutine.
type modelSwitchMsg struct {
ref string
profile string
ctrl control.SessionAPI
oldCtrl control.SessionAPI
label string
commands []command.Command
skills []skill.Skill
host *plugin.Host
failurePrefix string
successNotice string
err error
}
// fetchBalance queries the provider's wallet balance off the event loop. It's a
// no-op readout ("") when the provider declares no balance_url or the fetch
// fails, so the status line stays quiet rather than surfacing an error.
func fetchBalance(ctrl control.Status) tea.Cmd {
return func() tea.Msg {
b, err := ctrl.Balance(context.Background())
if err != nil || b == nil {
return balanceMsg{}
}
return balanceMsg{text: b.Display()}
}
}
// promptResolvedMsg carries the result of fetching an MCP prompt (an async
// prompts/get). display is the command line echoed as the user bubble; sent is
// the rendered prompt text that becomes the model turn.
type promptResolvedMsg struct {
display string
sent string
err error
}
// refsResolvedMsg carries the result of resolving the @references in a
// submitted line (async file reads / MCP resources/read).
type refsResolvedMsg struct {
sent string
display string
restore string
block string
errs []string
}
type clipboardImageMsg struct {
path string
err error
}
type clipboardPasteMsg struct {
path string
text string
err error
}
// newChatTUI assembles the initial model. The controller has already been wired
// with an event sink that feeds eventCh; the TUI issues commands to it and
// renders the events it emits. Label, history, host, and commands are read from
// the controller, so a resumed session pre-populates scrollback.
func newChatTUI(ctrl control.SessionAPI, missing string, eventCh chan event.Event, termW int) chatTUI {
ti := textarea.New()
configureChatTextarea(&ti)
sp := spinner.New()
sp.Spinner = spinner.Dot
sp.Style = themeStyle(activeCLITheme.accent)
commitBuf := []string{}
nativeScrollback := detectTermuxTerminal()
renderW := transcriptContentWidth(termW, nativeScrollback)
return chatTUI{
ctrl: ctrl,
label: ctrl.Label(),
missing: missing,
nativeScrollback: nativeScrollback,
mouseCaptureOff: mouseCaptureOffByDefault(),
input: ti,
spinner: sp,
submittedInputCursor: -1,
queueEditCursor: -1,
nextPasteID: 1,
reasoningLineIdx: -1,
reasoningTextIdx: -1,
answerIdx: -1,
toolStreamIdx: -1,
reasoning: &strings.Builder{},
pending: &strings.Builder{},
pendingCommit: &commitBuf,
renderer: newMarkdownRenderer(renderW),
diffMaxLines: diffFoldLimit,
showReasoning: nativeScrollback,
shellOutputs: make(map[string]string),
shellExpanded: make(map[string]bool),
shellTranscriptIdx: make(map[string]int),
toolLineCountByID: make(map[string]int),
eventCh: eventCh,
history: ctrl.History(),
host: ctrl.Host(),
commands: ctrl.Commands(),
skills: ctrl.SlashSkills(),
viewport: viewport.New(viewport.WithWidth(termW)),
statusLineCount: 2,
}
}
func transcriptContentWidth(termW int, nativeScrollback bool) int {
if !nativeScrollback {
termW-- // reserve the last column for the transcript scrollbar
}
return max(termW, 1)
}
// mouseCaptureOffByDefault lets a user opt out of in-app mouse capture for
// every run (e.g. a terminal/multiplexer combo where the native right-click
// menu and click-drag selection matter more than the scrollbar and
// wheel-scroll) without having to type "/mouse" each session.
func mouseCaptureOffByDefault() bool {
v := strings.TrimSpace(os.Getenv("REASONIX_DISABLE_MOUSE"))
return v != "" && v != "0"
}
func configureChatTextarea(ti *textarea.Model) {
ti.Prompt = ""
ti.CharLimit = 16384
ti.DynamicHeight = true
ti.MinHeight = 1
ti.MaxHeight = maxInputRows
ti.MaxContentHeight = ti.CharLimit
ti.SetHeight(1)
ti.ShowLineNumbers = false
applyTextareaTheme(ti)
// Use the real terminal cursor (not a styled virtual one) so View can place
// it at the insertion point and IME candidate windows anchor to the input.
ti.SetVirtualCursor(false)
// Plain Enter submits (the chatTUI handler intercepts it), so the textarea's
// own InsertNewline binding moves to Alt+Enter / Ctrl+J / Shift+Enter.
ti.KeyMap.InsertNewline = key.NewBinding(key.WithKeys("alt+enter", "ctrl+j", "shift+enter"))
ti.Focus()
}
func isTermuxTerminal() bool {
if os.Getenv("TERMUX_VERSION") != "" || os.Getenv("TERMUX_APP_PID") != "" || os.Getenv("TERMUX__PREFIX") != "" {
return true
}
return strings.Contains(os.Getenv("PREFIX"), "/com.termux/")
}
var detectTermuxTerminal = isTermuxTerminal
func (m *chatTUI) rememberSubmittedInput(input string) {
if strings.TrimSpace(input) == "" {
return
}
if len(m.submittedInputs) == 0 || m.submittedInputs[len(m.submittedInputs)-1] != input {
m.submittedInputs = append(m.submittedInputs, input)
}
m.submittedInputCursor = -1
m.submittedInputDraft = ""
}
func (m *chatTUI) recallSubmittedInput(delta int) bool {
if len(m.submittedInputs) == 0 {
return false
}
cursor := m.submittedInputCursor
if cursor < 0 {
if delta > 0 {
return false
}
if m.input.Line() != 0 {
return false // first-line Up enters history; lower lines navigate the draft
}
m.submittedInputDraft = m.input.Value()
cursor = len(m.submittedInputs) - 1
} else {
cursor += delta
}
if cursor < 0 {
cursor = 0
}
if cursor >= len(m.submittedInputs) {
m.submittedInputCursor = -1
m.input.SetValue(m.submittedInputDraft)
m.growInputToFit()
return true
}
m.submittedInputCursor = cursor
m.input.SetValue(m.submittedInputs[cursor])
m.growInputToFit()
return true
}
func (m *chatTUI) resetSubmittedInputRecall() {
m.submittedInputCursor = -1
m.submittedInputDraft = ""
}
// navigateQueue moves through the pending interject queue during tuiRunning.
// delta < 0 means ↑ (older), delta > 0 means ↓ (newer). Returns true if the
// input was updated.
func (m *chatTUI) navigateQueue(delta int) bool {
if len(m.pendingInterject) == 0 {
return false
}
cursor := m.queueEditCursor
if cursor < 0 {
if delta > 0 {
return false // already at "new draft" — nothing newer
}
// First ↑: save the current draft and jump to the last queued item.
m.queueEditDraft = m.input.Value()
cursor = len(m.pendingInterject) - 1
} else {
cursor += delta
}
if cursor < 0 {
cursor = 0
}
if cursor >= len(m.pendingInterject) {
// Past the end: restore the draft the user was composing.
m.queueEditCursor = -1
m.input.SetValue(m.queueEditDraft)
m.growInputToFit()
return true
}
m.queueEditCursor = cursor
m.input.SetValue(m.pendingInterject[cursor])
m.growInputToFit()
return true
}
// resetQueueNavigation resets the queue browsing cursor so the user returns to
// normal input mode. Any in-progress edit is discarded (the queued item keeps
// its previous value).
func (m *chatTUI) resetQueueNavigation() {
m.queueEditCursor = -1
m.queueEditDraft = ""
}
// renderQueueIndicator renders the pending-message queue as dim text to show
// above the input box when messages are queued during a running turn.
func (m chatTUI) renderQueueIndicator() string {
if m.state != tuiRunning || len(m.pendingInterject) == 0 {
return ""
}
queueStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // dim grey
highlightStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
var lines []string
for i, msg := range m.pendingInterject {
preview := msg
// Truncate long messages for the compact preview.
runes := []rune(preview)
if len(runes) > 50 {
preview = string(runes[:47]) + "…"
}
cursor := " "
style := queueStyle
if m.queueEditCursor == i {
cursor = "▸"
style = highlightStyle
}
lines = append(lines, style.Render(fmt.Sprintf(" %s [%d] %s", cursor, i+1, preview)))
}
return strings.Join(lines, "\n")
}
// prompts returns the MCP prompts discovered at startup (nil when no plugins).
func (m *chatTUI) prompts() []plugin.Prompt {
if m.host == nil {
return nil
}
return m.host.Prompts()
}
func (m chatTUI) Init() tea.Cmd {
return tea.Batch(
textarea.Blink,
waitForAgentEvent(m.eventCh),
fetchBalance(m.ctrl),
m.runStatusline(), // nil (no-op) unless a custom status line is configured
m.refreshGitStatus(),
)
}
func suspendWithMouseReset() tea.Cmd {
return tea.Sequence(tea.Raw(resetMouseTracking), tea.Suspend)
}
func (m chatTUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
wasAtBottom := m.viewport.AtBottom()
prevLines := len(m.transcript)
prevWidth := m.width
prevYOff := m.viewport.YOffset()
next, cmd := m.update(msg)
cm := next.(chatTUI)
contentW := transcriptContentWidth(cm.width, cm.nativeScrollback)
cm.viewport.SetWidth(contentW)
// Recompute the wrapped status-line count so bottomRows reserves the right
// height for the viewport. Use cm.width (same as boxW in View()) so the
// wrapping width matches what View() actually renders.
cm.statusLineCount = cm.computeStatusLineCount(cm.width)
cm.viewport.SetHeight(cm.transcriptHeight())
// Re-feed only when the content grew or the width changed (re-wrapping is
// the expensive part); a bare scroll or spinner tick keeps the offset.
if len(cm.transcript) != prevLines || cm.width != prevWidth || cm.transcriptDirty {
wrapped := wrapTranscript(strings.Join(cm.transcript, "\n"), contentW)
cm.viewport.SetContent(wrapped)
cm.wrappedLines = strings.Split(wrapped, "\n")
if wasAtBottom {
cm.viewport.GotoBottom() // tail-follow: stay pinned to newest output
}
}
if cm.forceGotoBottom {
cm.viewport.GotoBottom()
cm.forceGotoBottom = false
}
cm.transcriptDirty = false
// Any viewport scroll (wheel, PgUp/PgDn, edge auto-scroll, or tail-follow to
// newest output) shifts the whole window. Some terminals (Warp) mishandle
// the renderer's scroll/insert-line optimization and strand stale rows, so
// force a full clear+redraw whenever the offset actually moved.
if cm.viewport.YOffset() != prevYOff && !cm.nativeScrollback && !cm.sessionSwitch {
return cm, tea.Batch(tea.ClearScreen, cmd)
}
cm.sessionSwitch = false
return cm, cmd
}
// update runs the model's message handling. Update wraps it to keep the
// transcript viewport sized, fed, and tail-following after every message.
func (m chatTUI) update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.input.SetWidth(msg.Width - 4)
contentW := transcriptContentWidth(msg.Width, m.nativeScrollback)
m.renderer = newMarkdownRenderer(contentW)
// Commit the banner — and a resumed session's transcript — once, now
// that the width is known.
if !m.started {
m.started = true
var b strings.Builder
b.WriteString(renderTUIBanner(m.label, m.missing, contentW))
if len(m.history) > 0 {
r := newMarkdownRenderer(contentW)
for _, sec := range replaySectionsFor(m.history, contentW, r) {
b.WriteString(sec)
}
m.history = nil
}
m.commitLine(strings.TrimRight(b.String(), "\n"))
}
case tea.MouseWheelMsg:
switch msg.Button {
case tea.MouseWheelUp:
m.viewport.ScrollUp(3)
case tea.MouseWheelDown:
m.viewport.ScrollDown(3)
}
return m, nil
case tea.MouseClickMsg:
// Right-click copies the active selection (Windows Terminal convention);
// left-press in the transcript region begins a text selection — unless
// the click lands on the scrollbar or a shell-output hint line.
if msg.Button == tea.MouseRight && m.sel.active && !m.sel.empty() {
text := m.selectedText()
m.sel = selection{}
cmds = append(cmds, m.copySelectionWithNotice(text))
return m, finalize(m, cmds)
}
if msg.Button == tea.MouseLeft && m.inScrollbar(msg.X, msg.Y) {
m.sel = selection{}
m.autoScroll = 0
m.scrollbarDrag = true
m.scrollbarGrabOffset = m.scrollbarGrabRowOffset(msg.Y)
m.dragScrollbar(msg.Y)
return m, nil
}
if msg.Button == tea.MouseLeft && msg.Y < m.viewport.Height() {
// Check if the clicked line is a shell-output hint.
lineIdx := m.viewport.YOffset() + msg.Y
if lineIdx >= 0 && lineIdx < len(m.wrappedLines) {
clicked := m.wrappedLines[lineIdx]
if strings.Contains(clicked, "more lines") && strings.Contains(clicked, "Ctrl+B") {
m.toggleShellOutput()
return m, finalize(m, cmds)
}
}
at := m.transcriptCaret(msg.X, msg.Y)
m.sel = selection{active: true, anchor: at, head: at}
m.autoScroll = 0
}
return m, nil
case tea.MouseMotionMsg:
if m.scrollbarDrag {
m.dragScrollbar(msg.Y)
return m, nil
}
// Drag extends the live selection (CellMotion only reports motion while
// a button is held, so this is a drag). A drag held against the top or
// bottom edge starts an auto-scroll ticker so the selection can run past
// the visible window.
if m.sel.active {
m.sel.head = m.transcriptCaret(msg.X, msg.Y)
m.dragX = msg.X
prev := m.autoScroll
m.autoScroll = edgeScrollDir(msg.Y, m.viewport.Height())
if m.autoScroll != 0 && prev == 0 {
return m, autoScrollTick()
}
}
return m, nil
case autoScrollMsg:
// One edge-scroll step: scroll a single line, drag the selection head to
// the edge row, and keep ticking until the drag ends, leaves the edge, or
// the viewport can't scroll further (so it can't run away to the end).
if !m.sel.active || m.autoScroll == 0 {
return m, nil
}
edgeY := 0
if m.autoScroll > 0 {
m.viewport.ScrollDown(1)
edgeY = m.viewport.Height() - 1
} else {
m.viewport.ScrollUp(1)
}
m.sel.head = m.transcriptCaret(m.dragX, edgeY)
// Stop at the boundary so a held edge can't run away to the very end.
if (m.autoScroll > 0 && m.viewport.AtBottom()) || (m.autoScroll < 0 && m.viewport.AtTop()) {
m.autoScroll = 0
return m, nil
}
return m, autoScrollTick()
case tea.MouseReleaseMsg:
// Release finalizes the selection: a real drag auto-copies it (native
// terminal convention), while the highlight stays on as the visual
// "what's selected" cue and a right-click can still re-copy it. A plain
// click (no drag) clears any prior selection.
if m.scrollbarDrag {
m.dragScrollbar(msg.Y)
m.scrollbarDrag = false
m.scrollbarGrabOffset = 0
return m, nil
}
m.autoScroll = 0 // stop edge auto-scroll
if msg.Button == tea.MouseLeft && m.sel.active {
if m.sel.empty() {
m.sel = selection{}
} else {
cmds = append(cmds, m.copySelectionWithNotice(m.selectedText()))
}
}
return m, finalize(m, cmds)
case tea.PasteMsg:
if m.state != tuiRunning && m.attachPastedImages(msg.Content) {
return m, finalize(m, cmds)
}
if ref, ok := pastedFileRef(msg.Content); ok {
m.input.InsertString(ref + " ")
m.growInputToFit()
m.updateCompletion()
return m, finalize(m, cmds)
}
if !m.chooserTyping() && m.pendingApproval == nil && m.rewind == nil && m.resumePick == nil && m.mcp == nil && m.clearConfirm == nil && m.mcpImport == nil && m.skillPick == nil && m.shouldFoldPaste(msg.Content) {
m.insertFoldedPaste(msg.Content)
m.growInputToFit()
m.updateCompletion()
return m, finalize(m, cmds)
}
case tea.KeyPressMsg:
// Any keystroke dismisses a finished selection (copy is a right-click),
// except Ctrl+C/Super+C/Meta+C which may copy the selection to clipboard.
sel := m.sel
m.sel = selection{}
// Transcript scroll keys work in any state (PgUp/PgDn are never text).
switch msg.String() {
case "pgup":
m.viewport.PageUp()
return m, finalize(m, cmds)
case "pgdown":
m.viewport.PageDown()
return m, finalize(m, cmds)
case "ctrl+home":
m.viewport.GotoTop()
return m, finalize(m, cmds)
case "ctrl+end":
m.viewport.GotoBottom()
return m, finalize(m, cmds)
case "ctrl+z":
return m, suspendWithMouseReset()
}
// A question card is modal: keys drive it. In its free-text ("Type
// something") mode, the keystroke goes to the textarea — Enter confirms the
// custom answer, Esc backs out of typing — so input/IME work as usual.
if m.chooser != nil {
if m.chooser.typing {
switch msg.String() {
case "enter":
val := strings.TrimSpace(m.input.Value())
m.input.Reset()
m.chooser.typing = false
if val == "" {
return m, finalize(m, cmds)
}
m.chooser.custom[m.chooser.tab] = val
m.chooser.sel[m.chooser.tab] = map[int]bool{}
return m.chooserAdvance()
case "esc":
m.chooser.typing = false
m.input.Reset()
return m, finalize(m, cmds)
}
var ic tea.Cmd
m.input, ic = m.input.Update(msg)
cmds = append(cmds, ic)
m.growInputToFit()
return m, finalize(m, cmds)
}
return m.handleChooserKey(msg)
}
// The rewind picker is modal while open: keys navigate it.
if m.rewind != nil {
return m.handleRewindKey(msg)
}
// The MCP import picker is modal while open: keys select candidates.
if m.mcpImport != nil {
return m.handleMCPImportKey(msg)
}
// Copy picker is modal while open.
if m.copyPick != nil {
return m.handleCopyPickerKey(msg)
}
// The resume picker is modal while open: keys navigate it.
if m.resumePick != nil {
return m.handleResumePickerKey(msg)
}
// The MCP manager is modal while open: keys navigate it.
if m.mcp != nil {
return m.handleMCPManagerKey(msg)
}
// The destructive /clear confirmation is modal while open.
if m.clearConfirm != nil {
return m.handleClearConfirmKey(msg)
}
// The skill picker is modal while open: keys navigate it.
if m.skillPick != nil {
return m.handleSkillPickerKey(msg)
}
// A pending tool approval is modal: keystrokes answer it (y/a/n, Enter,
// Esc) rather than reaching the input.
if m.pendingApproval != nil {
return m.handleApprovalKey(msg)
}
// While the autocomplete menu is open it captures navigation/accept keys
// (↑/↓ move, Tab/Enter accept, Esc close); everything else falls through
// to the textarea and re-filters the menu at the end of Update.
if m.completion.active {
switch msg.String() {
case "up":
m.moveCompletion(-1)
return m, nil
case "down":
m.moveCompletion(1)
return m, nil
case "tab", "enter":
if msg.String() == "enter" && (m.completionExactLabel() || m.completionBareOverlayCommand()) {
m.completion = completion{}
break // fall through to regular Enter and submit the command
}
// When Enter is pressed and the selected completion is already fully
// present in the input, close the menu and submit instead of accepting
// the same item again (/resume 1 still has /resume 10 as a prefix match).
if msg.String() == "enter" && m.completionSelectedInsertPresent() {
m.completion = completion{}
break // fall through to regular Enter
}
m.acceptCompletion()
return m, nil
case "esc":
m.completion = completion{}
if m.state == tuiRunning {
break // a turn is running — also cancel it via the main Esc handler
}
return m, nil
}
}
switch msg.String() {
case "up":
if m.state == tuiRunning {
if m.navigateQueue(-1) {
return m, nil
}
} else if m.recallSubmittedInput(-1) {
return m, nil
}
case "down":
if m.state == tuiRunning {
if m.navigateQueue(1) {
return m, nil
}
} else if m.recallSubmittedInput(1) {
return m, nil
}
case "enter":
// Don't reset queue navigation — the Enter handler below needs
// queueEditCursor to decide whether to save an edit or enqueue.
default:
m.resetSubmittedInputRecall()
m.resetQueueNavigation()
}
switch msg.String() {
case "esc":
// "Back out" of the most specific in-progress state: un-send a just-sent
// turn (server not yet replied), cancel a streaming turn, or clear
// typed-but-unsent input. Mode switches (normal/plan/YOLO) are
// exclusively driven by Shift+Tab — Esc must not silently flip a
// session from plan or YOLO back to a less-permissive mode. PR #3051
// removed the YOLO half of this; plan mode was missed and is fixed
// here. Scrollback is the terminal's now, so there's no viewport to
// dismiss.
switch {
case m.state == tuiRunning && m.bubblePending:
m.unsendPending()
case m.state == tuiRunning:
m.ctrl.Cancel()
// Defensive: if the controller is no longer running (cancel
// completed synchronously, e.g. for shell commands), transition
// to idle immediately instead of waiting for TurnDone.
if !m.ctrl.Running() {
m.state = tuiIdle
m.confirmBubbleSent()
}
default:
// Idle (any mode): a double-Esc on an empty composer opens the
// rewind picker (Claude Code's gesture); a first Esc just arms
// it. Non-empty input clears as before.
if strings.TrimSpace(m.input.Value()) == "" {
if !m.lastEsc.IsZero() && time.Since(m.lastEsc) < 600*time.Millisecond {
m.lastEsc = time.Time{}
m.openRewind()
} else {
m.lastEsc = time.Now()
}
} else {
m.input.Reset()
m.pastedBlocks = nil
}
}
return m, nil
case "ctrl+c", "super+c", "meta+c":
if m.state == tuiRunning {
// Selection takes precedence: copy instead of cancel, same as idle.
if sel.active && !sel.empty() {
m.sel = sel
text := m.selectedText()
m.sel = selection{}
cmds = append(cmds, m.copySelectionWithNotice(text))
return m, finalize(m, cmds)
}
if m.bubblePending {
m.unsendPending() // server not yet replied — restore text, leave no trace
} else if m.cancelRequested() {
m.ctrl.Cancel()
return m, tea.Quit
} else {
m.ctrl.Cancel()
}
return m, nil
}
// Idle: an active text selection takes precedence over the
// composer-clear / double-press-quit gestures. Standard terminal
// convention is "Ctrl+C copies the selection" — the user can still
// clear the input with a second Ctrl+C once the selection is gone.
// Hoisting this branch above the clear branch also stops the
// previous behaviour where Ctrl+C would dismiss a selection AND
// wipe any draft text the user was typing — felt like the
// selection was being silently lost.
if sel.active && !sel.empty() {
m.sel = sel // restore so selectedText() can read it
text := m.selectedText()
m.sel = selection{}
cmds = append(cmds, m.copySelectionWithNotice(text))
return m, finalize(m, cmds)
}
// No selection: if the composer has text, a single press clears it
// (like Esc); on an empty composer a double-press within 1.5s quits.
if strings.TrimSpace(m.input.Value()) != "" {
m.input.Reset()
m.pastedBlocks = nil
m.lastCtrlCAt = time.Time{}
return m, nil
}
if !m.lastCtrlCAt.IsZero() && time.Since(m.lastCtrlCAt) < 1500*time.Millisecond {
return m, tea.Quit
}
m.lastCtrlCAt = time.Now()
m.notice(i18n.M.CtrlCQuitHint)
return m, finalize(m, nil)
case "ctrl+d":
return m, tea.Quit
case "ctrl+l":
if m.state != tuiRunning {
m.finalizeStreamed()
m.clearTranscriptDisplay()
m.commitLine(strings.TrimRight(
renderTUIBanner(m.label, "", transcriptContentWidth(m.width, m.nativeScrollback)), "\n"))
m.transcriptDirty = true
m.forceGotoBottom = true
m.notice(i18n.M.SlashClsDone)
}
return m, finalize(m, cmds)
case "ctrl+v", "ctrl+shift+v", "super+v", "meta+v":
if m.state == tuiRunning {
return m, nil
}
cmds = append(cmds, pasteClipboard())
return m, finalize(m, cmds)
case "ctrl+y", "super+y", "meta+y":
m.toggleYoloMode()
return m, nil
case "ctrl+o":
m.toggleVerboseReasoning(m.state != tuiRunning)
return m, finalize(m, cmds)
case "ctrl+b":
m.toggleShellOutput()
return m, finalize(m, cmds)
case "shift+tab":
// Shift+Tab toggles Plan only. Tool approval stays on its own axis:
// Ask/Auto are explicit choices, and YOLO is a separate Ctrl+Y toggle.
m.cycleMode()
return m, nil
case "enter":
if m.state == tuiRunning {
line := strings.TrimSpace(m.input.Value())
if line == "" {
m.viewport.GotoBottom()
return m, nil
}
if m.queueEditCursor >= 0 && m.queueEditCursor < len(m.pendingInterject) {
// Save the edited text back to the queue slot.
m.pendingInterject[m.queueEditCursor] = m.expandPastedBlocks(line)
m.notice(fmt.Sprintf("queue [%d] updated", m.queueEditCursor+1))
m.queueEditCursor = -1
m.queueEditDraft = ""
} else {
m.pendingInterject = append(m.pendingInterject, m.expandPastedBlocks(line))
m.notice("feedback queued — will send when the current turn finishes")
m.queueEditCursor = -1
m.queueEditDraft = ""
}
m.input.Reset()
m.pastedBlocks = nil
return m, finalize(m, cmds)
}
if m.modelSwitchPending {
return m, nil // ignore Enter while /model switch is building
}
line := strings.TrimSpace(m.input.Value())
if line == "" {
m.viewport.GotoBottom()
return m, nil
}
if line == "exit" || line == "quit" || line == ":q" {
return m, tea.Quit
}
m.rememberSubmittedInput(line)
// "# <note>" quick-adds a memory line locally, no model turn. The
// space keeps "#7" / "#issue" prompts from being swallowed.
if note, ok := control.MemoryQuickAddNote(line); ok {
m.input.Reset()
m.pastedBlocks = nil
if note == "" {
m.notice(i18n.M.QuickRememberEmpty)
} else if path, err := m.ctrl.QuickAdd(memory.ScopeProject, note); err != nil {
m.notice("memory: " + err.Error())
} else {
m.notice(fmt.Sprintf(i18n.M.QuickRememberDoneFmt, path))
}
return m, finalize(m, cmds)
}
// "!<cmd>" runs a shell command directly, bypassing the model.
if strings.HasPrefix(line, "!") {
cmd := strings.TrimPrefix(line, "!")
if strings.TrimSpace(cmd) == "" {
m.input.Reset()
m.pastedBlocks = nil
m.notice(i18n.M.ShellExecEmpty)
return m, finalize(m, cmds)
}
m.input.Reset()
m.pastedBlocks = nil
m.state = tuiRunning
m.runStart = time.Now()
m.elapsed = 0
m.turnTokens = 0
m.pendingRestore = line
m.bubbleStartIdx = len(m.transcript)
m.commitLine("")
m.commitLine(renderUserBubble(line, m.width, m.planMode))
m.bubblePending = true
m.turnDiscarded = false
m.confirmBubbleSent() // shell events arrive instantly
m.ctrl.RunShell(cmd)
return m, tea.Batch(m.spinner.Tick, elapsedTick())
}
// Slash commands run locally without going through the model. A
// '/'-leading line that's actually a dragged file path is an attachment,
// not a command, so it's rewritten to an @reference instead.
if control.SlashCodeCommentLine(line) {
// Slash-prefixed code comments are prompt text, not commands.
// Not a command. Fall through to normal message path.
} else if strings.HasPrefix(line, "/") {
if ref, ok := control.FileRefLine(line); ok {
line = ref
} else {
m.input.Reset()
m.pastedBlocks = nil
cmds = append(cmds, m.runSlashCommand(line))
return m, finalize(m, cmds)
}
}
sentLine := m.expandPastedBlocks(line)
m.input.Reset()
if goal, ok := m.ctrl.AutoStartResearchGoal(sentLine); ok {
m.pastedBlocks = nil
cmds = append(cmds, m.startTurnWithRaw("Start pursuing the active goal now.", line, line, goal))
return m, finalize(m, cmds)
}
// @references (local files / MCP resources, including inline image
// attachments) are resolved off the event loop by the controller; the turn
// starts when they resolve (refsResolvedMsg).
if m.ctrl.HasRefs(sentLine) {
cmds = append(cmds, m.resolveRefs(sentLine, sentLine, line))
return m, finalize(m, cmds)
}
// `raw` is the un-resolved user prompt used for auto-plan scoring AND the
// memory compiler's source_event. It must be the EXPANDED paste content
// (sentLine), not the folded label (line) — otherwise the memory compiler's
// execution contract replaces the user turn with one whose source_event is
// just the placeholder label, and the model never sees the pasted content.
cmds = append(cmds, m.startTurnWithRaw(sentLine, sentLine, line, sentLine))
return m, finalize(m, cmds)
}
case agentEventMsg:
e := event.Event(msg)
m.ingestEvent(e)
turnDone := e.Kind == event.TurnDone
gitMaybeChanged := e.Kind == event.ToolResult && !e.Tool.ReadOnly
// Coalesce a burst: the goroutine that produced this event has already
// exited (a Cmd reads the channel once), so it's safe to drain the events
// already buffered and ingest them now. One re-wrap then covers the whole
// batch instead of one per event — bounds the O(transcript) re-render cost
// when bash output or reasoning floods in. Capped so a sustained flood
// still yields to render periodically.
drain:
for drained := 0; drained < maxEventDrain; drained++ {
select {
case e2 := <-m.eventCh:
m.ingestEvent(e2)
if e2.Kind == event.TurnDone {
turnDone = true
}
if e2.Kind == event.ToolResult && !e2.Tool.ReadOnly {
gitMaybeChanged = true
}
default:
break drain
}
}
cmds = append(cmds, waitForAgentEvent(m.eventCh))
// A turn just spent tokens (and money) — refresh the balance readout and
// the custom status line (its context/cost inputs just changed).
if turnDone {
cmds = append(cmds, fetchBalance(m.ctrl))
if c := m.runStatusline(); c != nil {
cmds = append(cmds, c)
}
if len(m.pendingInterject) > 0 {
interject := m.pendingInterject[0]
m.pendingInterject = m.pendingInterject[1:]
// Reset queue navigation — the indices shifted.
m.queueEditCursor = -1
m.queueEditDraft = ""
cmds = append(cmds, m.startTurn(interject, interject, interject))
}
}
if turnDone || gitMaybeChanged {
if c := m.refreshGitStatus(); c != nil {
cmds = append(cmds, c)
}
}
case balanceMsg:
m.balance = msg.text
case statuslineMsg:
m.statuslineOut = msg.out
case gitStatusMsg:
m.gitStatus = msg.status
case compactDoneMsg:
if msg.err != nil {
m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashCompactFailed, msg.err))
} else {
_ = m.ctrl.Snapshot()
m.followSessionLease()
}
case tuiShutdownMsg:
if m.ctrl != nil {
_ = m.ctrl.Snapshot()
m.followSessionLease()
}
return m, tea.Quit
case modelSwitchMsg:
m.modelSwitchPending = false
m.pendingModelSwitch = nil
if msg.err != nil {
prefix := msg.failurePrefix
if prefix == "" {
prefix = "model"
}
m.notice(prefix + ": " + msg.err.Error())
// Build failed — no old controller to retire. The kept controller
// may still have been retargeted to a recovery branch by the
// pre-switch snapshot, so the lease must follow it.
m.followSessionLease()
} else {
m.ctrl = msg.ctrl
m.label = msg.label
m.commands = msg.commands
m.skills = msg.skills
m.host = msg.host
m.modelRef = msg.ref
if msg.profile != "" {
m.runtimeProfile = msg.profile
}
m.refreshEffortStatus()
// Stash the old controller for cleanup at exit. It cannot be
// closed here or in the build goroutine — Close() runs
// SessionEnd hooks and kills plugin subprocesses, both of
// which corrupt bubbletea's terminal raw mode.
if msg.oldCtrl != nil {
m.oldControllers = append(m.oldControllers, msg.oldCtrl)
}
// The lease follows the controller's session file. Normally a
// no-op (a carried conversation keeps its file); it moves when
// the pre-switch snapshot recovered onto a recovery branch — a
// fresh file created by this process, so failure is theoretical.
m.followSessionLease()
if msg.successNotice != "" {
m.notice(msg.successNotice)
} else {
m.notice(fmt.Sprintf(i18n.M.ModelSwitchedFmt, m.label))
}
cmds = append(cmds, fetchBalance(m.ctrl))
if c := m.runStatusline(); c != nil {
cmds = append(cmds, c)
}
// Do NOT re-issue waitForAgentEvent here — the goroutine from the
// last agentEventMsg handler is still blocked on the same channel.
// Starting a second one creates a race: two goroutines compete on
// p.Send (unbuffered), and the receiver may read them out of order,
// garbling the streamed text (words appear reordered).
}
case promptResolvedMsg:
switch {
case msg.err != nil:
m.commitLine(wrapForViewport(i18n.M.ErrorPrefix+" "+msg.err.Error(), m.width, activeCLITheme.warn))
case strings.TrimSpace(msg.sent) == "":
m.notice(i18n.M.SlashPromptEmpty)
default:
cmds = append(cmds, m.startTurn(msg.sent, msg.display, msg.display))
}
case mcpExternalDoneMsg:
if msg.err != nil {
m.notice(msg.label + ": " + msg.err.Error())
} else if msg.target != "" {
m.notice(msg.label + ": " + msg.target)
}
case refsResolvedMsg:
for _, e := range msg.errs {
m.notice(e) // surface a fetch failure but still send the turn
}
sent := msg.sent
if msg.block != "" {
sent = "Referenced context:\n\n" + msg.block + "\n\n" + msg.sent
}
// raw = msg.display (the expanded paste content, without resolved @-ref
// payloads) — NOT msg.restore (the folded label). See the non-refs branch
// above for why the memory compiler's source_event needs the expansion.
cmds = append(cmds, m.startTurnWithRaw(sent, msg.display, msg.restore, msg.display))
case clipboardImageMsg:
if msg.err != nil {
m.notice("paste image: " + msg.err.Error())
break
}
m.insertImageRef(msg.path)
case clipboardPasteMsg:
switch {
case msg.err != nil:
m.notice("paste: " + msg.err.Error())
case msg.path != "":
m.insertImageRef(msg.path)
case msg.text != "":
if m.attachPastedImages(msg.text) {
return m, finalize(m, cmds)
}
if ref, ok := pastedFileRef(msg.text); ok {
m.input.InsertString(ref + " ")
} else if m.shouldFoldPaste(msg.text) {
m.insertFoldedPaste(msg.text)
} else {
m.input.InsertString(msg.text)
}
m.growInputToFit()
m.updateCompletion()
return m, finalize(m, cmds)
}
case copyNoticeExpireMsg:
if msg.seq == m.copyNoticeSeq {
m.copyNoticeText = ""
}
case elapsedTickMsg:
if m.state == tuiRunning {
m.elapsed = int(time.Since(m.runStart).Seconds())
m.tickToolRunning()
cmds = append(cmds, elapsedTick())
}
case spinner.TickMsg:
if m.state == tuiRunning {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
cmds = append(cmds, cmd)
}
}
beforeInput := m.input.Value()
var ic tea.Cmd
m.input, ic = m.input.Update(msg)
cmds = append(cmds, ic)
m.growInputToFit()
// Re-filter the autocomplete menu against the freshly-edited input.
if _, ok := msg.(tea.KeyPressMsg); ok {
m.updateCompletion()
}
if shouldClearWideInputChange(beforeInput, m.input.Value()) {
cmds = append(cmds, tea.ClearScreen)
}
return m, finalize(m, cmds)
}
var clearWideInputChanges = runtime.GOOS == "windows"
func shouldClearWideInputChange(before, after string) bool {
return clearWideInputChanges &&
before != after &&
(hasWideInputCells(before) || hasWideInputCells(after))
}
func hasWideInputCells(s string) bool {
return s != "" && visibleWidth(s) != utf8.RuneCountInString(s)
}
// finalize drains the committed-line queue and batches the turn's commands. In
// the default alt-screen path the queue is already mirrored in m.transcript. In
// Termux finalized lines are also emitted into the terminal's native scrollback.
func finalize(m chatTUI, cmds []tea.Cmd) tea.Cmd {
if m.nativeScrollback && len(*m.pendingCommit) > 0 {
out := strings.TrimRight(clampWidth(strings.Join(*m.pendingCommit, "\n"), m.width), "\n")
*m.pendingCommit = (*m.pendingCommit)[:0]
var prints []tea.Cmd
for _, chunk := range chunkLines(out, m.scrollChunkHeight()) {
prints = append(prints, tea.Println(chunk))
}
cmds = append(cmds, tea.Sequence(prints...))
return tea.Batch(cmds...)
}
*m.pendingCommit = (*m.pendingCommit)[:0]
return tea.Batch(cmds...)
}
func (m *chatTUI) clearTranscriptDisplay() {
if m.pendingCommit != nil {
*m.pendingCommit = (*m.pendingCommit)[:0]
}
m.transcript = nil
m.wrappedLines = nil
m.viewport.SetContent("")
m.shellOutputs = make(map[string]string)
m.shellExpanded = make(map[string]bool)
m.shellTranscriptIdx = make(map[string]int)
m.toolLineCountByID = make(map[string]int)
m.toolStreamID = ""
m.toolStreamIdx = -1
m.toolTail = nil
m.toolPartial = ""
m.toolLineCount = 0
}
// scrollChunkHeight is the largest block (in lines) finalize prints at once in
// native-scrollback mode, leaving room for the pinned bottom frame.
func (m chatTUI) scrollChunkHeight() int {
if m.height <= 0 {
return 100
}
if n := m.height - m.bottomRows(); n > 1 {
return n
}
return 1
}
// chunkLines splits s into blocks of at most n lines each, preserving order and
// line content. A single block is returned when it already fits.
func chunkLines(s string, n int) []string {
if n < 1 {
n = 1
}
lines := strings.Split(s, "\n")
if len(lines) <= n {
return []string{s}
}
var out []string
for i := 0; i < len(lines); i += n {
end := i + n
if end > len(lines) {
end = len(lines)
}
out = append(out, strings.Join(lines[i:end], "\n"))
}
return out
}
// clampWidth hard-breaks any line wider than width so no scrollback line wraps
// in the terminal. bubbletea's inline renderer estimates how far to scroll for
// each printed block from each line's width (insertAbove: offset += width/w); an
// over-wide line that the terminal wraps throws that estimate off and drifts the
// pinned input box off-screen. Lines already within width are left byte-for-byte
// untouched (chunkByWidth preserves content and ANSI), so rendered tables and the
// wrapped answer — which the markdown renderer already fit to width — are safe;
// only stray long lines (tool-dispatch args, unwrapped code) get broken.
func clampWidth(s string, width int) string {
if width <= 0 {
return s
}
// ansi.Hardwrap breaks any line over `width` visible cols on grapheme
// boundaries, preserving ANSI and counting wide chars — exactly what we want,
// and lines already within width pass through unchanged.
return ansi.Hardwrap(s, width, false)
}
// commitLine queues one finalized block for the next scrollback flush.
func (m *chatTUI) commitLine(s string) {
*m.pendingCommit = append(*m.pendingCommit, s)
m.transcript = append(m.transcript, s)
}
// commitSpacer separates the next block (a thinking marker or a tool line) from
// the previous one with a single blank line, skipping it at the top of the
// transcript or when a blank already trails so spacers never double up.
func (m *chatTUI) commitSpacer() {
if n := len(m.transcript); n > 0 && strings.TrimSpace(m.transcript[n-1]) != "" {
m.commitLine("")
}
}
// bottomRows is the terminal-row height of the pinned bottom region: any open
// bottom panels (todo / approval / chooser / rewind / completion), the composer
// when visible, and the two fixed status rows. Full-screen managers such as MCP
// and skills normally render inside the main transcript area; in native
// scrollback mode they join the bottom rail because there is no main viewport.
func (m chatTUI) bottomRows() int {
rows := 0
for _, s := range []string{
m.renderTodoPanel(),
m.renderApprovalBanner(),
m.renderChooser(),
m.renderRewind(),
m.renderMCPImport(),
m.renderResumePicker(),
m.renderCopyPicker(),
m.renderCompletion(),
} {
if s != "" {
rows += strings.Count(s, "\n") + 1
}
}
// Remove the hardcoded working-line increment — it is counted inside
// statusLineCount via computeStatusLineCount, which also accounts for
// wrapping. The fallback to 2 (unwrapped) covers the initial frame and
// tests that don't call Update first.
if m.nativeScrollback {
if main := m.renderMainManager(); main != "" {
rows += strings.Count(main, "\n") + 1
}
}
if footer := m.renderMainManagerFooter(); footer != "" {
rows += strings.Count(footer, "\n") + 1
}
if !m.hideComposer() {
rows += m.input.Height() + 2
}
if m.statusLineCount > 0 {
return rows + m.statusLineCount
}
return rows + 2 // fallback for tests that don't set statusLineCount
}
// hideComposer is the single ownership gate for the bottom composer.
//
// Rule for new CLI panels:
// - If a panel is modal and keystrokes navigate/confirm/cancel the panel, hide
// the composer so users do not see an inactive chat input.
// - If a panel is input-owned (autocomplete, or chooser free-text mode), keep
// the composer visible because the textarea is the active control.
//
// Whenever a new slash-command overlay or approval-style prompt is added, update
// this function and the modal layout tests together. Otherwise the panel may
// reserve rows for a composer that cannot receive input, leaving a confusing
// blank/bordered area at the bottom of the TUI.
func (m chatTUI) hideComposer() bool {
if m.mcp != nil || m.clearConfirm != nil || m.mcpImport != nil || m.skillPick != nil || m.resumePick != nil || m.copyPick != nil || m.rewind != nil || m.pendingApproval != nil {
return true
}
return m.chooser != nil && !m.chooser.typing
}
// transcriptHeight is the row budget left for the transcript viewport once the
// pinned bottom region is accounted for (at least one row).
func (m chatTUI) transcriptHeight() int {
if h := m.height - m.bottomRows(); h > 1 {
return h
}
return 1
}
func (m chatTUI) renderMainManager() string {
if card := m.renderMCPManager(); card != "" {
return card
}
if card := m.renderClearConfirm(); card != "" {
return card
}
return m.renderSkillPicker()
}
func managerContentPanelStyle(width int) lipgloss.Style {
return choicePanelStyle.
Border(lipgloss.NormalBorder(), true, false, false, false).
Width(width)
}
func managerFooterPanelStyle(width int) lipgloss.Style {
return choicePanelStyle.
Border(lipgloss.NormalBorder(), false, false, true, false).
Width(width)
}
func (m chatTUI) renderMainManagerFooter() string {
hint := ""
switch {
case m.mcp != nil:
hint = m.mcp.footerHint()
case m.clearConfirm != nil:
hint = "Enter confirm · y clear · n/Esc cancel"
case m.skillPick != nil:
hint = m.skillPickerFooterHint()
}
if strings.TrimSpace(hint) == "" {
return ""
}
w := max(viewWidth(m.width), 40)
return managerFooterPanelStyle(w).Render(dim(hint))
}
func (m chatTUI) renderTranscriptWithMainManager(card string) string {
h := m.viewport.Height()
if h <= 0 {
return ""
}
cw := m.viewport.Width()
if cw <= 0 {
cw = max(m.width-1, 1)
}
cardLines := strings.Split(strings.TrimRight(card, "\n"), "\n")
if len(cardLines) > h {
cardLines = cardLines[:h]
}
maxTranscriptRows := h - len(cardLines)
if maxTranscriptRows > 0 && len(cardLines) > 0 && len(m.wrappedLines) > 0 {
maxTranscriptRows--
}
var rows []string
if maxTranscriptRows > 0 {
lines := m.wrappedLines
start := max(0, len(lines)-maxTranscriptRows)
rows = append(rows, lines[start:]...)
}
if len(rows) > 0 && len(cardLines) > 0 {
rows = append(rows, "")
}
rows = append(rows, cardLines...)
for len(rows) < h {
rows = append(rows, "")
}
for i, row := range rows {
rows[i] = padRight(ansi.Cut(row, 0, cw), cw)
}
return strings.Join(rows, "\n")
}
// reasoningViewMax bounds the live thinking buffer the streamed block renders
// from. Re-rendering the full chain of thought on every delta was O(n²) (a 2k-
// token thought churned ~4.7GB); rendering only the trailing window keeps each
// delta O(1). The full text still lives in m.reasoning for verbose mode.
const reasoningViewMax = 4096
// reasoningTailLines caps how many trailing visual lines the live block shows.
const reasoningTailLines = 12
// streamReasoning appends a chunk and rewrites the live reasoning block from a
// bounded trailing view (mirrors streamToolOutput), so the chain of thought is
// visible while the model works without re-rendering the whole thing per token.
func (m *chatTUI) streamReasoning(chunk string) {
m.reasoning.WriteString(chunk) // full text retained for verbose mode
if m.reasoningTextIdx < 0 {
return
}
m.reasoningView = append(m.reasoningView, chunk...)
if len(m.reasoningView) > reasoningViewMax {
drop := len(m.reasoningView) - reasoningViewMax
for drop < len(m.reasoningView) && !utf8.RuneStart(m.reasoningView[drop]) {
drop++
}
m.reasoningView = m.reasoningView[:copy(m.reasoningView, m.reasoningView[drop:])]
}
m.transcript[m.reasoningTextIdx] = reasoningBlock(string(m.reasoningView), m.width, reasoningTailLines)
m.transcriptDirty = true
}
// reasoningBlock renders raw thinking text as dim, width-wrapped lines under a
// "⎿" connector that ties the block to the "▎ thinking…" marker above it. A
// positive maxLines keeps only the trailing visual lines (the live view); 0
// renders all (verbose collapse).
func reasoningBlock(raw string, width, maxLines int) string {
w := width - len([]rune(connector))
if w < 8 {
w = 8
}
var lines []string
for _, ln := range strings.Split(strings.TrimRight(raw, "\n"), "\n") {
for _, wl := range strings.Split(ansi.Wrap(expandTabs(ln), w, ""), "\n") {
lines = append(lines, dim(wl))
}
}
if maxLines > 0 && len(lines) > maxLines {
lines = lines[len(lines)-maxLines:]
}
return connectorBlock(lines)
}
// toolStreamTailLines caps how many trailing output lines a running tool shows;
// the live block scrolls within this window so a chatty build doesn't flood.
const toolStreamTailLines = 8
// shellPreviewLines is how many lines of shell output to show by default after
// the command finishes. Ctrl+B toggles the full output.
const shellPreviewLines = 10
// shellExpandMaxLines caps how many lines Ctrl+B shows in expanded mode, so a
// very large output (e.g. thousands of lines) doesn't hang the TUI or push the
// input box off-screen.
const shellExpandMaxLines = 200
// streamToolOutput appends a chunk of a running tool's output and re-renders its
// live block (the last toolStreamTailLines lines) under the tool card, opening
// the block on the first chunk. Mirrors streamReasoning.
func (m *chatTUI) streamToolOutput(id, chunk string) {
if id == "" {
return
}
if m.toolStreamID != id {
// Switching to a different id means either:
// (a) the previous tool finished and a new one is starting — collapse
// the current id's live block, then append a fresh slot at the
// end of the transcript.
// (b) late ToolProgress for an earlier (already dispatched and
// possibly collapsed) tool — reuse the slot beginToolRunning
// already wrote for that id, so the live block stays directly
// under the earlier tool's card rather than stacking at the end.
if existingIdx, ok := m.shellTranscriptIdx[id]; ok && existingIdx >= 0 && existingIdx < len(m.transcript) {
// Stash the switched-away id's live count before resetting it;
// its late ToolResult reads it back via toolLineCountByID.
if m.toolStreamID != "" && m.toolStreamID != id {
n := m.toolLineCount
if m.toolPartial != "" {
n++
}
if n > 0 {
m.toolLineCountByID[m.toolStreamID] = n
}
}
m.toolStreamID = id
m.toolStreamIdx = existingIdx
m.toolTail = m.toolTail[:0]
m.toolPartial = ""
m.toolLineCount = 0
} else {
// Unknown id: collapse the active stream (its live count is intact).
m.collapseToolOutput(m.toolStreamID, "")
m.toolStreamID = id
m.toolTail = m.toolTail[:0]
m.toolPartial = ""
m.toolLineCount = 0
if m.nativeScrollback {
m.toolStreamIdx = -1
} else {
m.toolStreamIdx = len(m.transcript)
m.commitLine("")
}
}
}
// Accumulate full output for shell commands so Ctrl+B can expand it.
if strings.HasPrefix(id, "shell-") {
m.shellOutputs[id] += chunk
}
// Fold completed lines into the bounded tail; keep the trailing partial.
data := m.toolPartial + chunk
for {
i := strings.IndexByte(data, '\n')
if i < 0 {
break
}
m.pushToolLine(strings.TrimRight(data[:i], "\r"))
data = data[i+1:]
}
m.toolPartial = data
vis := m.toolTail
if m.toolPartial != "" {
vis = append(append([]string{}, m.toolTail...), m.toolPartial)
}
if m.nativeScrollback {
return
}
lines := make([]string, len(vis))
for i, ln := range vis {
lines[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
}
m.transcript[m.toolStreamIdx] = connectorBlock(lines)
m.transcriptDirty = true
}
// pushToolLine appends a completed output line to the bounded tail, dropping the
// oldest when it exceeds the window (the backing array stays ≤ window+1).
func (m *chatTUI) pushToolLine(line string) {
m.toolLineCount++
m.toolTail = append(m.toolTail, line)
if len(m.toolTail) > toolStreamTailLines {
copy(m.toolTail, m.toolTail[1:])
m.toolTail = m.toolTail[:toolStreamTailLines]
}
}
// collapseToolOutput replaces a finished tool's live block with a dim
// "⎿ N lines" summary, so the scrollback keeps a marker of the run without the
// full output (which the model already received). For shell commands ("shell-"
// prefix), it shows the first shellPreviewLines with a Ctrl+B hint instead.
// No-op when id isn't streaming. resultOutput (the ToolResult's final output)
// is the last-resort line-count source when the live state was already reset.
func (m *chatTUI) collapseToolOutput(id, resultOutput string) {
if m.nativeScrollback {
if id == "" || m.toolStreamID != id {
return
}
n := m.toolLineCount
if m.toolPartial != "" {
n++
}
if n > 0 {
if full, ok := m.shellOutputs[id]; ok {
lines := strings.Split(strings.TrimRight(full, "\n"), "\n")
total := len(lines)
if total > shellPreviewLines {
preview := make([]string, shellPreviewLines+1)
for i := 0; i < shellPreviewLines; i++ {
preview[i] = dim(clampPlain(lines[i], m.width-len([]rune(connector))))
}
preview[shellPreviewLines] = dim(fmt.Sprintf("… %d more lines (Ctrl+B)", total-shellPreviewLines))
m.commitLine(connectorBlock(preview))
} else {
rendered := make([]string, total)
for i, ln := range lines {
rendered[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
}
m.commitLine(connectorBlock(rendered))
}
m.shellTranscriptIdx[id] = len(m.transcript) - 1
} else {
m.commitLine(connectorBlock([]string{dim(fmt.Sprintf("%d lines", n))}))
}
}
m.toolStreamIdx = -1
m.toolStreamID = ""
m.toolTail = m.toolTail[:0]
m.toolPartial = ""
m.toolLineCount = 0
return
}
if m.toolStreamIdx < 0 || id == "" || m.toolStreamID != id {
// Slot no longer active (another tool took over, or this id never
// streamed). If beginToolRunning recorded a transcript index, collapse
// in place so a late ToolResult doesn't leave raw streamed text behind.
if idx, ok := m.shellTranscriptIdx[id]; ok && idx >= 0 && idx < len(m.transcript) {
m.collapseShellSlot(id, idx, resultOutput)
}
return
}
m.collapseShellSlot(id, m.toolStreamIdx, resultOutput)
m.toolStreamIdx = -1
m.toolStreamID = ""
m.toolTail = m.toolTail[:0]
m.toolPartial = ""
m.toolLineCount = 0
}
// collapseShellSlot finalises a tool's live block at idx. Used both by the
// active-tool path (idx == toolStreamIdx, streaming state intact) and the
// late-result path (idx recorded in shellTranscriptIdx at dispatch). Line-count
// sources, in order: live streaming state, shellOutputs ("shell-" ids only),
// the per-id count stashed by streamToolOutput, then the ToolResult's output.
func (m *chatTUI) collapseShellSlot(id string, idx int, resultOutput string) {
m.transcriptDirty = true
n := -1
if id == m.toolStreamID {
// Prefer the larger of the live count and resultOutput: resultOutput
// is the authoritative end-state, the live state may lag behind it.
n = m.toolLineCount
if m.toolPartial != "" {
n++
}
if resultOutput != "" {
fromResult := len(strings.Split(strings.TrimRight(resultOutput, "\n"), "\n"))
if fromResult > n {
n = fromResult
}
}
}
if n < 0 {
if full, ok := m.shellOutputs[id]; ok {
n = len(strings.Split(strings.TrimRight(full, "\n"), "\n"))
} else if c, ok := m.toolLineCountByID[id]; ok {
n = c
} else if resultOutput != "" {
n = len(strings.Split(strings.TrimRight(resultOutput, "\n"), "\n"))
}
}
if n < 0 {
// Nothing applies (e.g. a late result for a non-"shell-" id that never
// streamed): treat as zero rather than fabricate a "-1 lines" count.
n = 0
}
if n == 0 {
// Tool finished with no output: clear the "working…" placeholder but
// keep the slot (shellTranscriptIdx still points here for late progress).
m.transcript[idx] = ""
return
}
if full, ok := m.shellOutputs[id]; ok {
// Shell command: show first N lines + hint.
lines := strings.Split(strings.TrimRight(full, "\n"), "\n")
total := len(lines)
if total > shellPreviewLines {
preview := make([]string, shellPreviewLines+1)
for i := 0; i < shellPreviewLines; i++ {
preview[i] = dim(clampPlain(lines[i], m.width-len([]rune(connector))))
}
preview[shellPreviewLines] = dim(fmt.Sprintf("… %d more lines (Ctrl+B)", total-shellPreviewLines))
m.transcript[idx] = connectorBlock(preview)
} else {
rendered := make([]string, total)
for i, ln := range lines {
rendered[i] = dim(clampPlain(ln, m.width-len([]rune(connector))))
}
m.transcript[idx] = connectorBlock(rendered)
}
} else {
m.transcript[idx] = connectorBlock([]string{dim(fmt.Sprintf("%d lines", n))})
}
m.shellTranscriptIdx[id] = idx
}
// toggleShellOutput expands or collapses the output of the most recent shell
// command. When expanded, up to shellExpandMaxLines lines are shown; when
// collapsed, only the first shellPreviewLines are shown. Called on Ctrl+B.
func (m *chatTUI) toggleShellOutput() {
// Find the most recent shell output that has a transcript entry.
var lastID string
lastIdx := -1
for id, idx := range m.shellTranscriptIdx {
if idx >= 0 && idx < len(m.transcript) && idx > lastIdx {
lastID = id
lastIdx = idx
}
}
if lastID == "" {
return
}
full, ok := m.shellOutputs[lastID]
if !ok {
return
}
lines := strings.Split(strings.TrimRight(full, "\n"), "\n")
total := len(lines)
innerW := m.width - len([]rune(connector))
if innerW < 10 {
innerW = 80
}
if m.shellExpanded[lastID] {
// Collapse back to preview.
m.shellExpanded[lastID] = false
if total > shellPreviewLines {
preview := make([]string, shellPreviewLines+1)
for i := 0; i < shellPreviewLines; i++ {
preview[i] = dim(clampPlain(lines[i], innerW))
}
preview[shellPreviewLines] = dim(fmt.Sprintf("… %d more lines (Ctrl+B)", total-shellPreviewLines))
m.transcript[lastIdx] = connectorBlock(preview)
}
} else {
// Expand: show up to shellExpandMaxLines lines.
m.shellExpanded[lastID] = true
show := total
if show > shellExpandMaxLines {
show = shellExpandMaxLines
}
rendered := make([]string, show)
for i := 0; i < show; i++ {
rendered[i] = dim(clampPlain(lines[i], innerW))
}
if total > shellExpandMaxLines {
rendered = append(rendered, dim(fmt.Sprintf("… %d more lines", total-shellExpandMaxLines)))
}
m.transcript[lastIdx] = connectorBlock(rendered)
}
m.transcriptDirty = true
if m.nativeScrollback {
m.commitLine(m.transcript[lastIdx])
}
}
// toolWorkingFrames is the braille spinner cycled once per second on the
// "⎿ working · Ns" line of a tool that hasn't streamed output yet.
var toolWorkingFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// beginToolRunning opens an empty live block under a just-dispatched tool card,
// keyed by the call id. tickToolRunning fills it with a "working · Ns" line each
// second; if the tool later streams output, streamToolOutput reuses the same
// block; collapseToolOutput closes it on the result.
func (m *chatTUI) beginToolRunning(id string) {
if id == "" {
return
}
m.toolStreamID = id
m.toolTail = m.toolTail[:0]
m.toolPartial = ""
m.toolLineCount = 0
// Clear accumulated output for this tool ID so a re-run (e.g. repeated
// !pwd with the same "shell-pwd" id) doesn't append to old output.
delete(m.shellOutputs, id)
m.toolStreamStart = time.Now()
m.toolStreamFrame = 0
if m.nativeScrollback {
m.toolStreamIdx = -1
return
}
m.toolStreamIdx = len(m.transcript)
m.commitLine(connectorBlock([]string{dim(fmt.Sprintf(i18n.M.ChatToolWorkingFmt, toolWorkingFrames[0], 0))}))
// Remember the transcript slot for this id so a late ToolProgress for a
// previously dispatched (and possibly already collapsed) tool can reuse
// it instead of appending a fresh slot at the end of the transcript. For
// back-to-back tool calls this keeps each tool's live block directly
// under its own card.
m.shellTranscriptIdx[id] = m.toolStreamIdx
}
// tickToolRunning re-renders the working line of a tool that's dispatched but
// hasn't produced output yet. A no-op once output streams in or no tool runs.
func (m *chatTUI) tickToolRunning() {
if m.nativeScrollback {
return
}
if m.toolStreamIdx < 0 || m.toolLineCount != 0 || m.toolPartial != "" {
return
}
m.toolStreamFrame++
frame := toolWorkingFrames[m.toolStreamFrame%len(toolWorkingFrames)]
secs := int(time.Since(m.toolStreamStart).Seconds())
m.transcript[m.toolStreamIdx] = connectorBlock([]string{dim(fmt.Sprintf(i18n.M.ChatToolWorkingFmt, frame, secs))})
m.transcriptDirty = true
}
// commitReasoning closes the live thinking block: the "▎ thinking…" marker is
// rewritten to a dim "▎ thought for Ns" summary and the streamed text below it is
// removed (collapsed) — kept only in verbose mode. The viewport re-wraps from
// m.transcript, so the change is flagged via transcriptDirty.
func (m *chatTUI) commitReasoning() {
if m.reasoningNative {
if strings.TrimSpace(m.reasoning.String()) != "" || !m.thinkStart.IsZero() {
secs := int(time.Since(m.thinkStart).Seconds())
m.commitSpacer()
m.commitLine(dim(fmt.Sprintf(" ▎ "+i18n.M.ChatThoughtForFmt, secs)))
if m.showReasoning && strings.TrimSpace(m.reasoning.String()) != "" {
m.commitLine(reasoningBlock(m.reasoning.String(), m.width, 0))
}
}
m.reasoning.Reset()
m.reasoningView = m.reasoningView[:0]
m.reasoningNative = false
m.thinkStart = time.Time{}
return
}
if m.reasoningLineIdx < 0 {
return
}
secs := int(time.Since(m.thinkStart).Seconds())
m.transcript[m.reasoningLineIdx] = dim(fmt.Sprintf(" ▎ "+i18n.M.ChatThoughtForFmt, secs))
if m.reasoningTextIdx >= 0 {
if m.showReasoning && strings.TrimSpace(m.reasoning.String()) != "" {
m.transcript[m.reasoningTextIdx] = reasoningBlock(m.reasoning.String(), m.width, 0)
} else {
m.transcript = append(m.transcript[:m.reasoningTextIdx], m.transcript[m.reasoningTextIdx+1:]...)
}
}
m.transcriptDirty = true
m.reasoning.Reset()
m.reasoningView = m.reasoningView[:0]
m.reasoningLineIdx = -1
m.reasoningTextIdx = -1
}
// streamAnswer renders the answer streamed so far up to its last completed
// paragraph (flushableMarkdownPrefix) and writes it as one transcript block,
// rewritten in place as later paragraphs land — so a long reply appears chunk by
// chunk instead of all at once on turn end. The trailing, still-streaming block
// stays buffered (a half-written fence/list never renders early), and it only
// re-renders when a new paragraph actually closes.
func (m *chatTUI) streamAnswer() {
if m.nativeScrollback {
return
}
prefix := flushableMarkdownPrefix(m.pending.String())
if len(prefix) <= m.answerFlushed {
return
}
rendered := m.renderer.Render(prefix)
if rendered == "" {
return
}
m.answerFlushed = len(prefix)
block := strings.TrimRight(rendered, "\n")
if m.answerIdx < 0 {
m.answerIdx = len(m.transcript)
m.commitLine(block)
} else {
m.transcript[m.answerIdx] = block
m.transcriptDirty = true
}
}
// commitPending freezes the full accumulated answer as markdown — overwriting the
// streamed block if one is open (streamAnswer), else committing fresh. Joining
// commitReasoning then commitPending puts the answer on its own line, restoring
// the thinking→answer break the renderer strips.
func (m *chatTUI) commitPending() {
if m.pending.Len() == 0 {
m.answerIdx = -1
m.answerFlushed = 0
return
}
raw := m.pending.String()
rendered := m.renderer.Render(raw)
if rendered == "" {
rendered = raw
}
block := strings.TrimRight(rendered, "\n")
if m.answerIdx < 0 {
m.commitLine(block)
} else {
m.transcript[m.answerIdx] = block
m.transcriptDirty = true
}
m.pending.Reset()
m.answerIdx = -1
m.answerFlushed = 0
}
// flushableMarkdownPrefix returns the longest prefix of buf made of complete
// markdown blocks — text up to the last blank line outside any open fenced code
// block. A blank line inside a ``` / ~~~ fence isn't a boundary, so a half-written
// code block stays buffered until it closes.
func flushableMarkdownPrefix(buf string) string {
lines := strings.Split(buf, "\n")
inFence := false
boundary := -1
for i, ln := range lines {
t := strings.TrimSpace(ln)
if strings.HasPrefix(t, "```") || strings.HasPrefix(t, "~~~") {
inFence = !inFence
continue
}
if !inFence && t == "" {
boundary = i
}
}
if boundary <= 0 {
return ""
}
return strings.Join(lines[:boundary], "\n")
}
// planApprovalTool is the Tool name the controller puts on the ApprovalRequest it
// emits to gate a plan (mirrors control's constant). The banner, status line, and
// approval handler key on it to render the plan-specific prompt and to keep the
// [plan] tag in sync when the plan is approved.
const planApprovalTool = "exit_plan_mode"
// handleApprovalKey resolves a pending approval from a keystroke and re-arms the
// listener. 1/y/Enter allows once, 2/a allows for the rest of the session,
// 3/p writes an "always allow" rule to the config file for ordinary tool
// approvals. Fresh two-choice prompts use 2 for deny, while n/Esc and legacy 4
// still deny.
// Ctrl-C cancels the whole turn via the run context. For a plan approval
// (planApprovalTool), allowing also drops the local [plan] tag — the
// controller turns plan mode off on its side.
func (m chatTUI) handleApprovalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
freshToolApproval := control.RequiresFreshHumanApprovalTool(m.pendingApproval.Tool) && m.pendingApproval.Tool != planApprovalTool
freshSessionApproval := freshApprovalAllowsSession(m.pendingApproval.Tool)
answer := func(allow, session, persist bool) (tea.Model, tea.Cmd) {
if allow && m.pendingApproval.Tool == planApprovalTool {
m.planMode = false
}
m.ctrl.Approve(m.pendingApproval.ID, allow, session, persist)
m.pendingApproval = nil
return m, nil
}
switch msg.String() {
case "ctrl+c":
m.ctrl.Cancel()
return answer(false, false, false)
case "enter":
return answer(true, false, false)
case "esc":
return answer(false, false, false)
}
switch strings.ToLower(msg.String()) {
case "y", "1":
return answer(true, false, false)
case "a", "2":
if freshToolApproval && !freshSessionApproval {
if msg.String() == "2" {
return answer(false, false, false)
}
return m, nil
}
return answer(true, true, false)
case "3", "p":
if freshSessionApproval && msg.String() == "3" {
return answer(false, false, false)
}
if freshToolApproval {
return m, nil
}
return answer(true, true, true)
case "4", "n":
return answer(false, false, false)
}
return m, nil
}
func freshApprovalAllowsSession(toolName string) bool {
return toolName == control.SandboxEscapeApprovalTool || toolName == control.ManagedConfigWriteApprovalTool
}
var (
// Input box: only top + bottom borders, no sides. The concrete colors are
// refreshed from the active CLI theme during startup.
inputBoxStyle lipgloss.Style
approvalBannerStyle lipgloss.Style
todoPanelStyle lipgloss.Style
statusBlockStyle lipgloss.Style
workingStyle lipgloss.Style
)
func (m chatTUI) cancelRequested() bool {
if m.state != tuiRunning || m.ctrl == nil {
return false
}
return m.ctrl.CancelRequested()
}
func (m chatTUI) runningWorkingLine(cancelRequested, styled bool) string {
if m.state != tuiRunning {
return ""
}
if m.retryAttempt > 0 && !cancelRequested {
return fmt.Sprintf(" "+i18n.M.ChatStatusRetryingFmt, m.spinner.View(), m.retryAttempt, m.retryMax)
}
var working string
if cancelRequested {
working = fmt.Sprintf(" "+i18n.M.ChatStatusCancellingFmt, m.spinner.View(), m.elapsed)
} else {
working = fmt.Sprintf(" "+i18n.M.ChatStatusThinkingFmt, m.spinner.View(), m.elapsed)
}
if m.turnTokens > 0 {
working += " · ↓" + shortTokens(m.turnTokens)
}
if n := len(m.pendingInterject); n > 0 {
var queued string
if n == 1 {
queued = " · ✎ feedback queued"
} else {
queued = fmt.Sprintf(" · ✎ %d queued", n)
}
if styled {
working += dim(queued)
} else {
working += queued
}
}
return working
}
func (m chatTUI) View() tea.View {
boxW := m.width
if boxW < 10 {
boxW = 10
}
hideComposer := m.hideComposer()
shellMode := strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!")
cancelRequested := m.cancelRequested()
var box string
if !hideComposer {
style := inputBoxStyle.Width(boxW)
if shellMode {
style = style.BorderForeground(lipgloss.Color(statusShellColor.hex))
}
box = style.Render(m.input.View())
}
var modeTag string
if shellMode {
modeTag = lipgloss.NewStyle().
Background(lipgloss.Color(statusShellColor.hex)).
Foreground(lipgloss.Color("#ffffff")).
Bold(true).
Padding(0, 1).
Render("Shell")
} else {
color := statusAutoColor
foreground := "#111827"
switch {
case m.ctrl.AutoApproveTools():
color = statusYoloColor
foreground = "#ffffff"
case m.planMode:
color = statusPlanColor
foreground = "#ffffff"
}
modeTag = lipgloss.NewStyle().
Background(lipgloss.Color(color.hex)).
Foreground(lipgloss.Color(foreground)).
Bold(true).
Padding(0, 1).
Render(m.modeTagText())
}
ctxTag := m.contextTag()
var status string
switch {
case m.rewind != nil:
status = " " + modeTag + " · ⟲ rewind"
case m.mcpImport != nil:
status = " " + modeTag + " · MCP import"
case m.resumePick != nil:
status = " " + modeTag + " · " + i18n.M.StatusResumePicker
case m.mcp != nil:
status = " " + modeTag + " · MCP"
case m.skillPick != nil:
status = " " + modeTag + " · " + i18n.M.SkillPickerStatusLabel
case m.chooser != nil:
status = " " + modeTag + " · " + i18n.M.ChatStatusQuestion
case m.pendingApproval != nil && m.pendingApproval.Tool == planApprovalTool:
status = " " + modeTag + " · " + i18n.M.ChatStatusPlanApproval
case m.pendingApproval != nil:
status = " " + modeTag + " · " + i18n.M.ChatStatusToolApproval
case m.copyNoticeText != "":
status = " " + modeTag + " · " + green(m.copyNoticeText)
case cancelRequested:
status = " " + modeTag + " · " + i18n.M.CtrlCQuitHint
case shellMode:
status = " " + modeTag + " · " + i18n.M.ShellModeHint
case m.ctrl.AutoApproveTools():
status = " " + modeTag + " · " + i18n.M.ChatStatusYoloIdle + " " + dim("("+m.cycleHint()+")")
default:
status = " " + modeTag + " · " + i18n.M.ChatStatusIdle + " " + dim("("+m.cycleHint()+")")
}
if et := m.effortTag(); et != "" {
status += " · " + et
}
if gt := m.gitTag(); gt != "" {
status += " · " + gt
}
if mt := m.mouseTag(); mt != "" {
status += " · " + mt
}
// The spinning "thinking…" indicator is its own line ABOVE the input box (shown
// only while a turn runs); the status/data rows stay below. This mirrors Claude
// Code: live progress over the composer, shortcuts + stats under it.
working := m.runningWorkingLine(cancelRequested, true)
// Second status row: the live data (model, git, effort, context gauge, cache
// rates, jobs, balance). It lives on its own row so it's always visible; if it
// exceeds the terminal width it wraps to additional rows instead of being
// truncated. Wrapping is safe in the alt-screen view — there's no scrollback
// to strand — and computeStatusLineCount reserves the correct height.
var data []string
if mt := m.modelTag(); mt != "" {
data = append(data, mt)
}
if wt := m.workModeTag(); wt != "" {
data = append(data, wt)
}
if cache := m.cacheTag(); cache != "" {
data = append(data, cache)
}
if ctxTag != "" {
data = append(data, ctxTag)
}
if jt := m.jobsTag(); jt != "" {
data = append(data, jt)
}
if m.balance != "" {
data = append(data, dim(m.balance))
}
dataLine := " " + strings.Join(data, " · ")
// A configured custom status line replaces the built-in data row entirely.
if m.statuslineCmd != "" && m.statuslineOut != "" {
dataLine = " " + m.statuslineOut
}
// Bottom region pinned under the transcript viewport: optional panels, the
// composer when visible, then the two status rows. Its height feeds
// transcriptHeight so the viewport above fills exactly the rest of the screen.
var parts []string
rowsAboveBox := 0 // terminal rows occupied by panels/working line before the composer
if todo := m.renderTodoPanel(); todo != "" {
parts = append(parts, todo)
rowsAboveBox += strings.Count(todo, "\n") + 1
}
if banner := m.renderApprovalBanner(); banner != "" {
parts = append(parts, banner)
rowsAboveBox += strings.Count(banner, "\n") + 1
}
if card := m.renderChooser(); card != "" {
parts = append(parts, card)
rowsAboveBox += strings.Count(card, "\n") + 1
}
if card := m.renderRewind(); card != "" {
parts = append(parts, card)
rowsAboveBox += strings.Count(card, "\n") + 1
}
if card := m.renderMCPImport(); card != "" {
parts = append(parts, card)
rowsAboveBox += strings.Count(card, "\n") + 1
}
if card := m.renderResumePicker(); card != "" {
parts = append(parts, card)
rowsAboveBox += strings.Count(card, "\n") + 1
}
if card := m.renderCopyPicker(); card != "" {
parts = append(parts, card)
rowsAboveBox += strings.Count(card, "\n") + 1
}
if menu := m.renderCompletion(); menu != "" {
parts = append(parts, menu)
rowsAboveBox += strings.Count(menu, "\n") + 1
}
if m.nativeScrollback {
if card := m.renderMainManager(); card != "" {
parts = append(parts, card)
rowsAboveBox += strings.Count(card, "\n") + 1
}
}
// Layout: the working spinner (when running), then the composer when visible,
// then the two status rows (line 1 = mode + run config + worktree identity, line 2 = live run data).
// Each row is wrapped to width so long content flows onto additional rows
// instead of being truncated. Padding to full width prevents stale cells.
if working != "" {
parts = append(parts, workingStyle.Width(boxW).MaxWidth(boxW).Render(wrapStatusLine(working, boxW)))
rowsAboveBox++
}
if footer := m.renderMainManagerFooter(); footer != "" {
parts = append(parts, footer)
rowsAboveBox += strings.Count(footer, "\n") + 1
}
statusBlock := wrapStatusLine(status, boxW) + "\n" + wrapStatusLine(dataLine, boxW)
if !hideComposer {
if qi := m.renderQueueIndicator(); qi != "" {
parts = append(parts, qi)
rowsAboveBox += strings.Count(qi, "\n") + 1
}
parts = append(parts, box)
}
parts = append(parts, statusBlockStyle.Width(boxW).MaxWidth(boxW).Render(statusBlock))
if m.nativeScrollback {
v := tea.NewView(strings.Join(parts, "\n"))
if !hideComposer {
if cur := m.input.Cursor(); cur != nil {
cur.X += 1
cur.Y += rowsAboveBox + 1
v.Cursor = cur
}
}
return v
}
// Full-screen frame: the transcript viewport on top (it pads to exactly its
// height), the pinned bottom region beneath. Alt-screen owns the grid, so
// resize repaints cleanly — no scrollback reflow, no ghost borders.
mainArea := m.renderTranscript()
if card := m.renderMainManager(); card != "" {
mainArea = m.renderTranscriptWithMainManager(card)
}
v := tea.NewView(mainArea + "\n" + strings.Join(parts, "\n"))
v.AltScreen = true
if m.mouseCaptureOff {
// Release the mouse to the terminal: native click-drag selection and
// right-click context menu work again, at the cost of the in-app
// scrollbar, wheel-scroll, and drag-select while it's off.
v.MouseMode = tea.MouseModeNone
} else {
v.MouseMode = tea.MouseModeCellMotion // wheel scrolls the transcript; text selection is handled in-app
}
// Anchor the real terminal cursor at the textarea's insertion point only when
// the composer is visible. input.Cursor() is relative to the textarea; offset
// by the viewport height + rows above + the box's top border row (+1 column
// for PaddingLeft).
if !hideComposer {
if cur := m.input.Cursor(); cur != nil {
cur.X += 1
cur.Y += m.viewport.Height() + rowsAboveBox + 1
v.Cursor = cur
}
}
return v
}
// compactionCardLines renders a finished compaction as a titled card: a header
// with the message count and trigger, then the structured summary under a dim
// gutter so it reads as one block in scrollback. The summary is also the new
// context base, so this card is the user's window into exactly what was kept.
func compactionCardLines(c event.Compaction) []string {
trigger := c.Trigger
switch c.Trigger {
case "auto":
trigger = i18n.M.CompactionAuto
case "manual":
trigger = i18n.M.CompactionManual
}
header := fmt.Sprintf("%s · %d %s · %s", i18n.M.CompactionTitle, c.Messages, i18n.M.CompactionUnit, trigger)
lines := []string{accent("◆ " + header)}
for _, ln := range strings.Split(strings.TrimRight(c.Summary, "\n"), "\n") {
lines = append(lines, dim(" │ "+ln))
}
if c.Archive != "" {
lines = append(lines, dim(" │ archived "+c.Archive))
}
return lines
}
// contextTag renders the prompt-vs-context-window gauge for the status line,
// framed around the auto-compaction threshold: it shows how much headroom is
// left until the next compaction, and colours by proximity to that point rather
// than the raw window. Falls back to a plain percentage when compaction is disabled.
func (m chatTUI) contextTag() string {
used, window := m.ctrl.ContextSnapshot()
if used == 0 || window == 0 {
return ""
}
pct := used * 100 / window
ratio := m.ctrl.CompactRatio()
if ratio <= 0 || ratio >= 1 {
// Compaction disabled: just the raw gauge, coloured on window fill.
body := fmt.Sprintf("%s / %s ctx (%d%%)", shortTokens(used), shortTokens(window), pct)
switch {
case pct >= 85:
return themeStyle(activeCLITheme.danger).Render(body)
case pct >= 60:
return themeStyle(activeCLITheme.warn).Render(body)
default:
return dim(body)
}
}
threshold := int(ratio * 100)
// Headroom to the compaction point, as a percentage of the window (clamped at 0).
left := threshold - pct
if left < 0 {
left = 0
}
body := fmt.Sprintf("%s ctx (%d%%) · %d%% to compact", shortTokens(used), pct, left)
switch {
case pct >= threshold:
return themeStyle(activeCLITheme.danger).Render(fmt.Sprintf("%s ctx (%d%%) · compacting soon", shortTokens(used), pct))
case left <= 10:
return themeStyle(activeCLITheme.warn).Render(body)
default:
return dim(body)
}
}
func cacheRateLabel(format string, hit, denom int) string {
if denom <= 0 {
return ""
}
return fmt.Sprintf(format, fmt.Sprintf("%.2f%%", float64(hit)*100/float64(denom)))
}
// cacheTag renders both prompt cache-hit rates for the status line —
// "turn hit 88.00% · avg 78.00%": the single-turn rate (latest turn, the higher/steeper
// number on a non-compacting DeepSeek session) and the session-aggregate rate
// Σhit/Σ(hit+miss) (the steadier, cost-oriented number that matches the legacy
// dashboard). "" before any cache tokens have been reported.
func (m chatTUI) cacheTag() string {
now := ""
if u := m.ctrl.LastUsage(); u != nil {
// Only render when the provider actually reports cache token fields:
// falling back to PromptTokens as the denominator painted a bogus
// "turn hit 0.00%" for providers with no prompt-cache support.
now = cacheRateLabel(i18n.M.ChatStatusCacheNowFmt, u.CacheHitTokens, u.CacheHitTokens+u.CacheMissTokens)
}
avg := ""
if hit, miss := m.ctrl.SessionCache(); hit+miss > 0 {
avg = cacheRateLabel(i18n.M.ChatStatusCacheAvgFmt, hit, hit+miss)
}
switch {
case now != "" && avg != "":
return dim(now + " · " + avg)
case now != "":
return dim(now)
case avg != "":
return dim(avg)
}
return ""
}
// jobsTag shows the count of running background jobs in the status line. Job
// start/finish emit Notices that arrive on eventCh and re-render the frame, so
// the count stays current without a dedicated tick.
func (m chatTUI) jobsTag() string {
n := len(m.ctrl.Jobs())
if n == 0 {
return ""
}
return dim(fmt.Sprintf("⚙ %d", n))
}
func (m chatTUI) modelTag() string {
if strings.TrimSpace(m.label) == "" {
return ""
}
return dim(m.label)
}
func (m chatTUI) workModeTag() string {
if m.runtimeProfile == "" {
return ""
}
return dim(fmt.Sprintf(i18n.M.WorkModeStatusFmt, runtimeProfileDisplay(m.runtimeProfile)))
}
func (m chatTUI) effortTag() string {
if m.effortLevel == "" {
return ""
}
body := "effort " + m.effortLevel
if m.effortLevel != "auto" {
return lipgloss.NewStyle().Foreground(lipgloss.Color("#2563eb")).Bold(true).Render(body)
}
return dim(body)
}
// mouseTag is a persistent status-line marker while mouseCaptureOff is on, so
// the loss of in-app scrollbar/wheel-scroll/drag-select reads as a deliberate
// state rather than a bug the user has to guess at.
func (m chatTUI) mouseTag() string {
if !m.mouseCaptureOff {
return ""
}
return dim(i18n.M.MouseCaptureTag)
}
// shortTokens prints token counts compactly: 1_500 → "1.5K", 142_000 → "142.0K", 1_000_000 → "1.0M".
func shortTokens(n int) string {
switch {
case n >= 999_950:
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
case n >= 1_000:
return fmt.Sprintf("%.1fK", float64(n)/1_000)
default:
return fmt.Sprintf("%d", n)
}
}
// renderApprovalBanner is the slim notice shown above the input while a tool
// call (or a plan) awaits the user's decision.
func (m chatTUI) renderApprovalBanner() string {
w := m.width
if w < 10 {
w = 10
}
if m.pendingApproval == nil {
return ""
}
// A plan approval shows the gate prompt (the plan itself is already printed as
// the assistant's reply); a tool approval names the tool + subject.
if m.pendingApproval.Tool == planApprovalTool {
return approvalBannerStyle.Width(w).Render("⏸ " + i18n.M.PlanApprovalPrompt)
}
name, detail := approvalToolDetails(m.pendingApproval.Tool)
subj := strings.TrimSpace(m.pendingApproval.Subject)
if subj != "" {
subj = " " + truncateSubject(subj, w)
}
exactSessionRule := permission.SessionGrantRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject)
exactPersistentRule := permission.RememberRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject)
choices := i18n.M.FreshHumanApprovalChoices
if !control.RequiresFreshHumanApprovalTool(m.pendingApproval.Tool) {
choices = fmt.Sprintf(i18n.M.ToolApprovalChoices, exactSessionRule, exactPersistentRule)
}
if m.pendingApproval.Tool == control.SandboxEscapeApprovalTool {
choices = i18n.M.SandboxEscapeApprovalChoices
}
if m.pendingApproval.Tool == control.ManagedConfigWriteApprovalTool {
choices = i18n.M.ConfigWriteApprovalChoices
}
if m.pendingApproval.Tool == agent.PlanModeReadOnlyCommandApprovalTool {
choices = i18n.M.PlanModeReadOnlyCommandChoices
}
if !control.RequiresFreshHumanApprovalTool(m.pendingApproval.Tool) && m.pendingApproval.Tool == "bash" && permission.BashCommandPrefix(m.pendingApproval.Subject) != "" {
prefixRule := permission.RememberRuleForScope(m.pendingApproval.Tool, m.pendingApproval.Subject)
choices = fmt.Sprintf(i18n.M.BashPrefixChoices, prefixRule, prefixRule)
}
text := fmt.Sprintf(i18n.M.ToolApprovalPromptFmt, name, subj, detail, choices)
if reason := strings.TrimSpace(m.pendingApproval.Reason); reason != "" {
text += " · " + truncateSubject(reason, w)
}
return approvalBannerStyle.Width(w).Render("⏸ " + text)
}
// approvalToolDetails turns provider-visible tool IDs into user-facing labels.
// MCP tools are advertised as mcp__<server>__<tool>; showing the short tool name
// first keeps the approval prompt readable while preserving the source.
func approvalToolDetails(toolName string) (name, detail string) {
if toolName == agent.PlanModeReadOnlyCommandApprovalTool {
return i18n.M.ApprovalToolLabelPlanModeReadOnly, fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn)
}
if toolName == control.SandboxEscapeApprovalTool {
return i18n.M.ApprovalToolLabelSandboxEscape, fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn)
}
if toolName == control.ManagedConfigWriteApprovalTool {
return i18n.M.ApprovalToolLabelConfigWrite, fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn)
}
if server, short, ok := tool.SplitMCPName(toolName); ok {
lines := []string{}
if strings.EqualFold(short, "understand_image") {
lines = append(lines, i18n.M.ToolApprovalImageUse)
}
lines = append(lines, fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, server))
return short, strings.Join(lines, "\n")
}
return approvalToolLabel(toolName), fmt.Sprintf(i18n.M.ToolApprovalSourceFmt, i18n.M.ToolApprovalBuiltIn)
}
func approvalToolLabel(toolName string) string {
switch toolName {
case "bash":
return i18n.M.ApprovalToolLabelBash
case "edit_file":
return i18n.M.ApprovalToolLabelEditFile
case "write_file":
return i18n.M.ApprovalToolLabelWriteFile
case "multi_edit":
return i18n.M.ApprovalToolLabelMultiEdit
case "move_file":
return i18n.M.ApprovalToolLabelMoveFile
case "web_fetch":
return i18n.M.ApprovalToolLabelWebFetch
case "run_skill":
return i18n.M.ApprovalToolLabelRunSkill
case "remember":
return i18n.M.ApprovalToolLabelRemember
case "forget":
return i18n.M.ApprovalToolLabelForget
default:
return toolName
}
}
// todoPanelMaxRows caps how many task lines the pinned panel shows; a long list
// is truncated with a "+N more" footer so the bottom region stays compact.
const todoPanelMaxRows = 8
type todoPanelTodo struct {
Content string `json:"content"`
Status string `json:"status"`
ActiveForm string `json:"activeForm"`
Level int `json:"level"`
}
// renderTodoPanel renders the task list pinned above the input from the latest
// todo_write call (m.todoArgs): a "Tasks done/total" header, completed items
// dimmed/checked, the in-progress one highlighted (its activeForm if given),
// pending ones muted. It returns "" when there's no list or every item is done,
// so the panel appears while work is outstanding and clears itself when finished.
func (m chatTUI) renderTodoPanel() string {
var p struct {
Todos []todoPanelTodo `json:"todos"`
}
if err := json.Unmarshal([]byte(m.todoArgs), &p); err != nil || len(p.Todos) == 0 {
return ""
}
done := 0
for _, t := range p.Todos {
if t.Status == "completed" {
done++
}
}
if done == len(p.Todos) {
return "" // all finished — clear the panel
}
var b strings.Builder
fmt.Fprintf(&b, "%s %s\n", accent("To-dos"), dim(fmt.Sprintf("%d/%d", done, len(p.Todos))))
start, end := todoPanelWindow(p.Todos)
if start > 0 {
b.WriteString(dim(fmt.Sprintf(" +%d above", start)) + "\n")
}
for _, t := range p.Todos[start:end] {
indent := " "
if t.Level >= 1 {
indent = " " // sub-steps sit under their phase
}
switch t.Status {
case "completed":
b.WriteString(indent + green("✔") + " " + dim(t.Content) + "\n")
case "in_progress":
label := t.Content
if t.ActiveForm != "" {
label = t.ActiveForm
}
b.WriteString(indent + yellow("▶ "+label) + "\n")
default:
b.WriteString(indent + dim("○ "+t.Content) + "\n")
}
}
if end < len(p.Todos) {
b.WriteString(dim(fmt.Sprintf(" +%d more", len(p.Todos)-end)) + "\n")
}
return todoPanelStyle.Width(max(m.width, 10)).Render(strings.TrimRight(b.String(), "\n"))
}
func todoPanelWindow(todos []todoPanelTodo) (int, int) {
if len(todos) <= todoPanelMaxRows {
return 0, len(todos)
}
active := -1
for i, t := range todos {
if t.Status == "in_progress" {
active = i
break
}
}
if active < 0 {
return 0, todoPanelMaxRows
}
start := active - todoPanelMaxRows/2
if start < 0 {
start = 0
}
if maxStart := len(todos) - todoPanelMaxRows; start > maxStart {
start = maxStart
}
return start, start + todoPanelMaxRows
}
// truncateSubject trims a tool subject so the approval banner fits one line.
func truncateSubject(s string, width int) string {
max := width - 28
if max < 16 {
max = 16
}
return ansi.Truncate(s, max, "…")
}
// wrapStatusLine wraps a status line to `width` visible columns, ANSI-aware,
// so text that exceeds one row flows onto additional lines instead of being
// truncated with an ellipsis. Wrapping is permissive — spaces are preferred
// break points — and works within the alt-screen view so there is no scrollback
// artifact.
func wrapStatusLine(s string, width int) string {
if width <= 0 || s == "" {
return s
}
return ansi.Hardwrap(s, width, true)
}
// computeStatusLineCount returns the number of terminal rows the status block
// (working line + first status line + data line) will occupy after wrapping to
// `width`. It mirrors the construction in View() so the reserved height matches
// the rendered height exactly — the load-bearing invariant for bottomRows().
// Use the same width (m.width) that View() passes to wrapStatusLine.
func (m chatTUI) computeStatusLineCount(width int) int {
if m.ctrl == nil {
return 2 // safe default for tests without a real controller
}
shellMode := strings.HasPrefix(strings.TrimSpace(m.input.Value()), "!")
cancelRequested := m.cancelRequested()
// Replicate the first status line (mode tag + state) from View().
// ModeTag is rendered with Padding(0,1) in View() — add the same padding
// here so the visible width matches exactly.
modeTag := " " + m.modeTagText() + " "
if shellMode {
modeTag = " Shell "
}
status := " " + modeTag
switch {
case m.rewind != nil:
status += " · ⟲ rewind"
case m.mcpImport != nil:
status += " · MCP import"
case m.resumePick != nil:
status += " · " + i18n.M.StatusResumePicker
case m.mcp != nil:
status += " · MCP"
case m.skillPick != nil:
status += " · " + i18n.M.SkillPickerStatusLabel
case m.chooser != nil:
status += " · " + i18n.M.ChatStatusQuestion
case m.pendingApproval != nil && m.pendingApproval.Tool == planApprovalTool:
status += " · " + i18n.M.ChatStatusPlanApproval
case m.pendingApproval != nil:
status += " · " + i18n.M.ChatStatusToolApproval
case m.copyNoticeText != "":
status += " · " + m.copyNoticeText
case cancelRequested:
status += " · " + i18n.M.CtrlCQuitHint
case shellMode:
status += " · " + i18n.M.ShellModeHint
case m.ctrl.AutoApproveTools():
status += " · " + i18n.M.ChatStatusYoloIdle + " (" + m.cycleHint() + ")"
default:
status += " · " + i18n.M.ChatStatusIdle + " (" + m.cycleHint() + ")"
}
if et := m.effortTag(); et != "" {
status += " · " + et
}
if gt := m.gitTag(); gt != "" {
status += " · " + gt
}
if mt := m.mouseTag(); mt != "" {
status += " · " + mt
}
// Replicate the data line from View().
var data []string
if mt := m.modelTag(); mt != "" {
data = append(data, mt)
}
if wt := m.workModeTag(); wt != "" {
data = append(data, wt)
}
if cache := m.cacheTag(); cache != "" {
data = append(data, cache)
}
if ct := m.contextTag(); ct != "" {
data = append(data, ct)
}
if jt := m.jobsTag(); jt != "" {
data = append(data, jt)
}
if m.balance != "" {
data = append(data, m.balance)
}
dataLine := " " + strings.Join(data, " · ")
if m.statuslineCmd != "" && m.statuslineOut != "" {
dataLine = " " + m.statuslineOut
}
// Replicate the working (spinner) line from View(), shown only while a turn runs.
working := m.runningWorkingLine(cancelRequested, false)
// Count wrapped rows for every piece that View() renders as wrapped.
var lines int
if m.state == tuiRunning {
// working (spinner) line — wraps independently of the status block below.
lines += strings.Count(wrapStatusLine(working, width), "\n") + 1
}
lines += strings.Count(wrapStatusLine(status, width), "\n") + 1
lines += strings.Count(wrapStatusLine(dataLine, width), "\n") + 1
return lines
}
// growInputToFit resizes the textarea to the number of lines its value spans,
// capped at maxInputRows so a long paste doesn't crowd the screen.
const maxInputRows = 5
const foldedPasteMinChars = 1000
const foldedPasteMinLines = 5
type pastedBlock struct {
label string
text string
image bool // an image attachment: expands to its bare @ref, not a wrapped block
}
func (m *chatTUI) chooserTyping() bool {
return m.chooser != nil && m.chooser.typing
}
func (m *chatTUI) growInputToFit() {
if m.input.DynamicHeight {
return
}
lines := strings.Count(m.input.Value(), "\n") + 1
if lines < 1 {
lines = 1
}
if lines > maxInputRows {
lines = maxInputRows
}
if lines != m.input.Height() {
m.input.SetHeight(lines)
}
}
// cycleMode handles the Shift+Tab mode gesture. It toggles Plan only; tool
// approval modes stay on their own axis.
func (m *chatTUI) cycleMode() {
m.planMode = !m.planMode
if m.planMode {
m.ctrl.ClearGoal()
}
m.ctrl.SetPlanMode(m.planMode)
}
func (m chatTUI) desktopShortcutLayout() bool {
return m.cfg != nil && m.cfg.UIShortcutLayout() == "desktop"
}
func (m chatTUI) cycleHint() string {
return i18n.M.ChatStatusCycleHint
}
func (m *chatTUI) toggleYoloMode() {
if m.ctrl == nil {
return
}
if m.ctrl.ToolApprovalMode() == control.ToolApprovalYolo {
restore := m.yoloRestoreToolApprovalMode
if restore != control.ToolApprovalAuto {
restore = control.ToolApprovalAsk
}
m.ctrl.SetToolApprovalMode(restore)
m.yoloRestoreToolApprovalMode = ""
return
}
restore := m.ctrl.ToolApprovalMode()
if restore != control.ToolApprovalAuto {
restore = control.ToolApprovalAsk
}
m.yoloRestoreToolApprovalMode = restore
m.ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
}
func (m chatTUI) modeTagText() string {
goalMode := strings.TrimSpace(m.ctrl.Goal()) != "" && m.ctrl.GoalStatus() == control.GoalStatusRunning
toolApprovalMode := m.ctrl.ToolApprovalMode()
if m.desktopShortcutLayout() {
switch {
case m.planMode && toolApprovalMode == control.ToolApprovalYolo:
return "Plan+YOLO"
case goalMode && toolApprovalMode == control.ToolApprovalYolo:
return "Goal+YOLO"
case toolApprovalMode == control.ToolApprovalYolo:
return "YOLO"
case m.planMode:
return "Plan"
case goalMode && toolApprovalMode == control.ToolApprovalAuto:
return "Goal+Auto"
case goalMode:
return "Goal"
case toolApprovalMode == control.ToolApprovalAuto:
return "Auto"
default:
return "Ask"
}
}
switch {
case m.planMode && toolApprovalMode == control.ToolApprovalYolo:
return "Plan+YOLO"
case m.planMode && toolApprovalMode == control.ToolApprovalAuto:
return "Plan+Approve"
case goalMode && toolApprovalMode == control.ToolApprovalYolo:
return "Goal+YOLO"
case goalMode && toolApprovalMode == control.ToolApprovalAuto:
return "Goal+Approve"
case toolApprovalMode == control.ToolApprovalYolo:
return "YOLO"
case toolApprovalMode == control.ToolApprovalAuto:
return "Auto+Approve"
case m.planMode:
return "Plan"
case goalMode:
return "Goal"
default:
return "Auto"
}
}
func (m *chatTUI) toggleVerboseReasoning(notify bool) {
m.showReasoning = !m.showReasoning
if m.cfg != nil {
_ = m.cfg.SetShowReasoning(m.showReasoning)
_ = m.cfg.Save()
}
if !notify {
return
}
if m.showReasoning {
m.notice("verbose on — thinking text will be shown")
} else {
m.notice("verbose off — thinking text will stay collapsed")
}
}
// toggleMouseCapture flips whether Reasonix owns the mouse. It's session-only
// (unlike /verbose, this accommodates the terminal/multiplexer at hand rather
// than recording a lasting preference) — mirrors nativeScrollback, which is
// likewise never persisted to config. Clears any in-app selection/scrollbar
// drag in flight so a stale one can't be found mid-gesture once the terminal
// starts intercepting the events that would have finished it.
func (m *chatTUI) toggleMouseCapture() {
m.mouseCaptureOff = !m.mouseCaptureOff
m.sel = selection{}
m.scrollbarDrag = false
m.autoScroll = 0
if m.mouseCaptureOff {
m.notice(i18n.M.MouseCaptureOffHint)
} else {
m.notice(i18n.M.MouseCaptureOnHint)
}
}
// startTurn commits the user bubble to scrollback, resets the turn accumulator,
// and kicks off the controller turn. `sent` goes to the model uncomposed (the
// controller frames it with any plan marker); `displayed` is what the transcript
// shows, and `restore` is what Esc puts back while the bubble is still deferred.
func (m *chatTUI) startTurn(sent, displayed, restore string) tea.Cmd {
return m.startTurnWithRaw(sent, displayed, restore, sent)
}
// startTurnWithRaw is startTurn plus an explicit `raw` (the un-resolved user
// prompt) used only for the controller's auto-plan scoring, so resolved
// @-reference payloads can't inflate the complexity signal.
func (m *chatTUI) startTurnWithRaw(sent, displayed, restore, raw string) tea.Cmd {
return m.startControllerTurn(displayed, restore, func() { m.ctrl.SendWithRaw(sent, raw) })
}
// startControllerTurn owns the TUI-side turn setup for controller entry points.
// Most prompts use SendWithRaw; slash-invoked skills use SubmitDisplay so the
// controller can choose inline vs isolated subagent execution from the live
// skill's RunAs metadata without the TUI reimplementing that policy.
func (m *chatTUI) startControllerTurn(displayed, restore string, start func()) tea.Cmd {
// Flush any half-streamed leftover before the new turn (defensive).
m.commitReasoning()
m.commitPending()
// Echo the user bubble to scrollback now so it appears the instant Enter is
// pressed, not when the server's first packet lands. It stays un-sendable until
// then: Esc before the reply pops these lines back off (unsendPending) and
// restores the text to the input box, leaving nothing stranded.
m.pendingRestore = restore
m.pendingPastes = m.pasteLabelsIn(restore)
m.bubbleStartIdx = len(m.transcript)
m.commitLine("") // blank line separating turns
m.commitLine(renderUserBubble(displayed, m.width, m.planMode))
m.bubblePending = true
m.turnDiscarded = false
m.state = tuiRunning
m.runStart = time.Now()
m.elapsed = 0
m.turnTokens = 0
// The controller owns the run goroutine, its context, and cancellation; it
// streams events to eventCh and emits TurnDone when the turn settles.
start()
return tea.Batch(m.spinner.Tick, elapsedTick())
}
// confirmBubbleSent marks the already-echoed user bubble as really sent once a
// turn's first response packet arrives, so Esc no longer un-sends it (it cancels
// the stream instead). Also called defensively at turn end. A no-op once confirmed.
func (m *chatTUI) confirmBubbleSent() {
if !m.bubblePending {
return
}
m.bubblePending = false
m.pendingRestore = ""
}
// unsendPending "un-sends" the in-flight turn while the server hasn't replied yet
// (bubblePending): it pops the echoed bubble back off the transcript, restores the
// just-sent text to the input box, and cancels the request — marking the turn
// discarded so its already-buffered events reach nothing. Once a packet has arrived
// the bubble is confirmed and this path isn't taken (Esc cancels normally instead).
func (m *chatTUI) unsendPending() {
m.input.SetValue(m.pendingRestore)
m.growInputToFit()
m.transcript = m.transcript[:m.bubbleStartIdx]
m.transcriptDirty = true
m.bubblePending = false
m.pendingRestore = ""
m.pendingPastes = nil
m.turnDiscarded = true
m.ctrl.Cancel()
}
// ingestEvent routes one typed event from the agent. Reasoning (dim) and answer
// free-text accumulate in their live buffers; every other event first finalizes
// the reasoning and answer streamed so far, then commits its own line —
// preserving order. Switching on the event Kind replaces the old prefix-sniffing
// of a flattened byte stream: the structure is now explicit.
func (m *chatTUI) ingestEvent(e event.Event) {
if e.Kind == event.Retrying {
m.retryAttempt = e.RetryAttempt
m.retryMax = e.RetryMax
return
}
// Any other event means the connection got past the retry window (or the turn
// ended), so the transient "retrying" indicator clears.
m.retryAttempt = 0
m.retryMax = 0
if m.turnDiscarded {
// The turn was un-sent (Esc before any packet); swallow whatever was already
// buffered for it until it settles, so nothing lands in scrollback.
if e.Kind == event.TurnDone {
m.turnDiscarded = false
m.state = tuiIdle
}
return
}
// The first packet of any kind means the server replied — confirm the send so
// Esc cancels the stream instead of un-sending. TurnStarted is local (emitted
// before the request) and TurnDone is handled in its own case.
if e.Kind != event.TurnStarted && e.Kind != event.TurnDone {
m.confirmBubbleSent()
}
switch e.Kind {
case event.Reasoning:
if m.nativeScrollback {
if !m.reasoningNative {
m.thinkStart = time.Now()
m.reasoningNative = true
}
m.streamReasoning(e.Text)
break
}
if m.reasoningLineIdx < 0 {
// Show the marker plus a live text block the moment thinking starts; the
// text streams in below it and the block collapses to "thought for Ns"
// when it closes (kept expanded only in verbose mode).
m.commitSpacer()
m.thinkStart = time.Now()
m.reasoningLineIdx = len(m.transcript)
m.commitLine(dim(" ▎ " + i18n.M.ChatThinking))
m.reasoningTextIdx = len(m.transcript)
m.commitLine("")
m.reasoningView = m.reasoningView[:0]
}
m.streamReasoning(e.Text)
case event.Text:
m.commitReasoning() // reasoning ends as the answer begins
m.pending.WriteString(e.Text)
m.streamAnswer()
case event.Message:
// The answer stream is complete — freeze reasoning + the markdown answer.
m.commitReasoning()
m.commitPending()
case event.ToolDispatch:
// The early (partial) dispatch only carries the name — the full dispatch
// with args prints the line. The running spinner covers the gap meanwhile.
if e.Tool.Partial {
break
}
m.finalizeStreamed()
switch e.Tool.Name {
case "todo_write":
// The result decides whether this list becomes canonical; dispatch only
// means the model asked for an update.
case planApprovalTool:
// No longer a tool, but guard anyway: the plan is the assistant's reply.
default:
m.commitSpacer()
if block := diffBlock(e.Tool.Name, e.Tool.Args, e.Tool.FileDiff, m.width, m.diffMaxLines); block != nil {
for _, ln := range block {
m.commitLine(ln)
}
break
}
m.commitLine(toolCard(e.Tool.Name, e.Tool.Args, m.width))
m.beginToolRunning(e.Tool.ID)
}
case event.ToolProgress:
m.streamToolOutput(e.Tool.ID, e.Tool.Output)
case event.ToolResult:
// A successful result is silent (it only feeds the model); a blocked/failed
// call surfaces a red "⏺ Verb ⊘ <reason>" card. A live-output block (bash)
// collapses to a one-line "⎿ N lines" summary first. Pass the final
// output so collapseToolOutput has a last-resort source for the line
// count when the live state was already reset by a back-to-back tool.
m.collapseToolOutput(e.Tool.ID, e.Tool.Output)
if e.Tool.Name == "todo_write" && e.Tool.Err == "" {
m.todoArgs = e.Tool.Args
}
if e.Tool.Err != "" {
m.finalizeStreamed()
m.commitLine(" " + red("●") + " " + bold(toolDisplayName(e.Tool.Name)) + " " + red("⊘ "+e.Tool.Err))
}
case event.Usage:
if e.Usage != nil {
m.turnTokens += e.Usage.CompletionTokens
}
if line := agent.FormatUsageLine(e.Usage, e.Pricing, e.CacheDiagnostics); line != "" {
m.finalizeStreamed()
m.commitLine(line)
}
case event.Notice:
glyph := "·"
if e.Level == event.LevelWarn {
glyph = "!"
}
m.finalizeStreamed()
m.commitLine(fmt.Sprintf(" %s %s", glyph, e.Text))
case event.GuardianAssessment:
m.finalizeStreamed()
g := e.Guardian
line := fmt.Sprintf("Guardian %s · %s", g.Outcome, g.Tool)
if g.Subject != "" {
line += " · " + truncateSubject(g.Subject, m.width)
}
if g.RiskLevel != "" {
line += " · risk=" + g.RiskLevel
}
if g.UserAuthorization != "" {
line += " · authorization=" + g.UserAuthorization
}
if g.Rationale != "" {
line += " · " + g.Rationale
}
if g.Outcome == "deny" {
m.commitLine(" ! " + line)
} else {
m.commitLine(" · " + line)
}
case event.CompactionStarted:
m.finalizeStreamed()
m.commitLine(dim(" ⋯ " + i18n.M.CompactionWorking))
case event.CompactionDone:
// An aborted pass carries no summary; the accompanying Notice (auto) or
// compactDoneMsg error (manual) explains why, so don't draw an empty card.
if e.Compaction.Summary == "" {
break
}
m.finalizeStreamed()
for _, ln := range compactionCardLines(e.Compaction) {
m.commitLine(ln)
}
case event.Phase:
m.finalizeStreamed()
m.commitLine(fmt.Sprintf("[%s]", e.Text))
case event.ApprovalRequest:
// The controller's run goroutine is now blocked inside the gate awaiting
// this decision; the banner shows it in View and key input answers it via
// ctrl.Approve. At most one prompt is outstanding (the controller
// serialises them), so a plain field holds the current one.
a := e.Approval
m.pendingApproval = &a
case event.AskRequest:
// The `ask` tool raised a question card; the run goroutine blocks until
// ctrl.AnswerQuestion resolves it. Keys drive the card while it's set.
m.finalizeStreamed()
m.chooser = newChooser(e.Ask)
case event.MCPSurfaceReady:
if m.ctrl != nil {
m.host = m.ctrl.Host()
}
m.refreshMCPManager()
case event.TurnDone:
// The turn settled — freeze anything still streaming, surface a real error,
// and gate a plan-mode proposal on the user's approval. Autosave already
// happened in Controller so every frontend shares the same activity-time
// semantics.
m.commitReasoning()
m.commitPending()
// The bubble was echoed on Enter and an un-sent turn is swallowed above
// (turnDiscarded), so any turn reaching here keeps its bubble in scrollback;
// just clear the un-sendable flag.
m.confirmBubbleSent()
m.state = tuiIdle
m.queueEditCursor = -1
m.queueEditDraft = ""
m.clearSubmittedPastes()
if e.Err != nil && e.Err.Error() != "" && !strings.Contains(e.Err.Error(), "context canceled") {
m.commitLine(wrapForViewport(i18n.M.ErrorPrefix+" "+e.Err.Error(), m.width, activeCLITheme.warn))
}
// Plan-mode approval is now driven by the controller (it emits an
// ApprovalRequest when a plan-mode turn produces a proposal), so there's
// nothing to detect here.
}
}
// finalizeStreamed freezes any in-progress reasoning + answer into scrollback so
// a following event line lands after them, preserving chronological order.
func (m *chatTUI) finalizeStreamed() {
m.collapseToolOutput(m.toolStreamID, "")
m.commitReasoning()
m.commitPending()
}
func waitForAgentEvent(ch chan event.Event) tea.Cmd {
return func() tea.Msg { return agentEventMsg(<-ch) }
}
func elapsedTick() tea.Cmd {
return tea.Tick(time.Second, func(_ time.Time) tea.Msg { return elapsedTickMsg{} })
}
// runSlashCommand handles "/<cmd> <args>" input. Local commands queue their
// output to scrollback; MCP prompt / custom commands resolve to a model turn.
func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
cmd := strings.TrimSpace(strings.SplitN(input, " ", 2)[0])
if strings.HasPrefix(cmd, "/mcp__") {
return m.runMCPPrompt(input)
}
switch cmd {
case "/compact":
m.echoLocalCommand(input)
// Compaction makes a (network) summarizer call; run it off the Update loop
// so the TUI doesn't freeze. The CompactionStarted/Done events render the
// card as they arrive; compactDoneMsg only handles the terminal error /
// snapshot once the pass returns. Any text after "/compact" is focus
// guidance steering what the summary keeps.
focus := strings.TrimSpace(strings.TrimPrefix(input, cmd))
return func() tea.Msg { return compactDoneMsg{err: m.ctrl.Compact(context.Background(), focus)} }
case "/new":
m.echoLocalCommand(input)
if err := m.ctrl.NewSession(); err != nil {
m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashNewFailed, err))
return nil
}
m.followSessionLease()
// Native scrollback keeps the old transcript; mark the fork with a fresh banner.
m.resetFreshContextView(false)
m.notice(i18n.M.SlashNewDone)
case "/clear":
m.echoLocalCommand(input)
m.clearConfirm = &clearConfirm{confirm: 1}
case "/cls":
m.echoLocalCommand(input)
m.finalizeStreamed()
m.clearTranscriptDisplay()
m.commitLine(strings.TrimRight(
renderTUIBanner(m.label, "", transcriptContentWidth(m.width, m.nativeScrollback)), "\n"))
m.transcriptDirty = true
m.forceGotoBottom = true
m.notice(i18n.M.SlashClsDone)
case "/resume":
m.runResumeCommand(input)
case "/rename":
m.runRenameCommand(input)
case "/todo":
m.echoLocalCommand(input)
// Dismiss the pinned task list; a later todo_write brings it back.
m.todoArgs = ""
m.notice(i18n.M.SlashTodoCleared)
case "/verbose":
m.toggleVerboseReasoning(true)
case "/mouse":
m.toggleMouseCapture()
case "/sandbox":
m.echoLocalCommand(input)
m.showSandboxStatus()
case "/effort":
return m.runEffortCommand(input)
case "/work-mode", "/profile":
m.echoLocalCommand(input)
return m.runWorkModeCommand(input)
case "/auto-plan":
m.echoLocalCommand(input)
m.runAutoPlanCommand(input)
case "/reasoning-language":
m.echoLocalCommand(input)
m.runReasoningLanguageCommand(input)
case "/memory-v5":
m.echoLocalCommand(input)
m.runMemoryV5Command(input)
case "/rewind":
m.echoLocalCommand(input)
m.openRewind()
case "/tree":
m.echoLocalCommand(input)
m.showBranchTree()
case "/branch":
m.echoLocalCommand(input)
m.runBranchCommand(input)
case "/switch":
m.echoLocalCommand(input)
m.runSwitchCommand(input)
case "/mcp":
m.echoLocalCommand(input)
m.runMCPSubcommand(input)
case "/plugin", "/plugins":
m.echoLocalCommand(input)
m.runPluginSubcommand(input)
case "/model":
m.echoLocalCommand(input)
m.runModelSubcommand(input)
if m.pendingModelSwitch != nil {
return m.pendingModelSwitch
}
case "/provider":
m.echoLocalCommand(input)
m.runProviderCommand(input)
if m.pendingModelSwitch != nil {
return m.pendingModelSwitch
}
case "/skill", "/skills":
m.echoLocalCommand(input)
m.runSkillSubcommand(input)
if m.pendingModelSwitch != nil {
return m.pendingModelSwitch
}
case "/hooks":
m.echoLocalCommand(input)
m.runHooksSubcommand(input)
case "/reload-cmd":
m.echoLocalCommand(input)
if m.ctrl == nil {
m.notice("controller not ready")
return nil
}
if m.ctrl.Running() {
m.notice("wait for the current turn to finish, then retry /reload-cmd")
return nil
}
prev := len(m.commands)
err := m.ctrl.ReloadCommands(context.Background())
m.commands = m.ctrl.Commands()
m.updateCompletion()
if err != nil {
m.notice("reload-cmd: " + err.Error())
return nil
}
m.notice(fmt.Sprintf("commands reloaded: %d → %d commands", prev, len(m.commands)))
case "/paste-image":
return pasteClipboardImage()
case "/output-style", "/output-styles":
m.echoLocalCommand(input)
styles := outputstyle.List(outputstyle.Dirs())
if len(styles) == 0 {
m.notice(i18n.M.OutputStyleNone)
} else {
m.commitLine(renderOutputStyles(m.width, styles, m.outputStyle))
}
case "/diff-fold":
m.echoLocalCommand(input)
if m.diffMaxLines == 0 {
m.diffMaxLines = diffFoldLimit
m.notice(fmt.Sprintf(i18n.M.DiffFoldEnabledFmt, diffFoldLimit))
} else {
m.diffMaxLines = 0
m.notice(i18n.M.DiffFoldDisabled)
}
case "/theme":
m.echoLocalCommand(input)
m.runThemeSubcommand(input)
case "/language":
m.echoLocalCommand(input)
m.runLanguageSubcommand(input)
case "/help":
m.echoLocalCommand(input)
m.showHelp()
case "/memory":
m.echoLocalCommand(input)
m.showMemory()
case "/migrate", "/migration":
m.echoLocalCommand(input)
migration.RunLegacyRescueCommand(strings.TrimSpace(strings.TrimPrefix(input, cmd)), event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
m.notice(e.Text)
}
}))
case "/goal":
return m.runGoalSubcommand(input)
case "/remember":
note := strings.TrimSpace(strings.TrimPrefix(input, cmd))
if note == "" {
m.notice("nothing to remember")
} else if path, err := m.ctrl.QuickAdd(memory.ScopeProject, note); err != nil {
m.notice("memory: " + err.Error())
} else {
m.notice("remembered → " + path)
}
case "/quit", "/exit":
return tea.Quit
case "/copy":
return m.runCopyCommand(input)
case "/export":
m.runExportCommand(input)
case "/forget":
m.forgetMemory(strings.TrimSpace(strings.TrimPrefix(input, cmd)))
default:
// A custom command wins over a skill of the same name; both resolve to a turn.
if sent, ok := m.ctrl.CustomCommand(input); ok {
return m.startTurn(sent, input, input)
}
if _, ok := m.ctrl.RunSkill(input); ok {
fields := strings.Fields(input)
name := strings.TrimPrefix(fields[0], "/")
for _, sk := range m.ctrl.Skills() {
if sk.Name == name && sk.RunAs == skill.RunSubagent && len(fields) == 1 {
m.echoLocalCommand(input)
m.notice("usage: /" + name + " <task>")
return nil
}
}
return m.startControllerTurn(input, input, func() { m.ctrl.SubmitDisplay(input, input) })
}
m.notice(fmt.Sprintf("%s: %s", i18n.M.SlashUnknown, cmd))
}
return nil
}
func (m *chatTUI) runGoalSubcommand(input string) tea.Cmd {
cmd, ok := control.ParseGoalCommand(input)
if !ok {
m.echoLocalCommand(input)
m.notice(i18n.M.GoalEmpty)
return nil
}
switch cmd.Action {
case control.GoalCommandSet:
m.planMode = false
m.ctrl.SetPlanMode(false)
m.ctrl.SetGoalWithResearchMode(cmd.Text, cmd.ResearchMode)
m.ctrl.GoalStrict(cmd.Strict)
m.notice(fmt.Sprintf(i18n.M.GoalSetFmt, control.ShortGoalForNotice(cmd.Text)))
return m.startTurn("Start pursuing the active goal now.", input, input)
case control.GoalCommandClear:
m.echoLocalCommand(input)
m.ctrl.ClearGoal()
m.notice(i18n.M.GoalCleared)
default:
m.echoLocalCommand(input)
goal := m.ctrl.Goal()
if strings.TrimSpace(goal) == "" {
m.notice(i18n.M.GoalEmpty)
} else {
m.notice(fmt.Sprintf(i18n.M.GoalCurrentFmt, goal))
}
}
return nil
}
// runCopyCommand copies the Nth-latest assistant message from the current turn
// (after the last user message) to the clipboard.
//
// - "/copy" — shows a numbered list of assistant messages to choose from.
// - "/copy N" — copies the Nth message directly (1 = most recent).
//
// Counting does not cross user message boundaries.
func (m *chatTUI) runCopyCommand(input string) tea.Cmd {
m.echoLocalCommand(input)
// "/copy N" copies the Nth-newest assistant message directly (1 = most
// recent), matching the picker's newest-first ordering. A bare "/copy"
// (or a non-numeric argument) opens the interactive picker instead.
arg := strings.TrimSpace(strings.TrimPrefix(input, "/copy"))
if n, err := strconv.Atoi(arg); err == nil && n > 0 {
msgs := m.ctrl.History()
parts := copyAssistantParts(msgs)
if len(parts) == 0 {
m.notice(i18n.M.SlashCopyEmpty)
return nil
}
// copyAssistantParts is oldest-first; index 0 of the reversed slice
// is the most recent, so "/copy 1" = parts[len-1].
idx := len(parts) - n
if idx < 0 || idx >= len(parts) {
m.notice(i18n.M.SlashCopyEmpty)
return nil
}
m.notice(i18n.M.SlashCopyDone)
return copyToClipboard(parts[idx])
}
m.openCopyPicker()
return nil
}
// firstLine returns the first non-empty line of s, truncated to 80 runes.
func firstLine(s string) string {
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); t != "" {
runes := []rune(t)
if len(runes) > 80 {
return string(runes[:77]) + "..."
}
return t
}
}
return "..."
}
// copyAssistantParts returns the Content of assistant messages after the last
// user message in msgs, skipping empty strings and model placeholders ("…", "...").
// The result is chronological (oldest first).
func copyAssistantParts(msgs []provider.Message) []string {
lastUserIdx := -1
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == provider.RoleUser {
lastUserIdx = i
break
}
}
start := lastUserIdx + 1
if lastUserIdx < 0 {
start = 0
}
var parts []string
for i := start; i < len(msgs); i++ {
if msgs[i].Role != provider.RoleAssistant {
continue
}
c := strings.TrimSpace(msgs[i].Content)
if c == "" || c == "..." || c == "…" {
continue
}
parts = append(parts, c)
}
return parts
}
// runExportCommand exports the entire session as a markdown file, excluding
// system messages, reasoning/thinking content, and tool calls/results.
func (m *chatTUI) runExportCommand(input string) {
m.echoLocalCommand(input)
msgs := m.ctrl.History()
if len(msgs) == 0 {
m.notice(i18n.M.SlashExportEmpty)
return
}
var b strings.Builder
b.WriteString("# reasonix session\n\n")
lastRole := provider.Role("")
exportedMessages := 0
for _, msg := range msgs {
switch msg.Role {
case provider.RoleUser:
// Skip internal steer messages.
if _, isSteer := agent.SteerText(msg.Content); isSteer {
continue
}
content := exportUserContent(msg.Content)
if content == "" {
continue
}
if lastRole != provider.RoleUser {
b.WriteString("## User\n\n")
}
b.WriteString(content)
b.WriteString("\n\n")
exportedMessages++
lastRole = provider.RoleUser
case provider.RoleAssistant:
content := strings.TrimSpace(msg.Content)
if content == "" {
continue
}
if lastRole != provider.RoleAssistant {
b.WriteString("## Assistant\n\n")
}
b.WriteString(content)
b.WriteString("\n\n")
exportedMessages++
lastRole = provider.RoleAssistant
}
}
if exportedMessages == 0 {
m.notice(i18n.M.SlashExportEmpty)
return
}
// Choose a filename. If the workspace has a root, save there; otherwise
// the current directory. Use a timestamp-based name.
dir := "."
if m.ctrl != nil {
if wr := m.ctrl.WorkspaceRoot(); wr != "" {
dir = wr
}
}
ts := time.Now().Format("20060102-150405")
filename := fmt.Sprintf("session-%s.md", ts)
path := filepath.Join(dir, filename)
if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
m.notice(fmt.Sprintf("%s: %v", i18n.M.SlashUnknown, err))
return
}
m.notice(fmt.Sprintf(i18n.M.SlashExportDoneFmt, path))
}
func exportUserContent(content string) string {
content = control.StripComposePrefixes(content)
content = control.StripReferencedContextPrefix(content)
return strings.TrimSpace(content)
}
func (m *chatTUI) echoLocalCommand(input string) {
input = strings.TrimSpace(input)
if input == "" {
return
}
m.commitLine(dim(" " + input))
}
// commandNames renders the custom command list for /help, "" when there are none.
func (m *chatTUI) commandNames() string {
names := make([]string, 0, len(m.commands))
for _, c := range m.commands {
if !c.Hidden {
names = append(names, "/"+c.Name)
}
}
return strings.Join(names, " · ")
}
// showSandboxStatus displays the current sandbox configuration and whether
// the OS sandbox backend is available. It reads from the stored config so
// the user can inspect sandbox state without leaving the TUI (closes #3316).
func (m *chatTUI) showSandboxStatus() {
if m.cfg == nil {
m.notice("sandbox: config not loaded")
return
}
bash := m.cfg.BashMode()
network := m.cfg.Sandbox.Network
available := sandbox.Available()
roots := m.cfg.WriteRoots()
var b strings.Builder
b.WriteString("sandbox\n")
b.WriteString(" phase 0 file-writer confinement\n")
if len(roots) > 0 {
fmt.Fprintf(&b, " write_roots %s\n", strings.Join(roots, ", "))
}
if m.cfg.Sandbox.WorkspaceRoot != "" {
fmt.Fprintf(&b, " workspace_root %s\n", m.cfg.Sandbox.WorkspaceRoot)
}
if len(m.cfg.Sandbox.AllowWrite) > 0 {
fmt.Fprintf(&b, " allow_write %s\n", strings.Join(m.cfg.Sandbox.AllowWrite, ", "))
}
b.WriteString(" phase 1 OS bash sandbox\n")
fmt.Fprintf(&b, " bash %s", bash)
if bash == "enforce" && !available {
b.WriteString(" (unavailable: no OS sandbox on this host; bash execution is refused. " + sandbox.UnavailableRemediation() + ")")
}
b.WriteString("\n")
fmt.Fprintf(&b, " network %v\n", network)
m.notice(b.String())
}
// runMCPSubcommand handles "/mcp" (status), "/mcp add …" (connect a server live
// and persist it), and "/mcp remove <name>" (disconnect + drop from config). Add
// connects synchronously — like /compact, an explicit command may briefly block
// the UI while the handshake runs.
func (m *chatTUI) runMCPSubcommand(input string) {
args := tokenizeArgs(input) // args[0] == "/mcp"
if len(args) < 2 {
m.openMCPManager("")
return
}
switch args[1] {
case "list", "ls":
// The completion menu offers "list"; treat it as the status view (same as
// the legacy /mcp output) rather than an unknown subcommand.
m.showMCPStatus()
case "show":
if len(args) < 3 {
m.notice("usage: /mcp show <name>")
return
}
m.openMCPManager(args[2])
case "tools":
if len(args) < 3 {
m.notice("usage: /mcp tools <name>")
return
}
m.openMCPManager(args[2])
if m.mcp != nil {
m.mcp.stage = mcpStageTools
}
case "add":
entry, err := parseMCPAdd(args[2:])
if err != nil {
m.notice(err.Error())
return
}
n, err := m.ctrl.AddMCPServer(entry)
if err != nil {
m.notice("mcp add: " + err.Error())
return
}
m.notice(fmt.Sprintf("connected %s — %d tools, saved to config (available next message)", entry.Name, n))
case "connect":
if len(args) < 3 {
m.notice("usage: /mcp connect <name>")
return
}
n, err := m.ctrl.ConnectConfiguredMCPServer(args[2])
if err != nil {
m.notice("mcp connect: " + err.Error())
return
}
m.host = m.ctrl.Host()
m.notice(fmt.Sprintf("connected %s — %d tools (available next message)", args[2], n))
case "remove", "rm":
if len(args) < 3 {
m.notice("usage: /mcp remove <name>")
return
}
name := args[2]
disconnected, err := m.ctrl.RemoveMCPServer(name)
if err != nil {
m.notice("mcp remove: " + err.Error())
return
}
if disconnected {
m.notice("disconnected " + name + " and removed it from config")
} else {
m.notice("removed " + name + " from config")
}
case "import":
m.openMCPImportPicker()
default:
m.notice("unknown /mcp subcommand " + args[1] + " — try: /mcp, /mcp list, /mcp show, /mcp add, /mcp connect, /mcp import, /mcp remove")
}
}
// showMCPStatus queues the connected MCP servers, their counts, and the prompt
// commands / resource refs they expose — the discovery surface for /mcp.
func (m *chatTUI) showMCPStatus() {
if m.host == nil || (len(m.host.Servers()) == 0 && len(m.host.Failures()) == 0) {
m.notice(i18n.M.SlashMCPNone)
return
}
m.commitLine(renderMCPStatus(m.width, m.host.Servers(), m.host.Prompts(), m.host.Resources(), m.host.Failures()))
}
// notice queues a dim informational line to scrollback.
func (m *chatTUI) notice(note string) {
m.commitLine(dim(" · " + note))
}
// resolveRefs resolves a line's @references off the event loop via the
// controller, delivering a refsResolvedMsg with the tagged context block.
func (m *chatTUI) resolveRefs(sent, display, restore string) tea.Cmd {
return func() tea.Msg {
block, errs := m.ctrl.ResolveRefs(context.Background(), sent)
return refsResolvedMsg{sent: sent, display: display, restore: restore, block: block, errs: errs}
}
}
// runMCPPrompt resolves a /mcp__server__prompt command off the event loop via
// the controller, delivering a promptResolvedMsg with the rendered prompt.
func (m *chatTUI) runMCPPrompt(input string) tea.Cmd {
return func() tea.Msg {
sent, found, err := m.ctrl.MCPPrompt(context.Background(), input)
if !found {
name := strings.TrimPrefix(strings.Fields(input)[0], "/")
return promptResolvedMsg{display: input, err: fmt.Errorf("%s: /%s", i18n.M.SlashUnknown, name)}
}
return promptResolvedMsg{display: input, sent: sent, err: err}
}
}
// replaySectionsFor turns a loaded session into scrollback blocks: user bubbles
// and assistant markdown. Tool messages are dropped — needed in session state
// but noise in the visible transcript on resume.
func replaySectionsFor(history []provider.Message, width int, renderer *mdRenderer) []string {
var out []string
for _, m := range history {
switch m.Role {
case provider.RoleUser:
// Steer messages are surfaced as a notice line, not a user bubble.
if steerText, isSteer := agent.SteerText(m.Content); isSteer {
out = append(out, fmt.Sprintf(" ↪ %s\n\n", steerText))
continue
}
content := control.StripComposePrefixes(m.Content)
out = append(out, renderUserBubble(content, width, false)+"\n\n")
case provider.RoleAssistant:
body := strings.TrimSpace(m.Content)
if body == "" {
continue
}
rendered := renderer.Render(body)
if rendered == "" {
rendered = body
}
out = append(out, rendered+"\n")
}
}
return out
}
// renderTUIBanner is the title + tip + optional missing-key warning printed once
// at the top of the session.
func renderTUIBanner(label, missing string, width int) string {
var b strings.Builder
b.WriteString(accent("◆") + " " + bold("reasonix") + " " + dim("· "+label) + "\n")
b.WriteString(dim(" "+i18n.M.ChatTip) + "\n")
if missing != "" {
b.WriteString(wrapForViewport(" ! "+missing, width, activeCLITheme.warn) + "\n")
}
return b.String()
}
// wrapForViewport hard-wraps text to fit width columns and colours every line.
func wrapForViewport(text string, width int, fg cliColor) string {
if width <= 0 {
width = 80
}
return themeStyle(fg).Width(width).Render(text)
}
// renderUserBubble renders the just-submitted prompt as a transcript line. Keep
// it visually lighter than the real bottom composer so a fresh session does not
// look like it has a second input box in the transcript.
func renderUserBubble(line string, width int, planMode bool) string {
line = displayLineForImageRefs(line)
prefix := " "
if planMode {
prefix = " [plan] "
}
if !colorEnabled {
return "│ " + prefix + line
}
return " " + accent(prefix+line)
}
var cliImageRefRe = regexp.MustCompile(`(?:^|\s)@\.reasonix/attachments/clipboard-\d{8}-\d{6}\.\d+(?:-(?:\d{6}|[a-f0-9]{8}))?\.(?:png|jpg|jpeg|gif|webp)`)
func displayLineForImageRefs(line string) string {
idx := 0
out := cliImageRefRe.ReplaceAllStringFunc(line, func(_ string) string {
idx++
return " [image" + strconv.Itoa(idx) + "]"
})
return strings.TrimSpace(out)
}
// eventSink is the event.Sink the agent emits to in TUI mode. Each event
// becomes an agentEventMsg. The channel is generously buffered so streaming
// bursts don't back-pressure the agent goroutine.
type eventSink struct {
ch chan<- event.Event
}
func (s *eventSink) Emit(e event.Event) { s.ch <- e }