Files
zero/internal/tui/model.go
Vasanth T 378d538e24 perf(agent): execution profiles with one-shot posture escalation (PR10b+PR10c) (#740)
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c)

* fix(execprofile): harden profiles against review findings

- fast escalates on critical-risk mutations, not high: the sandbox marks
  every shell command high before command analysis, so a high trigger
  spent the one-shot on the first go test of any task
- escalation can now restore the provider-default effort a profile
  displaced (PostureEscalation.RestoreDefaultEffort; a plain empty
  target means leave untouched)
- TUI /turns while a profile is active pins the budget: disarms the
  escalation turn target (mirroring exec's explicit --max-turns rule)
  and profile reverts use touched bits instead of value equality, so
  explicit choices survive even when they coincide with profile values
- spec-draft runs get the same escalation policy as the main run
  instead of the displaced budget with no safety net
- bench validates and canonicalizes --exec-profile before spawning:
  an unknown name previously produced an exit-0 report full of
  millisecond usage-error walls
- turn summary prints exec-profile alongside mode; zero exec rejects
  an inline-empty --exec-profile=; balanced trace label documented and
  pinned as deliberate attribution

* fix(execprofile): explicit effort choices disarm the escalation effort restore

An explicit effort set AFTER profile selection could be cleared by a
mid-run escalation (the effort analog of the /turns pin, introduced by
RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on
a policy clone, and the spec-draft path only arms it when no explicit
--spec-reasoning-effort governs the draft.

* fix(execprofile): address review findings on the profile surfaces

- --use-spec projects away a profile's self-correct knob instead of
  silently arming an option the draft runner ignores (the explicit
  --self-correct --use-spec combination is already a parse-time error)
- RunTurnBench canonicalizes and validates ExecProfile at the library
  boundary, so a direct caller's casing/whitespace cannot fork report
  labels and an unknown name fails before anything runs
- reverting a profile whose displaced budget was zero clears
  ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have
  kept the removed profile's budget)
- the TUI effort-disarm test fails instead of skipping when the catalog
  stops filling low effort

* fix(tui): reconcile an active profile's effort fill on model switch

The fill is per-model, not a session preference: fast selected on a
model without low fills nothing, and switching to a model that supports
it should behave like selecting the profile there; the reverse switch
must not keep sending a level the destination does not support. Both
switch paths (the /model command and the cross-provider picker) now
re-derive the fill, leaving explicitly touched efforts alone and
keeping the escalation's effort restore in step. Also: the revert-env
test asserts key removal via LookupEnv, and applyProfileTurnBudget's
pinned-budget parameter is named and documented as covering mode
presets, not just the flag.

* fix(tui): void profile effort bookkeeping when the model-switch drop erases it

The /model path's pre-existing unsupported-effort drop ran before the
profile reconciliation and left contradictory state behind: a dropped
touched override kept blocking the profile from governing, and a
dropped profile fill left a stale applied value with the effort restore
still armed. The drop itself is load-bearing (the TUI run path forwards
the effort to the provider unfiltered), so instead of preserving a
known-unsupported level, the voided choice now resets the profile's
effort bookkeeping and the reconcile re-derives from scratch. The whole
sequence lives in one ring-parameterized helper driven by the switch
target's authoritative ring, unit-tested against synthetic rings for
both the void-then-refill arc and the supported-override survival.

* fix(tui): only drop an explicit effort when the target ring is authoritative

A nil ring meant both a catalog model with no effort controls and a
live-discovered custom model the catalog cannot vouch for. The drop now
requires an authoritative ring (a resolved catalog entry, whose ring may
legitimately be empty); an unknown ring preserves an explicit preference,
matching the cross-provider picker's behavior, while the profile's own
fill still applies only where support is known.
2026-07-19 00:53:17 +05:30

5374 lines
207 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"unicode"
"unicode/utf8"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/lsp"
internalmcp "github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/notify"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers/providerio"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/usercommands"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const tuiToolOutputLimit = 240
const defaultResponseStyle = "concise"
const chatWheelScrollLines = 5
const ctrlCExitConfirmDuration = 3 * time.Second
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
// window, same amber footer treatment) so the two "stop it" keybinds feel
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
// suggestion overlay that had already closed — silently threw away an
// in-progress run with no chance to reconsider.
const escCancelConfirmDuration = 3 * time.Second
const escCancelConfirmText = "Press Esc again to cancel"
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
// tick size (chatWheelScrollLines) would jump too far per step for that.
const dragEdgeScrollInterval = 70 * time.Millisecond
const dragEdgeScrollStep = 1
type model struct {
ctx context.Context
cwd string
appVersion string
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
userConfigPath string
doctorUserConfigPath string
projectConfigPath string
gitBranch string
providerName string
modelName string
modelCatalog modelregistry.Registry
providerProfile config.ProviderProfile
savedProviders []config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
registry *tools.Registry
// lspManager is created once per session and reused across prompts so gopls (and
// other language servers) stay warm — a fresh manager per run would cold-start
// the server on the first edit of every turn. Nil when cwd is unknown; runs then
// fall back to a per-run manager. Torn down in quit().
lspManager *lsp.Manager
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
mcpViewStateCache MCPViewState
mcpViewStateReady bool
mcpCommandSeq int
mcpCommandCancel context.CancelFunc
sandboxSetupSeq int
sandboxSetupInFlight bool
doctorCommandSeq int
doctorInFlight bool
doctorFrame int
activeSession sessions.Metadata
sessionEvents []sessions.Event
// titledSessions records session ids for which a model-generated title has
// already been attempted this process, so a finished turn re-fires the title
// generator at most once per session (even before its async result lands).
// Lazily initialized.
titledSessions map[string]bool
// retitle* drive the sequential /retitle backfill: queued session ids still
// awaiting a title, whether a backfill is running, and its progress counters.
retitleQueue []string
retitleActive bool
retitleTotal int
retitleDone int
retitleOK int
usageTracker *usage.Tracker
sessionCompactor SessionCompactor
prService *PrService
prState PrState
prWatcherStop func()
runtimeMessageSink func(tea.Msg)
prepareRunCompletionWarning func()
runCompletionWarning func() string
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
// Active execution profile (set by /profile; applies to the NEXT run).
// The displaced/applied pairs let a switch or /profile balanced restore
// exactly what the profile replaced while leaving later manual overrides
// (/turns, Ctrl+T, /selfcorrect) alone: each knob is only reverted when it
// still holds the value the profile applied.
execProfileName string
execProfileDisplacedMaxTurns int
execProfileAppliedMaxTurns int
execProfileAppliedEffort modelregistry.ReasoningEffort
execProfileArmedSelfCorrect bool
// The touched bits record explicit user choices made while a profile is
// active (/turns, /effort, Ctrl+T, /selfcorrect); a touched knob is never
// reverted, even when its value coincides with what the profile applied.
execProfileTurnsTouched bool
execProfileEffortTouched bool
execProfileSelfCorrectTouched bool
responseStyle string
keyBindings keyBindings
themeMode themeMode // palette preference: auto (default), dark, light
hasDarkBg bool // last terminal background-detection result (auto mode)
userAgent string
compactRequests int
compactInFlight bool
compactFrame int
lastCompactResult *CompactResult
lastCompactError string
unpricedRequests int
unpricedTokens int
lastUsage usage.Normalized
lastUsageSeen bool
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
// /context can show a rolling average turn latency (the "is it slow?" signal).
// Reset by /new.
turnLatencySum time.Duration
turnLatencyCount int
turnTTFTSum time.Duration
turnTTFTCount int
transcript []transcriptRow
transcriptDetailed bool
helpOverlay bool // the `?` keyboard-shortcut overlay is open
// leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord.
leaderHelpOverlay bool
// leaderPending is true after Ctrl+X until a second key, Esc, or timeout
// resolves the chord (see leader.go). leaderSeq invalidates a stale tick.
leaderPending bool
leaderSeq int
transcriptBodyHeights *transcriptBodyHeightCache
input textinput.Model
composer composerState
composerActive bool
composerCursorVisible bool
composerPastePreviews []composerPastePreview
composerSelection composerSelectionState
dictation dictationController
sttKeyPrompt *sttKeyPromptState
// plan holds the sticky plan panel state (steps, expansion, timings)
// synced from the update_plan tool. See plan_panel.go.
plan planPanelState
specialists specialistTracker
stepWork map[string][]planStepWork // file mutations + commands captured per in_progress plan step, for the clickable step detail
stepNarration map[string][]string // the agent's own prose narration captured per in_progress plan step, for the step detail's explanation
planDetailOpen bool // a plan-step detail card is currently shown (click-to-toggle)
planDetailStep int // which step index the shown detail card is for
planDetailGen int // bumped each run; an in-flight explanation result from an older gen is dropped
stepExplanation map[string]string // model-written step write-ups, keyed by planStepExplanationKey, cached so re-clicking is instant
subchat subchatState
altScreen bool
setup setupState
setupSave func(SetupSelection) (SetupResult, error)
// spinner animates the running-tool glyph in card heads. Its tick is started
// with each run and stops itself once pending clears (the TickMsg is simply
// not forwarded), so an idle UI schedules no timers.
spinner spinner.Model
// spinnerPhase advances once per spinner tick while a run is in flight and is
// the shared animation clock for the cosine ripple on the working status line
// (ripple.go). Reusing the spinner's existing tick keeps a single ~80ms timer
// driving both the braille glyph and the colour wave — no second ticker.
spinnerPhase int
// spinnerTicking tracks whether the spinner's self-scheduling tick loop is
// currently alive, so a kick (ensureSpinnerTick) never double-issues the tick
// when the loop is already running. Set true whenever a Tick cmd is returned
// from the TickMsg handler / beginRun, cleared when the handler stops the loop.
spinnerTicking bool
pending bool
// turnStartedAt is when the in-flight run began; the working status line
// renders the live elapsed time from it so a long or stalled turn never looks
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
turnStartedAt time.Time
// lastCharTime tracks when the last non-Enter key was received, for paste detection.
lastCharTime time.Time
// lastKeyTime tracks every keypress timestamp for burst calculation.
lastKeyTime time.Time
// burstCount counts consecutive keypresses within 100ms (paste mode).
burstCount int
// terminalFocused tracks whether the terminal window currently has focus, per
// tea.FocusMsg/tea.BlurMsg. Defaults to true since many terminals/multiplexers
// never send focus events at all, and defaulting to "unfocused" would wrongly
// hide the cursor for those users.
terminalFocused bool
queuedMessage string
// loops holds the session's active /loop definitions (see loop.go). activeLoopID
// tags the in-flight run when it is a loop iteration (empty = a user turn), so the
// completion seam knows whether to advance a loop. loopSeq invalidates a stale
// pending poll tick when loops are stopped; loopTicking guards against scheduling
// a second poll ticker. loopLeavePrompt arms a one-shot confirm before /clear or
// /quit while loops are active (see handleSubmit).
loops []*loopState
activeLoopID string
loopSeq int
loopCounter int
loopTicking bool
loopLeavePrompt commandKind
exiting bool
runCancel context.CancelFunc
runID int
activeRunID int
// flushRunIDs holds the ids of runs cancelled while still in flight, mapped
// to the session they were recording into AT CANCEL TIME. Each cancelled
// agent goroutine keeps running to completion and returns its accumulated
// sessionEvents (including EventSessionCheckpoint payloads captured before
// each mutating tool) in a final agentResponseMsg. activeRunID is already
// zeroed by then, so without this the message would be dropped and the
// checkpoint blobs already written to disk would be orphaned (breaking
// /rewind). It is a MAP (not a single id) so a second cancel before the
// first goroutine returns doesn't overwrite/lose the first run's pending
// flush; the recorded session id keeps the late flush out of whatever
// session is active by then (e.g. after /resume), which would otherwise
// contaminate the new session's log with the old run's events. The
// agentResponseMsg handler persists each such run's session events (only) so
// the checkpoints stay referenced, then removes the id.
flushRunIDs map[int]string
liveUsageCounts map[int]int
// swarmSessionMap maps a swarm task id to its member's durable child session
// id (carried up by swarm_collect's Meta), so the AGENTS sidebar rows can drill
// into a member's conversation. Persists across turns; only completed members
// have an entry.
swarmSessionMap map[string]string
pendingPermission *pendingPermissionPrompt
pendingAskUser *pendingAskUserPrompt
pendingSpecReview *pendingSpecReviewPrompt
width int
height int
// hidePinnedPlan suppresses the pinned plan panel above the composer. Set on
// the chat-column model copy in the two-column layout, where the plan is
// surfaced in the context sidebar instead so it isn't shown twice.
hidePinnedPlan bool
// sidebarHidden is the user's Ctrl+B preference to collapse the right context
// sidebar; when set, the chat reflows to full width. Distinct from the
// availability conditions in sidebarAvailable (geometry / mode / overlays).
sidebarHidden bool
// selectedFile is the touched file selected by clicking its FILES sidebar
// row: its edit cards tint in the chat (rowTouchesSelectedFile) and a second
// click opens the drill-in file view. "" when nothing is selected; Esc clears.
selectedFile string
// fileView is the drill-in view for a touched file (file_view.go): while
// active the chat column's body shows the file's diff/content instead of the
// transcript, mirroring the subchat drill-in.
fileView fileViewState
// Git-sweep state (files_git_sweep.go): the startup snapshot of already-dirty
// paths (nil until Init's sweep answers), the newly dirty files discovered by
// live sweeps (bash/subagent mutations that carry no changedFiles), the
// single-flight guard, and the "not a git repo / no git" latch.
gitFileBaseline map[string]bool
gitTouched []gitSweepFile
gitSweepInFlight bool
gitSweepUnavailable bool
// swarmDoneAt records when each swarm member was first seen finished (done/
// failed) in a swarm_status report, so the sidebar can linger it briefly with a
// fading ✓ before dropping it (a smooth exit, not an abrupt pop). Stamped in the
// spinner tick; keyed by member id. Always non-nil (initialised in newModel).
swarmDoneAt map[string]time.Time
now func() time.Time
chatScrollOffset int
// chatBodyLines is the live body's line count at the last update; used to pin
// the viewport (hold the read position) when content streams in while the user
// has scrolled up. 0 means "at the bottom / not pinned".
chatBodyLines int
// Flush-frontier state (see flush.go). In inline mode, transcript[:flushed]
// is already in native scrollback; in alt-screen mode this frontier stays
// idle so history cannot reveal prior shell output.
// flushedAny gates the first turn-separator blank line; flushQueue/
// printInFlight serialize ordered scrollback prints; headerPrinted records
// the one-time inline title-bar print at startup.
flushed int
flushedAny bool
flushQueue []string
printInFlight bool
headerPrinted bool
// Composer input history (shell-style ↑/↓ recall of submitted inputs).
// lastPrompt is the verbatim text of the most recent submitted prompt, so
// /retry can resend it and /edit can recall it into the composer.
lastPrompt string
// lastImages/lastImageLabels/lastDocuments remember the attachments consumed
// by the most recent submitted prompt. launchPrompt clears the pending queues
// once a turn is sent, so /retry re-stages these to reproduce the exact same
// request — otherwise a vision/PDF-backed prompt would silently retry as
// text-only and answer a different task. They share the underlying image bytes
// with the sent turn (never mutated in place), so no deep copy is needed.
lastImages []zeroruntime.ImageBlock
lastImageLabels []string
lastDocuments []pendingDocument
// historyIdx == len(inputHistory) means "not navigating"; historyDraft
// preserves whatever was typed before recall started.
inputHistory []string
historyIdx int
historyDraft string
// streamingText is the live assistant text for the current segment, accumulated
// as []byte so each delta is an O(1) amortized append instead of the O(n²) that
// string += delta incurs across a long generation. Read via streamingTextString().
// A []byte (not strings.Builder) because the model is copied by value on every
// Update, which would trip strings.Builder's copy check.
streamingText []byte
streamingReasoning string // live provider reasoning for the current segment
streamingReasoningExpanded bool
// turnStreamedRunes accumulates every reasoning+answer rune streamed in the
// current turn so the working line can show a live, monotonic token estimate.
// It is NOT reset at segment boundaries (where streamingText/Reasoning clear),
// only at turn start (beginRun), so the count climbs across a multi-tool turn
// instead of snapping back to zero after each tool call.
turnStreamedRunes int
// Streaming-text fade state. lineAges is keyed to LOGICAL lines of
// streamingText (one entry per \n in the accumulated text), and
// lastStreamActivity is the time of the most recent delta (used for
// the in-progress last line — the one the model is currently typing
// into). fadeActive is true from the first agentTextMsg of a run
// until the matching agentResponseMsg, and gates both the per-line
// fade application in interimBlock and the streamingFadeTick
// re-render loop. The state is reset on stream end, on cancel, and
// on terminal resize (where the visual line count may change and
// per-line ages are no longer meaningful).
lineAges []time.Time
lastStreamActivity time.Time
fadeActive bool
fadeDisabled bool // streaming fade off (ZERO_NO_FADE / SSH / tmux / low-color / reduced motion)
reducedMotion bool // ZERO_REDUCED_MOTION / no-TTY: static spinner glyph, no fade
// In-progress tool call whose arguments are streaming (a file being written),
// shown live by streamingToolCallView so a long write/edit isn't a frozen
// spinner. Cleared when the call completes (next text/turn) — see updateModel.
// streamCallDecoder decodes the streamed args incrementally (O(1) per delta).
streamCallID string
streamCallName string
streamCallDecoder *streamingDecoder
// Slash-command autocomplete (purely additive UI state). suggestions is the
// live match list for the current "/token"; suggestionIdx is the highlighted
// row. commandPaletteOpen keeps a zero-match command search active so invalid
// query text stays in the palette instead of leaking into the composer.
// filePaletteOpen does the same for a trailing "@token" file search.
suggestions []commandSuggestion
suggestionIdx int
commandPaletteOpen bool
filePaletteOpen bool
// suggestionsAreFiles is true when the overlay is showing "@file" matches
// rather than "/command" matches, so completion inserts a path token instead
// of replacing the whole input.
suggestionsAreFiles bool
// suggestionsAreSpecialists is true when the overlay is showing leading
// "@specialist" matches; completion inserts "@name " and the submit path
// expands the mention into a Task-delegation directive (launchPrompt).
suggestionsAreSpecialists bool
lastMouseSelection mouseSelectionTarget
mouseCapture bool
// mouseReleased, when true, forces terminal mouse capture OFF so the user can
// drag-select and copy text natively (Ctrl+E toggles it). App mouse features
// (clickable suggestions, right-click paste, transcript select) pause while on.
mouseReleased bool
transcriptSelection transcriptSelectionState
// hover identifies the single clickable row (if any) currently under the
// mouse cursor with no button pressed, so it renders in a distinct style —
// the visual cue that it's clickable. Requires AllMotion mouse reporting
// (see wantsMouseCapture) since idle cursor movement carries no button.
hover hoverTarget
copyStatus string
copyStatusSeq int
exitConfirmActive bool
exitConfirmSeq int
// cancelConfirmActive/cancelConfirmSeq mirror exitConfirmActive/exitConfirmSeq
// (same seq-gated tea.Tick pattern) but guard a DIFFERENT action: Esc
// cancelling a running turn. The two are deliberately separate state (not a
// shared flag) since they're different actions with different consequences
// (quit the app vs. cancel the current run) that are armed by different
// keys — Ctrl+C and Esc respectively.
cancelConfirmActive bool
cancelConfirmSeq int
// edgeScrollDelta drives the smooth-glide auto-scroll while a drag holds past
// the transcript's top/bottom edge: 0 when idle, else the signed per-tick step
// (matches transcriptEdgeScrollDelta's sign convention). A self-scheduling
// tea.Tick chain (see dragEdgeScrollTickCmd) keeps stepping it at a fixed small
// increment regardless of whether new raw mouse-motion events arrive — a
// terminal only reports motion on actual cursor movement, so without a timer
// the scroll would stop dead the instant the physical mouse holds still, even
// while parked past the edge. edgeScrollSeq invalidates a stale in-flight tick
// (mirroring exitConfirmSeq/copyStatusSeq) whenever the drag moves back into
// the body, releases, or the chain is otherwise stopped.
edgeScrollDelta int
edgeScrollSeq int
// edgeScrollMouseX is the column the tick chain keeps extending the selection
// at — captured from the triggering drag since a timer tick carries no mouse
// position of its own.
edgeScrollMouseX int
// picker, when non-nil, is an open interactive selector overlay (/model,
// /effort with no argument). It captures ↑/↓/Enter/Esc and applies
// the chosen value through the existing command handlers.
picker *commandPicker
providerWizard *providerWizardState
mcpManager *mcpManagerState
mcpAddWizard *mcpAddWizardState
favoriteModels map[string]bool
// recentModels is the automatic history of provider+model switches, newest
// first, capped to config.MaxRecentModels. Unlike favoriteModels (manual
// pins), this is maintained by recordRecentModel on every successful
// switch and persisted via config.SetRecentModels.
recentModels []config.RecentModelEntry
recapsEnabled bool // post-turn "※ recap:" line (config: recaps on|off)
recappedRuns map[int]bool // per-run guard so a recap fires at most once per turn
modelPickerLoading bool
modelPickerLoadingProviderID string
modelPickerLoadError string
// modelPickerLiveByProvider holds live-discovered models per provider (keyed by
// catalog descriptor ID), so /model shows each provider's real current models —
// the same list the provider-setup wizard discovers — not the static catalog.
modelPickerLiveByProvider map[string][]providermodeldiscovery.Model
// ollamaContextWindowByModel holds context-window sizes fetched from a local
// Ollama daemon's native /api/show endpoint (keyed by model name), for
// custom/local models that have no curated-catalog entry and whose
// OpenAI-compatible /v1/models listing doesn't carry that metadata at all —
// see modelContextWindow.
ollamaContextWindowByModel map[string]int
// pendingImages holds image attachments staged by /image for the next user
// turn; pendingImageLabels are their display names (base(path)) for the chip
// row. Both are cleared after a prompt is submitted (or /image clear). nil =
// no attachments = today's text-only behavior exactly.
pendingImages []zeroruntime.ImageBlock
pendingImageLabels []string
// pendingDocuments holds PDF text layers staged by /image for the next user
// turn; the text is prepended to the prompt as a preamble at submit time and
// the slice is cleared (or by /image clear). nil = no documents staged.
pendingDocuments []pendingDocument
// captureRunImages, when set, is invoked with the images a run is launched
// with. Nil in production; used by tests to assert image threading without a
// real provider round-trip.
captureRunImages func([]zeroruntime.ImageBlock)
}
type agentTextMsg struct {
runID int
delta string
}
type exitConfirmExpiredMsg struct {
seq int
}
// cancelConfirmExpiredMsg mirrors exitConfirmExpiredMsg for the Esc
// cancel-a-run confirmation (see cancelConfirmActive).
type cancelConfirmExpiredMsg struct {
seq int
}
// dragEdgeScrollTickMsg advances the smooth-glide auto-scroll one step (see
// edgeScrollDelta). seq must match m.edgeScrollSeq or the tick is stale (the
// drag moved back into the body, released, or was otherwise stopped since this
// tick was scheduled) and is silently dropped — the self-scheduling chain simply
// doesn't reschedule itself, so it terminates rather than ticking forever.
type dragEdgeScrollTickMsg struct {
seq int
}
// toolCallStreamStartMsg / toolCallStreamDeltaMsg carry a tool call's live
// argument stream from the agent goroutine to the update loop, so a file being
// written renders as it streams (see streamingToolCallView).
type toolCallStreamStartMsg struct {
runID int
id string
name string
}
type toolCallStreamDeltaMsg struct {
runID int
id string
fragment string
}
type agentReasoningMsg struct {
runID int
delta string
}
type agentUsageMsg struct {
runID int
modelID string
usage zeroruntime.Usage
}
type agentResponseMsg struct {
runID int
rows []transcriptRow
usageEvents []zeroruntime.Usage
usageModelID string
sessionEvents []pendingSessionEvent
specReview *pendingSpecReviewPrompt
err error
// Turn metadata for settled rows that do not otherwise carry it.
turnTools int
turnElapsed time.Duration
// ttft is time-to-first-token for the turn (0 when nothing streamed — a
// tool-only or errored turn). Set only on the success path.
ttft time.Duration
}
type agentRowMsg struct {
runID int
row transcriptRow
}
// planUpdateMsg carries a snapshot of plan items from the update_plan tool
// result callback to the live model. The callback runs on the agent goroutine
// and captures model by value, so it cannot mutate m.plan directly — it sends
// this message through the runtimeMessageSink instead.
type planUpdateMsg struct {
runID int
items []tools.PlanItem
}
// planStepExplanationMsg carries the model's fresh, plain-English write-up of a
// clicked plan step back to the live model (the one-shot request runs on a
// goroutine via a tea.Cmd, so it can't mutate m directly). text is the written
// explanation; err is set when the request failed (the card then falls back to
// the local summary). key caches the result so re-clicking the step in the same
// state is instant; stepIndex re-renders the card in place when it's still open.
type planStepExplanationMsg struct {
stepIndex int
key string
gen int // the planDetailGen when the request started; stale gens are ignored
text string
err error
}
// specialistStartMsg carries specialist start info from the OnToolCall
// callback to the live model (same rationale as planUpdateMsg).
type specialistStartMsg struct {
runID int
name string
description string
childSessionID string
}
// specialistCompleteMsg carries specialist completion info from the
// OnToolResult callback to the live model.
type specialistCompleteMsg struct {
runID int
toolCallID string
childSessionID string
status specialistStatus
errorMsg string
}
// swarmSessionsMsg carries swarm task_id -> member session_id pairs (from
// swarm_collect's Meta) so the AGENTS sidebar rows can drill into a member's
// session like a specialist card.
type swarmSessionsMsg struct {
runID int
sessions map[string]string
}
// specialistProgressMsg carries a live tool-call progress update from the
// specialist child process, sent via OnToolProgress → runtimeMessageSink.
type specialistProgressMsg struct {
runID int
toolCallID string
toolName string
detail string
}
type mcpCommandOrigin int
const (
mcpCommandOriginTranscript mcpCommandOrigin = iota
mcpCommandOriginManager
mcpCommandOriginWizard
)
type mcpCommandRequest struct {
id int
origin mcpCommandOrigin
args []string
raw string
managerSelected int
managerQuery string
wizardDisabled bool
}
type mcpCommandResultMsg struct {
request mcpCommandRequest
result MCPCommandResult
}
type doctorCommandResultMsg struct {
id int
text string
}
type sandboxSetupCommandResultMsg struct {
id int
result SandboxSetupCommandResult
}
type prStateMsg struct {
state PrState
}
type prWatcherStartedMsg struct {
stop func()
}
type permissionDecision = agent.PermissionDecisionAction
const (
permissionDecisionAllow permissionDecision = agent.PermissionDecisionAllow
permissionDecisionAllowStrict permissionDecision = agent.PermissionDecisionAllowStrict
permissionDecisionAllowForSession permissionDecision = agent.PermissionDecisionAllowForSession
permissionDecisionAllowPrefix permissionDecision = agent.PermissionDecisionAllowPrefix
permissionDecisionAlwaysAllowPrefix permissionDecision = agent.PermissionDecisionAlwaysAllowPrefix
permissionDecisionDeny permissionDecision = agent.PermissionDecisionDeny
permissionDecisionAlwaysAllow permissionDecision = agent.PermissionDecisionAlwaysAllow
permissionDecisionCancel permissionDecision = agent.PermissionDecisionCancel
)
type permissionRequestMsg struct {
runID int
request agent.PermissionRequest
decide func(agent.PermissionDecision)
}
type pendingPermissionPrompt struct {
request agent.PermissionRequest
decide func(agent.PermissionDecision)
// cursor is the highlighted option index (into permissionOptions): 0 is the
// resting approval choice. Moved by ↑/↓/Tab; confirmed by Enter or a click.
// Hotkeys resolve the matching request-provided option directly.
cursor int
}
// askUserRequestMsg is the TUI-loop equivalent of permissionRequestMsg: the
// agent goroutine sends it (via the runtime sink) and blocks until the model
// hands answers back through the answer callback.
type askUserRequestMsg struct {
runID int
request agent.AskUserRequest
answer func([]string)
}
// pendingAskUserPrompt tracks an in-progress questionnaire rendered in the composer
// region as a row of tabs — one per question plus a trailing Confirm tab. Questions
// are answered in any order (Tab switches); the answer callback is invoked exactly
// once when the user submits on the Confirm tab or dismisses (Esc). active is the
// current tab (0..N-1 = questions, N = Confirm); states holds the per-question
// picker/free-text state and committed answer. See ask_user_prompt.go.
type pendingAskUserPrompt struct {
request agent.AskUserRequest
answer func([]string)
active int
states []askUserAnswerState
}
type pendingSpecReviewPrompt struct {
SpecID string
SpecTitle string
SpecFilePath string
RelativePath string
DraftSessionID string
}
type tuiAgentRunOptions struct {
registry *tools.Registry
permissionMode agent.PermissionMode
systemPrompt string
specDraft bool
}
func newModel(ctx context.Context, options Options) model {
if ctx == nil {
ctx = context.Background()
}
cwd := options.Cwd
if cwd == "" {
if current, err := os.Getwd(); err == nil {
cwd = current
}
}
userConfigDir, _ := config.UserConfigDir()
loadedUserCommands := usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))
registry := options.Registry
if registry == nil {
registry = options.AgentOptions.Registry
}
if registry == nil {
registry = tools.NewRegistry()
}
sessionStore := options.SessionStore
if sessionStore == nil {
sessionStore = sessions.NewStore(sessions.StoreOptions{})
}
sandboxStore := options.SandboxStore
modelCatalog, err := modelregistry.DefaultRegistry()
if err != nil {
panic(err)
}
usageTracker := options.UsageTracker
if usageTracker == nil {
usageTracker = usage.NewTracker(usage.TrackerOptions{Registry: &modelCatalog})
}
prService := options.PrService
if prService == nil {
prService = NewPrService(cwd)
}
doctorUserConfigPath := options.DoctorUserConfigPath
if doctorUserConfigPath == "" {
doctorUserConfigPath = options.UserConfigPath
}
permissionMode := options.PermissionMode
if permissionMode == "" {
permissionMode = options.AgentOptions.PermissionMode
}
if permissionMode == "" {
permissionMode = agent.PermissionModeAuto
}
input := textinput.New()
input.Prompt = "❯ "
input.Placeholder = composerPlaceholder
// Bubble's Ctrl+V binding reads the clipboard itself. Keep it disabled so
// terminal bracketed paste (Paste: true) is the single paste path.
input.KeyMap.Paste.SetEnabled(false)
input.Focus()
runSpinner := spinner.New(spinner.WithSpinner(spinner.MiniDot))
notifier := notify.New(os.Stderr, notify.Config{
Mode: notify.Mode(strings.TrimSpace(options.Notify.Mode)),
FocusMode: notify.FocusMode(strings.TrimSpace(options.Notify.FocusMode)),
})
// Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Delivery failures stay
// silent here: the TUI owns the alt-screen, so writing to stderr would
// corrupt the display.
notify.MaybeAddWebhookSink(notifier, os.Getenv, nil)
notifier.SetFocused(true)
resolvedKeyBindings, keyBindingWarnings := sanitizeKeyBindings(resolveKeyBindings(options.KeyBindings))
m := model{
ctx: ctx,
cwd: cwd,
appVersion: strings.TrimSpace(options.Version),
swarmDoneAt: map[string]time.Time{},
userCommands: loadedUserCommands,
loadSkills: options.LoadSkills,
composerCursorVisible: true,
terminalFocused: true,
userConfigPath: options.UserConfigPath,
doctorUserConfigPath: doctorUserConfigPath,
projectConfigPath: options.ProjectConfigPath,
savedProviders: options.SavedProviders,
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
modelCatalog: modelCatalog,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
recentModels: normalizeRecentModelEntries(options.RecentModels),
recapsEnabled: options.RecapsEnabled,
provider: options.Provider,
newProvider: options.NewProvider,
probeProviderHealth: options.ProbeProviderHealth,
discoverProviderModels: options.DiscoverProviderModels,
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
registry: registry,
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
sandboxSetupCommand: options.SandboxSetupCommand,
agentOptions: options.AgentOptions,
sessionCompactor: options.SessionCompactor,
runtimeMessageSink: options.RuntimeMessageSink,
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
keyBindings: resolvedKeyBindings,
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
userAgent: options.UserAgent,
usageTracker: usageTracker,
transcript: initialTranscript(),
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
prService: prService,
prState: prService.GetState(),
input: input,
spinner: runSpinner,
prepareRunCompletionWarning: options.PrepareRunCompletionWarning,
runCompletionWarning: options.RunCompletionWarning,
now: time.Now,
notifier: notifier,
altScreen: options.AltScreen,
liveUsageCounts: map[int]int{},
swarmSessionMap: map[string]string{},
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
dictation: newDictationController(options),
}
// Apply an explicit theme immediately; auto stays on the dark default until
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
if m.themeMode != themeAuto {
applyTheme(m.themeMode, true)
}
m.reducedMotion = defaultReducedMotion()
// The streaming-text fade (a lime→ink glow on freshly streamed lines) is
// disabled: it read as a distracting glow rather than a subtle liveness cue.
// Streaming text always renders statically at base ink (the disabled path in
// styleStreamingLine), so no accent glow and no per-line fade ticks.
m.fadeDisabled = true
// One session-long LSP manager (cheap to build — servers start lazily on the
// first Check), reused across prompts so gopls stays warm between turns.
if cwd != "" {
m.lspManager = lsp.NewManager(cwd)
}
m.refreshMCPViewState()
for _, warning := range keyBindingWarnings {
m = m.appendSystemNotice(warning)
}
return m
}
func (m model) doctorOptions(connectivity bool) doctor.Options {
var health *providerhealth.Result
if connectivity && m.probeProviderHealth != nil && config.HasProviderProfile(m.providerProfile) {
ctx := m.ctx
if ctx == nil {
ctx = context.Background()
}
result := m.probeProviderHealth(ctx, providerhealth.Options{
Profile: m.providerProfile,
Connectivity: true,
UserAgent: m.userAgent,
})
health = &result
}
return doctor.Options{
Now: m.now,
Runtime: "go",
UserConfig: m.doctorUserConfigPath,
ProjectConfig: m.projectConfigPath,
Provider: m.providerProfile,
WorkspaceRoot: m.cwd,
Connectivity: connectivity,
ProviderHealth: health,
}
}
const (
composerPlaceholder = "describe a task for zero…"
composerMaxVisibleLines = 4
)
// composerCursorBlinkInterval is the on/off period of the composer text cursor.
const composerCursorBlinkInterval = 530 * time.Millisecond
// composerTypingIdleThreshold is how long a typing pause must last before the
// cursor resumes blinking; comfortably above normal inter-keystroke gaps
// (~150-300ms) so it won't flicker mid-sentence.
const composerTypingIdleThreshold = 500 * time.Millisecond
// composerBlinkMsg toggles the composer cursor's visibility each tick. The custom
// composer render draws its own cursor (not textinput's), so it drives its own
// blink rather than relying on textinput.Blink.
type composerBlinkMsg struct{}
func composerBlinkCmd() tea.Cmd {
return tea.Tick(composerCursorBlinkInterval, func(time.Time) tea.Msg {
return composerBlinkMsg{}
})
}
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
// on program start, so m.height/m.width are normally set before the first
// render. But that's the terminal proactively pushing a size — if it's
// ever missed (a slow/unusual terminal, a multiplexer, a startup race),
// nothing else ever asks again: m.height stays its zero value forever,
// `if m.altScreen && m.height > 0` (transcriptView) falls back to the
// unpadded, non-fullscreen render path for the rest of the session, and
// the alt-screen viewport never gets filled below the actual content.
// Explicitly requesting it here means Zero doesn't depend solely on the
// terminal's unprompted push — mirrors the RequestBackgroundColor request
// below for the same reason.
cmds = append(cmds, tea.RequestWindowSize)
// Baseline git snapshot for the FILES sidebar sweep: whatever is already
// dirty when the TUI opens is pre-existing state, not this session's work
// (files_git_sweep.go). Async; a non-git workspace just disables the sweep.
if strings.TrimSpace(m.cwd) != "" {
cmds = append(cmds, gitSweepCmd(m.ctx, m.cwd, true))
}
// In auto mode, ask the terminal for its background color; the reply arrives
// as tea.BackgroundColorMsg and selects light vs dark (see updateModel).
if m.themeMode == themeAuto {
cmds = append(cmds, tea.RequestBackgroundColor)
}
// Warm model discovery for the active provider in the background so the
// context-usage gauge (used / total tokens + % fill) knows the active model's
// window from launch — including proxy/custom models not in the curated
// registry. Async: never blocks startup; if discovery is unavailable the gauge
// just shows the used-token count until the window is otherwise learned.
if descriptor, ok := m.activeProviderDescriptor(); ok {
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
cmds = append(cmds, cmd)
}
// The generic discovery above has no source for a local Ollama model's
// context window (see ollamaContextWindowDiscoveryCmd); probe its
// native /api/show separately so the gauge works for custom/local
// Ollama models too, not just ones in the curated catalog.
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, m.providerProfile.BaseURL, m.modelName); cmd != nil {
cmds = append(cmds, cmd)
}
}
if m.prService != nil && m.runtimeMessageSink != nil {
service := m.prService
sink := m.runtimeMessageSink
ctx := m.ctx
if ctx == nil {
ctx = context.Background()
}
cmds = append(cmds, func() tea.Msg {
stop := WatchPRStateContext(ctx, service, func(state PrState) {
sink(prStateMsg{state: state})
})
return prWatcherStartedMsg{stop: stop}
})
}
return tea.Batch(cmds...)
}
func (m *model) stopPRWatcher() {
if m.prWatcherStop == nil {
return
}
m.prWatcherStop()
m.prWatcherStop = nil
}
// noBlockingModal reports that no modal surface (permission prompt, ask_user,
// spec review, provider/MCP wizard, MCP manager, or picker) is up, so a global
// shortcut may act instead of falling through to a modal's own handler. Shared
// by every shortcut that should defer to whichever modal is focused.
func (m model) noBlockingModal() bool {
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
m.sttKeyPrompt == nil
}
func (m model) quit() (tea.Model, tea.Cmd) {
if m.providerWizard != nil {
m.providerWizard.resetAimlapiOnboard()
}
m.stopPRWatcher()
m.stopAllBackgroundTerminalSessions()
m.shutdownLSPManager()
return m, tea.Quit
}
// shutdownLSPManager gracefully stops the session-long language servers on exit.
// Best-effort with a short deadline so a slow server can't hang the quit; the
// servers are our child processes and would be reaped on exit regardless.
func (m model) shutdownLSPManager() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if m.lspManager != nil {
_ = m.lspManager.Shutdown(shutdownCtx)
}
// The warm sherpa-onnx streaming server is a session-long child process too;
// tear it down alongside the language servers (§6a).
if m.dictation.shutdownServer != nil {
_ = m.dictation.shutdownServer(shutdownCtx)
}
}
func (m model) handleCtrlC() (tea.Model, tea.Cmd) {
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
m.clearComposer()
m.clearSuggestions()
m = m.disarmExitConfirmation()
return m, nil
}
if m.exitConfirmActive {
m = m.disarmExitConfirmation()
m.cancelRun()
m.exiting = true
// A cancelled run may still need to flush checkpoint/session events; quit
// only after agentResponseMsg drains flushRunIDs so /rewind stays valid.
if len(m.flushRunIDs) > 0 {
return m, nil
}
return m.quit()
}
m.cancelRun()
m.exitConfirmActive = true
m.exitConfirmSeq++
seq := m.exitConfirmSeq
return m, tea.Tick(ctrlCExitConfirmDuration, func(time.Time) tea.Msg {
return exitConfirmExpiredMsg{seq: seq}
})
}
func (m model) disarmExitConfirmation() model {
if m.exitConfirmActive {
m.exitConfirmActive = false
m.exitConfirmSeq++
}
return m
}
func (m model) disarmCancelConfirmation() model {
if m.cancelConfirmActive {
m.cancelConfirmActive = false
m.cancelConfirmSeq++
}
return m
}
// Update routes every message through updateModel, then advances the flush
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
// instead of printing into terminal scrollback (see flush.go).
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(flushedMsg); ok {
m.printInFlight = false
return m.drainFlushQueue()
}
next, cmd := m.updateModel(msg)
nm, ok := next.(model)
if !ok {
return next, cmd
}
nm = nm.syncChatScroll()
nm, mouseCmd := nm.syncMouseCapture()
nm, flushCmd := nm.settleTranscript()
return nm, batchCommands(cmd, mouseCmd, flushCmd)
}
func batchCommands(cmds ...tea.Cmd) tea.Cmd {
filtered := make([]tea.Cmd, 0, len(cmds))
for _, cmd := range cmds {
if cmd != nil {
filtered = append(filtered, cmd)
}
}
switch len(filtered) {
case 0:
return nil
case 1:
return filtered[0]
default:
return tea.Batch(filtered...)
}
}
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case composerBlinkMsg:
switch {
case !m.terminalFocused:
m.composerCursorVisible = false // hidden while unfocused
case m.now().Sub(m.lastCharTime) < composerTypingIdleThreshold:
m.composerCursorVisible = true // solid while actively typing
default:
m.composerCursorVisible = !m.composerCursorVisible // idle + focused: blink as before
}
return m, composerBlinkCmd()
case tea.BackgroundColorMsg:
// Terminal background-color reply (from Init's RequestBackgroundColor). In
// auto mode it selects light vs dark; applyTheme repaints (clears the render
// cache). An explicit dark/light theme ignores it but still records the bg.
m.hasDarkBg = msg.IsDark()
if m.themeMode == themeAuto {
applyTheme(themeAuto, m.hasDarkBg)
}
return m, nil
case tea.MouseMsg:
if m.setup.visible {
return m.handleSetupMouse(msg)
}
return m.handleMouse(msg)
case transcriptCopiedMsg:
m.copyStatusSeq++
if msg.err != nil {
// Keep the selection so the user can retry; just surface the failure.
m.copyStatus = "Copy failed"
} else {
m.transcriptSelection = transcriptSelectionState{}
m.copyStatus = "Copied!"
}
seq := m.copyStatusSeq
return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return transcriptCopyStatusExpiredMsg{seq: seq}
})
case transcriptCopyStatusExpiredMsg:
if msg.seq == m.copyStatusSeq {
m.copyStatus = ""
}
return m, nil
case exitConfirmExpiredMsg:
if msg.seq == m.exitConfirmSeq {
m.exitConfirmActive = false
}
return m, nil
case cancelConfirmExpiredMsg:
if msg.seq == m.cancelConfirmSeq {
m.cancelConfirmActive = false
}
return m, nil
case leaderExpiredMsg:
if msg.seq == m.leaderSeq {
m.leaderPending = false
}
return m, nil
case dragEdgeScrollTickMsg:
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
return m, nil // stale, or the chain was stopped since this tick was scheduled
}
m = m.dragToEdgeScroll(m.edgeScrollDelta, m.edgeScrollMouseX)
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
case providerWizardOAuthMsg:
return m.applyProviderWizardOAuth(msg)
case aimlapiOnboardMsg:
return m.applyAimlapiOnboard(msg)
case aimlapiExistingBalanceMsg:
return m.applyExistingAimlapiBalance(msg)
case providerWizardDeviceCodeMsg:
return m.applyProviderWizardDeviceCode(msg)
case providerManagerCredsMsg:
return m.applyProviderManagerCreds(msg)
case providerManagerCleanupMsg:
return m.applyProviderManagerCleanup(msg)
case clipboardReadMsg:
// Result of a right-click paste. Insert on success; surface a brief
// status if the clipboard couldn't be read (e.g. no clipboard utility on
// a remote session). An empty clipboard is a silent no-op.
if msg.err != nil {
m.copyStatusSeq++
m.copyStatus = "Paste failed"
seq := m.copyStatusSeq
return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg {
return transcriptCopyStatusExpiredMsg{seq: seq}
})
}
if msg.content == "" {
// Empty text clipboard — may be a screenshot. Probe for image.
return m, readClipboardImageCmd()
}
return m.routePaste(msg.content)
case clipboardImageMsg:
if msg.err != nil {
return m.appendImageNotice("Clipboard image read failed: " + msg.err.Error()), nil
}
if msg.data == nil {
return m, nil // no image — silent no-op
}
return m.attachClipboardImage(msg.data, msg.mediaType), nil
case tea.PasteMsg:
// A paste into the cloud-STT key prompt fills the key (the common way to
// enter an API key), not the composer.
if m.sttKeyPrompt != nil {
m.sttKeyPrompt.input += strings.TrimSpace(msg.Content)
return m, nil
}
return m.routePaste(msg.Content)
case dictationStartedMsg:
return m.handleDictationStarted(msg)
case dictationTranscribedMsg:
return m.handleDictationTranscribed(msg)
case sttPartialMsg:
return m.handleDictationPartial(msg), nil
case sttDownloadProgressMsg:
return m.handleDictationDownloadProgress(msg), nil
case dictationDownloadedMsg:
return m.handleDictationDownloaded(msg)
case sttModelsFetchedMsg:
return m.handleSTTModelsFetched(msg), nil
case recTickMsg:
return m.handleRecTick()
case sttLevelMsg:
return m.handleDictationLevel(msg), nil
case tea.KeyboardEnhancementsMsg:
return m.handleKeyboardEnhancements(msg), nil
case tea.KeyReleaseMsg:
// Voice mode's hold-to-record ends on Space release; every other release
// event is ignored (dispatch elsewhere is press-based).
if m.dictation.voiceModeEnabled && keyIs(msg, tea.KeySpace) {
return m.handleVoiceSpaceRelease()
}
return m, nil
case tea.KeyPressMsg:
// Paste-detection timing trackers. MUST run before any early return
// so burst counting stays accurate regardless of which branch fires.
now := m.now()
if !m.lastKeyTime.IsZero() && now.Sub(m.lastKeyTime) < 100*time.Millisecond {
m.burstCount++
} else {
m.burstCount = 0
}
m.lastKeyTime = now
if !keyIs(msg, tea.KeyEnter) {
m.lastCharTime = now
}
// Enter the solid-while-typing state right away: only composerBlinkMsg
// evaluates the typing threshold, so if the blink phase had just hidden
// the caret, the typed character would render caret-less for up to a
// full tick before the timer catches up.
m.composerCursorVisible = true
if m.setup.visible {
return m.handleSetupKey(msg)
}
// The cloud-STT API-key prompt is modal: it owns every keystroke (masked
// input) until Enter saves or Esc cancels.
if m.sttKeyPrompt != nil {
return m.handleSTTKeyPromptKey(msg)
}
m.transcriptSelection = transcriptSelectionState{}
m.composerSelection = composerSelectionState{}
m.clearMouseSelection()
if !keyCtrl(msg, 'c') {
m = m.disarmExitConfirmation()
}
// Mirrors the exit-confirmation reset above: any key that isn't itself
// the confirming Esc means the user moved on to something else, so a
// later, unrelated Esc must arm a fresh confirmation rather than
// silently cancel off a stale press from seconds ago.
if !keyIs(msg, tea.KeyEsc) {
m = m.disarmCancelConfirmation()
}
// The `?` help overlay is modal: `?`, Esc, q, or Enter close it; every
// other key is swallowed so nothing types into the hidden composer.
if m.helpOverlay {
if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') {
m.helpOverlay = false
}
m.burstCount = 0
return m, nil
}
// Ctrl+X ? leader-chord map: same dismiss keys as the general help overlay.
if m.leaderHelpOverlay {
if keyText(msg) == "?" || keyText(msg) == "q" || keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyEnter) || keyCtrl(msg, 'c') {
m.leaderHelpOverlay = false
}
m.burstCount = 0
return m, nil
}
if keyCtrl(msg, 'c') {
if m.leaderPending {
m = m.clearLeader()
}
return m.handleCtrlC()
}
if m.leaderPending {
// Leader owns every keystroke until resolved; never type into the composer.
return m.handleLeaderKey(msg)
}
if keyCtrl(msg, 'x') && m.canArmLeader() {
return m.armLeader()
}
// Emacs Ctrl+P / Ctrl+N move selection in open menus. Runs before the
// switch so menus win over global Ctrl+P (plan toggle). Idle Ctrl+P
// falls through to that binding; idle Ctrl+N is a reserved no-op so it
// never reaches remapped configurable bindings (e.g. toggleSidebar).
if !m.transcriptDetailed && (keyCtrl(msg, 'p') || keyCtrl(msg, 'n')) {
delta := 1
if keyCtrl(msg, 'p') {
delta = -1
}
if next, cmd, ok := m.moveModalSelection(delta); ok {
return next, cmd
}
if keyCtrl(msg, 'n') {
return m, nil
}
}
switch {
case m.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):
return m.toggleDetailedTranscript(), nil
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
// Mode toggle for the file drill-in, only while the composer is empty
// (so mid-sentence typing is never hijacked) and no modal is up (so a
// permission prompt / ask-user / wizard keeps its own key handling).
if keyText(msg) == "f" {
return m.setFileViewMode(fileViewFull), nil
}
return m.setFileViewMode(fileViewDiff), nil
case m.keyMatch(m.keyBindings.toggleMouse, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'e') }) && canFireComposerGatedToggle(m.keyBindings.toggleMouse, defaultToggleMouseChord, m.composerValue() == ""):
// Release/recapture the mouse so the user can drag-select and copy text
// natively (mouse capture otherwise intercepts terminal selection). The
// composer-empty requirement only applies when the binding resolves to
// the conflicting default Ctrl+E chord (unset, or explicitly configured
// to the same chord), which readline navigation (move-to-end-of-line)
// also claims while typing; a binding that resolves to a genuinely
// different chord still fires mid-type.
m.mouseReleased = !m.mouseReleased
if m.mouseReleased {
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
}
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && keyIs(msg, tea.KeySpace) && !keyHasMod(msg, tea.ModCtrl) && !keyAlt(msg) && m.noBlockingModal():
// Voice mode (/voice) repurposes Space into the record gesture — the only
// dictation trigger — so it must not also type a space. Turn voice mode
// off (/voice) to type normally.
return m.handleVoiceSpacePress(msg)
case keyIs(msg, tea.KeyEsc):
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
// reaching the run-cancel fallback. Capture whether this press really
// is the confirming second Esc BEFORE any of those branches can fire,
// then disarm unconditionally: an Esc that gets consumed by one of
// them wasn't a confirm, so it must not leave cancelConfirmActive
// armed for some later, unrelated Esc to silently act on.
wasConfirmingCancel := m.pending && m.cancelConfirmActive
m = m.disarmCancelConfirmation()
// An active dictation recording cancels on Esc (releases the mic, drops
// the audio) — but only if this Esc isn't a confirming run-cancel
// press. Without this guard, a user mid-recording who double-Esc's
// to kill the run finds the first Esc swallowed by dictation and
// the run still going. The pending run cancel happens further
// down at the bottom of the Esc branch.
if m.dictation.active() && !wasConfirmingCancel {
return m.cancelDictation()
}
// Subchat view exits on Esc (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil
}
// File drill-in exits on Esc (returns to the chat at its saved scroll
// position); the file stays selected so a second Esc clears that. Only
// with no blocking modal up: Esc on a permission prompt / ask-user /
// wizard must reach THAT surface's deny/cancel handling below, not
// silently close the drill-in behind it.
if m.fileView.active && m.noBlockingModal() {
return m.exitFileView(), nil
}
if m.mcpCommandCancel != nil {
m.cancelMCPCommand()
if m.mcpAddWizard != nil {
m.mcpAddWizard.result = mcpAddWizardResult{Title: "MCP setup cancelled", State: "cancelled", Message: "MCP action was cancelled.", ActionHint: "Edit config"}
m.mcpAddWizard.step = mcpAddWizardStepResult
return m, nil
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: "MCP action cancelled"})
return m, nil
}
if m.transcriptDetailed {
m.transcriptDetailed = false
return m, nil
}
// Esc on an ask-user prompt: from the "type my own" free-text it steps
// back to the selector for that question; otherwise it cancels the
// questionnaire (not the run), delivering whatever answers were collected
// so the agent loop unblocks and degrades to its best-assumption path.
if m.pendingAskUser != nil {
return m.escapeAskUser()
}
if m.pendingSpecReview != nil {
m.burstCount = 0
return m.cancelSpecReview()
}
if m.pendingPermission != nil && m.pendingPermission.request.ToolName == tools.RequestPermissionsToolName {
return m.resolvePermission(permissionDecisionDeny)
}
if m.providerWizard != nil {
// Delegate so multi-level surfaces (provider manager list → edit →
// field, manage-key step) can walk BACK one level; the wizard's own
// handler closes the overlay for the single-level steps.
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.mcpAddWizard = nil
return m, nil
}
if m.mcpManager != nil {
m.mcpManager = nil
return m, nil
}
// An open picker cancels first; then an active suggestion overlay is
// dismissed. Neither cancels the run or clears the input.
if m.picker != nil {
if m.picker.kind == pickerModel {
m.clearModelPickerLoadState()
}
if m.picker.kind == pickerTheme {
// A live theme preview was applied while navigating; restore the
// committed palette since Esc dismisses without choosing.
m.restoreCommittedTheme()
}
m.picker = nil
return m, nil
}
if m.suggestionsActive() {
return m.dismissSuggestions(), nil
}
// A selected FILES row clears before anything run-related: the
// selection is a passive highlight, so Esc dropping it is cheap and
// expected (mirrors how editors clear selection on Esc).
if m.selectedFile != "" {
m.selectedFile = ""
return m, nil
}
if m.hasQueuedMessage() {
return m.clearQueuedMessage(), nil
}
m.clearSuggestions()
if m.pending {
if wasConfirmingCancel {
m.clearComposer()
m.cancelRun()
return m, nil
}
// First Esc only arms the confirmation — preserve whatever
// draft the user has typed, since nothing has actually been
// cancelled yet and they may not press Esc again.
m.cancelConfirmActive = true
m.cancelConfirmSeq++
seq := m.cancelConfirmSeq
return m, tea.Tick(escCancelConfirmDuration, func(time.Time) tea.Msg {
return cancelConfirmExpiredMsg{seq: seq}
})
}
m.clearComposer()
return m, nil
case keyIs(msg, tea.KeyEnter):
if m.transcriptDetailed {
if command := parseCommand(m.input.Value()); command.kind == commandTranscript {
m.input.SetValue("")
return m.toggleDetailedTranscript(), nil
}
return m, nil
}
if m.pendingPermission != nil {
// Enter confirms the highlighted option (default: allow once); the
// a/y/d hotkeys and a click still resolve directly.
m.burstCount = 0
return m.confirmPermissionCursor()
}
if m.pendingAskUser != nil {
m.burstCount = 0
return m.confirmAskUser()
}
if m.pendingSpecReview != nil {
m.burstCount = 0
return m, nil
}
if m.providerWizard != nil {
m.burstCount = 0
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.burstCount = 0
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
m.burstCount = 0
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
m.burstCount = 0
return m.choosePicker()
}
if keyAlt(msg) || keyShift(msg) {
if next, ok := m.applyComposerKey(msg); ok {
return next, nil
}
}
// Enter on file suggestions inserts the @file token for continued
// composing. Command suggestions execute only when the selected command
// is self-contained; commands that require a value are inserted so the
// user can finish the argument first.
if m.suggestionsActive() {
return m.chooseSuggestion()
}
// Timing-based paste protection: under Termux, context-menu paste
// injects characters one at a time (including newlines as raw
// KeyEnter events). A sustained burst of 3+ keys within 100ms
// means we are inside a char-by-char paste — insert newline
// instead of submitting. Gated to Termux so fast desktop typing
// (which can reach similar inter-key intervals) is never affected.
if os.Getenv("TERMUX_VERSION") != "" && m.burstCount > 2 {
state := m.currentComposerState()
m = m.insertComposerTextWithPastePreview(state, "\n", "")
m.clearSuggestions()
return m, nil
}
// Composer-based paste protection: when the composer already has
// multiline text (e.g. pasted via bracketed paste / Ctrl+Shift+V),
// plain Enter inserts a newline instead of submitting so each
// pasted \n does not trigger a premature submit. Uses the same
// burstCount > 2 threshold as the Termux path so a single fast
// key + Enter on a multiline prompt still submits.
if m.composerActive && m.burstCount > 2 && strings.Contains(m.composer.text, "\n") {
state := m.currentComposerState()
m = m.insertComposerTextWithPastePreview(state, "\n", "")
m.clearSuggestions()
return m, nil
}
m.burstCount = 0
return m.handleSubmit()
case keyIs(msg, tea.KeyTab) && keyShift(msg):
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserTab(-1), nil
}
// shift+tab toggles the permission mode between Auto and Ask (Unsafe
// is intentionally not reachable by a casual keypress — see
// nextPermissionMode), but only when nothing modal is up: a permission
// prompt, ask_user questionnaire, or open picker all take precedence
// and let the key fall through to their own handlers below.
if m.noBlockingModal() {
m.permissionMode = nextPermissionMode(m.permissionMode)
return m, nil
}
case m.keyMatch(m.keyBindings.cycleReasoning, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 't') }):
if m.transcriptDetailed {
return m, nil
}
// Ctrl+T cycles reasoning effort (auto -> low ->
// medium -> high -> auto), but only when nothing modal is up — the
// same gate shift+tab uses above. Not gated on m.pending: cycling
// mid-run is allowed and takes effect on the next turn, matching
// /effort. cycleReasoningEffort is a silent no-op on models with no
// effort controls.
if m.noBlockingModal() {
return m.cycleReasoningEffort()
}
case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }):
// Ctrl+P toggles the plan panel expansion (collapse/expand step list).
// Modal selection is handled before this switch so menus win over toggle.
if m.noBlockingModal() && !m.plan.isEmpty() {
m.plan.expanded = !m.plan.expanded
return m, nil
}
case m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }) && canFireComposerGatedToggle(m.keyBindings.toggleSidebar, defaultToggleSidebarChord, m.composerValue() == ""):
// Ctrl+B collapses / restores the right context sidebar. The composer-empty
// requirement only applies when the binding resolves to the conflicting
// default Ctrl+B chord (unset, or explicitly configured to the same
// chord), which readline navigation (move-to-beginning-of-line) also
// claims while typing; a binding that resolves to a genuinely different
// chord still fires mid-type. Only acts when
// the sidebar would otherwise be on screen (managed mode, wide enough,
// real conversation) so it's a no-op — not a confusing notice — on the
// home screen or a narrow terminal. Hiding reflows the chat to full
// width, so mirror the width-change bookkeeping (re-wrap the streaming
// fade, resize the composer) the WindowSizeMsg path does.
if !m.transcriptDetailed && m.noBlockingModal() && m.sidebarToggleAllowed() {
// Just show/hide — no transcript notice. The reflow IS the feedback,
// and emitting a line every toggle piled up noise in the chat.
m.sidebarHidden = !m.sidebarHidden
m.lineAges = nil
m.input.SetWidth(maxInt(20, m.chatColumnWidth()-14))
return m, nil
}
case keyCtrl(msg, 'f'):
if m.picker != nil && m.picker.kind == pickerModel {
if m.modelPickerIsLoading() {
return m, nil
}
return m.toggleModelFavorite(), nil
}
case keyText(msg) == "?" && !keyAlt(msg) && !keyHasMod(msg, tea.ModCtrl):
// `?` opens the keyboard-shortcut overlay, but ONLY on an empty
// composer with nothing modal up — otherwise it must type a literal
// "?" into the prompt. Falls through to the rune-insert path below
// when the composer is non-empty or a popup is active.
if m.composerValue() == "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active && !m.suggestionsActive() {
m.helpOverlay = true
return m, nil
}
case keyBackspace(msg):
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.deleteQueryRune()
// Editing the filter changes which row is highlighted; keep the
// theme preview in sync with it (no-op for other pickers).
m.previewSelectedTheme()
return m, nil
}
// On an empty composer, Backspace removes the last attachment chip
// ([Image #N] / [Doc #N]) so you can drop one you don't need without
// clearing them all. With text present it deletes a character as usual.
if m.composerValue() == "" {
if next, removed := m.removeLastAttachment(); removed {
return next, nil
}
}
case keyIs(msg, tea.KeyTab):
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserTab(1), nil
}
if m.providerWizard != nil {
m.burstCount = 0
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.burstCount = 0
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
m.burstCount = 0
return m.handleMCPManagerKey(msg)
}
if m.picker == nil && m.suggestionsActive() {
m.moveSuggestion(1)
return m, nil
}
case keyIs(msg, tea.KeyPgUp):
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyIs(msg, tea.KeyPgDown):
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
case keyShift(msg) && keyIs(msg, tea.KeyUp):
// Shift+Up scrolls the transcript up one line. Must be checked before
// plain KeyUp so shifted arrows aren't consumed by the composer-path.
if m.transcriptDetailed {
return m, nil
}
// Suggestions keep Shift+arrows for the composer path (unchanged); plain
// ↑/↓ and Ctrl+P/N still move the palette via moveModalSelection.
if m.suggestionsActive() {
break
}
if next, cmd, ok := m.moveModalSelection(-1); ok {
return next, cmd
}
if m.composerValue() != "" {
break // let the input handle multiline navigation
}
m = m.clearHover()
return m.scrollChat(1), nil
case keyShift(msg) && keyIs(msg, tea.KeyDown):
// Shift+Down scrolls the transcript down one line. Must be checked
// before plain KeyDown so shifted arrows aren't consumed.
if m.transcriptDetailed {
return m, nil
}
if m.suggestionsActive() {
break
}
if next, cmd, ok := m.moveModalSelection(1); ok {
return next, cmd
}
if m.composerValue() != "" {
break // let the input handle multiline navigation
}
m = m.clearHover()
return m.scrollChat(-1), nil
case keyIs(msg, tea.KeyDown):
if m.transcriptDetailed {
m = m.clearHover()
return m.scrollChat(-1), nil
}
if next, cmd, ok := m.moveModalSelection(1); ok {
return next, cmd
}
if next, ok := m.moveComposerVisualCursor(1); ok {
return next, nil
}
if m.historyRecallActive() {
return m.recallHistory(1), nil
}
case keyIs(msg, tea.KeyUp):
// ArrowUp exits subchat view (returns to main chat).
if m.subchat.active {
m.chatScrollOffset = m.subchat.exit()
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
return m, nil
}
if m.transcriptDetailed {
m = m.clearHover()
return m.scrollChat(1), nil
}
if next, cmd, ok := m.moveModalSelection(-1); ok {
return next, cmd
}
if next, ok := m.moveComposerVisualCursor(-1); ok {
return next, nil
}
// A queued message takes ↑ priority over history recall: pop it back
// into the composer for editing before it sends on the next turn.
if m.hasQueuedMessage() && m.pendingSpecReview == nil {
return m.popQueuedMessageForEdit(), nil
}
if m.historyRecallActive() {
return m.recallHistory(-1), nil
}
case keyCtrl(msg, 'u'):
// Ctrl+U scrolls up half a page, or moves the cursor up in
// permission/ask-user prompts. Falls through to the active modal
// (wizard, etc.) when none of the above are in focus.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(-1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(-1), nil
}
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
break
}
if m.composerValue() != "" {
break // let the input handle its own Ctrl+U (delete-to-bol)
}
m = m.clearHover()
return m.scrollChat(m.chatPageScrollLines()), nil
case keyCtrl(msg, 'd'):
// Ctrl+D scrolls down half a page, or moves the cursor down in
// permission/ask-user prompts. Falls through to the active modal
// when none of the above are in focus.
if m.transcriptDetailed {
return m, nil
}
if m.pendingPermission != nil {
return m.movePermissionCursor(1), nil
}
if m.pendingAskUser != nil {
return m.moveAskUserCursor(1), nil
}
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
break
}
if m.composerValue() != "" {
break // let the input handle its own Ctrl+D (delete-next-char)
}
m = m.clearHover()
return m.scrollChat(-m.chatPageScrollLines()), nil
}
if m.transcriptDetailed {
return m, nil
}
if m.pendingAskUser != nil {
_, state, ok := m.pendingAskUser.activeQuestion()
if !ok {
return m, nil // Confirm tab: ignore stray keys
}
// In picker mode a printable keystroke means the user wants to type their
// own answer, so flip into free-text first instead of letting the text
// accumulate invisibly and then be discarded when Enter picks an option.
if !state.typing && keyPrintable(msg) {
state.typing = true
m.input.SetValue("")
}
if state.typing {
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
return m, nil // picker mode: non-navigation keys do nothing
}
if m.pendingSpecReview != nil {
m.burstCount = 0
return m.handleSpecReviewKey(msg)
}
if m.pendingPermission != nil {
m.burstCount = 0
return m.handlePermissionKey(msg)
}
if m.providerWizard != nil {
m.burstCount = 0
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.burstCount = 0
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
m.burstCount = 0
return m.handleMCPManagerKey(msg)
}
// An open picker is modal over the input: swallow remaining keys so they
// don't type into the field. ↑/↓/Enter/Esc were already handled above.
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
if keyPrintable(msg) {
m.picker.appendQuery(keyRunes(msg))
// Filtering changes the highlighted row; keep the theme preview in
// sync with it (no-op for other pickers).
m.previewSelectedTheme()
}
return m, nil
}
if next, ok := m.applyComposerKey(msg); ok {
return next, nil
}
if m.composerActive && strings.Contains(m.composer.text, "\n") {
return m, nil
}
// The key fell through to the text input: let it update, then refresh the
// autocomplete match list from the new value.
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
m.resetComposerFromInput()
m.recomputeSuggestions()
return m, cmd
case tea.FocusMsg:
m.terminalFocused = true
// Sync the caret with focus immediately: leaving it to the next
// composerBlinkMsg tick can keep it hidden for up to a tick after the
// terminal regains focus (and, on blur below, leave it visible in an
// unfocused terminal for the same window).
m.composerCursorVisible = true
if m.notifier != nil {
m.notifier.SetFocused(true)
}
return m, nil
case tea.BlurMsg:
m.terminalFocused = false
m.composerCursorVisible = false
if m.notifier != nil {
m.notifier.SetFocused(false)
}
return m, nil
case toolCallStreamStartMsg:
if msg.runID != m.activeRunID {
return m, nil
}
// A new tool call opened — reset the live "writing" block to it.
m.streamCallID = msg.id
m.streamCallName = msg.name
m.streamCallDecoder = newStreamingDecoder()
return m, nil
case toolCallStreamDeltaMsg:
if msg.runID != m.activeRunID || msg.id != m.streamCallID || m.streamCallDecoder == nil {
return m, nil
}
m.streamCallDecoder.feed(msg.fragment)
// A streamed tool-call argument (e.g. a file's contents in write_file) is
// real generated output: count it toward the live token estimate so the
// "↑ N tok" pulse climbs during a long write, and bump lastStreamActivity so
// the quiet-generation hint stays clear of an actively-streaming provider.
m.turnStreamedRunes += utf8.RuneCountInString(msg.fragment)
m.lastStreamActivity = m.now()
return m, nil
case agentTextMsg:
if msg.runID != m.activeRunID {
return m, nil
}
// Streaming text means any in-progress tool call has finished — clear the
// live "writing" block so it doesn't linger over new prose.
m.clearStreamingToolCall()
m.streamingText = append(m.streamingText, msg.delta...)
m.turnStreamedRunes += utf8.RuneCountInString(msg.delta)
// recordStreamingDelta appends a time.Time to lineAges for every
// newline in the delta and bumps lastStreamActivity. It also
// re-stamps the in-progress last entry so the line that's still
// being filled stays visibly fresh.
m.recordStreamingDelta(msg.delta)
// The fade's tick is self-perpetuating (the streamingFadeTickMsg
// case schedules the next one). Schedule the FIRST tick only on
// the inactive→active transition; subsequent deltas just refresh
// state and rely on the existing tick chain.
// When the fade is disabled (ZERO_NO_FADE / SSH / tmux / low-color),
// fadeActive stays false so styleStreamingLine renders streaming text
// statically at base ink, and no self-perpetuating tick is scheduled.
if !m.fadeDisabled {
startTick := !m.fadeActive
m.fadeActive = true
if startTick {
return m, streamingFadeTick()
}
}
return m, nil
case agentReasoningMsg:
if msg.runID != m.activeRunID {
return m, nil
}
m.streamingReasoning += msg.delta
m.turnStreamedRunes += utf8.RuneCountInString(msg.delta)
// Reasoning IS live provider output, so refresh the activity clock — else the
// quiet-generation hint can wrongly read "still generating…" mid-think.
if msg.delta != "" {
m.lastStreamActivity = m.now()
}
return m, nil
case spinner.TickMsg:
// Record when swarm members first finish so the sidebar can linger them
// with a fading ✓ before removal. Cheap (the tick only fires while a run is
// in flight or the sidebar holds agents — exactly when this can change).
m.stampSwarmDone()
// Not forwarding the tick while idle stops the spinner's self-scheduling,
// so no timer fires between runs. The one exception is an active sidebar
// holding agents: their cool ripple animation needs the phase to keep
// advancing even when no run is pending, so the tick loop stays alive while
// sidebarHasAgents() holds (and stops the moment the agents/sidebar clear).
if !m.pending && !m.compactInFlight && !m.doctorInFlight {
// The tick also keeps advancing while the aimlapi.com onboarding sub-flow is
// busy (its progress screen is spinner-only), even though no agent run is in
// flight, so its shared MiniDot spinner keeps animating.
//
// Return the FPS-throttled tick that Update hands back — NOT m.spinner.Tick,
// which fires immediately and busy-loops the frame at event-loop speed. That
// makes the glyph spin far too fast (and burns CPU) on screens that sit here
// for a while, e.g. the aimlapi checkout wait. The active-run path below
// already does this; this keeps idle animation at the same cadence.
if (m.sidebarHasAgents() || m.aimlapiOnboardAnimating()) && !m.reducedMotion {
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
m.spinnerPhase++
m.spinnerTicking = true
return m, cmd
}
m.spinnerTicking = false
return m, nil
}
m.spinnerTicking = true
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
// Advance the shared ripple phase in lock-step with the spinner glyph;
// frozen under reduced motion so the colour wave stops with the glyph.
if !m.reducedMotion {
m.spinnerPhase++
}
if m.compactInFlight {
if !m.reducedMotion {
m.compactFrame++ // frozen frame under reduced motion -> static ring
}
m = m.setCompactStatusRow(m.compactText(true))
}
if m.doctorInFlight {
if !m.reducedMotion {
m.doctorFrame++
}
m = m.setDoctorStatusRow(m.doctorConnectivityRunningText())
}
return m, cmd
case streamingFadeTickMsg:
// The fade's own tick (separate from the spinner so a slower
// cadence is enough). Short-circuits when fadeActive is false,
// which is how the ticker stops cleanly at stream end: the
// agentResponseMsg handler sets fadeActive = false, and the
// next tick that lands after that point returns nil here.
if !m.fadeActive {
return m, nil
}
return m, streamingFadeTick()
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// A resize re-wraps content at a new width, shifting every row's bodyY;
// a stale transcript-hover target could coincidentally land on an
// unrelated clickable row (same reasoning as clearHover's other callers).
m = m.clearHover()
// Reset the streaming-text fade state. A width change can re-wrap
// the in-progress text into a different number of visual lines,
// which invalidates the per-line age mapping. The next delta
// will reseed lineAges and restart the tick.
m.lineAges = nil
m.lastStreamActivity = m.now()
// Size the composer so long input scrolls horizontally with the cursor
// visible instead of being clipped invisibly past the right edge.
m.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
// The title bar prints once into native scrollback when the inline
// renderer is active. In alt-screen mode it stays pinned inside View.
if !m.altScreen && !m.headerPrinted && msg.Width > 0 {
m.headerPrinted = true
m.flushQueue = append(m.flushQueue, m.titleBar(chatWidth(msg.Width)))
}
// A resumed/idle session may already hold sidebar agents now that geometry
// (and thus sidebarActive) is known; kick the ripple tick loop if so. No-op
// when the loop is already running or there is nothing to animate.
return m, m.ensureSpinnerTick()
case permissionRequestMsg:
// The agent goroutine that raised this request is BLOCKED waiting on the
// decision callback, so every branch below must resolve it exactly once —
// or store it in pendingPermission, which resolves it on the user's reply.
// Dropping a request without resolving parks the run forever (the reported
// "stuck for 33 minutes" deadlock): the agent waits on a decision channel
// nothing will ever signal, with no visible prompt and no network activity.
if msg.runID != m.activeRunID {
// A superseded/stale run: unblock its parked goroutine now rather than
// relying on that run's context cancel to fire first.
if msg.decide != nil {
msg.decide(agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run superseded"})
}
return m, nil
}
if msg.request.Action != agent.PermissionActionPrompt {
// Not a user-facing prompt (e.g. a sandbox-allowed command that still
// blocked because it requested additional permissions). The UI has
// nothing to ask, so resolve immediately and FAIL CLOSED — never
// silently grant access that was never surfaced to the user. (The agent
// now marks such elevation requests as prompts, so in practice this is a
// defensive backstop; matches the ACP handler's fail-closed contract.)
if msg.decide != nil {
msg.decide(autoResolvedPermissionDecision(msg.request.Action))
}
return m, nil
}
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
promptRow.runID = msg.runID
m.transcript = appendTranscriptRow(m.transcript, promptRow)
m.pendingPermission = &pendingPermissionPrompt{
request: msg.request,
decide: msg.decide,
}
return m, nil
case askUserRequestMsg:
if msg.runID != m.activeRunID {
if msg.answer != nil {
msg.answer(nil)
}
return m, nil
}
// A request with no questions has nothing to answer — resolve it
// immediately so the run isn't stalled waiting on manual input. Mirror the
// normal flow: record the (empty) request in the transcript and answer with
// an empty slice (not nil) so downstream sees the same Answers shape.
if len(msg.request.Questions) == 0 {
m.transcript = appendTranscriptRow(m.transcript, askUserTranscriptRow(msg.request))
if msg.answer != nil {
msg.answer([]string{})
}
return m, nil
}
m.transcript = appendTranscriptRow(m.transcript, askUserTranscriptRow(msg.request))
m.pendingAskUser = &pendingAskUserPrompt{
request: msg.request,
answer: msg.answer,
states: newAskUserStates(msg.request.Questions),
}
m.clearComposer()
m.clearSuggestions()
return m, nil
case loopTickMsg:
// Idle poll for due loops. A stale tick (loops changed) or an empty loop set
// ends the ticker; otherwise fire the earliest due loop if idle and reschedule.
if msg.seq != m.loopSeq || len(m.loops) == 0 {
m.loopTicking = false
return m, nil
}
var fireCmd tea.Cmd
m, fireCmd = m.fireDueLoopIfIdle()
return m, tea.Batch(fireCmd, m.scheduleLoopTick())
case agentResponseMsg:
if msg.runID != m.activeRunID {
// A run cancelled while in flight still finishes in its goroutine and
// returns its accumulated session events here. Persist ONLY those events
// (notably the EventSessionCheckpoint payloads captured before each
// mutating tool) so the checkpoint blobs stay referenced and /rewind
// works; the cancel path already wrote the "Run cancelled." marker, so
// skip transcript rows, the trailing cancellation error, and any pending
// state changes.
if flushSessionID, flushing := m.flushRunIDs[msg.runID]; flushing {
delete(m.flushRunIDs, msg.runID)
// The cancelled run still consumed tokens; record them so the usage
// readout doesn't undercount interrupted turns.
liveUsageCount := m.liveUsageCounts[msg.runID]
for index, event := range msg.usageEvents {
if index < liveUsageCount {
continue
}
var usageRows []transcriptRow
m, usageRows = m.recordUsageEvent(msg.usageModelID, event)
for _, row := range usageRows {
m.transcript = appendTranscriptRow(m.transcript, row)
}
}
delete(m.liveUsageCounts, msg.runID)
// Events are persisted into the session the run was recording into AT
// CANCEL TIME — the active session may have changed since (/resume),
// and writing there would contaminate its log with checkpoint payloads
// whose blobs live under the original session. appendSessionEvents*
// only returns rows for persist FAILURES; surface them so a failed
// checkpoint/tool flush (which would silently degrade /rewind) is
// visible rather than swallowed.
var flushRows []transcriptRow
events := flushableSessionEvents(msg.sessionEvents)
if flushSessionID == m.activeSession.SessionID {
m, flushRows = m.appendSessionEvents(events)
} else {
flushRows = m.appendSessionEventsTo(flushSessionID, events)
}
for _, row := range flushRows {
m.transcript = appendTranscriptRow(m.transcript, row)
}
// A Ctrl+C during an in-flight run defers its quit until the run's
// checkpoint session events have been flushed (above). Now that the
// last pending flush is drained, fire the deferred quit.
if m.exiting && len(m.flushRunIDs) == 0 {
return m.quit()
}
}
return m, nil
}
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// Fully reset the fade state at stream end. The next render
// emits the final row in solid ink (no settling animation), and
// the pending streamingFadeTickMsg that lands after this point
// short-circuits because fadeActive is false. Clearing lineAges
// and lastStreamActivity here too prevents stale age data from
// carrying over to the next turn (and stops lineAges from
// growing indefinitely across many runs).
m.resetStreamingFade()
// The run is complete: release its context now instead of waiting for the
// parent context — every prompt leaked a CancelFunc (and its timer
// resources) until app exit otherwise.
if m.runCancel != nil {
m.runCancel()
}
m.runCancel = nil
m.activeRunID = 0
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
// A fully successful turn means the task is done. Weaker models often
// forget the final update_plan, leaving the panel stuck mid-progress;
// reconcile it to complete here. Read pendingAskUser/pendingPermission
// BEFORE the reset below clears them, and skip spec-draft reviews — those
// are legitimate mid-plan err==nil yields where the plan is NOT done.
if msg.err == nil && msg.specReview == nil &&
m.pendingAskUser == nil && m.pendingPermission == nil {
m.plan.completeRemaining(m.now())
}
m.pendingPermission = nil
if m.pendingAskUser != nil && m.pendingAskUser.answer != nil {
m.pendingAskUser.answer(nil)
}
m.pendingAskUser = nil
liveUsageCount := m.liveUsageCounts[msg.runID]
for index, event := range msg.usageEvents {
if index < liveUsageCount {
continue
}
var usageRows []transcriptRow
m, usageRows = m.recordUsageEvent(msg.usageModelID, event)
for _, row := range usageRows {
m.transcript = appendTranscriptRow(m.transcript, row)
}
}
delete(m.liveUsageCounts, msg.runID)
var sessionRows []transcriptRow
m, sessionRows = m.appendSessionEvents(msg.sessionEvents)
for _, row := range sessionRows {
m.transcript = appendTranscriptRow(m.transcript, row)
}
for _, row := range msg.rows {
if row.kind == rowReasoning {
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
}
m.transcript = appendTranscriptRow(m.transcript, row)
}
if msg.err != nil {
// A failed turn has no final answer row to supersede the streamed
// text the user already watched — keep the partial answer instead of
// letting it vanish from history.
if row, ok := reasoningTranscriptRow("", msg.runID, m.streamingReasoning); ok {
m.transcript = appendTranscriptRow(m.transcript, row)
}
if text := strings.TrimRight(m.streamingTextString(), "\n"); strings.TrimSpace(text) != "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
}
// The error row terminates the turn, so it carries the done-line
// metadata a final assistant row would have carried. A recognized
// provider failure (auth/rate-limit/connectivity/…) also carries a
// one-line next step so the user isn't left staring at a raw blob.
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowError,
text: msg.err.Error(),
hint: errhint.TUIHint(msg.err),
final: true,
turnTools: msg.turnTools,
turnElapsed: msg.turnElapsed,
})
}
if m.runCompletionWarning != nil {
if notice := strings.TrimSpace(m.runCompletionWarning()); notice != "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: notice})
}
}
m.streamingText = nil
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
// Roll the completed run's wall-time into the session's rolling average so
// /context can surface typical turn latency, not just token counts.
if msg.turnElapsed > 0 {
m.turnLatencySum += msg.turnElapsed
m.turnLatencyCount++
}
if msg.ttft > 0 {
m.turnTTFTSum += msg.ttft
m.turnTTFTCount++
}
if msg.specReview != nil {
m = m.activateSpecReview(*msg.specReview)
}
if m.notifier != nil {
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
}
// A successful turn gives the session real content; if it still carries its
// default first-message title, generate a concise one in the background
// (one-shot per session). A failed turn is skipped — there's nothing to name.
var titleCmd, recapCmd tea.Cmd
if msg.err == nil {
m, titleCmd = m.maybeAutoTitleActiveSession()
// Post-turn recap (gated on the recaps preference): one short sentence
// summarizing the turn's final answer, shown as a "※ recap:" footnote.
var finalAnswer string
for _, row := range msg.rows {
if row.kind == rowAssistant && row.final {
finalAnswer = row.text
}
}
m, recapCmd = m.maybeRecapTurn(msg.runID, finalAnswer)
}
// End-of-turn git sweep: catch file mutations the tool stream couldn't
// report (bash scaffolding, subagent edits) so the FILES sidebar is
// complete once the turn settles.
var sweepCmd tea.Cmd
m, sweepCmd = m.maybeGitSweep()
// If this run was a loop iteration, advance that loop (schedule its next wake
// or stop it). Done before launchQueuedMessageIfReady so a user's queued prompt
// still wins the immediate re-launch; the loop fires on the next idle tick.
var loopTickCmd tea.Cmd
if loopID := m.activeLoopID; loopID != "" {
m.activeLoopID = ""
loopFinalAnswer := ""
for _, row := range msg.rows {
if row.kind == rowAssistant && row.final {
loopFinalAnswer = row.text
}
}
m = m.advanceLoop(loopID, loopFinalAnswer, msg.err)
// advanceLoop -> removeLoop may have stopped the ticker; restart it if
// other loops remain.
m, loopTickCmd = m.ensureLoopTick()
}
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
return m.handleRecapGenerated(msg)
case compactResultMsg:
if !m.compactInFlight {
return m, nil
}
m.compactInFlight = false
m.compactFrame = 0
m.lastCompactResult = nil
m.lastCompactError = ""
if msg.err != nil {
m.lastCompactError = msg.err.Error()
m = m.setCompactStatusRow(m.compactText(true))
return m, nil
}
if msg.hasSessionSnapshot {
m.activeSession = msg.activeSession
m.sessionEvents = append([]sessions.Event{}, msg.sessionEvents...)
m.transcript = append([]transcriptRow{}, msg.transcript...)
m.resetFlushFrontier("· compacted ·")
}
m.lastCompactResult = &msg.result
m = m.setCompactStatusRow(m.compactText(true))
return m, nil
case planUpdateMsg:
if msg.runID != m.activeRunID {
return m, nil
}
m.plan.updateFromItems(msg.items, m.now())
return m, nil
case planStepExplanationMsg:
// Drop a result from a previous run: beginRun bumps planDetailGen and clears
// stepExplanation, so a stale in-flight write-up must not repopulate the
// cache or overwrite the new run's data.
if msg.gen != m.planDetailGen {
return m, nil
}
// Cache the write-up so re-clicking the step is instant; an empty result
// (failed/blank) caches "" so the card shows the local fallback summary and
// we don't retry the model on every re-click. Only re-render the card when
// this step's detail is still the one open (the user may have closed it or
// clicked another step while the request was in flight).
if m.stepExplanation == nil {
m.stepExplanation = map[string]string{}
}
text := strings.TrimSpace(msg.text)
if msg.err != nil {
text = ""
}
m.stepExplanation[msg.key] = text
if m.planDetailOpen && m.planDetailStep == msg.stepIndex &&
msg.stepIndex >= 0 && msg.stepIndex < len(m.plan.steps) &&
planStepExplanationKey(m.plan.steps[msg.stepIndex]) == msg.key {
m.transcript = dropTranscriptRowsByID(m.transcript, planStepDetailRowID)
m.transcript = m.appendPlanStepCard(msg.stepIndex, text, false)
}
return m, nil
case agentUsageMsg:
if msg.runID != m.activeRunID {
return m, nil
}
var usageRows []transcriptRow
m, usageRows = m.recordUsageEvent(msg.modelID, msg.usage)
if m.liveUsageCounts == nil {
m.liveUsageCounts = map[int]int{}
}
m.liveUsageCounts[msg.runID]++
for _, row := range usageRows {
m.transcript = appendTranscriptRow(m.transcript, row)
}
return m, nil
case specialistStartMsg:
if msg.runID != m.activeRunID {
return m, nil
}
m.specialists.start(msg.name, msg.description, msg.childSessionID, m.now())
return m, nil
case specialistCompleteMsg:
if msg.runID != m.activeRunID {
return m, nil
}
// The specialist was started with the tool call ID as a temporary key
// (the real session ID isn't known until the child process creates it).
// Reconcile: complete by the tool call ID, then rewrite the tracker
// entry's childSessionID to the real session ID so subchat.enter can
// find the child session's events in the store.
m.specialists.complete(msg.toolCallID, msg.status, 0, msg.errorMsg, m.now())
if msg.childSessionID != "" && msg.childSessionID != msg.toolCallID {
m.specialists.reconcileSessionID(msg.toolCallID, msg.childSessionID)
}
if info, ok := m.specialists.getBySessionID(msg.childSessionID); ok {
if info.childSessionID == "" {
info.childSessionID = msg.toolCallID
}
cardRow := transcriptRow{
kind: rowSpecialist,
runID: msg.runID,
specialistInfo: &info,
}
m.transcript = appendTranscriptRow(m.transcript, cardRow)
}
return m, nil
case specialistProgressMsg:
if msg.runID != m.activeRunID {
return m, nil
}
// Each progress message is one specialist tool call (OnToolProgress fires only
// for EventToolCall); bump the card's tool-call counter so it stops showing a
// permanent "0 tool calls" (M18). The tracker is still keyed by the tool-call
// id at this point (reconciled to the session id only on completion).
m.specialists.incrementToolCount(msg.toolCallID)
m.specialists.setCurrentTool(msg.toolCallID, msg.toolName, msg.detail)
return m, nil
case agentRowMsg:
if msg.runID != m.activeRunID {
return m, nil
}
if msg.row.kind == rowReasoning {
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
}
// A tool call ends the current streamed text segment. The segment is the
// assistant's working narration ("Let me check X…") — append it as a
// non-final assistant row so it stays in history instead of silently
// vanishing when the tool card replaces the interim block.
if msg.row.kind == rowToolCall {
if row, ok := reasoningTranscriptRow("", msg.runID, m.streamingReasoning); ok {
m.transcript = appendTranscriptRow(m.transcript, row)
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
}
if text := strings.TrimRight(m.streamingTextString(), "\n"); strings.TrimSpace(text) != "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
// This interim narration is the agent explaining what it's about to
// do — attribute it to the active plan step so the step-detail card
// can replay the agent's own account of the work.
m = m.captureStepNarration(text)
}
m.streamingText = nil
// The tool call has finalized into its card — drop the live "writing"
// preview so it doesn't linger or duplicate beneath the card.
m.clearStreamingToolCall()
}
// Collapse a repeated swarm status/collect card so re-checks don't flood
// the chat with identical blocks.
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
m.transcript = appendTranscriptRow(m.transcript, msg.row)
m = m.captureStepWork(msg.row)
// A finished command tool may have mutated files git can see but no
// changedFiles reports (npm create, heredoc writes, subagent edits) —
// re-sweep so the FILES sidebar picks them up mid-turn.
if msg.row.kind == rowToolResult && isPlanCommandTool(msg.row.tool) {
var sweep tea.Cmd
m, sweep = m.maybeGitSweep()
return m, sweep
}
return m, nil
case swarmSessionsMsg:
// Merge completed swarm members' session ids so their AGENTS sidebar rows
// become drill-in clickable. Session ids are durable facts, so this is not
// gated on the active run.
if m.swarmSessionMap == nil {
m.swarmSessionMap = map[string]string{}
}
for taskID, sessionID := range msg.sessions {
if taskID != "" && sessionID != "" {
m.swarmSessionMap[taskID] = sessionID
}
}
return m, nil
case doctorCommandResultMsg:
if msg.id == 0 || msg.id == m.doctorCommandSeq {
m.doctorInFlight = false
m.doctorFrame = 0
m = m.setDoctorStatusRow(msg.text)
}
return m, nil
case sandboxSetupCommandResultMsg:
if msg.id == 0 || msg.id == m.sandboxSetupSeq {
m.sandboxSetupInFlight = false
m = m.setSandboxSetupStatusRow(sandboxSetupResultText(msg.result))
}
return m, nil
case prStateMsg:
m.prState = msg.state
return m, nil
case gitSweepMsg:
return m.handleGitSweepMsg(msg), nil
case prWatcherStartedMsg:
if msg.stop == nil {
return m, nil
}
if m.prWatcherStop != nil {
m.prWatcherStop()
}
m.prWatcherStop = msg.stop
return m, nil
case bashResultMsg:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: msg.output})
return m, nil
case providerModelsDiscoveredMsg:
return m.applyProviderModelsDiscovered(msg), nil
case setupModelsDiscoveredMsg:
return m.applySetupModelsDiscovered(msg), nil
case setupOAuthMsg:
return m.applySetupOAuth(msg)
case setupOAuthDeviceMsg:
return m.applySetupOAuthDeviceCode(msg)
case modelPickerModelsDiscoveredMsg:
return m.applyModelPickerModelsDiscovered(msg), nil
case ollamaContextWindowDiscoveredMsg:
if msg.err == nil && msg.contextWindow > 0 {
if m.ollamaContextWindowByModel == nil {
m.ollamaContextWindowByModel = map[string]int{}
}
m.ollamaContextWindowByModel[msg.modelName] = msg.contextWindow
}
return m, nil
case mcpCommandResultMsg:
return m.applyMCPCommandResultMessage(msg), nil
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m model) View() tea.View {
var content string
if m.setup.visible {
content = m.setupView(chatWidth(m.width))
} else if m.helpOverlay || m.leaderHelpOverlay || !m.transcriptDetailed {
// When helpOverlay / leaderHelpOverlay is active the panel is composited into
// the normal transcript view as a true overlay (scrim + vertical centering), matching
// how the suggestion picker / provider wizard / pickers are drawn.
content = m.transcriptView()
} else {
content = m.detailedTranscriptView()
}
view := tea.NewView(content)
view.AltScreen = m.altScreen
// Paint the whole frame with the active theme's surface. Zero never paints the
// terminal's own canvas, so without this a theme's text falls on the terminal
// background — fine when they share polarity, but a light theme's dark text on a
// dark terminal (or vice versa) is invisible, and a color theme never shows its
// real surface. Painting the panel makes every theme self-contained and legible
// on any terminal, and fills the transparent popup interiors (e.g. the /theme
// picker) too. Alt-screen only, so inline output never leaves a painted
// background behind in the user's scrollback after exit.
if m.altScreen {
view.BackgroundColor = zeroTheme.bgPanel
}
// Always requested, independent of the notifier: the composer cursor's
// focus/blink behavior (composerBlinkMsg above) needs tea.FocusMsg/BlurMsg
// regardless of notification config. A standard, widely supported DEC
// private mode (CSI ?1004h) that unsupported terminals silently ignore.
view.ReportFocus = true
// Voice mode's Space-hold gesture needs key-release events (Kitty protocol).
// Request them only while voice mode is on — the renderer re-sends the request
// only when the value changes, so gating this costs nothing (§10).
view.KeyboardEnhancements.ReportEventTypes = m.dictation.voiceModeEnabled
if m.wantsMouseCapture() {
if isRunningUnderPRoot() {
// Under PRoot the AllMotion (1003) sequence doesn't work
// reliably, breaking touch-gesture scrolling. Fall back to
// CellMotion which still delivers wheel events, clicks, and
// drag — the only thing lost is hover-highlighting.
view.MouseMode = tea.MouseModeCellMotion
} else {
// AllMotion (not CellMotion) is required for hover highlighting:
// it reports cursor movement even with no button pressed.
// CellMotion only reports motion while a button is held (drag) —
// see bubbletea's MouseMode docs. AllMotion has marginally worse
// terminal compatibility but is well supported by the terminals
// this app targets; the existing 15ms mouse-event throttle
// (mouseEventThrottleInterval) already bounds the redraw rate
// from the extra motion events.
view.MouseMode = tea.MouseModeAllMotion
}
}
return view
}
// transcriptEmpty reports whether the chat surface has no real content yet
// (only the welcome row), which is when the empty state renders.
func (m model) transcriptEmpty() bool {
for _, row := range m.transcript {
if row.kind != rowWelcome {
return false
}
}
return true
}
// transcriptView renders the visible chat surface: in inline mode this is the
// live tail not yet settled into native scrollback; in alt-screen mode it is
// the managed conversation view. Streaming/modal blocks and composer chrome are
// always rendered here.
func (m model) transcriptView() string {
// Two-column layout: in alt-screen managed mode on a wide-enough terminal,
// the chat renders into a left column and a context sidebar (FILES / PLAN /
// tokens) into a right column. The chat is rendered by the existing scroll
// engine at the reduced column width via a model copy, then joined with the
// sidebar row-by-row. The subchat drill-in keeps its own single-column view.
if m.sidebarActive() && !m.subchat.active {
return m.twoColumnTranscriptView()
}
width := chatWidth(m.width)
// Subchat drill-in: when active, show the child session's transcript with
// a nav bar instead of the main chat.
if m.subchat.active {
navBar := renderSubchatNavBar(m.subchat.childSessionTitle, width)
childBodyItems := m.transcriptBodyItemsFromRows(m.subchat.childRows, width)
footer := m.footerView(width)
if m.altScreen && m.height > 0 {
return m.scrollableTranscriptItemsView(navBar, childBodyItems, footer, width, "")
}
bodyLayout := layoutTranscriptBodyItems(childBodyItems)
body := navBar + "\n\n" + bodyLayout.String()
return body + footer
}
helpOverlayContent := ""
if m.helpOverlay {
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
}
leaderHelpOverlayContent := ""
if m.leaderHelpOverlay {
leaderHelpOverlayContent = m.renderLeaderHelpOverlay(width)
}
suggestionOverlay := m.suggestionOverlay(width)
providerOverlay := m.providerWizardOverlay(width)
mcpAddOverlay := m.mcpAddWizardOverlay(width)
mcpOverlay := m.mcpManagerOverlay(width)
pickerOverlay := m.pickerOverlay(width)
sttKeyOverlay := m.sttKeyPromptOverlay(width)
viewportOverlay := ""
switch {
case sttKeyOverlay != "":
viewportOverlay = sttKeyOverlay
case helpOverlayContent != "":
viewportOverlay = helpOverlayContent
case leaderHelpOverlayContent != "":
viewportOverlay = leaderHelpOverlayContent
case providerOverlay != "":
viewportOverlay = providerOverlay
case mcpAddOverlay != "":
viewportOverlay = mcpAddOverlay
case mcpOverlay != "":
viewportOverlay = mcpOverlay
case pickerOverlay != "":
viewportOverlay = pickerOverlay
case suggestionOverlay != "":
viewportOverlay = suggestionOverlay
}
emptyOverlay := ""
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
emptyOverlay = viewportOverlay
}
bodyItems := m.transcriptBodyItems(width, emptyOverlay, false)
footer := m.footerView(width)
overlayForViewport := viewportOverlay
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
overlayForViewport = ""
}
// Plan panel renders inline in the transcript body (as a transcript row),
// not pinned at the top. It appears above the specialist cards like a
// chat message, the way todo/plan updates render inline.
if m.altScreen && m.height > 0 {
header := m.pinnedTitleBar(width)
return m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport)
}
bodyLayout := layoutTranscriptBodyItems(bodyItems)
body := bodyLayout.String()
if overlayForViewport != "" {
body += "\n" + overlayForViewport + "\n"
}
return body + footer
}
// twoColumnTranscriptView renders the alt-screen chat into a left column and
// the context sidebar (FILES / PLAN / tokens) into a right column. The chat is
// produced by the existing scroll engine at the reduced chat-column width (via
// chatColumnWidth, which every frame/geometry caller already routes through),
// yielding exactly m.height lines at the column width; the sidebar block is
// built to the same height and joined row-by-row. Overlays/wizards never reach
// here — sidebarActive() returns false while any is up, falling back to the
// single-column path. Caller guarantees sidebarActive() && !subchat.active.
func (m model) twoColumnTranscriptView() string {
chatW := m.chatColumnWidth()
sidebarW := sidebarWidth(m.width)
width := chatW
suggestionOverlay := m.suggestionOverlay(width)
bodyItems := m.transcriptBodyItems(width, "", false)
footer := m.footerView(width)
overlayForViewport := suggestionOverlay
if m.transcriptEmpty() && !m.pending {
overlayForViewport = ""
}
header := m.pinnedTitleBar(width)
chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport))
sidebar := m.renderContextSidebar(sidebarW, len(chatBlock))
rows := joinColumns(chatBlock, sidebar, chatW, sidebarW)
return strings.Join(rows, "\n")
}
func (m model) titleBarInTranscriptBody() bool {
return !m.altScreen && !m.headerPrinted
}
func (m model) pinnedTitleBar(width int) string {
if !m.altScreen || m.height <= 0 {
return ""
}
// The file drill-in replaces the title bar with its nav line (path + key
// hints). Both are exactly one line, and every frame computation routes
// through here, so the swap never desyncs the viewport geometry.
if m.fileView.active {
return m.fileViewNavBar(width)
}
return m.titleBar(width)
}
func (m model) footerView(width int) string {
var footer strings.Builder
// While an ask-user questionnaire is active it REPLACES the composer box (the
// text box becomes the questionnaire): render the tabbed prompt + status line and
// skip the plan panel / idle hints / composer for a focused modal.
if m.pendingAskUser != nil {
footer.WriteString(renderAskUserQuestionnaire(*m.pendingAskUser, m.input.Value(), width))
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
}
// Pinned plan panel: sits directly above the composer so it stays visible
// while the transcript scrolls underneath (a streaming turn no longer pushes
// the plan off-screen). Budgeted to at most a third of the screen height; a
// taller plan collapses to a one-line summary so the composer always stays
// on screen. Skipped in the subchat drill-in: m.plan belongs to the PARENT
// run, not the subagent/swarm child session being viewed there, so pinning it
// above that composer would show unrelated state.
if !m.subchat.active {
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
footer.WriteString(plan)
footer.WriteString("\n")
}
}
// The row above the composer: transient copy feedback takes priority; otherwise
// a faint idle affordance — discoverable key hints on the left, a jump-to-bottom
// cue on the right when scrolled up. Always one line (blank when nothing shows),
// so the footer height is unchanged.
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
} else if left, right := m.composerIdleHint(), m.jumpToBottomHint(); left != "" || right != "" {
footer.WriteString(fitStyledLine(joinHeaderLine(" "+left, right, width), width))
}
footer.WriteString("\n")
// A message typed while a run is active is queued for the next turn; show its
// preview directly ABOVE the input box (not below), so it reads as "waiting to
// send" sitting on top of what you're currently typing.
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString(queued)
footer.WriteString("\n")
}
footer.WriteString(m.composerBox(width))
if hint := m.composerDescriptionHint(width); hint != "" {
footer.WriteString("\n")
footer.WriteString(hint)
}
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
}
// composerIdleHint returns a faint one-line key-shortcut hint shown above the
// composer on an empty, idle prompt, so the chord bindings are discoverable
// without opening the ? overlay. Empty while typing, during a run, in the
// full-screen transcript, or under any modal/overlay so it never competes for
// attention. Width-tiered so a narrow terminal only shows the essential pointer.
func (m model) composerIdleHint() string {
// Leader-pending is always shown (even mid-type) so the user knows the next
// key is a chord, not composer input.
if m.leaderPending {
return zeroTheme.faint.Render("Ctrl+X — await shortcut (m model · p provider · ? list · Esc cancel)")
}
// Managed (alt-screen) mode only: inline mode prints to native scrollback where
// this footer row isn't a stable surface. Hidden while typing, during a run, in
// the full-screen transcript, or under any modal/overlay.
if !m.altScreen || m.pending || m.composerValue() != "" || !m.noBlockingModal() ||
m.subchat.active || m.suggestionsActive() || m.transcriptDetailed {
return ""
}
sidebarKey := labelOr(m.keyBindings.toggleSidebar, "Ctrl+B")
detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O")
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
var hint string
switch widthTier(m.width) {
case tierTiny:
return "" // too cramped for a hint
case tierNarrow:
hint = "? shortcuts"
case tierMedium:
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar", sidebarKey)
default:
hint = fmt.Sprintf("? shortcuts · Ctrl+X cmds · %s sidebar · %s detail · %s copy · Shift+Tab mode", sidebarKey, detailKey, mouseKey)
}
return zeroTheme.faint.Render(hint)
}
// jumpToBottomHint returns a faint "↓ N more · PgDn" cue when the transcript is
// scrolled up (chatScrollOffset counts lines below the fold), so it's clear new
// output may be below and how to catch up. Empty at the bottom.
func (m model) jumpToBottomHint() string {
if m.chatScrollOffset <= 0 {
return ""
}
return zeroTheme.faint.Render(fmt.Sprintf("↓ %d more · PgDn", m.chatScrollOffset))
}
// pinnedPlanMaxHeight is the line budget for the pinned plan panel: at most a
// third of the screen, so even a long plan can't crowd out the transcript or
// the composer. Beyond this the panel collapses to its one-line summary. Falls
// back to a generous cap when the height isn't known yet (unmeasured/headless).
func (m model) pinnedPlanMaxHeight() int {
if m.height <= 0 {
return 12
}
budget := m.height / 3
if budget < 3 {
budget = 3
}
return budget
}
type tuiRect struct {
x int
y int
width int
height int
}
func (r tuiRect) contains(x int, y int) bool {
return x >= r.x && y >= r.y && x < r.x+r.width && y < r.y+r.height
}
func (r tuiRect) local(x int, y int) (int, int, bool) {
if !r.contains(x, y) {
return 0, 0, false
}
return x - r.x, y - r.y, true
}
type transcriptFrameLayout struct {
width int
height int
headerRect tuiRect
bodyRect tuiRect
footerRect tuiRect
composerRect tuiRect
statusRect tuiRect
headerLines []string
bodyHeight int
footerLines []string
fullFooterLines []string
footerClip int
}
func (m model) scrollableTranscriptFrame(header string, footer string) transcriptFrameLayout {
headerLines := viewLines(header)
fullFooterLines := viewLines(footer)
footerLines := append([]string(nil), fullFooterLines...)
maxFooterLines := maxInt(0, m.height-1)
if len(footerLines) > maxFooterLines {
footerLines = footerLines[len(footerLines)-maxFooterLines:]
}
if len(headerLines)+len(footerLines) >= m.height {
maxHeaderLines := maxInt(0, m.height-len(footerLines)-1)
if len(headerLines) > maxHeaderLines {
headerLines = headerLines[:maxHeaderLines]
}
}
if len(headerLines)+len(footerLines) >= m.height {
maxFooterLines = maxInt(0, m.height-len(headerLines)-1)
if len(footerLines) > maxFooterLines {
footerLines = footerLines[len(footerLines)-maxFooterLines:]
}
}
bodyHeight := m.height - len(headerLines) - len(footerLines)
if bodyHeight < 1 {
bodyHeight = 1
}
width := m.chatColumnWidth()
footerTop := len(headerLines) + bodyHeight
frame := transcriptFrameLayout{
width: width,
height: m.height,
headerRect: tuiRect{width: width, height: len(headerLines)},
bodyRect: tuiRect{y: len(headerLines), width: width, height: bodyHeight},
footerRect: tuiRect{y: footerTop, width: width, height: len(footerLines)},
headerLines: headerLines,
bodyHeight: bodyHeight,
footerLines: footerLines,
fullFooterLines: fullFooterLines,
footerClip: maxInt(0, len(fullFooterLines)-len(footerLines)),
}
frame.composerRect = frame.footerSubrect(viewLines(m.composerBox(width)))
if len(fullFooterLines) > 0 {
frame.statusRect = frame.footerLineRect(len(fullFooterLines) - 1)
}
return frame
}
func (f transcriptFrameLayout) footerSubrect(sequence []string) tuiRect {
if len(sequence) == 0 || len(f.footerLines) == 0 {
return tuiRect{}
}
top := lineSequenceIndex(f.fullFooterLines, sequence)
if top < 0 {
return tuiRect{}
}
visibleTop := maxInt(top, f.footerClip)
visibleBottom := minInt(top+len(sequence), f.footerClip+len(f.footerLines))
if visibleTop >= visibleBottom {
return tuiRect{}
}
return tuiRect{
y: f.footerRect.y + visibleTop - f.footerClip,
width: f.width,
height: visibleBottom - visibleTop,
}
}
func (f transcriptFrameLayout) footerLineRect(line int) tuiRect {
if line < f.footerClip || line >= f.footerClip+len(f.footerLines) {
return tuiRect{}
}
return tuiRect{
y: f.footerRect.y + line - f.footerClip,
width: f.width,
height: 1,
}
}
func (m model) scrollableTranscriptView(header string, body string, footer string, width int, overlay string) string {
return m.scrollableTranscriptLayoutView(header, transcriptBodyLayout{lines: viewLines(body)}, footer, width, overlay)
}
func (m model) scrollableTranscriptLayoutView(header string, body transcriptBodyLayout, footer string, width int, overlay string) string {
frame := m.scrollableTranscriptFrame(header, footer)
window := transcriptViewportForLayout(body, frame, m.chatScrollOffset).window()
bodyWindow := body.visibleLines(window)
return m.renderScrollableTranscriptWindow(frame, bodyWindow, window, width, overlay)
}
func (m model) scrollableTranscriptItemsView(header string, items []transcriptBodyItem, footer string, width int, overlay string) string {
frame := m.scrollableTranscriptFrame(header, footer)
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
window := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
body := layoutVisibleTranscriptBodyItems(items, metrics, window)
return m.renderScrollableTranscriptWindow(frame, body.lines, window, width, overlay)
}
func (m model) renderScrollableTranscriptWindow(frame transcriptFrameLayout, bodyWindow []string, window transcriptViewportWindow, width int, overlay string) string {
for len(bodyWindow) < window.height {
bodyWindow = append(bodyWindow, "")
}
bodyWindow = overlayViewportLines(bodyWindow, overlay, width)
lines := make([]string, 0, len(frame.headerLines)+len(bodyWindow)+len(frame.footerLines))
lines = append(lines, frame.headerLines...)
lines = append(lines, bodyWindow...)
lines = append(lines, frame.footerLines...)
for index, line := range lines {
lines[index] = fitStyledLine(line, width)
}
return strings.Join(lines, "\n")
}
func overlayViewportLines(lines []string, overlay string, width int) []string {
if strings.TrimSpace(overlay) == "" || len(lines) == 0 {
return lines
}
overlayLines := viewLines(overlay)
if len(overlayLines) == 0 {
return lines
}
left, overlayLines, overlayWidth := normalizeOverlayBlock(overlayLines, width)
if overlayWidth <= 0 {
return lines
}
// Scrim: dim the whole transcript backdrop so a floating overlay (slash-command
// palette, picker, wizard) clearly stands out instead of blending into the live
// chat behind it. Header and composer are rendered separately and stay bright.
for index := range lines {
lines[index] = scrimViewportLine(lines[index], width)
}
start := maxInt(0, (len(lines)-len(overlayLines))/2)
for offset, line := range overlayLines {
target := start + offset
if target >= len(lines) {
break
}
lines[target] = overlayViewportLine(lines[target], line, left, overlayWidth, width)
}
return lines
}
// scrimViewportLine dims one backdrop line: it strips the line's own colors and
// re-renders the text faint, so the dimmed transcript recedes behind the overlay.
// Blank lines are left untouched.
func scrimViewportLine(line string, width int) string {
plain := ansi.Strip(line)
if strings.TrimSpace(plain) == "" {
return line
}
return zeroTheme.faint.Render(plain)
}
func normalizeOverlayBlock(lines []string, width int) (int, []string, int) {
left := -1
for _, line := range lines {
if strings.TrimSpace(ansi.Strip(line)) == "" {
continue
}
spaces := leadingPlainSpaces(line)
if left < 0 || spaces < left {
left = spaces
}
}
if left < 0 {
left = 0
}
left = minInt(left, maxInt(0, width-1))
trimmed := make([]string, 0, len(lines))
blockWidth := 0
for _, line := range lines {
if left > 0 && len(line) >= left {
line = line[left:]
}
trimmed = append(trimmed, line)
blockWidth = maxInt(blockWidth, lipgloss.Width(line))
}
blockWidth = minInt(blockWidth, maxInt(0, width-left))
return left, trimmed, blockWidth
}
func leadingPlainSpaces(line string) int {
spaces := 0
for spaces < len(line) && line[spaces] == ' ' {
spaces++
}
return spaces
}
func overlayViewportLine(base string, overlay string, left int, overlayWidth int, width int) string {
if width <= 0 {
return ""
}
left = clampInt(left, 0, width)
overlayWidth = minInt(overlayWidth, width-left)
rightStart := minInt(width, left+overlayWidth)
base = fitStyledLine(base, width)
prefix := padStyledLine(ansi.Cut(base, 0, left), left)
panel := padStyledLine(overlay, overlayWidth)
suffix := padStyledLine(ansi.Cut(base, rightStart, width), width-rightStart)
return prefix + panel + suffix
}
func padStyledLine(line string, width int) string {
if width <= 0 {
return ""
}
line = fitStyledLine(line, width)
if pad := width - lipgloss.Width(line); pad > 0 {
line += strings.Repeat(" ", pad)
}
return line
}
func viewLines(value string) []string {
if value == "" {
return nil
}
return strings.Split(strings.TrimSuffix(value, "\n"), "\n")
}
func (m model) scrollChat(delta int) model {
if !m.altScreen || delta == 0 {
return m
}
viewport, ok := m.chatTranscriptViewport()
if !ok {
return m
}
m.chatScrollOffset = viewport.scroll(delta).offset
if m.chatScrollOffset == 0 {
m.chatBodyLines = 0
}
return m
}
func (m model) chatMaxScrollOffset() int {
_, maxOffset := m.chatScrollMetrics()
return maxOffset
}
func (m model) chatScrollMetrics() (int, int) {
viewport, ok := m.chatTranscriptViewport()
if !ok {
return 0, 0
}
return viewport.totalLines, viewport.maxOffset()
}
func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
if !m.altScreen || m.height <= 0 {
return transcriptViewport{}, false
}
width := m.chatColumnWidth()
if m.transcriptDetailed {
items := m.transcriptBodyItems(width, "", true)
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
footer := m.detailedTranscriptFooter(width)
frame := m.scrollableTranscriptFrame(header, footer)
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
}
items := m.transcriptBodyItems(width, "", false)
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
}
// syncChatScroll pins the viewport to what the user is reading. The scroll offset
// is measured from the bottom, so when the transcript grows (streaming) the window
// would otherwise follow the new bottom and drag the user off their spot. While
// the user has scrolled up, shift the offset by however many lines the body changed
// so the absolute view holds; at the bottom (offset 0) it follows normally. Only the
// scrolled-up path renders the body, so the common case stays cheap.
func (m model) syncChatScroll() model {
if !m.altScreen || m.chatScrollOffset <= 0 {
// At the bottom (or inline mode): follow the tail; reset the pin baseline.
m.chatBodyLines = 0
return m
}
current, maxOffset := m.chatScrollMetrics()
m.chatScrollOffset = clampInt(m.chatScrollOffset, 0, maxOffset)
if m.chatScrollOffset <= 0 {
m.chatBodyLines = 0
return m
}
if m.chatBodyLines == 0 {
// Just scrolled up: establish the baseline, no adjustment this frame.
m.chatBodyLines = current
return m
}
// Shift by the signed delta so the absolute view holds whether the body grew
// (streaming appended lines) or shrank (a tool card collapsed, transcript
// cleared). Clamp at zero so a large shrink lands the user back at the tail
// rather than underflowing past it.
m.chatScrollOffset = clampInt(m.chatScrollOffset+current-m.chatBodyLines, 0, maxOffset)
m.chatBodyLines = current
return m
}
func (m model) chatPageScrollLines() int {
if m.height <= 0 {
return 10
}
return maxInt(3, m.height-8)
}
// interimBlock renders the live assistant text while a turn streams. It uses
// the same lightweight markdown renderer as completed assistant rows, so
// tables and simple formatting stabilize as soon as enough tokens arrive.
// Before the first delta arrives it falls back to the spinner so the surface
// still shows liveness. The cursor needs no ticker — it appears exactly while
// pending.
// liveReasoningBodyCap caps an EXPANDED live ("Thinking…") reasoning block to
// roughly half the screen so it doesn't fill the terminal and its clickable
// toggle header stays on-screen. Returns 0 (no cap) when the height is unknown.
func (m model) liveReasoningBodyCap() int {
if m.height <= 0 {
return 0
}
return maxInt(6, m.height/2)
}
func (m model) interimBlock(width int) string {
text := strings.TrimRight(m.streamingTextString(), "\n")
reasoning := strings.TrimRight(m.streamingReasoning, "\n")
blocks := []string{}
if strings.TrimSpace(reasoning) != "" {
blocks = append(blocks, renderReasoningBlock(reasoning, m.streamingReasoningExpanded, width, true, 0, m.liveReasoningBodyCap()))
}
if strings.TrimSpace(text) == "" {
if writing := m.streamingToolCallView(width); writing != "" {
blocks = append(blocks, writing)
}
blocks = append(blocks, m.workingStatusLine())
// During a long think the reasoning block is collapsed to just its header;
// show a live tail of the streaming reasoning beneath the working line so
// the screen keeps changing (never looks stuck) and the user can see WHAT
// the model is reasoning about. Skipped when expanded (the full body shows).
if reasoning != "" && !m.streamingReasoningExpanded {
blocks = append(blocks, reasoningPreviewLines(reasoning, width)...)
}
return strings.Join(blocks, "\n")
}
// Live streaming block: prose streams normally, but an open fenced code block
// is buffered until its closing fence arrives so the code appears as one
// highlighted block instead of recoloring token-by-token.
lines := renderStreamingAssistantMarkdownText(text, assistantMeasure(width), width)
for index, line := range lines {
// styleStreamingLine fades plain prose but leaves already-highlighted
// markdown/code lines alone, so live colors match the committed row.
lines[index] = m.styleStreamingLine(line, index, len(lines))
}
lines = m.appendStreamingCursor(lines, width)
blocks = append(blocks, strings.Join(lines, "\n"))
// Live preview of a file currently being written, so a long write_file/edit
// shows the code streaming in rather than looking frozen.
if writing := m.streamingToolCallView(width); writing != "" {
blocks = append(blocks, writing)
}
// Always show the live working line (spinner + verb + elapsed) BELOW the
// streamed text so an upstream stall keeps animating, never a frozen screen.
blocks = append(blocks, m.workingStatusLine())
return strings.Join(blocks, "\n")
}
// workingStatusLine renders the live "working" indicator shown on every pending
// render: an animated spinner, the rotating working verb, and the elapsed time.
// It is shown even once partial text has streamed so an upstream stall never
// looks like a frozen terminal — the spinner tick (~80ms, time-based) drives the
// re-render, so the elapsed clock keeps advancing for ANY provider/model even
// when no stream data arrives.
// spinnerGlyph is the liveness glyph every renderer should use instead of
// m.spinner.View() directly: the animated frame normally, or a steady dot under
// reduced motion. The caller applies its own color; liveness is preserved by the
// advancing elapsed timer, so the static glyph never reads as frozen.
func (m model) spinnerGlyph() string {
if m.reducedMotion {
return "•"
}
return m.spinner.View()
}
// workingActivity labels what the agent is doing right now for the working
// status line: "writing" while the final answer streams, otherwise "thinking"
// (reasoning, waiting on the model, or a tool in flight). Cheap and robust — no
// transcript scan — so it can't misreport on a long, output-less step.
func (m model) workingActivity() string {
if strings.TrimSpace(m.streamingTextString()) != "" {
return "writing"
}
return "thinking"
}
// toolCardSuppressedInTranscript reports tools whose transcript card is redundant
// because a dedicated UI surface already shows their state: Task (its specialist
// card) and update_plan (the pinned plan panel + PLAN sidebar). Their session
// events are still recorded; only the visible card is skipped.
func toolCardSuppressedInTranscript(name string) bool {
return name == "Task" || name == "update_plan"
}
func (m model) workingStatusLine() string {
// Cosine ripple FX: "Working" breathes through a cold-to-warm theme ramp, the
// wave moving one character per spinner tick (shared m.spinnerPhase clock). A
// 6-char wavelength fits the 7-letter word so a full oscillation is visible.
// Under reduced motion the phase is frozen, so this renders a static gradient.
working := rippleText("Working", ripplePalette(), m.spinnerPhase, 6)
line := zeroTheme.accent.Render(m.spinnerGlyph()) + " " + working
// Phase label so a long, output-less step reads as live progress rather than a
// frozen screen: "writing" while the answer streams, "thinking" otherwise
// (reasoning, waiting on the model, or running a tool).
line += zeroTheme.faint.Render(" · " + m.workingActivity())
if !m.turnStartedAt.IsZero() {
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.now().Sub(m.turnStartedAt)))
}
// Live token estimate so the working line visibly climbs as the model reasons
// and writes, instead of a static figure. Shown from the start of the turn (at
// 0) so the counter is never missing — the authoritative totals stay in the
// status line and sidebar; this is the at-a-glance "it's generating" pulse.
line += zeroTheme.faint.Render(" · " + m.workingTokenIndicator())
// If the model has gone quiet (no streamed text, reasoning, OR tool-call output
// for a while — common when a provider buffers a large tool call instead of
// streaming it), say so plainly with an advancing timer, so a long silent
// generation never reads as a frozen screen. Only on the working line when the
// context sidebar isn't showing it — the sidebar's ACTIVITY pulse carries it
// whenever the sidebar is up, so it never appears in both places at once.
if !m.sidebarActive() {
if hint := m.quietGenerationHint(); hint != "" {
line += zeroTheme.amber.Render(" · " + hint)
}
}
// A second line carries live plan progress (how far along + the current step)
// so a long working stretch shows the task advancing without consulting the
// sidebar. Replaces the old per-call update_plan transcript cards. Empty when
// there is no active plan.
if planLine := m.workingPlanLine(); planLine != "" {
line += "\n" + planLine
}
return line
}
// workingPlanLine is the optional second line under the working indicator: the
// plan's done/total and the step currently in progress. Empty when there is no
// plan or the plan is already complete.
func (m model) workingPlanLine() string {
if m.plan.isEmpty() || m.plan.isComplete() {
return ""
}
total := len(m.plan.steps)
done := 0
for _, step := range m.plan.steps {
if step.status == "completed" || step.status == "failed" {
done++
}
}
text := fmt.Sprintf("· plan %d/%d", done, total)
if current := truncateStep(currentStepContent(m.plan.steps), 48); current != "" {
text += " · " + current
}
return " " + zeroTheme.faint.Render(text)
}
// workingTokenIndicator renders a live "↑ <n> tok" estimate of the tokens
// generated so far in the current turn, so the working line keeps moving while
// the model reasons and writes. It is shown for the whole turn — starting at
// "↑ 0 tok" before the first delta and climbing — so the counter never blinks
// out. Providers only report exact usage when a step finishes, so this estimates
// from the streamed reasoning+answer length at the usual ~4 characters per
// token; turnStreamedRunes accumulates across the whole turn (it survives the
// per-segment buffer clears), giving a monotonic climb that resets on the next
// turn.
func (m model) workingTokenIndicator() string {
tokens := m.turnStreamedRunes / 4
if m.turnStreamedRunes > 0 && tokens < 1 {
tokens = 1
}
return "↑ " + humanCount(tokens) + " tok"
}
// quietWorkingHint is how long the stream must be silent (no streamed text,
// reasoning, or tool-call output) during an active turn before the working line
// calls out that it's still generating — so a provider that buffers a big tool
// call (instead of streaming the file as it's written) doesn't read as stuck.
const quietWorkingHint = 8 * time.Second
// quietGenerationHint returns a "still generating…" cue with an advancing
// quiet-timer when the active turn has produced no streamed output for a while,
// else "". The advancing number is itself the liveness signal.
//
// Past half the provider's idle timeout, the cue escalates to name what's
// actually happening and when Zero will act on its own: a heartbeating-but-
// silent stream (observed on chatgpt/gpt-5.x and ollama reasoning models,
// see providerio.ErrStreamStalled) is bounded by the content-stall watchdog at
// providerio.ContentStallTimeout(idle), but until it fires this exact same
// plain "still generating… Xs" text is indistinguishable from a genuine hang —
// the ticking number was the only signal, and it looks identical whether real
// (if slow) content is coming or nothing ever will.
func (m model) quietGenerationHint() string {
if m.activeRunID == 0 {
return ""
}
last := m.lastStreamActivity
if last.IsZero() {
last = m.turnStartedAt
}
if last.IsZero() {
return ""
}
quiet := m.now().Sub(last)
if quiet < quietWorkingHint {
return ""
}
if idleTimeout := providerio.ResolveStreamIdleTimeout(0); idleTimeout > 0 && quiet >= idleTimeout/2 {
ceiling := providerio.ContentStallTimeout(idleTimeout)
return fmt.Sprintf("still generating… %s — unusually quiet, Zero will auto-recover by ~%s if it doesn't resume", formatWorkingElapsed(quiet), formatWorkingElapsed(ceiling))
}
return "still generating… " + formatWorkingElapsed(quiet)
}
// formatWorkingElapsed renders a turn's running time compactly: "8s", "1m04s".
func formatWorkingElapsed(d time.Duration) string {
if d < 0 {
d = 0
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
return fmt.Sprintf("%dm%02ds", int(d/time.Minute), int(d.Seconds())%60)
}
// reasoningPreviewLines renders the last 1-2 lines of the in-flight reasoning
// stream as a dimmed preview so a long "Thinking" phase shows live, changing
// content instead of a static header. Each line shows its TAIL (the most recent
// text) so a single continuously-growing reasoning line still visibly moves as
// tokens arrive. Returns nil when there is no reasoning text.
func reasoningPreviewLines(reasoning string, width int) []string {
var lines []string
for _, raw := range strings.Split(strings.TrimSpace(reasoning), "\n") {
if t := strings.TrimSpace(raw); t != "" {
lines = append(lines, t)
}
}
if len(lines) == 0 {
return nil
}
if len(lines) > 2 {
lines = lines[len(lines)-2:]
}
avail := width - 2
if avail < 8 {
avail = 8
}
out := make([]string, 0, len(lines))
for _, line := range lines {
out = append(out, " "+zeroTheme.faint.Render(previewTail(line, avail)))
}
return out
}
// previewTail returns the last `width` runes of s, prefixed with "…" when text
// was dropped, so a streaming preview shows the newest content. s is plain text
// (reasoning deltas carry no ANSI), so rune counting is a safe width proxy.
func previewTail(s string, width int) string {
runes := []rune(s)
if width <= 0 || len(runes) <= width {
return s
}
if width == 1 {
return string(runes[len(runes)-1:])
}
return "…" + string(runes[len(runes)-(width-1):])
}
func (m model) appendStreamingCursor(lines []string, width int) []string {
// Pulse the caret on the shared spinner clock so the typing edge reads as alive
// even during fade-tick gaps or upstream stalls. Width-stable (bright ↔ dim,
// never on/off, so the line never jitters). Steady bright under reduced motion.
cursor := zeroTheme.accent.Render("▌")
if !m.reducedMotion && (m.spinnerPhase/6)%2 == 1 {
cursor = zeroTheme.faint.Render("▌")
}
if len(lines) == 0 {
return []string{cursor}
}
last := len(lines) - 1
if width > 0 && lipgloss.Width(lines[last])+1 > width {
return append(lines, cursor)
}
lines[last] += cursor
return lines
}
// composerLine renders the borderless composer.
func (m model) composerLine(width int) string {
input := m.input
if m.hasQueuedMessage() {
input.Placeholder = queuedEditHint
}
hideInputForSuggestions := m.suggestionsActive() && (!m.suggestionsAreFiles || fileSuggestionOnlyInput(m.input.Value()))
if hideInputForSuggestions {
input.SetValue("")
input.Placeholder = ""
input.CursorEnd()
}
state := composerState{text: input.Value(), cursor: input.Position()}
if m.composerActive {
state = m.composer
}
if hideInputForSuggestions {
state = composerState{}
}
argumentHint := commandArgumentHintForInput(input.Value())
if argumentHint != "" && input.Position() != len([]rune(input.Value())) {
argumentHint = ""
}
if argumentHint != "" {
input.SetWidth(0)
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint, m.composerCursorVisible), width)
}
previews := validComposerPastePreviews(state, m.composerPastePreviews)
displayState := composerDisplayStateForPastePreviews(state, previews)
displaySelection := composerSelectionState{}
if start, end, ok := m.composerSelection.rangeFor(state); ok {
displaySelection = composerSelectionState{
active: true,
anchor: composerDisplayCursorForPastePreviews(start, previews),
cursor: composerDisplayCursorForPastePreviews(end, previews),
}
}
return renderComposerInput(input, displayState, width, m.composerCursorVisible, displaySelection)
}
type composerVisualLine struct {
first bool
start int
end int
}
func renderComposerInput(input textinput.Model, state composerState, width int, cursorVisible bool, selection composerSelectionState) string {
state = normalizeComposerState(state)
if width <= 0 {
return ""
}
if state.text == "" {
// Empty box: show a (blinking) cursor before the placeholder so the focused
// input always has a visible caret. A plain space when blinked off keeps the
// placeholder column stable.
cursor := " "
if cursorVisible {
cursor = composerCursor(" ")
}
return fitStyledLine(composerVisualLinePrefix(input, true)+cursor+zeroTheme.faint.Render(input.Placeholder), width)
}
segments, cursorLine := composerVisibleVisualLines(input, state, width)
lines := make([]string, 0, len(segments))
for index, segment := range segments {
lines = append(lines, fitStyledLine(renderComposerVisualLine(input, state, segment, index == cursorLine, cursorVisible, selection), width))
}
return strings.Join(lines, "\n")
}
func composerVisibleVisualLines(input textinput.Model, state composerState, width int) ([]composerVisualLine, int) {
segments := composerWrappedVisualLines(input, state, width)
cursorLine := composerCursorVisualLine(segments, state.cursor)
if len(segments) <= composerMaxVisibleLines {
return segments, cursorLine
}
start := clamp(cursorLine-composerMaxVisibleLines+1, 0, len(segments)-composerMaxVisibleLines)
end := start + composerMaxVisibleLines
cursorLine -= start
segments = segments[start:end]
if len(segments) > 0 {
segments[0].first = true
}
return segments, cursorLine
}
func composerWrappedVisualLines(input textinput.Model, state composerState, width int) []composerVisualLine {
runes := []rune(state.text)
segments := []composerVisualLine{}
first := true
start := 0
for index, r := range runes {
if r != '\n' {
continue
}
segments = appendComposerWrappedVisualLines(segments, input, runes, start, index, width, &first)
start = index + 1
}
segments = appendComposerWrappedVisualLines(segments, input, runes, start, len(runes), width, &first)
return segments
}
func appendComposerWrappedVisualLines(segments []composerVisualLine, input textinput.Model, runes []rune, start int, end int, width int, first *bool) []composerVisualLine {
if start >= end {
segments = append(segments, composerVisualLine{first: *first, start: start, end: end})
*first = false
return segments
}
for start < end {
lineFirst := *first
measure := maxInt(1, width-lipgloss.Width(composerVisualLinePrefix(input, lineFirst)))
split := start
used := 0
for split < end {
nextWidth := lipgloss.Width(string(runes[split]))
if used+nextWidth > measure {
break
}
used += nextWidth
split++
}
if split == start {
split++
}
segments = append(segments, composerVisualLine{first: lineFirst, start: start, end: split})
*first = false
start = split
}
return segments
}
func composerCursorVisualLine(segments []composerVisualLine, cursor int) int {
if len(segments) == 0 {
return 0
}
for index, segment := range segments {
if cursor < segment.start || cursor > segment.end {
continue
}
if cursor == segment.end && index+1 < len(segments) && segments[index+1].start == cursor {
continue
}
return index
}
return len(segments) - 1
}
func renderComposerVisualLine(input textinput.Model, state composerState, segment composerVisualLine, hasCursor bool, cursorVisible bool, selection composerSelectionState) string {
runes := []rune(state.text)
prefix := composerVisualLinePrefix(input, segment.first)
textStyle := zeroTheme.ink.Inline(true)
selectionStart, selectionEnd, hasSelection := selection.rangeFor(state)
cursorIndex := -1
if hasCursor && !hasSelection {
cursorIndex = clamp(state.cursor, segment.start, segment.end)
}
var line strings.Builder
line.WriteString(prefix)
for index := segment.start; index < segment.end; index++ {
cell := string(runes[index])
switch {
case index == cursorIndex && cursorVisible:
line.WriteString(composerCursor(cell))
case hasSelection && index >= selectionStart && index < selectionEnd:
line.WriteString(zeroTheme.selection.Render(cell))
default:
line.WriteString(textStyle.Render(cell))
}
}
if cursorIndex == segment.end && cursorVisible {
line.WriteString(composerCursor(" "))
}
return line.String()
}
func composerVisualLinePrefix(input textinput.Model, first bool) string {
if first {
return zeroTheme.userPrompt.Render(input.Prompt)
}
return " "
}
func composerDisplayStateForPastePreviews(state composerState, previews []composerPastePreview) composerState {
state = normalizeComposerState(state)
valid := validComposerPastePreviews(state, previews)
if len(valid) == 0 {
return state
}
runes := []rune(state.text)
display := make([]rune, 0, len(runes))
last := 0
for _, preview := range valid {
display = append(display, runes[last:preview.start]...)
display = append(display, []rune(preview.label)...)
last = preview.end
}
display = append(display, runes[last:]...)
return composerState{
text: string(display),
cursor: composerDisplayCursorForPastePreviews(state.cursor, valid),
}
}
func composerDisplayCursorForPastePreviews(cursor int, previews []composerPastePreview) int {
delta := 0
for _, preview := range previews {
labelLen := len([]rune(preview.label))
hiddenLen := preview.end - preview.start
displayStart := preview.start + delta
switch {
case cursor <= preview.start:
return cursor + delta
case cursor <= preview.end:
return displayStart + labelLen
default:
delta += labelLen - hiddenLen
}
}
return cursor + delta
}
func (m model) moveComposerVisualCursor(direction int) (model, bool) {
if direction == 0 {
return m, false
}
width := chatWidth(m.width)
if width < 8 {
return m, false
}
input := m.input
state := m.currentComposerState()
state = normalizeComposerState(state)
if state.text == "" {
return m, false
}
previews := validComposerPastePreviews(state, m.composerPastePreviews)
displayState := composerDisplayStateForPastePreviews(state, previews)
segments := composerWrappedVisualLines(input, displayState, maxInt(1, width-4))
if len(segments) <= 1 {
return m, false
}
cursorLine := composerCursorVisualLine(segments, displayState.cursor)
targetLine := clamp(cursorLine+direction, 0, len(segments)-1)
if targetLine == cursorLine {
return m, true
}
column := composerVisualCursorColumn(displayState, segments[cursorLine])
displayState.cursor = composerCursorForVisualColumn(displayState, segments[targetLine], column)
state.cursor = composerOriginalCursorForPastePreviews(displayState.cursor, previews)
m.setComposerState(state)
return m, true
}
func composerOriginalCursorForPastePreviews(displayCursor int, previews []composerPastePreview) int {
if len(previews) == 0 {
return displayCursor
}
delta := 0
for _, preview := range previews {
labelLen := len([]rune(preview.label))
hiddenLen := preview.end - preview.start
displayStart := preview.start + delta
displayEnd := displayStart + labelLen
switch {
case displayCursor <= displayStart:
return displayCursor - delta
case displayCursor <= displayEnd:
return preview.end
default:
delta += labelLen - hiddenLen
}
}
return displayCursor - delta
}
func composerVisualCursorColumn(state composerState, segment composerVisualLine) int {
state = normalizeComposerState(state)
runes := []rune(state.text)
cursor := clamp(state.cursor, segment.start, segment.end)
column := 0
for index := segment.start; index < cursor && index < len(runes); index++ {
column += lipgloss.Width(string(runes[index]))
}
return column
}
func composerCursorForVisualColumn(state composerState, segment composerVisualLine, column int) int {
state = normalizeComposerState(state)
runes := []rune(state.text)
used := 0
for index := segment.start; index < segment.end && index < len(runes); index++ {
width := lipgloss.Width(string(runes[index]))
if used+width > column {
return index
}
used += width
}
return segment.end
}
func commandArgumentHintComposerLine(input textinput.Model, argumentHint string, cursorVisible bool) string {
hintRunes := []rune(argumentHint)
if len(hintRunes) == 0 {
return input.View()
}
displayValue := strings.TrimRightFunc(input.Value(), unicode.IsSpace)
// This alternate composer path must follow the same caret contract as
// renderComposerInput: hidden while the terminal is unfocused and blinking
// per composerCursorVisible, not a permanently painted cursor cell.
cursor := zeroTheme.faint.Render(string(hintRunes[0]))
if cursorVisible {
cursor = composerCursor(cursor)
}
return zeroTheme.userPrompt.Render(input.Prompt) +
zeroTheme.ink.Inline(true).Render(displayValue) +
zeroTheme.faint.Render(" ") +
cursor +
zeroTheme.faint.Render(string(hintRunes[1:]))
}
func composerCursor(char string) string {
return zeroTheme.selection.Render(char)
}
func commandArgumentHintForInput(value string) string {
command := parseCommand(value)
if command.name == "" || strings.TrimSpace(command.text) != "" {
return ""
}
return commandRequiredInputHint(command.name)
}
func (m model) composerBox(width int) string {
if width < 8 {
return fitStyledLine(m.composerLine(width), width)
}
innerWidth := maxInt(1, width-4)
content := m.composerLine(innerWidth)
lines := strings.Split(content, "\n")
rendered := make([]string, 0, len(lines)+3)
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", width-2)+"╮"))
// Attachment chips ([Image #1] …) render INSIDE the box, above the input line,
// instead of as a separate row above the box.
if chips := renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments); chips != "" {
fitted := fitStyledLine(zeroTheme.muted.Render(chips), innerWidth)
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
}
for _, line := range lines {
fitted := fitStyledLine(line, innerWidth)
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
}
rendered = append(rendered, m.composerDividerLine(width))
return strings.Join(rendered, "\n")
}
// composerDescriptionHint returns the description line that sits below the
// composer box, claude-code style, when the input is a single unambiguous
// slash command. Returns "" when the user is mid-prompt, the palette is closed,
// or more than one command matches. Slash commands only; the @file palette
// already shows its rows. The inline argument hint ([low|medium|...]) is
// unchanged and continues to render inside the composer box.
func (m model) composerDescriptionHint(width int) string {
if width < 8 {
return ""
}
if m.suggestionsAreFiles {
return ""
}
if !m.commandPaletteOpen || len(m.suggestions) != 1 {
return ""
}
if m.suggestionIdx != 0 {
return ""
}
value := strings.TrimSpace(m.input.Value())
if !strings.HasPrefix(value, "/") || strings.ContainsAny(value, " \t\n") {
return ""
}
suggestion := m.suggestions[0]
desc := strings.TrimSpace(suggestion.Desc)
if desc == "" {
return ""
}
return fitStyledLine(zeroTheme.muted.Render(desc), width)
}
// startsTurn reports whether a row begins a new conversational turn and therefore
// gets a blank line of separation above it (tool rows stay grouped together).
func startsTurn(kind rowKind) bool {
switch kind {
case rowUser, rowAssistant, rowSystem, rowError:
return true
default:
return false
}
}
// isToolCardKind reports whether a row renders as a tool card (a running call or
// its collapsed result). Used to add one blank line between consecutive tool
// cards in a turn. Specialist cards are excluded — they own their own grouping
// (summary line + injected spacing) and must not be double-spaced.
func isToolCardKind(kind rowKind) bool {
return kind == rowToolCall || kind == rowToolResult
}
func needsSeparatorBeforeToolCard(previous rowKind, current rowKind) bool {
if !isToolCardKind(current) {
return false
}
return isToolCardKind(previous) || previous == rowAssistant || previous == rowUser
}
func shouldRuleBeforeTurn(previous rowKind, current rowKind) bool {
return current == rowAssistant && isToolCardKind(previous)
}
func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
if m.pendingPermission == nil {
return m, nil
}
key := strings.ToLower(msg.String())
for _, option := range permissionOptions(m.pendingPermission.request) {
if option.hotkey == key {
return m.resolvePermission(option.choice)
}
}
return m, nil
}
func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cmd) {
pending := m.pendingPermission
if pending == nil {
return m, nil
}
if pending.decide != nil {
pending.decide(agent.PermissionDecision{
Action: decision,
Reason: permissionDecisionReason(decision),
})
}
m.pendingPermission = nil
return m, nil
}
func permissionDecisionReason(decision permissionDecision) string {
switch decision {
case permissionDecisionAllow:
return "approved in TUI"
case permissionDecisionAllowStrict:
return "approved with model review request in TUI"
case permissionDecisionAllowForSession:
return "approved for this session in TUI"
case permissionDecisionAllowPrefix:
return "approved command prefix for this session in TUI"
case permissionDecisionAlwaysAllowPrefix:
return "persistently approved command prefix in TUI"
case permissionDecisionAlwaysAllow:
return "persistently approved in TUI"
case permissionDecisionCancel:
return "cancelled in TUI"
case permissionDecisionDeny:
return "denied in TUI"
default:
return "denied in TUI"
}
}
// choosePicker applies the highlighted picker item through the same handler the
// typed command would have used, appends the resulting status text, and closes
// the picker. Behavior is identical to running "/model <id>" or "/effort <v>".
func (m model) choosePicker() (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
picker := m.picker
if picker != nil && picker.kind == pickerModel {
m.clearModelPickerLoadState()
}
m.picker = nil
if picker == nil {
return m, nil
}
item, ok := picker.current()
if !ok {
if picker.kind == pickerTheme {
// No selectable row (e.g. the filter matched nothing): undo any live
// preview so the palette matches the committed m.themeMode.
m.restoreCommittedTheme()
}
return m, nil
}
var cmd tea.Cmd
switch picker.kind {
case pickerModel:
text := ""
owner := strings.TrimSpace(item.OwnerProvider)
_, ownerIsSavedProvider := m.savedProviderByName(owner)
if owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) && ownerIsSavedProvider {
// A model from another saved provider: switch provider + model together.
m, text, _, cmd = m.switchProviderModel(owner, item.Value)
} else {
// OwnerProvider is blank, matches the active provider, or (registry-fallback
// / stale-history rows) doesn't resolve to any saved provider: apply against
// the active provider instead of attempting an unresolvable provider switch.
m, text = m.handleModelCommand(item.Value)
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
case pickerEffort:
text := ""
m, text = m.handleEffortCommand(item.Value)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
case pickerSession:
// item.Value is the chosen session id; handleResumeCommand hydrates it and
// rebuilds the transcript (returning "" on success, an error note on failure).
text := ""
m, text = m.handleResumeCommand(item.Value)
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerSkill:
// Fill the composer with "/name " so the user adds their request before
// submitting (a bare second Enter runs it without one); names the slash
// path cannot reach run immediately instead.
m, cmd = m.chooseSkillFromPicker(item)
case pickerSTTModel:
// Selecting the local engine with no model (and auto-download available)
// chains into the variant-download picker instead of finalizing.
if next, fetchCmd, opened := m.maybeOpenSTTDownloadPicker(item.Value); opened {
return next, fetchCmd
}
text := ""
m, text = m.handleSTTModelSelection(item.Value)
if text != "" { // empty when a key prompt opened instead of finalizing
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
case pickerSTTDownload:
return m.handleSTTDownloadSelection(item.Value)
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
text := ""
m, text = m.handleThemeCommand(item.Value)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
if m.themeMode == themeAuto {
// Re-probe the terminal background so a committed `auto` re-detects
// light/dark instead of reusing the preview's reading — mirrors the
// text /theme dispatch (M17).
return m, tea.RequestBackgroundColor
}
}
return m, cmd
}
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
if !m.suggestionsActive() || len(m.suggestions) == 0 {
return m, nil
}
wasFiles := m.suggestionsAreFiles
wasDirectory := m.selectedSuggestionIsDirectory()
requiresInput := m.selectedCommandSuggestionRequiresInput()
next := m.completeSuggestion()
if !wasFiles {
next.resetComposerFromInput()
}
if wasFiles && wasDirectory {
next.recomputeSuggestions()
return next, nil
}
if !wasFiles {
if requiresInput {
return next, nil
}
return next.handleSubmit()
}
return next, nil
}
func (m model) handleSubmit() (tea.Model, tea.Cmd) {
input := m.composerValue()
// A drag-dropped image/PDF path that reached the composer (e.g. inserted as
// text) attaches instead of being parsed as an unknown "/…" command.
if path, ok := droppedAttachmentPath(input, m.cwd); ok {
m = m.handleImageCommand(path)
m.clearComposer()
m.clearSuggestions()
return m, nil
}
command := parseCommand(input)
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
// repeat of that same command only; any other submission — including one queued
// or deferred by the early returns below — disarms it. Runs before those returns
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
m.loopLeavePrompt = commandEmpty
}
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
// new run must not start: the deferred tea.Quit would abort it mid-flight
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
if command.kind == commandPrompt && m.exiting {
return m, nil
}
if command.kind == commandPrompt && m.pending {
return m.queueMessage(command.text), nil
}
if command.kind == commandPrompt && m.compactInFlight {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Compact\nstatus: warning\nCompaction is running. Your next prompt will use the compacted context when this finishes.",
})
return m, nil
}
m.rememberInput(input)
m.clearComposer()
m.clearSuggestions()
// Snap the viewport back to the bottom for a real submission, but not for an
// empty Enter (a no-op) — that would yank the user away from wherever they
// had scrolled without anything actually being submitted.
if command.kind != commandEmpty {
m.chatScrollOffset = 0
}
return m.dispatchCommand(command)
}
// dispatchCommand runs a parsed slash/prompt command after submit/leader
// preamble (history, composer clear, leave-prompt disarm) has already run.
func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
switch command.kind {
case commandEmpty:
return m, nil
case commandHelp:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: helpText()})
return m, nil
case commandClear:
// A foreground loop keeps firing after /clear (it wipes the screen, not the
// session), so warn once before clearing the context it will run into.
if m.loopActive() && m.loopLeavePrompt != commandClear {
m.loopLeavePrompt = commandClear
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /clear keeps them firing behind a cleared screen. Run /clear again to confirm, or /loop stop all first.")
return m, nil
}
m.loopLeavePrompt = commandEmpty
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
// Clearing wipes the visible transcript only — the session's context is
// intact, so the next prompt still replays the full history. Say so, and
// point to /new, so "cleared screen" isn't mistaken for "fresh context."
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Transcript cleared. The agent still has the full session context — use /new to start a fresh session."})
// Scrollback above can't be un-printed; a faint divider marks where the
// cleared surface ended and the frontier restarts for the fresh transcript.
m.resetFlushFrontier("· cleared ·")
// /clear wipes the transcript but not the session, so loops keep running;
// say so rather than let them silently fire into a "cleared" screen.
if m.loopActive() {
m = m.appendLoopSystem(m.loopFooterSummary() + " still running — /loop stop all to end them.")
}
return m, nil
case commandNew:
// A fresh session mid-run would strand the in-flight turn's events; make the
// user cancel first. Idle, /new saves the current session (already on disk)
// and clears the conversation in place.
if m.pending || m.compactInFlight {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "A run is in progress. Press Esc to cancel it first, then /new."})
return m, nil
}
return m.startNewSession(), nil
case commandLoop:
return m.handleLoopCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
if m.loopActive() && m.loopLeavePrompt != commandExit {
m.loopLeavePrompt = commandExit
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
return m, nil
}
m.loopLeavePrompt = commandEmpty
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
// defer the quit until its checkpoint session events flush — quitting
// immediately would orphan the blobs and break /rewind.
m.cancelRun()
m.exiting = true
if len(m.flushRunIDs) > 0 {
return m, nil
}
return m.quit()
case commandTools:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
return m, nil
case commandSkills:
// With skills installed, /skills opens a searchable picker (like /model);
// the text card remains only as the no-skills install hint.
if picker := m.newSkillPicker(); picker != nil {
m.picker = picker
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
return m, nil
case commandMCP:
if strings.TrimSpace(command.text) == "" {
return m.openMCPManager(), nil
}
return m.startMCPTranscriptCommand(command.text)
case commandPermissions:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.permissionsText()})
return m, nil
case commandPS:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.backgroundTerminalsText()})
return m, nil
case commandStop:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.stopBackgroundTerminalsText(command.text)})
return m, nil
case commandSandboxSetup:
return m.startSandboxSetupCommand(command.text)
case commandProvider:
arg := strings.ToLower(strings.TrimSpace(command.text))
if arg == "" || arg == "add" {
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
// Bare /provider opens the list-first manager over the saved
// providers; /provider add jumps straight into the add wizard.
if arg == "add" {
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
return m.openProviderManager()
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
return m, nil
case commandModel:
if strings.TrimSpace(command.text) == "" {
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
next, cmd := m.openModelPicker()
if next.picker != nil {
return next, cmd
}
}
text := ""
m, text = m.handleModelCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandSTTModel:
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
return m.openSTTModelPicker()
case commandVoice:
return m.toggleVoiceMode()
case commandContext:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()})
return m, nil
case commandConfig:
if arg := strings.ToLower(strings.TrimSpace(command.text)); arg != "" {
var text string
m, text = m.handleConfigCommand(arg)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.configText()})
return m, nil
case commandDebug:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.debugText()})
return m, nil
case commandPlan:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()})
return m, nil
case commandDoctor:
return m.startDoctorCommand(command.text)
case commandSearch:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.searchText(command.text)})
return m, nil
case commandResume:
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "Cannot resume sessions while a run is active.",
})
return m, nil
}
// Bare `/resume` opens an interactive session picker (like /model & /provider);
// `/resume <id>` and `/resume latest` still resolve directly. The picker falls
// back to the text path when there is nothing to resume.
if strings.TrimSpace(command.text) == "" {
if next, ok := m.openSessionPicker(); ok {
return next, nil
}
}
text := ""
m, text = m.handleResumeCommand(command.text)
if strings.HasPrefix(text, sessionsCardsPrefix) {
// The list payload renders as stacked session cards, not a note.
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowSystem,
tool: "sessions",
text: strings.TrimPrefix(text, sessionsCardsPrefix),
})
} else if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
return m, nil
case commandRetitle:
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "Cannot retitle sessions while a run is active.",
})
return m, nil
}
text := ""
var retitleCmd tea.Cmd
m, retitleCmd, text = m.startSessionRetitle()
if text != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
return m, retitleCmd
case commandSpec:
return m.handleSpecCommand(command.text)
case commandInit:
return m.handleInitCommand()
case commandCompact:
text := ""
var compactCmd tea.Cmd
m, text, compactCmd = m.handleCompactCommand(command.text)
m = m.setCompactStatusRow(text)
return m, compactCmd
case commandTranscript:
return m.toggleDetailedTranscript(), nil
case commandRewind:
text := ""
m, text = m.handleRewindCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandEffort:
if strings.TrimSpace(command.text) == "" {
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
if picker := m.newEffortPicker(); picker != nil {
m.picker = picker
return m, nil
}
}
text := ""
m, text = m.handleEffortCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandStyle:
text := ""
m, text = m.handleStyleCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandSelfCorrect:
text := ""
m, text = m.handleSelfCorrectCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTurns:
// Changing the budget mid-run would mutate the inherited ZERO_MAX_TURNS env
// that sub-agents spawned later in THIS run read, making the run's budget
// inconsistent. Require an idle session (the new budget applies next run).
if m.pending && strings.TrimSpace(command.text) != "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Turns\nFinish or stop the current run before changing the tool-turn budget."})
return m, nil
}
text := ""
m, text = m.handleTurnsCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandProfile:
// Same idle-session rule as /turns: switching the profile mutates the
// turn budget (and its ZERO_MAX_TURNS propagation), so a change needs
// an idle session; bare /profile (status) is always allowed.
if m.pending && strings.TrimSpace(command.text) != "" && !strings.EqualFold(strings.TrimSpace(command.text), "status") {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Profile\nFinish or stop the current run before switching the execution profile."})
return m, nil
}
text := ""
m, text = m.handleProfileCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandTheme:
// Bare `/theme` opens the popup picker (live preview on move, apply on
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
// (or `/theme list`) still runs the text handler directly.
if strings.TrimSpace(command.text) == "" {
m.picker = m.newThemePicker()
return m, nil
}
text := ""
m, text = m.handleThemeCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
if m.themeMode == themeAuto {
// Re-probe the terminal background so /theme auto re-detects light/dark
// instead of reusing a stale reading from startup; the BackgroundColorMsg
// handler re-applies the auto palette with the fresh result (M17).
return m, tea.RequestBackgroundColor
}
return m, nil
case commandImage:
m = m.handleImageCommand(command.text)
return m, nil
case commandAddDir:
m = m.handleAddDirCommand(command.text)
return m, nil
case commandUnknown:
// A "/name" not in the builtin registry may be a user-defined command
// from .zero/commands/<name>.md — expand its template and run it as a
// normal prompt before reporting "unknown".
if next, cmd, handled := m.handleUserCommand(command.text); handled {
return next, cmd
}
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
// (deterministic invocation, vs waiting for the model to pull it in).
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
return next, cmd
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "unknown command: " + command.text,
})
return m, nil
case commandBash:
cmdText := strings.TrimSpace(command.text)
if cmdText == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Usage: !<shell command>"})
return m, nil
}
// A "!cmd" shell escape runs OUTSIDE the agent sandbox, so gate it behind
// the explicit unsafe permission mode. In auto/ask mode it is not executed;
// the user is told how to enable it. This keeps a sandbox-bypassing exec
// from running without a deliberate safety posture.
if m.permissionMode != agent.PermissionModeUnsafe {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Shell escape (!) is disabled in " + string(m.permissionMode) + " mode — it bypasses the sandbox. Relaunch with --skip-permissions-unsafe to run shell commands directly.",
})
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "$ " + cmdText})
return m, runBashEscape(m.cwd, cmdText)
case commandRetry:
// /retry launches a run, so it needs the same guards a normal prompt gets:
// never start one while exiting (would strand the shutdown flush) or during
// compaction (would race compactResultMsg's wholesale rewrite of
// transcript/sessionEvents and silently drop events).
if m.exiting {
return m, nil
}
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Retry\ncannot retry while a run is in progress."})
return m, nil
}
if m.compactInFlight {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: "Retry\nstatus: warning\nCompaction is running. Retry once it finishes.",
})
return m, nil
}
if strings.TrimSpace(m.lastPrompt) == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Retry\nno previous prompt to resend."})
return m, nil
}
// Re-stage the attachments the last prompt carried so launchPrompt rebuilds
// an identical request (document preamble + images + vision re-check). Without
// this the queues are empty and /retry would resend a text-only prompt,
// silently dropping the image/PDF context and answering a different task.
m.pendingImages = m.lastImages
m.pendingImageLabels = m.lastImageLabels
m.pendingDocuments = m.lastDocuments
return m.launchPrompt(m.lastPrompt)
case commandEdit:
if strings.TrimSpace(m.lastPrompt) == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Edit\nno previous prompt to recall."})
return m, nil
}
// Re-stage the remembered attachments alongside the recalled text so an
// edited resend carries the same image/PDF context — the reappearing chip
// row is the visible confirmation. Without this, editing a vision- or
// document-backed prompt would silently submit a text-only version and
// answer a different task (the same gap /retry guards against).
m.pendingImages = m.lastImages
m.pendingImageLabels = m.lastImageLabels
m.pendingDocuments = m.lastDocuments
m.input.SetValue(m.lastPrompt)
return m, nil
case commandCopy:
text := m.lastAssistantAnswer()
if strings.TrimSpace(text) == "" {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Copy\nno answer to copy yet."})
return m, nil
}
return m, copyTranscriptSelectionCmd(text)
case commandExport:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.handleExportCommand(command.text)})
return m, nil
case commandPrompt:
if intent, ok := detectMCPSetupIntent(command.text); ok {
return m.openMCPAddWizardFromIntent(intent), nil
}
return m.launchPrompt(command.text)
default:
return m, nil
}
}
// executeSlash runs a builtin slash command as if the user typed it and pressed
// Enter, without clearing the composer draft. Used by Ctrl+X leader chords so a
// mid-type prompt is preserved while /model (etc.) still opens.
func (m model) executeSlash(input string) (tea.Model, tea.Cmd) {
command := parseCommand(input)
if command.kind == commandEmpty || command.kind == commandPrompt {
return m, nil
}
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
m.loopLeavePrompt = commandEmpty
}
m.rememberInput(input)
m.clearSuggestions()
m.chatScrollOffset = 0
return m.dispatchCommand(command)
}
// launchPrompt starts a normal agent turn from text already accepted by the
// composer. Queued prompts use this path too, so session and image behavior
// stays identical to immediate submissions.
func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
// Remember the verbatim prompt (before specialist/document expansion) so /retry
// and /edit can act on exactly what the user submitted. Snapshot the staged
// attachments too: launchPrompt clears the pending queues below, so /retry
// re-stages these to resend an identical vision/PDF-backed request rather than
// a degraded text-only one.
m.lastPrompt = prompt
m.lastImages = m.pendingImages
m.lastImageLabels = m.pendingImageLabels
m.lastDocuments = m.pendingDocuments
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: prompt})
if m.provider == nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendAssistant,
text: "No provider configured. Run `zero setup` (guided) or `zero auth` (OAuth) from a shell, or set a provider API key env var, then relaunch.",
})
return m, nil
}
// A leading "@specialist <task>" is expanded into an explicit Task-delegation
// directive for the agent only; the transcript above keeps the user's verbatim
// "@mention". Non-mentions and mid-message "@file" references are unchanged.
if expanded, ok := expandSpecialistMention(prompt, m.agentOptions.Specialists); ok {
prompt = expanded
}
// Prepend any staged PDF document text as a model-facing preamble. The
// visible transcript above keeps the user's clean prompt; the agent (and the
// recorded session, for resume fidelity) sees the document text first.
if preamble := m.consumePendingDocuments(); preamble != "" {
prompt = preamble + prompt
}
var err error
m, err = m.ensureActiveSession(prompt)
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "session create error: " + err.Error(),
})
} else {
agentPrompt := m.sessionPrompt(prompt)
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "user",
"content": prompt,
})
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "session record error: " + err.Error(),
})
}
prompt = agentPrompt
}
// Re-check vision support against the CURRENT effective model at submit
// time, not just at /image attach time: the user may have attached on a
// vision model and then /model-switched to a non-vision one. If the active
// model can't accept images, drop them (with an inline notice mirroring
// exec's drop+warn wording) rather than sending them to a model that
// rejects them. Pending state is cleared either way below.
turnImages := m.pendingImages
if len(turnImages) > 0 && !m.modelSupportsVisionTUI() {
name := m.modelName
if name == "" {
name = "the active model"
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: fmt.Sprintf("Model %s does not support image input; ignoring %d image(s).", name, len(turnImages)),
})
turnImages = nil
}
m.pendingImages = nil
m.pendingImageLabels = nil
runCtx, cancel := context.WithCancel(m.ctx)
m = m.beginRun(cancel)
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, turnImages), m.spinner.Tick)
}
// beginRun stamps the shared run-start state for a new agent turn: a fresh run
// ID, the cancel func, pending = true, the turn-start timestamp (the source for
// the working status line's live elapsed clock), and a reset working-verb
// rotation so the brand word shows first. Centralized so every launch path
// (normal prompt + spec draft/impl) keeps these in sync — a missing
// turnStartedAt previously dropped the elapsed timer on spec-mode runs.
func (m model) beginRun(cancel context.CancelFunc) model {
if m.prepareRunCompletionWarning != nil {
m.prepareRunCompletionWarning()
}
m.runID++
m.activeRunID = m.runID
m.runCancel = cancel
m.pending = true
// Clear per-run tracking state so stale specialists and plans from the
// previous turn don't bleed into the new one.
m.specialists.clear()
m.plan.clear()
m.stepWork = nil
m.stepNarration = nil
m.stepExplanation = nil
m.planDetailOpen = false
m.planDetailGen++ // invalidate any in-flight step-explanation from the prior run
// A new run clears the sidebar's content (plan/agents), so the user's Ctrl+B
// hide was for the OLD context — reset it so the new run's sidebar isn't
// suppressed by a stale preference.
m.sidebarHidden = false
m.turnStartedAt = m.now()
m.turnStreamedRunes = 0
m.spinnerTicking = true
return m
}
// ensureSpinnerTick returns the spinner.Tick cmd to (re)start the self-scheduling
// tick loop when an active sidebar holds agents to animate but the loop is not
// already running (e.g. a resumed session whose swarm members exist before any
// run started this process). It returns nil — issuing no second timer — when the
// loop is already alive, when reduced motion is set, or when there is nothing to
// animate, so an idle plain session schedules no timer.
func (m *model) ensureSpinnerTick() tea.Cmd {
if m.spinnerTicking || m.reducedMotion {
return nil
}
if !m.sidebarHasAgents() && !m.aimlapiOnboardAnimating() {
return nil
}
m.spinnerTicking = true
return m.spinner.Tick
}
func (m model) launchQueuedMessageIfReady() (model, tea.Cmd) {
if !m.hasQueuedMessage() || m.pending || m.exiting || m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil {
return m, nil
}
prompt := m.queuedMessage
m.queuedMessage = ""
return m.launchPrompt(prompt)
}
// historyRecallActive reports whether ↑/↓ should navigate previously submitted
// inputs: history exists and no modal surface owns the arrow keys.
func (m model) historyRecallActive() bool {
return len(m.inputHistory) > 0 &&
m.pendingAskUser == nil && m.pendingPermission == nil && m.pendingSpecReview == nil
}
// recallHistory steps through submitted inputs (-1 = older, +1 = newer),
// stashing the in-progress draft so stepping back past the newest recalled
// entry restores whatever was being typed.
func (m model) recallHistory(direction int) model {
if m.historyIdx == len(m.inputHistory) {
if direction > 0 {
return m
}
m.historyDraft = m.composerValue()
}
next := clamp(m.historyIdx+direction, 0, len(m.inputHistory))
if next == m.historyIdx {
return m
}
m.historyIdx = next
if next == len(m.inputHistory) {
m.input.SetValue(m.historyDraft)
} else {
m.input.SetValue(m.inputHistory[next])
}
m.input.CursorEnd()
m.resetComposerFromInput()
m.recomputeSuggestions()
return m
}
// rememberInput records a submitted composer value for ↑ recall and resets the
// navigation cursor past the newest entry.
func (m *model) rememberInput(value string) {
trimmed := strings.TrimSpace(value)
if trimmed != "" && (len(m.inputHistory) == 0 || m.inputHistory[len(m.inputHistory)-1] != trimmed) {
m.inputHistory = append(m.inputHistory, trimmed)
}
m.historyIdx = len(m.inputHistory)
m.historyDraft = ""
}
func (m *model) cancelRun() {
if m.runCancel != nil {
m.runCancel()
}
m.clearStreamingToolCall() // a cancelled file-write must not linger into the next run
// A cancelled loop iteration bypasses the agentResponseMsg completion seam (its
// late message is drained through flushRunIDs, not advanceLoop), so clear the
// loop tag here and re-arm the interrupted loop for its next cadence. Otherwise
// the loop is left "running" forever (nextRunAt stays zero) and the NEXT
// unrelated turn would be misattributed as this loop's completion.
if m.activeLoopID != "" {
if l := m.findLoop(m.activeLoopID); l != nil {
if l.mode == loopModeSelfPaced {
l.nextRunAt = m.now().Add(clampSelfPaceDelay(adaptiveSelfPaceDelay(l.iteration)))
} else {
l.nextRunAt = m.now().Add(l.interval)
}
}
m.activeLoopID = ""
}
// Remember the in-flight run — and the session it was recording into — so
// its final agentResponseMsg is still drained for session-event persistence
// after activeRunID is cleared. Otherwise the checkpoint blobs it captured
// before each mutating tool are orphaned on disk and /rewind can't reference
// them; without the session id, a /resume before the flush lands would
// append the old run's events into the newly active session.
if m.pending && m.activeRunID != 0 {
if m.flushRunIDs == nil {
m.flushRunIDs = make(map[int]string)
}
m.flushRunIDs[m.activeRunID] = m.activeSession.SessionID
}
if m.pending {
// A cancelled run must terminate visibly in the transcript: first the
// partial streamed answer (if any), then the cancellation marker — the
// session log gets the same marker below.
if row, ok := reasoningTranscriptRow("", m.activeRunID, m.streamingReasoning); ok {
m.transcript = appendTranscriptRow(m.transcript, row)
}
if text := strings.TrimRight(m.streamingTextString(), "\n"); strings.TrimSpace(text) != "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Run cancelled."})
}
if m.pending && m.activeSession.SessionID != "" {
if next, err := (*m).appendSessionEvent(sessions.EventError, map[string]any{
"message": "Run cancelled.",
}); err == nil {
*m = next
}
}
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.cancelConfirmActive = false // whatever path got here, there's nothing left to confirm cancelling
m.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
m.pendingPermission = nil
m.pendingAskUser = nil
// The interim block renders streamingText live; a cancelled run's partial
// answer must not leak into (and concatenate with) the next turn's stream.
m.streamingText = nil
m.streamingReasoning = ""
m.streamingReasoningExpanded = false
// Hard-stop the fade and drop the per-line age map. The next turn's
// first agentTextMsg will seed a fresh lineAges slice and restart
// the tick.
m.resetStreamingFade()
}
func (m model) runAgent(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock) tea.Cmd {
return m.runAgentWithOptions(runID, runCtx, prompt, images, tuiAgentRunOptions{})
}
// selfCorrectAutonomyForMode maps the active permission mode to the self-correct
// autonomy gate: more autonomous modes auto-fix after a failed verification,
// while restrictive modes only surface the failure. Mirrors exec's --auto levels.
func selfCorrectAutonomyForMode(mode agent.PermissionMode) string {
switch mode {
case agent.PermissionModeUnsafe:
return "high"
case agent.PermissionModeAuto:
return "medium"
default: // ask, etc. — report the failure without starting an auto-fix round
return "low"
}
}
func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock, runOptions tuiAgentRunOptions) tea.Cmd {
return func() tea.Msg {
started := m.now()
// firstTokenAt is stamped when the first token (reasoning or text) streams,
// so the turn can report time-to-first-token alongside total wall time.
var firstTokenAt time.Time
toolCalls := 0
rows := []transcriptRow{}
usageEvents := []zeroruntime.Usage{}
sessionEvents := []pendingSessionEvent{}
usageModelID := m.modelName
var specReview *pendingSpecReviewPrompt
options := m.agentOptions
options.Registry = m.registry
if runOptions.registry != nil {
options.Registry = runOptions.registry
}
options.PermissionMode = m.permissionMode
if runOptions.permissionMode != "" {
options.PermissionMode = runOptions.permissionMode
}
if runOptions.systemPrompt != "" {
options.SystemPrompt = runOptions.systemPrompt
}
options.SessionID = m.activeSession.SessionID
options.ProviderName = m.providerName
options.Model = m.modelName
options.ReasoningEffort = string(m.reasoningEffort)
options.ResponseStyle = m.responseStyle
options.Cwd = m.cwd
options.Images = images
if m.captureRunImages != nil {
m.captureRunImages(images)
}
// Enable agent-loop compaction sized to the active model's context window.
// AgentContextWindow applies a positive fallback for unknown/custom models so
// compaction (proactive + reactive) is enabled for every model, not just
// catalogued ones.
options.ContextWindow = modelregistry.AgentContextWindow(m.modelContextWindow(m.modelName))
// Post-edit self-correction is on by default in the TUI but kept FAST: it
// runs LSP diagnostics over the changed files only — cheap, change-scoped,
// and a no-op when no language server is installed. The project test plan
// (`go test ./...`, whole-repo) is NOT run per edit by default — that would
// add the full suite's latency to every turn and let a pre-existing failure
// hijack the agent — so the test half is opt-in via `/selfcorrect on`
// (m.selfCorrectTests). The spec-draft (planning) path never wires it,
// matching exec; the per-turn lsp.Manager is torn down when this run
// returns; auto-fix vs report-only follows the active permission mode.
if !runOptions.specDraft && options.Cwd != "" {
// Prefer the session-long manager (kept warm across prompts). Only when it
// is absent — e.g. cwd was unknown at construction, or a test built the
// model directly — fall back to a per-run manager that is shut down here.
lspManager := m.lspManager
if lspManager == nil {
lspManager = lsp.NewManager(options.Cwd)
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = lspManager.Shutdown(shutdownCtx)
}()
}
options.SelfCorrect = agent.NewSelfCorrector(options.Cwd, agent.NewLSPDiagnosticsChecker(lspManager), agent.NewProjectVerifier(options.Cwd), agent.SelfCorrectConfig{
Enabled: true,
IncludeTests: m.selfCorrectTests,
IncludeLSP: true,
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
})
// Background post-edit diagnostics: the loop checks files changed by
// edit_file/write_file off the tool-call path and nudges the model with
// any errors before its next request. Shares the run's lazy manager.
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
}
// Some providers synthesize tool-call ids that repeat within a run (e.g.
// Gemini restarts its gemini_tool_N numbering on every provider turn).
// Transcript rows need distinct ids for dedup and call→result collapse,
// so repeats get an ordinal suffix; session payloads keep the provider's
// original ids.
callSeq := map[string]int{}
reasoningText := ""
reasoningSeq := 0
var reasoningStarted time.Time
var reasoningLast time.Time
flushReasoning := func(closedAt time.Time) {
if row, ok := reasoningTranscriptRow(fmt.Sprintf("reasoning_%d", reasoningSeq+1), runID, reasoningText); ok {
if !reasoningStarted.IsZero() {
if closedAt.IsZero() {
closedAt = reasoningLast
}
if !reasoningLast.IsZero() && closedAt.Before(reasoningLast) {
closedAt = reasoningLast
}
if elapsed := closedAt.Sub(reasoningStarted); elapsed > 0 {
row.turnElapsed = elapsed
}
}
reasoningSeq++
rows = append(rows, row)
m.sendAgentRow(runID, row)
}
reasoningText = ""
reasoningStarted = time.Time{}
reasoningLast = time.Time{}
}
onText := options.OnText
options.OnText = func(delta string) {
if firstTokenAt.IsZero() {
firstTokenAt = m.now()
}
if strings.TrimSpace(reasoningText) != "" {
flushReasoning(m.now())
}
m.sendAgentText(runID, delta)
if onText != nil {
onText(delta)
}
}
// Stream a tool call's arguments live so a long write_file/edit shows the
// code being written instead of a frozen spinner (see streamingToolCallView).
options.OnToolCallStart = func(id, name string) {
m.sendToolCallStreamStart(runID, id, name)
}
options.OnToolCallDelta = func(id, fragment string) {
m.sendToolCallStreamDelta(runID, id, fragment)
}
onPermissionRequest := options.OnPermissionRequest
options.OnPermissionRequest = func(ctx context.Context, request agent.PermissionRequest) (agent.PermissionDecision, error) {
if onPermissionRequest != nil {
return onPermissionRequest(ctx, request)
}
if m.runtimeMessageSink == nil {
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission prompt unavailable"}, nil
}
if m.notifier != nil {
m.notifier.Notify(notify.AwaitingInput, notify.DefaultMessage(notify.AwaitingInput))
}
decisionCh := make(chan agent.PermissionDecision, 1)
m.sendPermissionRequest(runID, request, func(decision agent.PermissionDecision) {
select {
case decisionCh <- decision:
default:
}
})
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventPermissionRequest,
Payload: request,
})
select {
case decision := <-decisionCh:
if strings.TrimSpace(decision.Reason) == "" {
decision.Reason = permissionDecisionReason(permissionDecision(decision.Action))
}
return decision, nil
case <-ctx.Done():
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: ctx.Err().Error()}, ctx.Err()
}
}
onAskUser := options.OnAskUser
options.OnAskUser = func(ctx context.Context, request agent.AskUserRequest) (agent.AskUserResponse, error) {
if onAskUser != nil {
return onAskUser(ctx, request)
}
if m.runtimeMessageSink == nil {
// No interactive surface: let the loop degrade gracefully.
return agent.AskUserResponse{}, fmt.Errorf("ask_user prompt unavailable")
}
// Only notify when there is actually something to answer — a request
// with no questions auto-resolves without ever prompting the user.
if m.notifier != nil && len(request.Questions) > 0 {
m.notifier.Notify(notify.AwaitingInput, notify.DefaultMessage(notify.AwaitingInput))
}
answerCh := make(chan []string, 1)
m.sendAskUserRequest(runID, request, func(answers []string) {
select {
case answerCh <- answers:
default:
}
})
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventMessage,
Payload: askUserSessionPayload(request),
})
select {
case answers := <-answerCh:
// Persist the answers next to the question event so the exchange
// is complete on /resume; rehydration renders them as a system note.
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventMessage,
Payload: map[string]any{
"role": "ask_user_answers",
"toolCallId": request.ToolCallID,
"answers": answers,
},
})
return agent.AskUserResponse{Answers: answers}, nil
case <-ctx.Done():
return agent.AskUserResponse{}, ctx.Err()
}
}
onReasoning := options.OnReasoning
options.OnReasoning = func(delta string) {
now := m.now()
if firstTokenAt.IsZero() && strings.TrimSpace(delta) != "" {
firstTokenAt = now
}
if strings.TrimSpace(reasoningText) == "" && strings.TrimSpace(delta) != "" {
reasoningStarted = now
}
if strings.TrimSpace(delta) != "" {
reasoningLast = now
}
reasoningText += delta
m.sendAgentReasoning(runID, delta)
if onReasoning != nil {
onReasoning(delta)
}
}
onToolCall := options.OnToolCall
options.OnToolCall = func(call agent.ToolCall) {
flushReasoning(m.now())
toolCalls++
callSeq[call.ID]++
row := transcriptRow{
kind: rowToolCall,
id: effectiveToolRowID(call.ID, callSeq[call.ID]),
text: "tool call: " + call.Name,
tool: call.Name,
detail: argHint(call.Arguments),
arg: argHintSecondary(call.Arguments),
runID: runID,
}
// A Task delegation is shown by the specialist card below, and update_plan
// is shown by the pinned plan panel + PLAN sidebar, so skip both redundant
// transcript cards — the dedicated UI supersedes them.
if !toolCardSuppressedInTranscript(call.Name) {
rows = append(rows, row)
m.sendAgentRow(runID, row)
}
// Track specialist delegation: when the Task tool is called, register
// the specialist start so the specialist card + task table can show
// live status. The child session ID is not known yet (it's created
// inside the executor), so we use the tool call ID as a temporary
// key and reconcile on the result.
if call.Name == "Task" {
name, desc := parseTaskCallArgs(call.Arguments)
if m.runtimeMessageSink != nil {
m.runtimeMessageSink(specialistStartMsg{
runID: runID,
name: name,
description: desc,
childSessionID: call.ID,
})
}
}
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventToolCall,
Payload: map[string]any{
"id": call.ID,
"name": call.Name,
"arguments": call.Arguments,
},
})
// Snapshot before-state of files this call will mutate, NOW (before the
// mutation runs), then batch the checkpoint event IN ORDER with the other
// session events so the recorded sequence matches execution (recording it
// out-of-band would reorder it ahead of the batched tool_call/result).
// SnapshotForCheckpoint writes the blobs; the batched event referencing
// them is flushed at end-of-run AND on cancel (flushRunIDs), so the blobs
// never stay orphaned — see its contract in internal/sessions.
if m.sessionStore != nil && m.activeSession.SessionID != "" {
var args map[string]any
if call.Arguments != "" {
_ = json.Unmarshal([]byte(call.Arguments), &args)
}
if targets := tools.MutationTargets(m.cwd, call.Name, args); len(targets) > 0 {
if payload, ok := m.sessionStore.SnapshotForCheckpoint(m.activeSession.SessionID, m.cwd, call.Name, targets); ok {
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventSessionCheckpoint,
Payload: payload,
})
}
}
}
if onToolCall != nil {
onToolCall(call)
}
}
options.OnToolProgress = func(toolCallID string, event streamjson.Event) {
if event.Type == streamjson.EventToolCall && m.runtimeMessageSink != nil {
m.runtimeMessageSink(specialistProgressMsg{
runID: runID,
toolCallID: toolCallID,
toolName: event.Name,
detail: toolCallSummary(event),
})
}
}
onToolResult := options.OnToolResult
options.OnToolResult = func(result agent.ToolResult) {
if runOptions.specDraft {
if info, ok := tuiSpecReviewFromToolResult(result, m.activeSession.SessionID); ok {
specReview = &info
}
}
row := transcriptRow{
kind: rowToolResult,
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
text: toolResultRowText(result),
tool: result.Name,
status: result.Status,
detail: toolResultDetail(result),
runID: runID,
changedFiles: result.ChangedFiles,
}
// A Task result is shown by the specialist card, and update_plan by the
// plan panel/sidebar, so skip both redundant transcript rows.
if !toolCardSuppressedInTranscript(result.Name) {
rows = append(rows, row)
m.sendAgentRow(runID, row)
}
// Sync the sticky plan panel when update_plan runs.
if result.Name == "update_plan" && m.registry != nil {
if planTool, ok := m.registry.Get("update_plan"); ok {
if reader, ok := planTool.(interface{ CurrentPlan() []tools.PlanItem }); ok {
if m.runtimeMessageSink != nil {
m.runtimeMessageSink(planUpdateMsg{runID: runID, items: reader.CurrentPlan()})
}
}
}
}
toolPayload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
}
if result.Redacted {
toolPayload["redacted"] = true
}
if len(result.Meta) > 0 {
toolPayload["meta"] = result.Meta
}
if len(result.ChangedFiles) > 0 {
toolPayload["changedFiles"] = result.ChangedFiles
}
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventToolResult,
Payload: toolPayload,
})
// Complete specialist tracking when the Task tool returns.
if result.Name == "Task" {
status := specialistCompleted
if result.Status == tools.StatusError {
status = specialistError
}
childSessionID := result.ToolCallID
if sid, ok := result.Meta["session_id"]; ok && sid != "" {
childSessionID = sid
}
if m.runtimeMessageSink != nil {
m.runtimeMessageSink(specialistCompleteMsg{
runID: runID,
toolCallID: result.ToolCallID,
childSessionID: childSessionID,
status: status,
errorMsg: result.Output,
})
}
}
// swarm_collect carries task_id -> session_id for completed members, so
// the AGENTS sidebar rows can drill into a member's session like a
// specialist card.
if result.Name == "swarm_collect" && len(result.Meta) > 0 && m.runtimeMessageSink != nil {
m.runtimeMessageSink(swarmSessionsMsg{runID: runID, sessions: result.Meta})
}
if onToolResult != nil {
onToolResult(result)
}
}
onPermission := options.OnPermission
options.OnPermission = func(event agent.PermissionEvent) {
// The audit event is recorded for every call so the session log stays
// complete; the visible row is only emitted when the event carries
// user-facing information (a real prompt, a denial, an explicit durable
// grant), not for silent auto-approvals.
if permissionEventIsNoteworthy(event) {
row := permissionTranscriptRow(event)
row.runID = runID
rows = append(rows, row)
m.sendAgentRow(runID, row)
}
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: tuiPermissionEventType(event),
Payload: event,
})
if onPermission != nil {
onPermission(event)
}
}
onUsage := options.OnUsage
options.OnUsage = func(event zeroruntime.Usage) {
usageEvents = append(usageEvents, event)
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventUsage,
Payload: usage.EventUsagePayload(event),
})
m.sendAgentUsage(runID, usageModelID, event)
if onUsage != nil {
onUsage(event)
}
}
result, err := agent.Run(runCtx, prompt, m.provider, options)
if err != nil {
flushReasoning(m.now())
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
if runOptions.specDraft {
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
err := fmt.Errorf("spec draft ended without submit_spec")
flushReasoning(m.now())
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
flushReasoning(m.now())
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
flushReasoning(m.now())
elapsed := m.now().Sub(started)
ttft := time.Duration(0)
if !firstTokenAt.IsZero() {
ttft = firstTokenAt.Sub(started)
}
rows = append(rows, transcriptRow{
kind: rowAssistant,
text: result.FinalAnswer,
final: true,
turnTools: toolCalls,
turnElapsed: elapsed,
})
if notice := result.TruncationNotice(); notice != "" {
rows = append(rows, transcriptRow{kind: rowSystem, text: notice})
}
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventMessage,
Payload: map[string]any{
"role": "assistant",
"content": result.FinalAnswer,
},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, turnTools: toolCalls, turnElapsed: elapsed, ttft: ttft}
}
}
func (m model) sendPermissionRequest(runID int, request agent.PermissionRequest, decide func(agent.PermissionDecision)) {
if m.runtimeMessageSink == nil {
return
}
m.runtimeMessageSink(permissionRequestMsg{runID: runID, request: request, decide: decide})
}
// autoResolvedPermissionDecision resolves a permission request the TUI cannot
// turn into a user prompt (Action != prompt). The agent is blocked awaiting a
// decision, so one must ALWAYS be produced. Only an explicit Cancel is honored
// as such; every other non-prompt action — including allow — is DENIED, so the
// UI never silently grants access it did not surface for approval.
func autoResolvedPermissionDecision(action agent.PermissionAction) agent.PermissionDecision {
if action == agent.PermissionActionCancel {
return agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run cancelled"}
}
return agent.PermissionDecision{
Action: agent.PermissionDecisionDeny,
Reason: "permission request could not be surfaced for approval",
}
}
func (m model) sendAskUserRequest(runID int, request agent.AskUserRequest, answer func([]string)) {
if m.runtimeMessageSink == nil {
return
}
m.runtimeMessageSink(askUserRequestMsg{runID: runID, request: request, answer: answer})
}
func tuiPermissionEventType(event agent.PermissionEvent) sessions.EventType {
if event.Action == agent.PermissionActionPrompt {
return sessions.EventPermissionRequest
}
if event.Action == agent.PermissionActionAllow || event.Action == agent.PermissionActionDeny || event.Action == agent.PermissionActionCancel {
return sessions.EventPermissionDecision
}
return sessions.EventPermission
}
func (m model) sendAgentRow(runID int, row transcriptRow) {
if m.runtimeMessageSink == nil {
return
}
m.runtimeMessageSink(agentRowMsg{runID: runID, row: row})
}
func (m model) sendAgentText(runID int, delta string) {
if m.runtimeMessageSink == nil {
return
}
m.runtimeMessageSink(agentTextMsg{runID: runID, delta: delta})
}
// streamingTextString returns the accumulated live assistant text. streamingText
// is stored as []byte for O(1) amortized appends; the conversion here is bounded
// by the segment length, the same cost the renderer already pays.
func (m model) streamingTextString() string {
return string(m.streamingText)
}
func (m model) sendToolCallStreamStart(runID int, id, name string) {
if m.runtimeMessageSink == nil {
return
}
m.runtimeMessageSink(toolCallStreamStartMsg{runID: runID, id: id, name: name})
}
func (m model) sendToolCallStreamDelta(runID int, id, fragment string) {
if m.runtimeMessageSink == nil {
return
}
m.runtimeMessageSink(toolCallStreamDeltaMsg{runID: runID, id: id, fragment: fragment})
}
// clearStreamingToolCall drops the in-progress live "writing" block (id + name +
// accumulated args). Called whenever the streamed tool call is no longer the
// active live preview: it finalizes into a card, text resumes, the run ends, or
// the run is cancelled. Releasing the args buffer also caps memory after a write.
func (m *model) clearStreamingToolCall() {
m.streamCallID = ""
m.streamCallName = ""
m.streamCallDecoder = nil
}
func (m model) sendAgentReasoning(runID int, delta string) {
if m.runtimeMessageSink == nil {
return
}
m.runtimeMessageSink(agentReasoningMsg{runID: runID, delta: delta})
}
func (m model) sendAgentUsage(runID int, modelID string, event zeroruntime.Usage) {
if m.runtimeMessageSink == nil {
return
}
m.runtimeMessageSink(agentUsageMsg{runID: runID, modelID: modelID, usage: event})
}
// toolResultDetail is the card body source: the rich card-only Display.Preview
// (a code/diff preview) when present on a successful result, else the Output that
// the model also saw. Error results keep their Output so the failure shows.
func toolResultDetail(result agent.ToolResult) string {
if result.Status != tools.StatusError && strings.TrimSpace(result.Display.Preview) != "" {
return result.Display.Preview
}
return result.Output
}
func toolResultRowText(result agent.ToolResult) string {
status := result.Status
if status == "" {
status = tools.StatusOK
}
return fmt.Sprintf("tool result: %s %s %s", result.Name, status, truncateTUIOutput(result.Output, tuiToolOutputLimit))
}