4202 lines
140 KiB
Go
4202 lines
140 KiB
Go
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 }
|