项目文件夹

文件
2026-07-13 13:00:08 +08:00

4202 行
140 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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 }