mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
* feat(execution): add a stdin-only write for interactive processes * feat(tui): attach overlay for interactive terminal sessions * feat(tui): add /attach for interactive terminal sessions * feat(tools): point the model at tty sessions for interactive prompts * feat(tui): open the terminal automatically for tty exec sessions * feat(tools): tell the model to keep polling while the user types * feat(tui): give attached terminal sessions the full viewport * feat(tools): route sudo through an escalated tty session * fix(tui): route the auto-attach tick to its run * fix(tui): defer auto-attach while another view owns the screen * fix(tui): keep bracketed-paste framing for attached sessions * fix(tools): let tty sessions run interactive commands * fix(tui): capture the auto-attach baseline before the exec starts
6188 lines
240 KiB
Go
6188 lines
240 KiB
Go
package tui
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"charm.land/bubbles/v2/spinner"
|
||
"charm.land/bubbles/v2/textinput"
|
||
tea "charm.land/bubbletea/v2"
|
||
"charm.land/lipgloss/v2"
|
||
uv "github.com/charmbracelet/ultraviolet"
|
||
"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/peermsg"
|
||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||
"github.com/Gitlawb/zero/internal/providers"
|
||
"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/terminalpet"
|
||
"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
|
||
|
||
// activeAnimationFrameInterval keeps active-only status motion smooth without
|
||
// running a timer when Zero is idle. It also drives the shared liveness spinner.
|
||
const activeAnimationFrameInterval = time.Second / 30
|
||
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
|
||
// allowEscalation mirrors Options.AllowEscalation: it gates the per-run model
|
||
// switchers, and the caller gates the escalate_model tool on the same flag.
|
||
allowEscalation bool
|
||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||
newTurnSessionProvider func(config.ProviderProfile, zeroruntime.Provider) zeroruntime.TurnSessionProvider
|
||
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
|
||
awaitToolReadiness func(context.Context)
|
||
// 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
|
||
peerService *peermsg.Service
|
||
peerInbox []peermsg.InboundMessage
|
||
peerApprovalQueue []peermsg.InboundMessage
|
||
peerPendingApproval *peermsg.InboundMessage
|
||
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
|
||
pendingSessionTitle string
|
||
sessionEvents []sessions.Event
|
||
btw btwState
|
||
// btwRunIDSeq is the highest run ID issued by any completed or abandoned BTW
|
||
// surface. It survives returning to the parent so a late message from an old
|
||
// side run can never match a run in a later BTW conversation.
|
||
btwRunIDSeq int
|
||
// 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
|
||
renamePrompt *sessionRenamePrompt
|
||
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
|
||
// permissionModeBeforePlan holds whatever mode was active when /plan on
|
||
// entered PermissionModePlan, so /plan off can restore it exactly (mirrors
|
||
// the execProfile displaced/applied pattern below).
|
||
permissionModeBeforePlan agent.PermissionMode
|
||
selfCorrectTests bool
|
||
reasoningEffort modelregistry.ReasoningEffort
|
||
serviceTier string
|
||
// 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
|
||
petClient *terminalpet.Client
|
||
petRenderer *terminalpet.ImageRenderer
|
||
attachmentRenderers []*terminalpet.ImageRenderer
|
||
petEntries map[string]terminalpet.Entry
|
||
petID string
|
||
petName string
|
||
petAnimation *terminalpet.Animation
|
||
petPreview *terminalpet.Animation
|
||
petPreviewSlug string
|
||
petPreviewError string
|
||
petPreviewLoading bool
|
||
petPreviewSeq uint64
|
||
petPreviewCancel context.CancelFunc
|
||
petRequestedSlug string
|
||
petPhase int
|
||
petTickSeq uint64
|
||
petPlaybackState terminalpet.State
|
||
petClickAnimationIndex int
|
||
petOutcome terminalpet.State
|
||
petOutcomeAt time.Time
|
||
petLayoutRendering bool
|
||
petPositionSet bool
|
||
petPositionX int
|
||
petPositionY int
|
||
petDragActive bool
|
||
petDragMoved bool
|
||
petDragStartedDocked bool
|
||
petDragOffsetX int
|
||
petDragOffsetY int
|
||
petDragTargetX int
|
||
petDragTargetY int
|
||
petDragTargetOffsetX int
|
||
petDragTargetOffsetY int
|
||
petPositionOffsetX int
|
||
petPositionOffsetY int
|
||
petCellPixelWidth int
|
||
petCellPixelHeight int
|
||
petPixelDrag bool
|
||
petPixelAnchorSet bool
|
||
petDragOffsetPixelX int
|
||
petDragOffsetPixelY int
|
||
petDragState terminalpet.State
|
||
petLastClickAt time.Time
|
||
keyBindings keyBindings
|
||
themeMode themeMode // palette preference: system (default) or named palette
|
||
hasDarkBg bool // last terminal background-detection result, if one is delivered
|
||
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
|
||
composerBlinkSeq int
|
||
composerBlinkIdleTicks int
|
||
composerBlinkTicking bool
|
||
composerPastePreviews []composerPastePreview
|
||
composerSelection composerSelectionState
|
||
dictation dictationController
|
||
sttKeyPrompt *sttKeyPromptState
|
||
terminalAttach *terminalAttachState
|
||
terminalAutoAttach *terminalAutoAttachState
|
||
terminalAttachSeen map[int]bool
|
||
// 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 turn-level activity glyph. 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. It
|
||
// drives only bounded secondary motion such as the streaming caret and agent
|
||
// lifecycle fades; it never creates a second live spinner.
|
||
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
|
||
// turnTimer is shared with the agent command so both the live status and the
|
||
// settled "worked for" duration exclude time blocked on a user permission.
|
||
turnTimer *activeTurnTimer
|
||
// 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
|
||
// goalContinuationsSuspended keeps a hidden parent from launching autonomous
|
||
// work while the user is in an isolated BTW conversation.
|
||
goalContinuationsSuspended bool
|
||
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
|
||
// runDetailsOpen keeps the optional run summary focused without permanently
|
||
// shrinking the conversation surface.
|
||
runDetailsOpen bool
|
||
sidebarHidden bool
|
||
// selectedFile is the touched file selected from a file summary: 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. Alt-screen mode advances the same
|
||
// frontier, but keeps the settled prefix as cached body items so fullscreen
|
||
// scrolling still exposes the complete transcript without rebuilding it on
|
||
// every frame.
|
||
// 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
|
||
flushedPreviousKind rowKind
|
||
flushedHavePreviousKind bool
|
||
flushQueue []string
|
||
printInFlight bool
|
||
headerPrinted bool
|
||
altScreenSettledItems []transcriptBodyItem
|
||
altScreenSettledWidth int
|
||
altScreenSettledFrontier int
|
||
|
||
// 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)
|
||
// streamClearDisabled turns off the full-redraw-on-streamed-newline
|
||
// workaround for terminals that render scroll regions correctly
|
||
// (ZERO_NO_STREAM_CLEAR=1). lastStreamClear rate-limits the redraws the
|
||
// workaround schedules so heavy streaming output (code, logs, diffs)
|
||
// coalesces to a bounded number of repaints per second instead of one
|
||
// per newline. pendingStreamClear tracks a newline that arrived while
|
||
// throttled: the redraw it would have triggered is deferred (flushed by
|
||
// a scheduled streamClearFlushMsg, or at stream end) instead of dropped
|
||
// outright, so a throttled newline that happens to be the last one of
|
||
// the turn still gets its caret repaired.
|
||
streamClearDisabled bool
|
||
lastStreamClear time.Time
|
||
pendingStreamClear bool
|
||
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
|
||
transcriptInteraction *transcriptRenderInteraction
|
||
// 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
|
||
// transientNotice is a single, replaceable confirmation shown above the
|
||
// composer. Unlike copyStatus it is shared by lightweight slash commands
|
||
// and is never persisted into the transcript.
|
||
transientNotice transientNotice
|
||
transientNoticeSeq int
|
||
transientNoticeTimerSeq 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 // idle orientation note (config: recaps on|off)
|
||
recapSeq int
|
||
recapRunning bool
|
||
recapCancel context.CancelFunc
|
||
recapTimerCancel context.CancelFunc
|
||
recapIdleArmed bool
|
||
recapIdleRunID int
|
||
idleRecap string
|
||
compactionModel string // preferences.compactionModel (see providers.CompactionModelID)
|
||
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
|
||
// pendingImageThumbnails are decoded previews for a bounded gallery of staged
|
||
// images. They are only rendered by terminals with an inline-image protocol;
|
||
// every other terminal continues to use the compact text attachment row.
|
||
pendingImageThumbnails []*terminalpet.Animation
|
||
|
||
// 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
|
||
}
|
||
|
||
// streamClearThrottle is the minimum gap between full-screen stream-clear
|
||
// redraws. Newlines that arrive inside this window mark a deferred clear
|
||
// (pendingStreamClear) instead of firing immediately, so heavy streaming
|
||
// output coalesces to ~10 repaints/second while still guaranteeing a
|
||
// eventual caret repair.
|
||
const streamClearThrottle = 100 * time.Millisecond
|
||
|
||
// streamClearFlushMsg fires once, roughly when the stream-clear throttle
|
||
// window (see lastStreamClear) has elapsed, to flush a ClearScreen that a
|
||
// throttled newline deferred rather than fired directly. It's a no-op if
|
||
// nothing is pending by the time it lands (the common case, since most
|
||
// throttled newlines are followed by another one that flushes them first).
|
||
type streamClearFlushMsg struct{}
|
||
|
||
// scheduleStreamClearFlush returns a one-shot command that delivers a
|
||
// streamClearFlushMsg after d. Used to guarantee a deferred stream-clear
|
||
// redraw is eventually flushed even if no later newline or stream-end event
|
||
// does it first (see the streamClearFlushMsg case in Update).
|
||
func scheduleStreamClearFlush(d time.Duration) tea.Cmd {
|
||
return tea.Tick(d, func(time.Time) tea.Msg { return streamClearFlushMsg{} })
|
||
}
|
||
|
||
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 is the model in force when the run ended. usageModelIDs is
|
||
// the model in force when each usageEvents entry fired: a mid-run
|
||
// escalation changes it partway through the run, and billing the events
|
||
// before the switch to the escalated model would be as wrong as billing
|
||
// the ones after it to the starting model. Read through usageModelIDAt.
|
||
usageModelID string
|
||
usageModelIDs []string
|
||
sessionEvents []pendingSessionEvent
|
||
specReview *pendingSpecReviewPrompt
|
||
err error
|
||
goalAware bool
|
||
// 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
|
||
}
|
||
|
||
// usageModelIDAt is the model in force when usageEvents[index] fired. The
|
||
// per-event record wins; usageModelID is the fallback for a message built
|
||
// without one, which is what every constructor before escalation produced.
|
||
func (msg agentResponseMsg) usageModelIDAt(index int) string {
|
||
if index < len(msg.usageModelIDs) && msg.usageModelIDs[index] != "" {
|
||
return msg.usageModelIDs[index]
|
||
}
|
||
return msg.usageModelID
|
||
}
|
||
|
||
type peerMessageMsg struct {
|
||
message peermsg.InboundMessage
|
||
admit chan<- bool
|
||
}
|
||
|
||
type peerStatusMsg struct{ event peermsg.StatusEvent }
|
||
|
||
type peerHeldReleasedMsg struct{ message peermsg.InboundMessage }
|
||
|
||
type peerDecisionMsg struct {
|
||
message peermsg.InboundMessage
|
||
allow bool
|
||
}
|
||
|
||
type peerRuntimeErrorMsg struct{ err error }
|
||
|
||
type peerReceiptErrorMsg struct{ err error }
|
||
|
||
type peerApprovalExpiredMsg struct{ messageID string }
|
||
|
||
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
|
||
// typing is true once the user chose "tell Zero what to do differently": the
|
||
// card replaces its option list with a free-text field (sharing the composer
|
||
// input, like the ask_user questionnaire). Submitting sends a Deny decision
|
||
// whose Reason is the typed text, so the model reads it as the tool result and
|
||
// adjusts course in the same turn instead of the run being cancelled.
|
||
typing bool
|
||
// savedDraft holds whatever was in the shared composer input when feedback
|
||
// mode was entered. The field is cleared for typing and restored on both
|
||
// submit and cancel, so a half-typed or queued next-turn message survives the
|
||
// detour (permissionRequestMsg, unlike ask_user, does not clear the composer).
|
||
savedDraft string
|
||
}
|
||
|
||
// 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
|
||
transientSystemPrompt 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()
|
||
// The main composer paints its own cursor through composerCursorVisible.
|
||
// Keep the embedded textinput cursor static so the shared textinput.Blink
|
||
// message cannot start a second, invisible 530ms tick chain. Setup inputs
|
||
// still use the normal Bubbles cursor and continue blinking independently.
|
||
inputStyles := input.Styles()
|
||
inputStyles.Cursor.Blink = false
|
||
input.SetStyles(inputStyles)
|
||
|
||
runSpinner := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
||
runSpinner.Spinner.FPS = activeAnimationFrameInterval
|
||
|
||
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,
|
||
composerBlinkSeq: 1,
|
||
composerBlinkTicking: 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),
|
||
compactionModel: options.CompactionModel,
|
||
recapsEnabled: options.RecapsEnabled,
|
||
provider: options.Provider,
|
||
newProvider: options.NewProvider,
|
||
newTurnSessionProvider: options.NewTurnSessionProvider,
|
||
probeProviderHealth: options.ProbeProviderHealth,
|
||
discoverProviderModels: options.DiscoverProviderModels,
|
||
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
|
||
registry: registry,
|
||
awaitToolReadiness: options.AwaitToolReadiness,
|
||
sessionStore: sessionStore,
|
||
peerService: options.PeerService,
|
||
sandboxStore: sandboxStore,
|
||
mcpConfig: options.MCPConfig,
|
||
mcpPermissionStore: options.MCPPermissionStore,
|
||
mcpTokenStore: options.MCPTokenStore,
|
||
mcpCommand: options.MCPCommand,
|
||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||
agentOptions: options.AgentOptions,
|
||
allowEscalation: options.AllowEscalation,
|
||
sessionCompactor: options.SessionCompactor,
|
||
runtimeMessageSink: options.RuntimeMessageSink,
|
||
permissionMode: permissionMode,
|
||
reasoningEffort: options.ReasoningEffort,
|
||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||
petEntries: map[string]terminalpet.Entry{},
|
||
petID: strings.TrimSpace(options.SavedPet),
|
||
keyBindings: resolvedKeyBindings,
|
||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||
hasDarkBg: true,
|
||
userAgent: options.UserAgent,
|
||
usageTracker: usageTracker,
|
||
transcript: initialTranscript(),
|
||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||
transcriptInteraction: &transcriptRenderInteraction{},
|
||
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),
|
||
}
|
||
petRoot := userConfigDir
|
||
if strings.TrimSpace(options.UserConfigPath) != "" {
|
||
petRoot = filepath.Dir(options.UserConfigPath)
|
||
}
|
||
if strings.TrimSpace(petRoot) != "" {
|
||
m.petClient = terminalpet.NewClient(petRoot)
|
||
if m.petID != "" && m.petID != terminalpet.DisabledID {
|
||
if animation, loadErr := m.petClient.LoadInstalled(m.petID); loadErr == nil {
|
||
m.petAnimation = animation
|
||
m.petName = m.petID
|
||
if entry, entryErr := m.petClient.InstalledEntry(m.petID); entryErr == nil {
|
||
m.petName = entry.Label()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Apply an explicit palette immediately. System is applied by Run immediately
|
||
// before Bubble Tea starts, which keeps package-level helper rendering
|
||
// deterministic while models are being constructed in tests.
|
||
if m.themeMode != themeSystem {
|
||
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
|
||
// Terminals that handle scroll regions correctly can opt back into the
|
||
// fast incremental path; the redraw workaround (see the ClearScreen
|
||
// scheduling in updateModel) is otherwise on, rate-limited.
|
||
if v := strings.TrimSpace(os.Getenv("ZERO_NO_STREAM_CLEAR")); v != "" && v != "0" && !strings.EqualFold(v, "false") {
|
||
m.streamClearDisabled = 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
|
||
|
||
// composerBlinkIdleTicksBeforeSettle bounds the software cursor animation.
|
||
// Once the terminal is focused but otherwise idle, the cursor finishes a short
|
||
// blink sequence and settles visible instead of waking the TUI forever.
|
||
const composerBlinkIdleTicksBeforeSettle = 4
|
||
|
||
// 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{ seq int }
|
||
|
||
func composerBlinkCmd(seq int) tea.Cmd {
|
||
return tea.Tick(composerCursorBlinkInterval, func(time.Time) tea.Msg {
|
||
return composerBlinkMsg{seq: seq}
|
||
})
|
||
}
|
||
|
||
func (m model) armComposerBlink() (model, tea.Cmd) {
|
||
m.composerCursorVisible = true
|
||
m.composerBlinkIdleTicks = 0
|
||
if m.composerBlinkTicking {
|
||
return m, nil
|
||
}
|
||
m.composerBlinkTicking = true
|
||
m.composerBlinkSeq++
|
||
return m, composerBlinkCmd(m.composerBlinkSeq)
|
||
}
|
||
|
||
func (m model) Init() tea.Cmd {
|
||
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd(m.composerBlinkSeq)}
|
||
if m.petAnimation != nil && !m.reducedMotion {
|
||
cmds = append(cmds, petTickCmd(m.petTickSeq, m.petFrameDelay()))
|
||
}
|
||
// Every image protocol wants this, not just the ones that support pixel
|
||
// dragging. Sixel needs it most: it erases itself by writing over cells, so
|
||
// without the pixels-per-cell figure it cannot know how many cells it
|
||
// covered, and falls back to constants describing the reserved area rather
|
||
// than the image.
|
||
if m.petCellMetricsWanted() {
|
||
cmds = append(cmds, tea.Raw(ansi.WindowOp(16)))
|
||
}
|
||
// 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.
|
||
cmds = append(cmds, tea.RequestWindowSize)
|
||
// Read the terminal background only to keep a selected palette legible. This
|
||
// query never changes the terminal canvas; View deliberately leaves both
|
||
// terminal-wide color fields unset.
|
||
cmds = append(cmds, tea.RequestBackgroundColor)
|
||
// 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))
|
||
}
|
||
// 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 && m.renamePrompt == nil && m.terminalAttach == 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.btw.active {
|
||
if !m.pending && m.composerValue() != "" && m.noBlockingModal() && !m.transcriptDetailed && !m.subchat.active {
|
||
m.clearComposer()
|
||
m.clearSuggestions()
|
||
return m, nil
|
||
}
|
||
return m.leaveBTW()
|
||
}
|
||
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()
|
||
}
|
||
var recapActivityCmd tea.Cmd
|
||
switch msg.(type) {
|
||
case tea.KeyPressMsg, tea.PasteMsg, tea.MouseMsg:
|
||
m, recapActivityCmd = m.resetIdleRecapAfterActivity()
|
||
}
|
||
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()
|
||
var composerCmd tea.Cmd
|
||
switch msg.(type) {
|
||
case tea.KeyPressMsg, tea.PasteMsg, tea.FocusMsg:
|
||
if nm.terminalFocused {
|
||
nm, composerCmd = nm.armComposerBlink()
|
||
}
|
||
}
|
||
nm, noticeCmd := nm.ensureTransientNoticeTimer()
|
||
return nm, batchCommands(cmd, mouseCmd, flushCmd, recapActivityCmd, composerCmd, noticeCmd)
|
||
}
|
||
|
||
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) {
|
||
if size, ok := msg.(tea.WindowSizeMsg); ok {
|
||
m = m.resizeBTWParent(size)
|
||
}
|
||
if next, cmd, routed := m.routeBTWParentMessage(msg); routed {
|
||
return next, cmd
|
||
}
|
||
switch msg := msg.(type) {
|
||
case uv.CellSizeEvent:
|
||
if msg.Width > 0 && msg.Height > 0 {
|
||
m.petCellPixelWidth = msg.Width
|
||
m.petCellPixelHeight = msg.Height
|
||
}
|
||
return m, nil
|
||
case peerMessageMsg:
|
||
admitted := m.canAcceptPeerMessage(msg.message)
|
||
if msg.admit != nil {
|
||
msg.admit <- admitted
|
||
}
|
||
if !admitted {
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Dropped peer message because the inbound queue is full."})
|
||
return m, nil
|
||
}
|
||
return m.handlePeerMessage(msg.message)
|
||
case peerStatusMsg:
|
||
return m.handlePeerStatus(msg.event), nil
|
||
case peerHeldReleasedMsg:
|
||
return m.handleReleasedPeerMessage(msg.message)
|
||
case peerDecisionMsg:
|
||
return m.handlePeerDecision(msg.message, msg.allow)
|
||
case peerApprovalExpiredMsg:
|
||
return m.handlePeerApprovalExpired(msg.messageID)
|
||
case peerReceiptErrorMsg:
|
||
if msg.err != nil {
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "peer receipt: " + msg.err.Error()})
|
||
}
|
||
return m, nil
|
||
case peerRuntimeErrorMsg:
|
||
if msg.err != nil {
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "peer messaging unavailable: " + msg.err.Error()})
|
||
}
|
||
return m, nil
|
||
case composerBlinkMsg:
|
||
if !m.composerBlinkTicking || msg.seq != m.composerBlinkSeq {
|
||
return m, nil
|
||
}
|
||
switch {
|
||
case !m.terminalFocused:
|
||
m.composerCursorVisible = false
|
||
m.composerBlinkTicking = false
|
||
return m, nil
|
||
case m.now().Sub(m.lastCharTime) < composerTypingIdleThreshold:
|
||
m.composerCursorVisible = true
|
||
m.composerBlinkIdleTicks = 0
|
||
default:
|
||
m.composerBlinkIdleTicks++
|
||
if m.composerBlinkIdleTicks >= composerBlinkIdleTicksBeforeSettle {
|
||
m.composerCursorVisible = true
|
||
m.composerBlinkTicking = false
|
||
return m, nil
|
||
}
|
||
m.composerCursorVisible = !m.composerCursorVisible
|
||
}
|
||
return m, composerBlinkCmd(m.composerBlinkSeq)
|
||
case tea.BackgroundColorMsg:
|
||
// A background reading changes only the contrast direction used for named
|
||
// palettes. It never repaints the terminal canvas.
|
||
m.hasDarkBg = msg.IsDark()
|
||
if m.themeMode != themeSystem {
|
||
applyTheme(m.themeMode, m.hasDarkBg)
|
||
}
|
||
return m, nil
|
||
case tea.MouseMsg:
|
||
// Attached to a live terminal: the overlay owns the viewport, so mouse
|
||
// events go nowhere rather than hitting transcript selection below.
|
||
if m.terminalAttach != nil {
|
||
return m, nil
|
||
}
|
||
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 transientNoticeExpiredMsg:
|
||
if msg.seq == m.transientNoticeSeq {
|
||
m.transientNotice = transientNotice{}
|
||
}
|
||
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:
|
||
// While attached to a live terminal, a paste forwards verbatim to the
|
||
// PTY stdin — never into the composer or transcript.
|
||
if m.terminalAttach != nil {
|
||
return m.handleTerminalAttachPaste(msg.Content)
|
||
}
|
||
// 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 Ctrl+Space release; every other
|
||
// release event is ignored (dispatch elsewhere is press-based).
|
||
if m.dictation.voiceModeEnabled && voiceCaptureReleaseKey(msg) {
|
||
return m.handleVoiceCaptureRelease()
|
||
}
|
||
return m, nil
|
||
case terminalAttachTickMsg:
|
||
return m.refreshTerminalAttach()
|
||
case interactiveExecStartMsg:
|
||
if msg.runID != m.activeRunID {
|
||
return m, nil
|
||
}
|
||
m.terminalAutoAttach = &terminalAutoAttachState{
|
||
runID: msg.runID,
|
||
known: msg.known,
|
||
deadline: m.now().Add(terminalAutoAttachTimeout),
|
||
}
|
||
return m, terminalAutoAttachTickCmd(msg.runID)
|
||
case terminalAutoAttachTickMsg:
|
||
return m.pollTerminalAutoAttach(msg)
|
||
case tea.KeyPressMsg:
|
||
if m.petDragActive {
|
||
pixelDrag := m.petPixelDrag
|
||
if !keyIs(msg, tea.KeyEsc) && !keyCtrl(msg, 'c') {
|
||
return m, nil
|
||
}
|
||
m.cancelPetDrag()
|
||
m.lastKeyTime = time.Time{}
|
||
m.burstCount = 0
|
||
if pixelDrag {
|
||
return m, petPixelMouseDisableCmd()
|
||
}
|
||
return m, nil
|
||
}
|
||
// 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)
|
||
}
|
||
// Attached to a live terminal session: keystrokes (including Ctrl+C,
|
||
// which maps to 0x03 for the process) go to the PTY until Esc detaches.
|
||
if m.terminalAttach != nil {
|
||
return m.handleTerminalAttachKey(msg)
|
||
}
|
||
if m.renamePrompt != nil {
|
||
return m.handleSessionRenameKey(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.runDetailsOpen:
|
||
if keyIs(msg, tea.KeyEsc) || m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }) {
|
||
m.runDetailsOpen = false
|
||
}
|
||
return m, nil
|
||
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.showTransientNoticeInline("Mouse interaction re-enabled.", transientNoticeSuccess), nil
|
||
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && voiceCaptureKey(msg) && m.noBlockingModal():
|
||
// Voice mode reserves Ctrl+Space for recording; ordinary Space continues
|
||
// through the composer path below.
|
||
return m.handleVoiceCapturePress(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()
|
||
}
|
||
// Esc in the permission feedback field steps back to the option list
|
||
// rather than resolving, so a stray keystroke is recoverable.
|
||
if m.pendingPermission != nil && m.pendingPermission.typing {
|
||
return m.cancelPermissionTyping()
|
||
}
|
||
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.pendingPermission != nil && m.pendingPermission.request.ToolName == peerPermissionToolName {
|
||
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 == pickerPet {
|
||
m.cancelPetPreview()
|
||
}
|
||
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.setSelectedFile("")
|
||
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)
|
||
m = m.syncPeerIdentity()
|
||
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.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }) && canFireComposerGatedToggle(m.keyBindings.toggleSidebar, defaultToggleSidebarChord, m.composerValue() == ""):
|
||
// Ctrl+B opens a compact, on-demand run summary. The composer-empty rule
|
||
// preserves readline's Ctrl+B move-to-beginning behavior; a remapped
|
||
// binding continues to work while composing.
|
||
if !m.transcriptDetailed && m.noBlockingModal() && m.runDetailsAllowed() {
|
||
m.runDetailsOpen = !m.runDetailsOpen
|
||
return m, nil
|
||
}
|
||
case keyCtrl(msg, 'v'), keySuper(msg, 'v'):
|
||
// Ctrl+V probes the clipboard for an IMAGE only. Text pasting stays
|
||
// exclusively on the terminal's bracketed-paste path (Bubble's own
|
||
// Ctrl+V binding is disabled in newModel for exactly that reason), so
|
||
// this cannot double-insert text. It is needed because a clipboard
|
||
// holding a screenshot produces no bracketed paste at all: the terminal
|
||
// has no text to send, so routePaste never runs and its empty-content
|
||
// image probe never fires. Right-click paste reached that probe only
|
||
// because it always delivers a clipboardReadMsg, empty or not.
|
||
// readClipboardImageCmd yields no message when the clipboard holds no
|
||
// image, so Ctrl+V with text on the clipboard stays a no-op here and is
|
||
// handled by the bracketed paste exactly as before.
|
||
//
|
||
// Cmd+V is matched too, since macOS reports Command as ModSuper rather
|
||
// than ModCtrl. That only helps on terminals that deliver the key to the
|
||
// application: one that handles Cmd+V itself pastes the clipboard TEXT
|
||
// and sends no key event, so an image-only clipboard still produces
|
||
// nothing for this to react to.
|
||
if m.noBlockingModal() {
|
||
return m, readClipboardImageCmd()
|
||
}
|
||
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):
|
||
// In permission feedback mode Backspace is a plain edit of the feedback
|
||
// text. This case runs before the typing branch below and, on an empty
|
||
// field (feedback mode clears the composer), would otherwise fall to the
|
||
// removeLastAttachment path and silently drop a staged image/doc that
|
||
// savedDraft does not restore. Route it to the shared input instead.
|
||
if m.pendingPermission != nil && m.pendingPermission.typing {
|
||
var cmd tea.Cmd
|
||
m.input, cmd = m.input.Update(msg)
|
||
return m, cmd
|
||
}
|
||
if m.picker != nil {
|
||
if m.modelPickerIsLoading() {
|
||
return m, nil
|
||
}
|
||
m.picker.deleteQueryRune()
|
||
if m.picker.kind == pickerPet {
|
||
return m.schedulePetPreview()
|
||
}
|
||
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 {
|
||
// Feedback mode: a printable keystroke (and editing keys like
|
||
// backspace) types into the shared composer input, mirroring the
|
||
// ask_user free-text path above. Enter/Esc/↑/↓ were already handled
|
||
// earlier in this switch; the remaining keys reach the input here.
|
||
if m.pendingPermission.typing {
|
||
var cmd tea.Cmd
|
||
m.input, cmd = m.input.Update(msg)
|
||
return m, cmd
|
||
}
|
||
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))
|
||
if m.picker.kind == pickerPet {
|
||
return m.schedulePetPreview()
|
||
}
|
||
}
|
||
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:
|
||
var petMouseCmd tea.Cmd
|
||
if m.petDragActive {
|
||
pixelDrag := m.petPixelDrag
|
||
m.cancelPetDrag()
|
||
m.lastKeyTime = time.Time{}
|
||
m.burstCount = 0
|
||
if pixelDrag {
|
||
petMouseCmd = petPixelMouseDisableCmd()
|
||
}
|
||
}
|
||
m.terminalFocused = false
|
||
m.composerCursorVisible = false
|
||
m.composerBlinkTicking = false
|
||
m.composerBlinkSeq++
|
||
if m.notifier != nil {
|
||
m.notifier.SetFocused(false)
|
||
}
|
||
return m, petMouseCmd
|
||
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)
|
||
var cmds []tea.Cmd
|
||
// The streaming caret (appendStreamingCursor) is appended to whatever
|
||
// visual line is currently last. Some terminal/renderer combinations
|
||
// (observed over multipass + Windows Terminal) fail to clear the
|
||
// caret's old cell when a newline moves it to a new line, leaving
|
||
// ghost carets behind. A newline is exactly the moment that risk
|
||
// exists, so force one full-screen redraw right then rather than
|
||
// leaving it to the incremental diff. Rate-limited: heavy streaming
|
||
// output (code, logs, diffs) would otherwise turn every coalesced
|
||
// newline into a full-screen repaint, a real throughput/latency cost
|
||
// on SSH and slow links. ~10 redraws/second is enough to keep the
|
||
// caret clean without dominating the write path; terminals that
|
||
// render scroll regions correctly can opt out entirely with
|
||
// ZERO_NO_STREAM_CLEAR=1. A newline that arrives inside the throttle
|
||
// window still owes a repair — it's marked pending and a one-shot
|
||
// timer is scheduled to flush it (see streamClearFlushMsg), instead
|
||
// of being dropped outright. That covers a throttled newline that
|
||
// turns out to be the turn's last one (agentResponseMsg also flushes
|
||
// any still-pending clear at stream end, belt-and-suspenders) as
|
||
// well as one buried in the middle of a long, still-streaming turn.
|
||
if strings.Contains(msg.delta, "\n") && !m.streamClearDisabled {
|
||
now := m.now()
|
||
if elapsed := now.Sub(m.lastStreamClear); elapsed >= streamClearThrottle {
|
||
m.lastStreamClear = now
|
||
m.pendingStreamClear = false
|
||
cmds = append(cmds, tea.ClearScreen)
|
||
} else if !m.pendingStreamClear {
|
||
m.pendingStreamClear = true
|
||
cmds = append(cmds, scheduleStreamClearFlush(streamClearThrottle-elapsed))
|
||
}
|
||
}
|
||
// 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 {
|
||
cmds = append(cmds, streamingFadeTick())
|
||
}
|
||
}
|
||
return m, tea.Batch(cmds...)
|
||
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 active agent state:
|
||
// its short lifecycle fade needs the phase to keep advancing until the
|
||
// agents 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 bounded secondary motion in lock-step with the activity glyph;
|
||
// freeze it under reduced motion.
|
||
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 streamClearFlushMsg:
|
||
// Flush a newline-triggered redraw that the stream-clear throttle
|
||
// deferred (see agentTextMsg) once its window has elapsed. This runs
|
||
// independent of the streaming fade (which is unconditionally off —
|
||
// fadeDisabled is hardcoded true in newModel — so its tick can't be
|
||
// relied on to drive this), and independent of stream end: a turn
|
||
// that keeps streaming for a while after the throttled newline would
|
||
// otherwise leave the ghost caret up for the rest of the turn.
|
||
now := m.now()
|
||
if m.pendingStreamClear && now.Sub(m.lastStreamClear) >= streamClearThrottle {
|
||
m.lastStreamClear = now
|
||
m.pendingStreamClear = false
|
||
return m, tea.ClearScreen
|
||
}
|
||
return m, nil
|
||
case tea.WindowSizeMsg:
|
||
m.resizeFreePetPosition(m.width, m.height, msg.Width, msg.Height)
|
||
m.width = msg.Width
|
||
m.height = msg.Height
|
||
if msg.Width < runDetailsMinWidth {
|
||
m.runDetailsOpen = false
|
||
}
|
||
// 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))
|
||
// An attached terminal fills the viewport, so the PTY tracks the
|
||
// live size instead of a fixed default.
|
||
m = m.resizeAttachedTerminalPTY()
|
||
// 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 agents; keep their short lifecycle
|
||
// fade alive. No-op when the loop is already running or nothing animates.
|
||
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
|
||
}
|
||
promptEvent := permissionEventFromRequest(msg.request)
|
||
promptRow := permissionTranscriptRow(promptEvent)
|
||
// The focused modal owns the actionable reason while the decision is
|
||
// pending. Keep only structured block context in the transcript row so
|
||
// the same explanation is not shown immediately above the card.
|
||
if promptEvent.Block == nil {
|
||
promptRow.detail = ""
|
||
} else {
|
||
promptRow.detail = permissionBlockDetail(promptEvent)
|
||
}
|
||
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.usageModelIDAt(index), 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
|
||
if msg.err != nil {
|
||
m.petOutcome = terminalpet.Failed
|
||
} else {
|
||
m.petOutcome = terminalpet.Review
|
||
}
|
||
m.petOutcomeAt = m.now()
|
||
m.pending = false
|
||
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
|
||
// A newline-triggered redraw deferred by the stream-clear throttle
|
||
// (see agentTextMsg) may never get a later newline or fade tick to
|
||
// flush it if this was the turn's last delta — flush it here so the
|
||
// ghost caret isn't left behind at stream end.
|
||
var pendingClearCmd tea.Cmd
|
||
if m.pendingStreamClear {
|
||
m.pendingStreamClear = false
|
||
pendingClearCmd = tea.ClearScreen
|
||
}
|
||
// 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.terminalAutoAttach = nil
|
||
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.usageModelIDAt(index), 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
|
||
if msg.goalAware {
|
||
m = m.reconcileGoalAfterRun(msg.usageEvents, msg.err)
|
||
}
|
||
// 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()
|
||
// Arm an idle recap only after a successful answer. The timer performs
|
||
// no provider work unless the session stays untouched long enough.
|
||
var finalAnswer string
|
||
for _, row := range msg.rows {
|
||
if row.kind == rowAssistant && row.final {
|
||
finalAnswer = row.text
|
||
}
|
||
}
|
||
m, recapCmd = m.maybeScheduleIdleRecap(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()
|
||
}
|
||
hadQueuedMessage := strings.TrimSpace(m.queuedMessage) != ""
|
||
next, queuedCmd := m.launchQueuedMessageIfReady()
|
||
var peerCmd tea.Cmd
|
||
if queuedCmd == nil {
|
||
next, peerCmd = next.launchQueuedPeerIfReady()
|
||
}
|
||
var peerApprovalCmd tea.Cmd
|
||
if queuedCmd == nil && peerCmd == nil {
|
||
next, peerApprovalCmd = next.openNextPeerApproval()
|
||
}
|
||
var goalCmd tea.Cmd
|
||
if msg.goalAware && !hadQueuedMessage && peerCmd == nil && next.pendingPermission == nil && msg.specReview == nil {
|
||
next, goalCmd = next.launchGoalContinuationIfReady()
|
||
}
|
||
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, peerCmd, peerApprovalCmd, loopTickCmd, goalCmd)
|
||
case sessionTitleGeneratedMsg:
|
||
return m.handleSessionTitleGenerated(msg)
|
||
case recapIdleMsg:
|
||
return m.handleRecapIdle(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.
|
||
beforeCollapse := len(m.transcript)
|
||
m.transcript = collapseRepeatedStatusCard(m.transcript, msg.row)
|
||
if removed := beforeCollapse - len(m.transcript); removed > 0 {
|
||
m.flushed = max(0, m.flushed-removed)
|
||
m.altScreenSettledWidth = 0
|
||
}
|
||
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 petCatalogLoadedMsg:
|
||
return m.applyPetCatalog(msg)
|
||
case petPreviewDebounceMsg:
|
||
return m.startPetPreview(msg)
|
||
case petPreviewLoadedMsg:
|
||
m = m.applyPetPreview(msg)
|
||
if m.petPreview != nil && m.petAnimation == nil && !m.reducedMotion {
|
||
m.petTickSeq++
|
||
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
|
||
}
|
||
return m, nil
|
||
case petInstalledMsg:
|
||
return m.applyPetInstall(msg)
|
||
case petTickMsg:
|
||
if msg.seq != m.petTickSeq {
|
||
return m, nil
|
||
}
|
||
if (m.petAnimation == nil && (m.picker == nil || m.petPreview == nil)) || m.reducedMotion {
|
||
return m, nil
|
||
}
|
||
_, state := m.petPlayback()
|
||
if state != m.petPlaybackState {
|
||
m.petPlaybackState = state
|
||
m.petPhase = 0
|
||
} else {
|
||
m.petPhase++
|
||
}
|
||
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
|
||
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()
|
||
}
|
||
if m.petRenderer != nil {
|
||
m.petRenderer.Set(m.petImageDraw(content))
|
||
}
|
||
for index, renderer := range m.attachmentRenderers {
|
||
renderer.Set(m.attachmentImageDraw(index))
|
||
}
|
||
|
||
view := tea.NewView(content)
|
||
view.AltScreen = m.altScreen
|
||
// Keep the terminal's canvas intact. Named themes may color local cards, but
|
||
// Zero never replaces the user's background, opacity, wallpaper, or profile.
|
||
// 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() {
|
||
// AllMotion (1003) is what hover highlighting needs: it reports cursor
|
||
// movement with no button pressed, where CellMotion (1002) reports motion
|
||
// only while a button is held. mouseModeFor decides which is safe here,
|
||
// and documents why one is not simply better than the other: a terminal
|
||
// that does not implement 1003 sends nothing at all rather than degrading.
|
||
// The 15ms throttle (mouseEventThrottleInterval) already bounds the redraw
|
||
// rate from AllMotion's extra motion events.
|
||
view.MouseMode = mouseModeFor(runtime.GOOS, os.Getenv, isRunningUnderPRoot())
|
||
}
|
||
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 {
|
||
if m.petLayoutActive() {
|
||
return m.floatingPetTranscriptView()
|
||
}
|
||
|
||
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)
|
||
runDetailsOverlay := m.runDetailsOverlay(width)
|
||
providerOverlay := m.providerWizardOverlay(width)
|
||
mcpAddOverlay := m.mcpAddWizardOverlay(width)
|
||
mcpOverlay := m.mcpManagerOverlay(width)
|
||
pickerOverlay := m.pickerOverlay(width)
|
||
sttKeyOverlay := m.sttKeyPromptOverlay(width)
|
||
terminalAttachOverlay := m.terminalAttachOverlay(width)
|
||
viewportOverlay := ""
|
||
switch {
|
||
case terminalAttachOverlay != "":
|
||
viewportOverlay = terminalAttachOverlay
|
||
case sttKeyOverlay != "":
|
||
viewportOverlay = sttKeyOverlay
|
||
case helpOverlayContent != "":
|
||
viewportOverlay = helpOverlayContent
|
||
case leaderHelpOverlayContent != "":
|
||
viewportOverlay = leaderHelpOverlayContent
|
||
case runDetailsOverlay != "":
|
||
viewportOverlay = runDetailsOverlay
|
||
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
|
||
}
|
||
|
||
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
|
||
// An attached terminal owns the keyboard and the viewport; the composer is
|
||
// inert, so like the ask_user and permission modals only the status line
|
||
// renders. Frame math routes through footerView, so this also shrinks the
|
||
// footer rect the overlay's row budget is derived from.
|
||
if m.terminalAttach != nil {
|
||
return m.footerStatusLine(width)
|
||
}
|
||
if m.renamePrompt != nil {
|
||
footer.WriteString(m.sessionRenamePromptView(width))
|
||
footer.WriteString("\n")
|
||
footer.WriteString(m.footerStatusLine(width))
|
||
return footer.String()
|
||
}
|
||
// 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.footerStatusLine(width))
|
||
return footer.String()
|
||
}
|
||
// A focused permission prompt owns the keyboard: its options (and the feedback
|
||
// field) consume every key, so the composer is inert. Suppress it and the idle
|
||
// hints/plan panel like the ask_user modal above, keeping only the status line.
|
||
// The card itself renders in the transcript body. This also keeps the shared
|
||
// input from echoing in two places once "tell Zero what to do differently"
|
||
// opens the on-card feedback field.
|
||
if m.pendingPermission != nil {
|
||
footer.WriteString(m.footerStatusLine(width))
|
||
return footer.String()
|
||
}
|
||
// 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 notice := m.transientNoticeLine(width); notice != "" {
|
||
footer.WriteString(notice)
|
||
} else if recap := strings.TrimSpace(m.idleRecap); recap != "" {
|
||
footer.WriteString(fitStyledLine(" "+zeroTheme.faint.Render("※ "+recap), 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))
|
||
footer.WriteString("\n")
|
||
footer.WriteString(m.footerStatusLine(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:
|
||
parts := []string{"? shortcuts", "Ctrl+X cmds"}
|
||
if m.runDetailsAvailable() {
|
||
parts = append(parts, sidebarKey+" details")
|
||
}
|
||
hint = strings.Join(parts, " · ")
|
||
default:
|
||
parts := []string{"? shortcuts", "Ctrl+X cmds"}
|
||
if m.runDetailsAvailable() {
|
||
parts = append(parts, sidebarKey+" details")
|
||
}
|
||
parts = append(parts, detailKey+" detail", mouseKey+" copy", "Shift+Tab mode")
|
||
hint = strings.Join(parts, " · ")
|
||
}
|
||
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))
|
||
}
|
||
|
||
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) 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 while keeping semantic styling intact.
|
||
// A transcript can contain syntax colors, diff backgrounds, warnings, and errors;
|
||
// reducing all of them to faint grey makes an overlay harder to understand instead
|
||
// of merely less prominent. Plain text uses the regular faint style. ANSI-styled
|
||
// text keeps its colors and is made faint between resets, which lets the overlay
|
||
// take focus while red, green, and syntax roles remain legible.
|
||
// Blank lines are left untouched.
|
||
func scrimViewportLine(line string, width int) string {
|
||
if strings.TrimSpace(ansi.Strip(line)) == "" {
|
||
return line
|
||
}
|
||
if !hasExternalANSIStyle(line) {
|
||
return zeroTheme.faint.Render(line)
|
||
}
|
||
|
||
const faintSGR = "\x1b[2m"
|
||
const resetSGR = "\x1b[0m"
|
||
var out strings.Builder
|
||
out.Grow(len(line) + 16)
|
||
out.WriteString(faintSGR)
|
||
for index := 0; index < len(line); {
|
||
if line[index] == '\x1b' {
|
||
if end := ansiSequenceEnd(line, index); end > index {
|
||
sequence := line[index:end]
|
||
out.WriteString(sequence)
|
||
if sgrClearsFaint(sequence) {
|
||
out.WriteString(faintSGR)
|
||
}
|
||
index = end
|
||
continue
|
||
}
|
||
}
|
||
out.WriteByte(line[index])
|
||
index++
|
||
}
|
||
out.WriteString(resetSGR)
|
||
return out.String()
|
||
}
|
||
|
||
// sgrClearsFaint reports whether an SGR sequence resets intensity. Lipgloss
|
||
// commonly emits ESC[0m around styled spans, but 22 also clears faint/bold, so
|
||
// both need the faint scrim reapplied for the next unstyled segment.
|
||
func sgrClearsFaint(sequence string) bool {
|
||
if !strings.HasPrefix(sequence, "\x1b[") || !strings.HasSuffix(sequence, "m") {
|
||
return false
|
||
}
|
||
params := strings.TrimSuffix(strings.TrimPrefix(sequence, "\x1b["), "m")
|
||
if params == "" {
|
||
return true
|
||
}
|
||
for _, param := range strings.Split(params, ";") {
|
||
if param == "0" || param == "22" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
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) 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 (motion cue + 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: a subtle liveness pulse, the current phase, 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 (~33ms, 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 the current live phase for the working status line.
|
||
// User-blocked states take precedence, then a streamed or outstanding tool call,
|
||
// then assistant text/reasoning. This makes a quiet but healthy run legible
|
||
// without guessing from elapsed time alone.
|
||
func (m model) workingActivity() string {
|
||
if m.pendingPermission != nil {
|
||
return "waiting for approval"
|
||
}
|
||
if m.pendingAskUser != nil {
|
||
return "waiting for your answer"
|
||
}
|
||
if m.streamCallName != "" {
|
||
return strings.ToLower(toolCardActionLabel(m.streamCallName, "", true))
|
||
}
|
||
if row, ok := m.activeToolCall(); ok {
|
||
return strings.ToLower(toolCardActionLabel(toolRowName(row), row.detail, true))
|
||
}
|
||
if strings.TrimSpace(m.streamingTextString()) != "" {
|
||
return "writing"
|
||
}
|
||
return "thinking"
|
||
}
|
||
|
||
// activeToolCallScanLimit bounds per-frame work while the spinner is active.
|
||
// A current tool call belongs near the transcript tail; if it falls outside this
|
||
// window we use the conservative "thinking" label rather than scan history on
|
||
// every animation frame.
|
||
const activeToolCallScanLimit = 200
|
||
|
||
// activeToolCall returns the newest unresolved tool call from the active run.
|
||
// Results are encountered before their calls while scanning backwards, so a
|
||
// small resolved set prevents an earlier completed call from being reported as
|
||
// live. Tool IDs are required: unkeyed historical rows cannot be paired safely.
|
||
func (m model) activeToolCall() (transcriptRow, bool) {
|
||
if !m.pending || m.activeRunID == 0 {
|
||
return transcriptRow{}, false
|
||
}
|
||
var resolved map[string]struct{}
|
||
for i, scanned := len(m.transcript)-1, 0; i >= 0 && scanned < activeToolCallScanLimit; i, scanned = i-1, scanned+1 {
|
||
row := m.transcript[i]
|
||
if row.runID != m.activeRunID || row.id == "" {
|
||
continue
|
||
}
|
||
key := rcKey(row.runID, row.id)
|
||
switch row.kind {
|
||
case rowToolResult:
|
||
if resolved == nil {
|
||
resolved = make(map[string]struct{})
|
||
}
|
||
resolved[key] = struct{}{}
|
||
case rowToolCall:
|
||
if _, complete := resolved[key]; !complete {
|
||
return row, true
|
||
}
|
||
}
|
||
}
|
||
return transcriptRow{}, false
|
||
}
|
||
|
||
func toolResultCardSuppressedInTranscript(name string, status tools.Status) bool {
|
||
return isHiddenPlumbingTool(name) && status != tools.StatusError
|
||
}
|
||
|
||
func (m model) workingStatusLine() string {
|
||
// Keep one quiet liveness signal at the start of the line. Tool and plan
|
||
// labels stay still, so the display reads as active without competing motion
|
||
// scattered through the transcript.
|
||
line := m.workingStatusLabel()
|
||
if indicator := m.workingStatusIndicator(); indicator != "" {
|
||
line = indicator + line
|
||
}
|
||
// 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.activeTurnElapsed(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.
|
||
if hint := m.quietGenerationHint(); hint != "" {
|
||
line += zeroTheme.amber.Render(" · " + hint)
|
||
}
|
||
return line
|
||
}
|
||
|
||
// 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 || m.pendingPermission != nil {
|
||
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)
|
||
}
|
||
reserved := m.petComposerReservedColumns(width)
|
||
boxWidth := width - reserved
|
||
innerWidth := maxInt(1, boxWidth-4)
|
||
content := m.composerLine(innerWidth)
|
||
lines := strings.Split(content, "\n")
|
||
rightPad := strings.Repeat(" ", reserved)
|
||
|
||
rendered := make([]string, 0, len(lines)+3)
|
||
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", boxWidth-2)+"╮")+rightPad)
|
||
// On graphics-capable terminals the first image receives a real thumbnail in
|
||
// this compact strip. Text-only terminals retain the numbered chip row below.
|
||
if m.attachmentThumbnailVisible(width) {
|
||
for _, line := range m.attachmentThumbnailLines(innerWidth) {
|
||
fitted := fitStyledLine(line, innerWidth)
|
||
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
|
||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
|
||
}
|
||
// A thumbnail gallery makes the first few attachments visible. Keep a compact
|
||
// numbered row whenever there is more than one item (or a document), so the
|
||
// rest of a longer batch is never silently hidden.
|
||
if chips := m.attachmentThumbnailSupplementalChips(); 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(" │")+rightPad)
|
||
}
|
||
} else 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(" │")+rightPad)
|
||
}
|
||
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(" │")+rightPad)
|
||
}
|
||
rendered = append(rendered, m.composerDividerLine(width))
|
||
return strings.Join(rendered, "\n")
|
||
}
|
||
|
||
// 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.choosePermissionOption(option.choice)
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cmd) {
|
||
return m.resolvePermissionWithReason(decision, permissionDecisionReason(decision))
|
||
}
|
||
|
||
// resolvePermissionWithReason resolves the pending prompt with an explicit reason
|
||
// string. It backs both the fixed-label choices (reason = permissionDecisionReason)
|
||
// and the free-text "tell Zero what to do differently" path, where the reason is
|
||
// the user's typed instruction and the action is Deny so the agent surfaces it as
|
||
// the tool result and keeps going.
|
||
func (m model) resolvePermissionWithReason(decision permissionDecision, reason string) (tea.Model, tea.Cmd) {
|
||
pending := m.pendingPermission
|
||
if pending == nil {
|
||
return m, nil
|
||
}
|
||
|
||
if pending.decide != nil {
|
||
pending.decide(agent.PermissionDecision{
|
||
Action: decision,
|
||
Reason: reason,
|
||
})
|
||
}
|
||
m.pendingPermission = nil
|
||
// Time spent at the prompt is user wait, not provider silence. Restart the
|
||
// quiet-generation clock so resuming a long-blocked run does not immediately
|
||
// claim the model has been inactive for the entire approval interval.
|
||
m.lastStreamActivity = m.now()
|
||
if pending.request.ToolName == peerPermissionToolName {
|
||
// Receipt delivery completes asynchronously. That completion advances
|
||
// the peer queue after this prompt is fully settled.
|
||
return m, nil
|
||
}
|
||
return m.openNextPeerApproval()
|
||
}
|
||
|
||
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 {
|
||
return m, nil
|
||
}
|
||
var cmd tea.Cmd
|
||
switch picker.kind {
|
||
case pickerModel:
|
||
previousProvider, previousModel := m.providerName, m.modelName
|
||
text := ""
|
||
var switchPersistErr error
|
||
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, switchPersistErr = 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, switchPersistErr = m.handleModelCommand(item.Value)
|
||
}
|
||
if m.providerName != previousProvider || m.modelName != previousModel {
|
||
notice, tone := m.modelAppliedNoticeFor(switchPersistErr)
|
||
return m.showTransientNoticeInline(notice, tone), cmd
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
case pickerEffort:
|
||
previous := m.reasoningEffort
|
||
text := ""
|
||
m, text = m.handleEffortCommand(item.Value)
|
||
if m.reasoningEffort != previous {
|
||
return m.showTransientNoticeInline(m.effortAppliedNotice(), transientNoticeSuccess), cmd
|
||
}
|
||
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 pickerPet:
|
||
return m.installPet(item.Value)
|
||
case pickerTheme:
|
||
// Selection is applied only after Enter. Moving through the picker renders a
|
||
// local preview and never changes the active palette.
|
||
text := ""
|
||
m, text = m.handleThemeCommand(item.Value)
|
||
if validThemeMode(item.Value) && !strings.Contains(text, "could not save theme preference") {
|
||
return m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess)
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
}
|
||
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) {
|
||
if m.btw.active && command.kind == commandExit && m.btw.parent != nil &&
|
||
(m.btw.parent.pending || m.btw.parent.compactInFlight || len(m.btw.parent.flushRunIDs) > 0) {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendSystem,
|
||
text: "The main session is still running. Return to it first with /btw or Ctrl+C before exiting.",
|
||
})
|
||
return m, nil
|
||
}
|
||
if m.btw.active && btwCommandUnavailable(command) {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendSystem,
|
||
text: command.name + " is unavailable in a BTW conversation. Return to the main session first with /btw or Ctrl+C.",
|
||
})
|
||
return m, nil
|
||
}
|
||
if m.permissionMode == agent.PermissionModePlan && planModeCommandUnavailable(command) {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendSystem,
|
||
text: command.name + " is unavailable in plan mode — it mutates the workspace or spawns a process outside the read-only gate. Exit with /plan off first.",
|
||
})
|
||
return m, nil
|
||
}
|
||
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 commandBTW:
|
||
return m.handleBTWCommand(command.text)
|
||
case commandLoop:
|
||
return m.handleLoopCommand(command.text)
|
||
case commandGoal:
|
||
return m.handleGoalCommand(command.text)
|
||
case commandPets:
|
||
return m.handlePetsCommand(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:
|
||
if len(m.backgroundTerminalSessions()) == 0 {
|
||
return m.showTransientNoticeInline("No background terminals running.", transientNoticeInfo), nil
|
||
}
|
||
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 commandAttach:
|
||
return m.attachTerminalCommand(command.text)
|
||
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
|
||
}
|
||
}
|
||
previousProvider, previousModel := m.providerName, m.modelName
|
||
text := ""
|
||
var persistErr error
|
||
m, text, persistErr = m.handleModelCommand(command.text)
|
||
if m.providerName != previousProvider || m.modelName != previousModel {
|
||
// The status text this branch drops is the only other place the failure
|
||
// appears, so the typed command needs the qualifier as much as the picker.
|
||
notice, tone := m.modelAppliedNoticeFor(persistErr)
|
||
return m.showTransientNoticeInline(notice, tone), nil
|
||
}
|
||
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:
|
||
text := ""
|
||
m, text = m.handlePlanCommand(command.text)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
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 commandRename:
|
||
if title := strings.TrimSpace(command.text); title != "" {
|
||
return m.renameActiveSession(title), nil
|
||
}
|
||
return m.openSessionRenamePrompt(), nil
|
||
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
|
||
}
|
||
}
|
||
previous := m.reasoningEffort
|
||
text := ""
|
||
m, text = m.handleEffortCommand(command.text)
|
||
if m.reasoningEffort != previous {
|
||
return m.showTransientNoticeInline(m.effortAppliedNotice(), transientNoticeSuccess), nil
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandFast:
|
||
previous := m.activeServiceTier()
|
||
text := ""
|
||
m, text = m.handleFastCommand(command.text)
|
||
if m.activeServiceTier() != previous {
|
||
return m.showTransientNoticeInline(m.fastAppliedNotice(), transientNoticeSuccess), nil
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandStyle:
|
||
previous := m.responseStyle
|
||
text := ""
|
||
m, text = m.handleStyleCommand(command.text)
|
||
if m.responseStyle != previous {
|
||
return m.showTransientNoticeInline("Style: "+m.responseStyle, transientNoticeSuccess), nil
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandSelfCorrect:
|
||
previous := m.selfCorrectTests
|
||
text := ""
|
||
m, text = m.handleSelfCorrectCommand(command.text)
|
||
if m.selfCorrectTests != previous {
|
||
return m.showTransientNoticeInline(m.selfCorrectAppliedNotice(), transientNoticeSuccess), nil
|
||
}
|
||
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
|
||
}
|
||
previous := m.agentOptions.MaxTurns
|
||
text := ""
|
||
m, text = m.handleTurnsCommand(command.text)
|
||
if m.agentOptions.MaxTurns != previous {
|
||
return m.showTransientNoticeInline(m.turnsAppliedNotice(), transientNoticeSuccess), nil
|
||
}
|
||
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
|
||
}
|
||
previous := m.execProfileName
|
||
text := ""
|
||
m, text = m.handleProfileCommand(command.text)
|
||
if m.execProfileName != previous {
|
||
return m.showTransientNoticeInline(m.profileAppliedNotice(), transientNoticeSuccess), nil
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandTheme:
|
||
// Bare `/theme` opens a picker with a contained candidate preview. An
|
||
// explicit `/theme <name>` (or `/theme list`) still runs the text handler.
|
||
if strings.TrimSpace(command.text) == "" {
|
||
m.picker = m.newThemePicker()
|
||
return m, nil
|
||
}
|
||
text := ""
|
||
m, text = m.handleThemeCommand(command.text)
|
||
if validThemeMode(command.text) && !strings.Contains(text, "could not save theme preference") {
|
||
return m.showTransientNotice(m.themeAppliedNotice(), transientNoticeSuccess)
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
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
|
||
m.refreshPendingImageThumbnail()
|
||
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.refreshPendingImageThumbnail()
|
||
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) {
|
||
return m.launchPromptInternal(prompt, nil)
|
||
}
|
||
|
||
func (m model) launchPromptInternal(prompt string, peer *peermsg.InboundMessage) (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.
|
||
var attachments transcriptAttachmentSummary
|
||
if peer == nil {
|
||
m.lastPrompt = prompt
|
||
m.lastImages = m.pendingImages
|
||
m.lastImageLabels = m.pendingImageLabels
|
||
m.lastDocuments = m.pendingDocuments
|
||
attachments = transcriptAttachmentSummary{
|
||
images: len(m.pendingImages),
|
||
documents: len(m.pendingDocuments),
|
||
}
|
||
// A switched model may no longer accept a staged image. The matching
|
||
// system notice below explains the drop; the sent user row must not claim
|
||
// the image was included.
|
||
if attachments.images > 0 && !m.modelSupportsVisionTUI() {
|
||
attachments.images = 0
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: prompt, attachments: attachments})
|
||
} else {
|
||
m.transcript = appendTranscriptRow(m.transcript, peerTranscriptRow(peerDisplayName(peer.From), peer.Body))
|
||
}
|
||
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 peer == nil {
|
||
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 peer == nil {
|
||
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 {
|
||
if m.activeLoopID == "" && sessions.IsResumableKind(m.activeSession.SessionKind) &&
|
||
m.activeSession.Goal != nil && m.activeSession.Goal.Status == sessions.GoalStatusActive {
|
||
if updated, resetErr := m.sessionStore.ResetGoalContinuations(m.activeSession.SessionID); resetErr != nil {
|
||
m = m.appendGoalError("reset automatic continuation count: " + resetErr.Error())
|
||
} else {
|
||
m.activeSession = updated
|
||
}
|
||
}
|
||
agentPrompt := m.sessionPrompt(prompt)
|
||
messagePayload := map[string]any{
|
||
"role": "user",
|
||
"content": prompt,
|
||
}
|
||
if peer == nil {
|
||
if !attachments.empty() {
|
||
messagePayload["attachments"] = map[string]int{
|
||
"images": attachments.images,
|
||
"documents": attachments.documents,
|
||
}
|
||
}
|
||
}
|
||
if peer != nil {
|
||
messagePayload["origin"] = "cross_session"
|
||
messagePayload["from"] = peerDisplayName(peer.From)
|
||
messagePayload["messageId"] = peer.ID
|
||
messagePayload["displayContent"] = peer.Body
|
||
}
|
||
m, err = m.appendSessionEvent(sessions.EventMessage, messagePayload)
|
||
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 peer != nil {
|
||
turnImages = nil
|
||
}
|
||
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
|
||
}
|
||
if peer == nil {
|
||
m.pendingImages = nil
|
||
m.pendingImageLabels = nil
|
||
m.pendingImageThumbnails = nil
|
||
}
|
||
runCtx, cancel := context.WithCancel(m.ctx)
|
||
if peer != nil {
|
||
runCtx = peermsg.WithInboundMessage(runCtx, *peer)
|
||
}
|
||
m = m.beginRun(cancel)
|
||
var agentCmd tea.Cmd
|
||
if peer != nil {
|
||
agentCmd = m.runAgentWithOptions(m.activeRunID, runCtx, prompt, turnImages, tuiAgentRunOptions{
|
||
transientSystemPrompt: peerTurnSystemPrompt,
|
||
})
|
||
} else {
|
||
agentCmd = m.runAgent(m.activeRunID, runCtx, prompt, turnImages)
|
||
}
|
||
return m, tea.Batch(agentCmd, 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 {
|
||
m = m.cancelIdleRecap()
|
||
if m.prepareRunCompletionWarning != nil {
|
||
m.prepareRunCompletionWarning()
|
||
}
|
||
m.runID++
|
||
m.activeRunID = m.runID
|
||
m.terminalAutoAttach = nil
|
||
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
|
||
m.runDetailsOpen = false
|
||
m.turnStartedAt = m.now()
|
||
m.turnTimer = newActiveTurnTimer(m.turnStartedAt)
|
||
m.lastStreamActivity = m.turnStartedAt
|
||
m.turnStreamedRunes = 0
|
||
m.spinnerTicking = true
|
||
return m
|
||
}
|
||
|
||
// ensureSpinnerTick returns the spinner.Tick cmd to (re)start the self-scheduling
|
||
// tick loop when active agent state needs its short lifecycle fade but the loop
|
||
// is not already running (e.g. a resumed session with live agents before any
|
||
// run starts). It returns nil — issuing no second timer — when the loop is
|
||
// already alive, reduced motion is set, or nothing needs animation.
|
||
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() {
|
||
goalWasActive := m.pending && m.activeSession.Goal != nil &&
|
||
m.activeSession.Goal.Status == sessions.GoalStatusActive
|
||
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
|
||
}
|
||
}
|
||
if goalWasActive && m.sessionStore != nil && m.activeSession.SessionID != "" {
|
||
updated, event, err := m.sessionStore.PauseGoalIfActive(m.activeSession.SessionID, "run cancelled by user")
|
||
if err != nil {
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowError, text: "Goal: pause after cancellation: " + err.Error()})
|
||
} else {
|
||
m.activeSession = updated
|
||
if event != nil {
|
||
m.sessionEvents = append(m.sessionEvents, *event)
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Goal paused. Use /goal resume to continue."})
|
||
}
|
||
}
|
||
}
|
||
m.pending = false
|
||
m.runCancel = nil
|
||
m.activeRunID = 0
|
||
m.terminalAutoAttach = nil
|
||
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()
|
||
if m.turnTimer != nil {
|
||
m.turnTimer.start(started)
|
||
}
|
||
// firstTokenElapsed is stamped from the pause-aware turn timer when the
|
||
// first reasoning or text token streams, so TTFT and total elapsed use
|
||
// the same clock.
|
||
var firstTokenElapsed time.Duration
|
||
firstTokenSeen := false
|
||
stampFirstToken := func(at time.Time) {
|
||
if firstTokenSeen {
|
||
return
|
||
}
|
||
firstTokenSeen = true
|
||
firstTokenElapsed = m.activeTurnElapsedAt(started, at)
|
||
}
|
||
toolCalls := 0
|
||
rows := []transcriptRow{}
|
||
usageEvents := []zeroruntime.Usage{}
|
||
sessionEvents := []pendingSessionEvent{}
|
||
usageModelID := m.modelName
|
||
// usageModelIDs records, per usage event, the model in force when it
|
||
// fired; the escalation switcher reassigns usageModelID mid-run.
|
||
usageModelIDs := []string{}
|
||
var specReview *pendingSpecReviewPrompt
|
||
if m.awaitToolReadiness != nil {
|
||
m.awaitToolReadiness(runCtx)
|
||
}
|
||
options := m.agentOptions
|
||
options.Registry = cloneToolRegistry(m.registry)
|
||
goalAwareRun := !runOptions.specDraft && m.activeLoopID == "" &&
|
||
sessions.IsResumableKind(m.activeSession.SessionKind)
|
||
if goalAwareRun {
|
||
options.Registry = m.goalRegistry()
|
||
}
|
||
if runOptions.registry != nil {
|
||
options.Registry = cloneToolRegistry(runOptions.registry)
|
||
if goalAwareRun && m.activeSession.SessionID != "" {
|
||
for _, tool := range tools.NewGoalTools(m.sessionStore, m.activeSession.SessionID) {
|
||
options.Registry.Register(tool)
|
||
}
|
||
}
|
||
}
|
||
peerAwareRun := runOptions.transientSystemPrompt != "" || m.sessionContainsPeerMessages()
|
||
if peerAwareRun && m.peerService != nil {
|
||
options.Registry.Register(tools.NewPeerReplyTool(m.peerService))
|
||
}
|
||
options.PermissionMode = m.permissionMode
|
||
if runOptions.permissionMode != "" {
|
||
options.PermissionMode = runOptions.permissionMode
|
||
}
|
||
if runOptions.systemPrompt != "" {
|
||
options.SystemPrompt = runOptions.systemPrompt
|
||
}
|
||
if runOptions.transientSystemPrompt != "" {
|
||
options.TransientSystemPrompt = runOptions.transientSystemPrompt
|
||
} else if peerAwareRun {
|
||
options.TransientSystemPrompt = peerTurnSystemPrompt
|
||
}
|
||
if goalAwareRun {
|
||
options.SystemPrompt = m.goalSystemPrompt(options.SystemPrompt)
|
||
}
|
||
options.SessionID = m.activeSession.SessionID
|
||
options.ProviderName = m.providerName
|
||
options.Model = m.modelName
|
||
options.ReasoningEffort = string(m.reasoningEffort)
|
||
options.ServiceTier = m.activeServiceTier()
|
||
options.ResponseStyle = m.responseStyle
|
||
options.Cwd = m.cwd
|
||
options.Images = images
|
||
if m.newTurnSessionProvider != nil && m.provider != nil {
|
||
options.TurnSessionProvider = m.newTurnSessionProvider(m.providerProfile, m.provider)
|
||
}
|
||
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))
|
||
// Route compaction summarization to a cheap model when one applies
|
||
// (env > config > curated default). The factory is lazy, re-evaluated
|
||
// against the model in force when the compactor asks (so an escalation
|
||
// away from the cheap model gains a summarizer), and the agent falls
|
||
// back to the main provider on any summarizer failure. Resolved against
|
||
// the ACTIVE profile so it tracks mid-session model switches.
|
||
options.Summarizer = providers.CompactionSummarizerFactory(m.providerProfile, m.compactionModel, m.newProvider)
|
||
// Let compaction re-derive its threshold after a mid-run escalate_model
|
||
// switch instead of keeping the original model's window.
|
||
options.ContextWindowFor = func(modelID string) int {
|
||
return modelregistry.AgentContextWindow(m.modelContextWindow(modelID))
|
||
}
|
||
// And make that switch reachable, when the operator asked for it. The
|
||
// consequences of an escalation were already handled here (the window
|
||
// above, and the summarizer resolved against the active profile) while
|
||
// nothing on this surface could cause one: escalate_model was registered
|
||
// only by exec.
|
||
//
|
||
// BUILT FROM THE ACTIVE PROFILE, NOT THE STARTUP ONE. A TUI session can
|
||
// change models with /model, so escalating from the profile captured at
|
||
// launch would switch from whatever the session began with rather than
|
||
// from what is in force now, and would carry that stale profile's base URL
|
||
// and credential with it. m.providerProfile tracks the switches, which is
|
||
// why this is built per turn rather than once in the caller.
|
||
if m.allowEscalation {
|
||
options.ModelSwitcher, options.ModelSessionSwitcher = providers.EscalationSwitchers(
|
||
m.providerProfile, m.provider, m.newProvider,
|
||
// Usage attribution follows the switch, as exec reassigns its
|
||
// currentModel: every usage event after a real escalation is billed
|
||
// to the escalated model, not the one the run started on.
|
||
func(modelID string) { usageModelID = modelID },
|
||
)
|
||
}
|
||
|
||
// 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) {
|
||
now := m.now()
|
||
stampFirstToken(now)
|
||
if strings.TrimSpace(reasoningText) != "" {
|
||
flushReasoning(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 m.turnTimer != nil {
|
||
m.turnTimer.pause(m.now())
|
||
defer func() {
|
||
m.turnTimer.resume(m.now())
|
||
}()
|
||
}
|
||
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 strings.TrimSpace(delta) != "" {
|
||
stampFirstToken(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,
|
||
}
|
||
// Specialist delegation and an in-flight plan update have dedicated UI, so
|
||
// omit their redundant call cards from the transcript. The completed plan
|
||
// result remains as a durable checklist in the conversation history.
|
||
if !toolCallCardSuppressedInTranscript(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.
|
||
// A tty exec_command wants the attach overlay: the session registers
|
||
// with the process manager inside the tool's Run, so the update loop
|
||
// polls for it on a tick.
|
||
if call.Name == tools.ExecCommandToolName && execCallWantsTTY(call.Arguments) && m.runtimeMessageSink != nil {
|
||
known := map[int]bool{}
|
||
if controller, ok := m.execSessionController(); ok {
|
||
for _, session := range controller.ExecSessions() {
|
||
known[session.ID] = true
|
||
}
|
||
}
|
||
m.runtimeMessageSink(interactiveExecStartMsg{runID: runID, known: known})
|
||
}
|
||
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),
|
||
meta: result.Meta,
|
||
runID: runID,
|
||
changedFiles: result.ChangedFiles,
|
||
changeSummaries: result.ChangeSummaries,
|
||
}
|
||
// A successful Task/TaskOutput result is represented by a specialist card.
|
||
// update_plan stays in the transcript as a rendered checklist; failures
|
||
// always remain visible because a dedicated surface cannot explain them.
|
||
if !toolResultCardSuppressedInTranscript(result.Name, result.Status) {
|
||
rows = append(rows, row)
|
||
m.sendAgentRow(runID, row)
|
||
}
|
||
// Keep the latest plan state in sync for run details and step drill-in.
|
||
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()})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||
Type: sessions.EventToolResult,
|
||
Payload: toolResultSessionPayload(result),
|
||
})
|
||
// 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)
|
||
usageModelIDs = append(usageModelIDs, usageModelID)
|
||
payload := usage.EventUsagePayload(event)
|
||
// AND ON THE PERSISTED EVENT TOO, not only the in-memory record: the
|
||
// report reconstructs cost from the payload, falling back to the
|
||
// session-wide model, so an escalated run would be priced entirely at
|
||
// the model it started on. Written only under escalation, which is the
|
||
// only way the model in force can change mid-run, matching what exec
|
||
// records under the same flag.
|
||
if m.allowEscalation {
|
||
payload["model"] = usageModelID
|
||
}
|
||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||
Type: sessions.EventUsage,
|
||
Payload: payload,
|
||
})
|
||
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, usageModelIDs: usageModelIDs, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(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, usageModelIDs: usageModelIDs, sessionEvents: sessionEvents, err: err, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||
}
|
||
flushReasoning(m.now())
|
||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, usageModelIDs: usageModelIDs, sessionEvents: sessionEvents, specReview: specReview, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: m.activeTurnElapsed(started)}
|
||
}
|
||
flushReasoning(m.now())
|
||
elapsed := m.activeTurnElapsed(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, usageModelIDs: usageModelIDs, sessionEvents: sessionEvents, goalAware: goalAwareRun, turnTools: toolCalls, turnElapsed: elapsed, ttft: firstTokenElapsed}
|
||
}
|
||
}
|
||
|
||
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 {
|
||
display := result.HumanDisplay()
|
||
if strings.TrimSpace(display.Preview) != "" && (result.Status != tools.StatusError || result.Outcome.Finalized()) {
|
||
return display.Preview
|
||
}
|
||
return result.ModelOutput()
|
||
}
|
||
|
||
// toolResultSessionPayload preserves both views of a tool result: output remains
|
||
// the provider-facing text used for session context, while displayPreview keeps
|
||
// the richer card body that was visible during the live run. The preview is only
|
||
// stored when it differs, so ordinary tool results retain their compact event.
|
||
func toolResultSessionPayload(result agent.ToolResult) map[string]any {
|
||
output := result.ModelOutput()
|
||
payload := map[string]any{
|
||
"toolCallId": result.ToolCallID,
|
||
"name": result.Name,
|
||
"status": string(result.Status),
|
||
"output": output,
|
||
}
|
||
if preview := toolResultDetail(result); strings.TrimSpace(preview) != "" && preview != output {
|
||
payload["displayPreview"] = preview
|
||
}
|
||
if result.Redacted {
|
||
payload["redacted"] = true
|
||
}
|
||
if len(result.Meta) > 0 {
|
||
payload["meta"] = result.Meta
|
||
}
|
||
if len(result.ChangedFiles) > 0 {
|
||
payload["changedFiles"] = result.ChangedFiles
|
||
}
|
||
if len(result.ChangeSummaries) > 0 {
|
||
payload["changeSummaries"] = result.ChangeSummaries
|
||
}
|
||
return payload
|
||
}
|
||
|
||
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.ModelOutput(), tuiToolOutputLimit))
|
||
}
|