mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* Improve harness context efficiency
* Address harness efficiency review findings
* Make recaps idle and cancellable
Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.
Also strengthen the formatter safety regression to exercise formatter-modified edit output.
* Track every replace-all edit span
Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.
* Make review regressions cross-platform
Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.
* fix(tools): derive SeenWhole from the recorded ranges
SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.
That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:
the intended change depends on content that has not been read exactly
in this session. Read the affected lines with read_file, then retry.
Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.
SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.
Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): key the large-file test on the resolved path
read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.
Also switches the line generator to fmt.Fprintf (staticcheck QF1012).
Both from CodeRabbit on #842.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): actually switch the line generator to fmt.Fprintf
The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.
Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.
* fix: keep exact-read workflows recoverable
* test: cover permission normalization wiring
* fix: commit file observations after output budgeting
* fix: defer file observations across retries
* test: normalize patch line endings
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
5682 lines
222 KiB
Go
5682 lines
222 KiB
Go
package tui
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
|
||
"charm.land/bubbles/v2/spinner"
|
||
"charm.land/bubbles/v2/textinput"
|
||
tea "charm.land/bubbletea/v2"
|
||
"charm.land/lipgloss/v2"
|
||
"github.com/charmbracelet/x/ansi"
|
||
|
||
"github.com/Gitlawb/zero/internal/agent"
|
||
"github.com/Gitlawb/zero/internal/config"
|
||
"github.com/Gitlawb/zero/internal/doctor"
|
||
"github.com/Gitlawb/zero/internal/errhint"
|
||
"github.com/Gitlawb/zero/internal/lsp"
|
||
internalmcp "github.com/Gitlawb/zero/internal/mcp"
|
||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||
"github.com/Gitlawb/zero/internal/notify"
|
||
"github.com/Gitlawb/zero/internal/providerhealth"
|
||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||
"github.com/Gitlawb/zero/internal/sandbox"
|
||
"github.com/Gitlawb/zero/internal/sessions"
|
||
"github.com/Gitlawb/zero/internal/skills"
|
||
"github.com/Gitlawb/zero/internal/streamjson"
|
||
"github.com/Gitlawb/zero/internal/tools"
|
||
"github.com/Gitlawb/zero/internal/usage"
|
||
"github.com/Gitlawb/zero/internal/usercommands"
|
||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||
)
|
||
|
||
const tuiToolOutputLimit = 240
|
||
const defaultResponseStyle = "concise"
|
||
const chatWheelScrollLines = 5
|
||
const ctrlCExitConfirmDuration = 3 * time.Second
|
||
const ctrlCExitConfirmText = "Press Ctrl+C again to exit"
|
||
|
||
// escCancelConfirmDuration/escCancelConfirmText guard Esc cancelling a running
|
||
// turn, mirroring ctrlCExitConfirmDuration/ctrlCExitConfirmText's pattern (same
|
||
// window, same amber footer treatment) so the two "stop it" keybinds feel
|
||
// consistent. Without this, a single stray Esc — e.g. meant to dismiss a
|
||
// suggestion overlay that had already closed — silently threw away an
|
||
// in-progress run with no chance to reconsider.
|
||
const escCancelConfirmDuration = 3 * time.Second
|
||
const escCancelConfirmText = "Press Esc again to cancel"
|
||
|
||
// dragEdgeScrollInterval/dragEdgeScrollStep drive the smooth-glide auto-scroll
|
||
// while a drag holds past the transcript edge (see edgeScrollDelta). A small step
|
||
// on a short, steady cadence reads as a smooth continuous scroll; the wheel-scroll
|
||
// tick size (chatWheelScrollLines) would jump too far per step for that.
|
||
const dragEdgeScrollInterval = 70 * time.Millisecond
|
||
const dragEdgeScrollStep = 1
|
||
|
||
type model struct {
|
||
ctx context.Context
|
||
cwd string
|
||
appVersion string
|
||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
|
||
userConfigPath string
|
||
doctorUserConfigPath string
|
||
projectConfigPath string
|
||
gitBranch string
|
||
providerName string
|
||
modelName string
|
||
modelCatalog modelregistry.Registry
|
||
providerProfile config.ProviderProfile
|
||
savedProviders []config.ProviderProfile
|
||
provider zeroruntime.Provider
|
||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||
discoverOllamaContextWindow func(ctx context.Context, baseURL string, model string) (int, error)
|
||
registry *tools.Registry
|
||
// lspManager is created once per session and reused across prompts so gopls (and
|
||
// other language servers) stay warm — a fresh manager per run would cold-start
|
||
// the server on the first edit of every turn. Nil when cwd is unknown; runs then
|
||
// fall back to a per-run manager. Torn down in quit().
|
||
lspManager *lsp.Manager
|
||
sessionStore *sessions.Store
|
||
sandboxStore *sandbox.GrantStore
|
||
mcpConfig config.MCPConfig
|
||
mcpPermissionStore *internalmcp.PermissionStore
|
||
mcpTokenStore *internalmcp.TokenStore
|
||
mcpCommand func(context.Context, []string) MCPCommandResult
|
||
sandboxSetupCommand func(context.Context) SandboxSetupCommandResult
|
||
mcpViewStateCache MCPViewState
|
||
mcpViewStateReady bool
|
||
mcpCommandSeq int
|
||
mcpCommandCancel context.CancelFunc
|
||
sandboxSetupSeq int
|
||
sandboxSetupInFlight bool
|
||
doctorCommandSeq int
|
||
doctorInFlight bool
|
||
doctorFrame int
|
||
activeSession sessions.Metadata
|
||
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
|
||
selfCorrectTests bool
|
||
reasoningEffort modelregistry.ReasoningEffort
|
||
// Active execution profile (set by /profile; applies to the NEXT run).
|
||
// The displaced/applied pairs let a switch or /profile balanced restore
|
||
// exactly what the profile replaced while leaving later manual overrides
|
||
// (/turns, Ctrl+T, /selfcorrect) alone: each knob is only reverted when it
|
||
// still holds the value the profile applied.
|
||
execProfileName string
|
||
execProfileDisplacedMaxTurns int
|
||
execProfileAppliedMaxTurns int
|
||
execProfileAppliedEffort modelregistry.ReasoningEffort
|
||
execProfileArmedSelfCorrect bool
|
||
// The touched bits record explicit user choices made while a profile is
|
||
// active (/turns, /effort, Ctrl+T, /selfcorrect); a touched knob is never
|
||
// reverted, even when its value coincides with what the profile applied.
|
||
execProfileTurnsTouched bool
|
||
execProfileEffortTouched bool
|
||
execProfileSelfCorrectTouched bool
|
||
responseStyle string
|
||
keyBindings keyBindings
|
||
themeMode themeMode // palette preference: auto (default), dark, light
|
||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||
userAgent string
|
||
compactRequests int
|
||
compactInFlight bool
|
||
compactFrame int
|
||
lastCompactResult *CompactResult
|
||
lastCompactError string
|
||
unpricedRequests int
|
||
unpricedTokens int
|
||
lastUsage usage.Normalized
|
||
lastUsageSeen bool
|
||
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
|
||
// /context can show a rolling average turn latency (the "is it slow?" signal).
|
||
// Reset by /new.
|
||
turnLatencySum time.Duration
|
||
turnLatencyCount int
|
||
turnTTFTSum time.Duration
|
||
turnTTFTCount int
|
||
transcript []transcriptRow
|
||
transcriptDetailed bool
|
||
helpOverlay bool // the `?` keyboard-shortcut overlay is open
|
||
// leaderHelpOverlay is the Ctrl+X ? modal listing every leader slash chord.
|
||
leaderHelpOverlay bool
|
||
// leaderPending is true after Ctrl+X until a second key, Esc, or timeout
|
||
// resolves the chord (see leader.go). leaderSeq invalidates a stale tick.
|
||
leaderPending bool
|
||
leaderSeq int
|
||
transcriptBodyHeights *transcriptBodyHeightCache
|
||
input textinput.Model
|
||
composer composerState
|
||
composerActive bool
|
||
composerCursorVisible bool
|
||
composerPastePreviews []composerPastePreview
|
||
composerSelection composerSelectionState
|
||
dictation dictationController
|
||
sttKeyPrompt *sttKeyPromptState
|
||
// plan holds the sticky plan panel state (steps, expansion, timings)
|
||
// synced from the update_plan tool. See plan_panel.go.
|
||
plan planPanelState
|
||
specialists specialistTracker
|
||
stepWork map[string][]planStepWork // file mutations + commands captured per in_progress plan step, for the clickable step detail
|
||
stepNarration map[string][]string // the agent's own prose narration captured per in_progress plan step, for the step detail's explanation
|
||
planDetailOpen bool // a plan-step detail card is currently shown (click-to-toggle)
|
||
planDetailStep int // which step index the shown detail card is for
|
||
planDetailGen int // bumped each run; an in-flight explanation result from an older gen is dropped
|
||
stepExplanation map[string]string // model-written step write-ups, keyed by planStepExplanationKey, cached so re-clicking is instant
|
||
subchat subchatState
|
||
altScreen bool
|
||
setup setupState
|
||
setupSave func(SetupSelection) (SetupResult, error)
|
||
// spinner animates the running-tool glyph in card heads. Its tick is started
|
||
// with each run and stops itself once pending clears (the TickMsg is simply
|
||
// not forwarded), so an idle UI schedules no timers.
|
||
spinner spinner.Model
|
||
// spinnerPhase advances once per spinner tick while a run is in flight and is
|
||
// the shared animation clock for the cosine ripple on the working status line
|
||
// (ripple.go). Reusing the spinner's existing tick keeps a single ~80ms timer
|
||
// driving both the braille glyph and the colour wave — no second ticker.
|
||
spinnerPhase int
|
||
// spinnerTicking tracks whether the spinner's self-scheduling tick loop is
|
||
// currently alive, so a kick (ensureSpinnerTick) never double-issues the tick
|
||
// when the loop is already running. Set true whenever a Tick cmd is returned
|
||
// from the TickMsg handler / beginRun, cleared when the handler stops the loop.
|
||
spinnerTicking bool
|
||
pending bool
|
||
// turnStartedAt is when the in-flight run began; the working status line
|
||
// renders the live elapsed time from it so a long or stalled turn never looks
|
||
// like a frozen terminal (for ANY provider, not just slow ones). Zero = idle.
|
||
turnStartedAt time.Time
|
||
// 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
|
||
// hidePinnedPlan suppresses the pinned plan panel above the composer. Set on
|
||
// the chat-column model copy in the two-column layout, where the plan is
|
||
// surfaced in the context sidebar instead so it isn't shown twice.
|
||
hidePinnedPlan bool
|
||
// sidebarHidden is the user's Ctrl+B preference to collapse the right context
|
||
// sidebar; when set, the chat reflows to full width. Distinct from the
|
||
// availability conditions in sidebarAvailable (geometry / mode / overlays).
|
||
sidebarHidden bool
|
||
// selectedFile is the touched file selected by clicking its FILES sidebar
|
||
// row: its edit cards tint in the chat (rowTouchesSelectedFile) and a second
|
||
// click opens the drill-in file view. "" when nothing is selected; Esc clears.
|
||
selectedFile string
|
||
// fileView is the drill-in view for a touched file (file_view.go): while
|
||
// active the chat column's body shows the file's diff/content instead of the
|
||
// transcript, mirroring the subchat drill-in.
|
||
fileView fileViewState
|
||
// Git-sweep state (files_git_sweep.go): the startup snapshot of already-dirty
|
||
// paths (nil until Init's sweep answers), the newly dirty files discovered by
|
||
// live sweeps (bash/subagent mutations that carry no changedFiles), the
|
||
// single-flight guard, and the "not a git repo / no git" latch.
|
||
gitFileBaseline map[string]bool
|
||
gitTouched []gitSweepFile
|
||
gitSweepInFlight bool
|
||
gitSweepUnavailable bool
|
||
// swarmDoneAt records when each swarm member was first seen finished (done/
|
||
// failed) in a swarm_status report, so the sidebar can linger it briefly with a
|
||
// fading ✓ before dropping it (a smooth exit, not an abrupt pop). Stamped in the
|
||
// spinner tick; keyed by member id. Always non-nil (initialised in newModel).
|
||
swarmDoneAt map[string]time.Time
|
||
now func() time.Time
|
||
chatScrollOffset int
|
||
// chatBodyLines is the live body's line count at the last update; used to pin
|
||
// the viewport (hold the read position) when content streams in while the user
|
||
// has scrolled up. 0 means "at the bottom / not pinned".
|
||
chatBodyLines int
|
||
|
||
// Flush-frontier state (see flush.go). In inline mode, transcript[:flushed]
|
||
// is already in native scrollback. 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
|
||
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
|
||
modelPickerLoading bool
|
||
modelPickerLoadingProviderID string
|
||
modelPickerLoadError string
|
||
// modelPickerLiveByProvider holds live-discovered models per provider (keyed by
|
||
// catalog descriptor ID), so /model shows each provider's real current models —
|
||
// the same list the provider-setup wizard discovers — not the static catalog.
|
||
modelPickerLiveByProvider map[string][]providermodeldiscovery.Model
|
||
// ollamaContextWindowByModel holds context-window sizes fetched from a local
|
||
// Ollama daemon's native /api/show endpoint (keyed by model name), for
|
||
// custom/local models that have no curated-catalog entry and whose
|
||
// OpenAI-compatible /v1/models listing doesn't carry that metadata at all —
|
||
// see modelContextWindow.
|
||
ollamaContextWindowByModel map[string]int
|
||
|
||
// pendingImages holds image attachments staged by /image for the next user
|
||
// turn; pendingImageLabels are their display names (base(path)) for the chip
|
||
// row. Both are cleared after a prompt is submitted (or /image clear). nil =
|
||
// no attachments = today's text-only behavior exactly.
|
||
pendingImages []zeroruntime.ImageBlock
|
||
pendingImageLabels []string
|
||
|
||
// pendingDocuments holds PDF text layers staged by /image for the next user
|
||
// turn; the text is prepended to the prompt as a preamble at submit time and
|
||
// the slice is cleared (or by /image clear). nil = no documents staged.
|
||
pendingDocuments []pendingDocument
|
||
|
||
// captureRunImages, when set, is invoked with the images a run is launched
|
||
// with. Nil in production; used by tests to assert image threading without a
|
||
// real provider round-trip.
|
||
captureRunImages func([]zeroruntime.ImageBlock)
|
||
}
|
||
|
||
type agentTextMsg struct {
|
||
runID int
|
||
delta string
|
||
}
|
||
|
||
// 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 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
|
||
}
|
||
|
||
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
|
||
specDraft bool
|
||
}
|
||
|
||
func newModel(ctx context.Context, options Options) model {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
|
||
cwd := options.Cwd
|
||
if cwd == "" {
|
||
if current, err := os.Getwd(); err == nil {
|
||
cwd = current
|
||
}
|
||
}
|
||
|
||
userConfigDir, _ := config.UserConfigDir()
|
||
loadedUserCommands := usercommands.Load(usercommands.DefaultPaths(cwd, userConfigDir))
|
||
|
||
registry := options.Registry
|
||
if registry == nil {
|
||
registry = options.AgentOptions.Registry
|
||
}
|
||
if registry == nil {
|
||
registry = tools.NewRegistry()
|
||
}
|
||
sessionStore := options.SessionStore
|
||
if sessionStore == nil {
|
||
sessionStore = sessions.NewStore(sessions.StoreOptions{})
|
||
}
|
||
sandboxStore := options.SandboxStore
|
||
modelCatalog, err := modelregistry.DefaultRegistry()
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
usageTracker := options.UsageTracker
|
||
if usageTracker == nil {
|
||
usageTracker = usage.NewTracker(usage.TrackerOptions{Registry: &modelCatalog})
|
||
}
|
||
prService := options.PrService
|
||
if prService == nil {
|
||
prService = NewPrService(cwd)
|
||
}
|
||
doctorUserConfigPath := options.DoctorUserConfigPath
|
||
if doctorUserConfigPath == "" {
|
||
doctorUserConfigPath = options.UserConfigPath
|
||
}
|
||
|
||
permissionMode := options.PermissionMode
|
||
if permissionMode == "" {
|
||
permissionMode = options.AgentOptions.PermissionMode
|
||
}
|
||
if permissionMode == "" {
|
||
permissionMode = agent.PermissionModeAuto
|
||
}
|
||
|
||
input := textinput.New()
|
||
input.Prompt = "❯ "
|
||
input.Placeholder = composerPlaceholder
|
||
// Bubble's Ctrl+V binding reads the clipboard itself. Keep it disabled so
|
||
// terminal bracketed paste (Paste: true) is the single paste path.
|
||
input.KeyMap.Paste.SetEnabled(false)
|
||
input.Focus()
|
||
|
||
runSpinner := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
||
|
||
notifier := notify.New(os.Stderr, notify.Config{
|
||
Mode: notify.Mode(strings.TrimSpace(options.Notify.Mode)),
|
||
FocusMode: notify.FocusMode(strings.TrimSpace(options.Notify.FocusMode)),
|
||
})
|
||
// Opt-in webhook fan-out (ZERO_NOTIFY_WEBHOOK_URL). Delivery failures stay
|
||
// silent here: the TUI owns the alt-screen, so writing to stderr would
|
||
// corrupt the display.
|
||
notify.MaybeAddWebhookSink(notifier, os.Getenv, nil)
|
||
notifier.SetFocused(true)
|
||
|
||
resolvedKeyBindings, keyBindingWarnings := sanitizeKeyBindings(resolveKeyBindings(options.KeyBindings))
|
||
|
||
m := model{
|
||
ctx: ctx,
|
||
cwd: cwd,
|
||
appVersion: strings.TrimSpace(options.Version),
|
||
swarmDoneAt: map[string]time.Time{},
|
||
userCommands: loadedUserCommands,
|
||
loadSkills: options.LoadSkills,
|
||
composerCursorVisible: true,
|
||
terminalFocused: true,
|
||
userConfigPath: options.UserConfigPath,
|
||
doctorUserConfigPath: doctorUserConfigPath,
|
||
projectConfigPath: options.ProjectConfigPath,
|
||
savedProviders: options.SavedProviders,
|
||
gitBranch: gitBranch(cwd),
|
||
providerName: options.ProviderName,
|
||
modelName: options.ModelName,
|
||
modelCatalog: modelCatalog,
|
||
providerProfile: options.ProviderProfile,
|
||
favoriteModels: favoriteModelSet(options.FavoriteModels),
|
||
recentModels: normalizeRecentModelEntries(options.RecentModels),
|
||
recapsEnabled: options.RecapsEnabled,
|
||
provider: options.Provider,
|
||
newProvider: options.NewProvider,
|
||
probeProviderHealth: options.ProbeProviderHealth,
|
||
discoverProviderModels: options.DiscoverProviderModels,
|
||
discoverOllamaContextWindow: options.DiscoverOllamaContextWindow,
|
||
registry: registry,
|
||
sessionStore: sessionStore,
|
||
sandboxStore: sandboxStore,
|
||
mcpConfig: options.MCPConfig,
|
||
mcpPermissionStore: options.MCPPermissionStore,
|
||
mcpTokenStore: options.MCPTokenStore,
|
||
mcpCommand: options.MCPCommand,
|
||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||
agentOptions: options.AgentOptions,
|
||
sessionCompactor: options.SessionCompactor,
|
||
runtimeMessageSink: options.RuntimeMessageSink,
|
||
permissionMode: permissionMode,
|
||
reasoningEffort: options.ReasoningEffort,
|
||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||
keyBindings: resolvedKeyBindings,
|
||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||
hasDarkBg: true,
|
||
userAgent: options.UserAgent,
|
||
usageTracker: usageTracker,
|
||
transcript: initialTranscript(),
|
||
transcriptBodyHeights: newTranscriptBodyHeightCache(defaultTranscriptBodyHeightCacheMaxEntries),
|
||
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),
|
||
}
|
||
// Apply an explicit theme immediately; auto stays on the dark default until
|
||
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
|
||
if m.themeMode != themeAuto {
|
||
applyTheme(m.themeMode, true)
|
||
}
|
||
m.reducedMotion = defaultReducedMotion()
|
||
// The streaming-text fade (a lime→ink glow on freshly streamed lines) is
|
||
// disabled: it read as a distracting glow rather than a subtle liveness cue.
|
||
// Streaming text always renders statically at base ink (the disabled path in
|
||
// styleStreamingLine), so no accent glow and no per-line fade ticks.
|
||
m.fadeDisabled = true
|
||
// 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
|
||
|
||
// composerTypingIdleThreshold is how long a typing pause must last before the
|
||
// cursor resumes blinking; comfortably above normal inter-keystroke gaps
|
||
// (~150-300ms) so it won't flicker mid-sentence.
|
||
const composerTypingIdleThreshold = 500 * time.Millisecond
|
||
|
||
// composerBlinkMsg toggles the composer cursor's visibility each tick. The custom
|
||
// composer render draws its own cursor (not textinput's), so it drives its own
|
||
// blink rather than relying on textinput.Blink.
|
||
type composerBlinkMsg struct{}
|
||
|
||
func composerBlinkCmd() tea.Cmd {
|
||
return tea.Tick(composerCursorBlinkInterval, func(time.Time) tea.Msg {
|
||
return composerBlinkMsg{}
|
||
})
|
||
}
|
||
|
||
func (m model) Init() tea.Cmd {
|
||
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
|
||
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
|
||
// on program start, so m.height/m.width are normally set before the first
|
||
// render. But that's the terminal proactively pushing a size — if it's
|
||
// ever missed (a slow/unusual terminal, a multiplexer, a startup race),
|
||
// nothing else ever asks again: m.height stays its zero value forever,
|
||
// `if m.altScreen && m.height > 0` (transcriptView) falls back to the
|
||
// unpadded, non-fullscreen render path for the rest of the session, and
|
||
// the alt-screen viewport never gets filled below the actual content.
|
||
// Explicitly requesting it here means Zero doesn't depend solely on the
|
||
// terminal's unprompted push — mirrors the RequestBackgroundColor request
|
||
// below for the same reason.
|
||
cmds = append(cmds, tea.RequestWindowSize)
|
||
// Baseline git snapshot for the FILES sidebar sweep: whatever is already
|
||
// dirty when the TUI opens is pre-existing state, not this session's work
|
||
// (files_git_sweep.go). Async; a non-git workspace just disables the sweep.
|
||
if strings.TrimSpace(m.cwd) != "" {
|
||
cmds = append(cmds, gitSweepCmd(m.ctx, m.cwd, true))
|
||
}
|
||
// In auto mode, ask the terminal for its background color; the reply arrives
|
||
// as tea.BackgroundColorMsg and selects light vs dark (see updateModel).
|
||
if m.themeMode == themeAuto {
|
||
cmds = append(cmds, tea.RequestBackgroundColor)
|
||
}
|
||
// Warm model discovery for the active provider in the background so the
|
||
// context-usage gauge (used / total tokens + % fill) knows the active model's
|
||
// window from launch — including proxy/custom models not in the curated
|
||
// registry. Async: never blocks startup; if discovery is unavailable the gauge
|
||
// just shows the used-token count until the window is otherwise learned.
|
||
if descriptor, ok := m.activeProviderDescriptor(); ok {
|
||
if cmd := m.modelPickerProviderDiscoveryCmd(descriptor, m.providerProfile); cmd != nil {
|
||
cmds = append(cmds, cmd)
|
||
}
|
||
// The generic discovery above has no source for a local Ollama model's
|
||
// context window (see ollamaContextWindowDiscoveryCmd); probe its
|
||
// native /api/show separately so the gauge works for custom/local
|
||
// Ollama models too, not just ones in the curated catalog.
|
||
if cmd := m.ollamaContextWindowDiscoveryCmd(descriptor, m.providerProfile.BaseURL, m.modelName); cmd != nil {
|
||
cmds = append(cmds, cmd)
|
||
}
|
||
}
|
||
if m.prService != nil && m.runtimeMessageSink != nil {
|
||
service := m.prService
|
||
sink := m.runtimeMessageSink
|
||
ctx := m.ctx
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
cmds = append(cmds, func() tea.Msg {
|
||
stop := WatchPRStateContext(ctx, service, func(state PrState) {
|
||
sink(prStateMsg{state: state})
|
||
})
|
||
return prWatcherStartedMsg{stop: stop}
|
||
})
|
||
}
|
||
return tea.Batch(cmds...)
|
||
}
|
||
|
||
func (m *model) stopPRWatcher() {
|
||
if m.prWatcherStop == nil {
|
||
return
|
||
}
|
||
m.prWatcherStop()
|
||
m.prWatcherStop = nil
|
||
}
|
||
|
||
// noBlockingModal reports that no modal surface (permission prompt, ask_user,
|
||
// spec review, provider/MCP wizard, MCP manager, or picker) is up, so a global
|
||
// shortcut may act instead of falling through to a modal's own handler. Shared
|
||
// by every shortcut that should defer to whichever modal is focused.
|
||
func (m model) noBlockingModal() bool {
|
||
return m.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil &&
|
||
m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil &&
|
||
m.sttKeyPrompt == nil && m.renamePrompt == 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()
|
||
return nm, batchCommands(cmd, mouseCmd, flushCmd, recapActivityCmd)
|
||
}
|
||
|
||
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 composerBlinkMsg:
|
||
switch {
|
||
case !m.terminalFocused:
|
||
m.composerCursorVisible = false // hidden while unfocused
|
||
case m.now().Sub(m.lastCharTime) < composerTypingIdleThreshold:
|
||
m.composerCursorVisible = true // solid while actively typing
|
||
default:
|
||
m.composerCursorVisible = !m.composerCursorVisible // idle + focused: blink as before
|
||
}
|
||
return m, composerBlinkCmd()
|
||
case tea.BackgroundColorMsg:
|
||
// Terminal background-color reply (from Init's RequestBackgroundColor). In
|
||
// auto mode it selects light vs dark; applyTheme repaints (clears the render
|
||
// cache). An explicit dark/light theme ignores it but still records the bg.
|
||
m.hasDarkBg = msg.IsDark()
|
||
if m.themeMode == themeAuto {
|
||
applyTheme(themeAuto, m.hasDarkBg)
|
||
}
|
||
return m, nil
|
||
case tea.MouseMsg:
|
||
if m.setup.visible {
|
||
return m.handleSetupMouse(msg)
|
||
}
|
||
return m.handleMouse(msg)
|
||
case transcriptCopiedMsg:
|
||
m.copyStatusSeq++
|
||
if msg.err != nil {
|
||
// Keep the selection so the user can retry; just surface the failure.
|
||
m.copyStatus = "Copy failed"
|
||
} else {
|
||
m.transcriptSelection = transcriptSelectionState{}
|
||
m.copyStatus = "Copied!"
|
||
}
|
||
seq := m.copyStatusSeq
|
||
return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg {
|
||
return transcriptCopyStatusExpiredMsg{seq: seq}
|
||
})
|
||
case transcriptCopyStatusExpiredMsg:
|
||
if msg.seq == m.copyStatusSeq {
|
||
m.copyStatus = ""
|
||
}
|
||
return m, nil
|
||
case exitConfirmExpiredMsg:
|
||
if msg.seq == m.exitConfirmSeq {
|
||
m.exitConfirmActive = false
|
||
}
|
||
return m, nil
|
||
case cancelConfirmExpiredMsg:
|
||
if msg.seq == m.cancelConfirmSeq {
|
||
m.cancelConfirmActive = false
|
||
}
|
||
return m, nil
|
||
case leaderExpiredMsg:
|
||
if msg.seq == m.leaderSeq {
|
||
m.leaderPending = false
|
||
}
|
||
return m, nil
|
||
case dragEdgeScrollTickMsg:
|
||
if msg.seq != m.edgeScrollSeq || m.edgeScrollDelta == 0 || !m.transcriptSelection.active {
|
||
return m, nil // stale, or the chain was stopped since this tick was scheduled
|
||
}
|
||
m = m.dragToEdgeScroll(m.edgeScrollDelta, m.edgeScrollMouseX)
|
||
return m, dragEdgeScrollTickCmd(m.edgeScrollSeq)
|
||
case providerWizardOAuthMsg:
|
||
return m.applyProviderWizardOAuth(msg)
|
||
case aimlapiOnboardMsg:
|
||
return m.applyAimlapiOnboard(msg)
|
||
case aimlapiExistingBalanceMsg:
|
||
return m.applyExistingAimlapiBalance(msg)
|
||
case providerWizardDeviceCodeMsg:
|
||
return m.applyProviderWizardDeviceCode(msg)
|
||
case providerManagerCredsMsg:
|
||
return m.applyProviderManagerCreds(msg)
|
||
case providerManagerCleanupMsg:
|
||
return m.applyProviderManagerCleanup(msg)
|
||
case clipboardReadMsg:
|
||
// Result of a right-click paste. Insert on success; surface a brief
|
||
// status if the clipboard couldn't be read (e.g. no clipboard utility on
|
||
// a remote session). An empty clipboard is a silent no-op.
|
||
if msg.err != nil {
|
||
m.copyStatusSeq++
|
||
m.copyStatus = "Paste failed"
|
||
seq := m.copyStatusSeq
|
||
return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg {
|
||
return transcriptCopyStatusExpiredMsg{seq: seq}
|
||
})
|
||
}
|
||
if msg.content == "" {
|
||
// Empty text clipboard — may be a screenshot. Probe for image.
|
||
return m, readClipboardImageCmd()
|
||
}
|
||
return m.routePaste(msg.content)
|
||
case clipboardImageMsg:
|
||
if msg.err != nil {
|
||
return m.appendImageNotice("Clipboard image read failed: " + msg.err.Error()), nil
|
||
}
|
||
if msg.data == nil {
|
||
return m, nil // no image — silent no-op
|
||
}
|
||
return m.attachClipboardImage(msg.data, msg.mediaType), nil
|
||
case tea.PasteMsg:
|
||
// A paste into the cloud-STT key prompt fills the key (the common way to
|
||
// enter an API key), not the composer.
|
||
if m.sttKeyPrompt != nil {
|
||
m.sttKeyPrompt.input += strings.TrimSpace(msg.Content)
|
||
return m, nil
|
||
}
|
||
return m.routePaste(msg.Content)
|
||
case dictationStartedMsg:
|
||
return m.handleDictationStarted(msg)
|
||
case dictationTranscribedMsg:
|
||
return m.handleDictationTranscribed(msg)
|
||
case sttPartialMsg:
|
||
return m.handleDictationPartial(msg), nil
|
||
case sttDownloadProgressMsg:
|
||
return m.handleDictationDownloadProgress(msg), nil
|
||
case dictationDownloadedMsg:
|
||
return m.handleDictationDownloaded(msg)
|
||
case sttModelsFetchedMsg:
|
||
return m.handleSTTModelsFetched(msg), nil
|
||
case recTickMsg:
|
||
return m.handleRecTick()
|
||
case sttLevelMsg:
|
||
return m.handleDictationLevel(msg), nil
|
||
case tea.KeyboardEnhancementsMsg:
|
||
return m.handleKeyboardEnhancements(msg), nil
|
||
case tea.KeyReleaseMsg:
|
||
// Voice mode's hold-to-record ends on Space release; every other release
|
||
// event is ignored (dispatch elsewhere is press-based).
|
||
if m.dictation.voiceModeEnabled && keyIs(msg, tea.KeySpace) {
|
||
return m.handleVoiceSpaceRelease()
|
||
}
|
||
return m, nil
|
||
case tea.KeyPressMsg:
|
||
// Paste-detection timing trackers. MUST run before any early return
|
||
// so burst counting stays accurate regardless of which branch fires.
|
||
now := m.now()
|
||
if !m.lastKeyTime.IsZero() && now.Sub(m.lastKeyTime) < 100*time.Millisecond {
|
||
m.burstCount++
|
||
} else {
|
||
m.burstCount = 0
|
||
}
|
||
m.lastKeyTime = now
|
||
if !keyIs(msg, tea.KeyEnter) {
|
||
m.lastCharTime = now
|
||
}
|
||
// Enter the solid-while-typing state right away: only composerBlinkMsg
|
||
// evaluates the typing threshold, so if the blink phase had just hidden
|
||
// the caret, the typed character would render caret-less for up to a
|
||
// full tick before the timer catches up.
|
||
m.composerCursorVisible = true
|
||
if m.setup.visible {
|
||
return m.handleSetupKey(msg)
|
||
}
|
||
// The cloud-STT API-key prompt is modal: it owns every keystroke (masked
|
||
// input) until Enter saves or Esc cancels.
|
||
if m.sttKeyPrompt != nil {
|
||
return m.handleSTTKeyPromptKey(msg)
|
||
}
|
||
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.keyMatch(m.keyBindings.toggleDetailed, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'o') }):
|
||
return m.toggleDetailedTranscript(), nil
|
||
case m.fileView.active && m.noBlockingModal() && m.composerValue() == "" && (keyText(msg) == "d" || keyText(msg) == "f"):
|
||
// Mode toggle for the file drill-in, only while the composer is empty
|
||
// (so mid-sentence typing is never hijacked) and no modal is up (so a
|
||
// permission prompt / ask-user / wizard keeps its own key handling).
|
||
if keyText(msg) == "f" {
|
||
return m.setFileViewMode(fileViewFull), nil
|
||
}
|
||
return m.setFileViewMode(fileViewDiff), nil
|
||
case m.keyMatch(m.keyBindings.toggleMouse, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'e') }) && canFireComposerGatedToggle(m.keyBindings.toggleMouse, defaultToggleMouseChord, m.composerValue() == ""):
|
||
// Release/recapture the mouse so the user can drag-select and copy text
|
||
// natively (mouse capture otherwise intercepts terminal selection). The
|
||
// composer-empty requirement only applies when the binding resolves to
|
||
// the conflicting default Ctrl+E chord (unset, or explicitly configured
|
||
// to the same chord), which readline navigation (move-to-end-of-line)
|
||
// also claims while typing; a binding that resolves to a genuinely
|
||
// different chord still fires mid-type.
|
||
m.mouseReleased = !m.mouseReleased
|
||
if m.mouseReleased {
|
||
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
|
||
return m.appendSystemNotice(fmt.Sprintf("Mouse released — drag to select and copy text. Press %s again to re-enable mouse interaction (clicks, right-click paste).", mouseKey)), nil
|
||
}
|
||
return m.appendSystemNotice("Mouse interaction re-enabled."), nil
|
||
case m.dictation.voiceModeEnabled && !m.transcriptDetailed && keyIs(msg, tea.KeySpace) && !keyHasMod(msg, tea.ModCtrl) && !keyAlt(msg) && m.noBlockingModal():
|
||
// Voice mode (/voice) repurposes Space into the record gesture — the only
|
||
// dictation trigger — so it must not also type a space. Turn voice mode
|
||
// off (/voice) to type normally.
|
||
return m.handleVoiceSpacePress(msg)
|
||
case keyIs(msg, tea.KeyEsc):
|
||
// Esc is heavily overloaded below (subchat exit, MCP cancel, ask-user,
|
||
// permission deny, wizard/picker/suggestions dismiss, ...) before ever
|
||
// reaching the run-cancel fallback. Capture whether this press really
|
||
// is the confirming second Esc BEFORE any of those branches can fire,
|
||
// then disarm unconditionally: an Esc that gets consumed by one of
|
||
// them wasn't a confirm, so it must not leave cancelConfirmActive
|
||
// armed for some later, unrelated Esc to silently act on.
|
||
wasConfirmingCancel := m.pending && m.cancelConfirmActive
|
||
m = m.disarmCancelConfirmation()
|
||
// An active dictation recording cancels on Esc (releases the mic, drops
|
||
// the audio) — but only if this Esc isn't a confirming run-cancel
|
||
// press. Without this guard, a user mid-recording who double-Esc's
|
||
// to kill the run finds the first Esc swallowed by dictation and
|
||
// the run still going. The pending run cancel happens further
|
||
// down at the bottom of the Esc branch.
|
||
if m.dictation.active() && !wasConfirmingCancel {
|
||
return m.cancelDictation()
|
||
}
|
||
// Subchat view exits on Esc (returns to main chat).
|
||
if m.subchat.active {
|
||
m.chatScrollOffset = m.subchat.exit()
|
||
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
|
||
return m, nil
|
||
}
|
||
// File drill-in exits on Esc (returns to the chat at its saved scroll
|
||
// position); the file stays selected so a second Esc clears that. Only
|
||
// with no blocking modal up: Esc on a permission prompt / ask-user /
|
||
// wizard must reach THAT surface's deny/cancel handling below, not
|
||
// silently close the drill-in behind it.
|
||
if m.fileView.active && m.noBlockingModal() {
|
||
return m.exitFileView(), nil
|
||
}
|
||
if m.mcpCommandCancel != nil {
|
||
m.cancelMCPCommand()
|
||
if m.mcpAddWizard != nil {
|
||
m.mcpAddWizard.result = mcpAddWizardResult{Title: "MCP setup cancelled", State: "cancelled", Message: "MCP action was cancelled.", ActionHint: "Edit config"}
|
||
m.mcpAddWizard.step = mcpAddWizardStepResult
|
||
return m, nil
|
||
}
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: "MCP action cancelled"})
|
||
return m, nil
|
||
}
|
||
if m.transcriptDetailed {
|
||
m.transcriptDetailed = false
|
||
return m, nil
|
||
}
|
||
// Esc on an ask-user prompt: from the "type my own" free-text it steps
|
||
// back to the selector for that question; otherwise it cancels the
|
||
// questionnaire (not the run), delivering whatever answers were collected
|
||
// so the agent loop unblocks and degrades to its best-assumption path.
|
||
if m.pendingAskUser != nil {
|
||
return m.escapeAskUser()
|
||
}
|
||
// 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.providerWizard != nil {
|
||
// Delegate so multi-level surfaces (provider manager list → edit →
|
||
// field, manage-key step) can walk BACK one level; the wizard's own
|
||
// handler closes the overlay for the single-level steps.
|
||
return m.handleProviderWizardKey(msg)
|
||
}
|
||
if m.mcpAddWizard != nil {
|
||
m.mcpAddWizard = nil
|
||
return m, nil
|
||
}
|
||
if m.mcpManager != nil {
|
||
m.mcpManager = nil
|
||
return m, nil
|
||
}
|
||
// An open picker cancels first; then an active suggestion overlay is
|
||
// dismissed. Neither cancels the run or clears the input.
|
||
if m.picker != nil {
|
||
if m.picker.kind == pickerModel {
|
||
m.clearModelPickerLoadState()
|
||
}
|
||
if m.picker.kind == pickerTheme {
|
||
// A live theme preview was applied while navigating; restore the
|
||
// committed palette since Esc dismisses without choosing.
|
||
m.restoreCommittedTheme()
|
||
}
|
||
m.picker = nil
|
||
return m, nil
|
||
}
|
||
if m.suggestionsActive() {
|
||
return m.dismissSuggestions(), nil
|
||
}
|
||
// A selected FILES row clears before anything run-related: the
|
||
// selection is a passive highlight, so Esc dropping it is cheap and
|
||
// expected (mirrors how editors clear selection on Esc).
|
||
if m.selectedFile != "" {
|
||
m.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)
|
||
return m, nil
|
||
}
|
||
case m.keyMatch(m.keyBindings.cycleReasoning, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 't') }):
|
||
if m.transcriptDetailed {
|
||
return m, nil
|
||
}
|
||
// Ctrl+T cycles reasoning effort (auto -> low ->
|
||
// medium -> high -> auto), but only when nothing modal is up — the
|
||
// same gate shift+tab uses above. Not gated on m.pending: cycling
|
||
// mid-run is allowed and takes effect on the next turn, matching
|
||
// /effort. cycleReasoningEffort is a silent no-op on models with no
|
||
// effort controls.
|
||
if m.noBlockingModal() {
|
||
return m.cycleReasoningEffort()
|
||
}
|
||
case m.keyMatch(m.keyBindings.togglePlan, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'p') }):
|
||
// Ctrl+P toggles the plan panel expansion (collapse/expand step list).
|
||
// Modal selection is handled before this switch so menus win over toggle.
|
||
if m.noBlockingModal() && !m.plan.isEmpty() {
|
||
m.plan.expanded = !m.plan.expanded
|
||
return m, nil
|
||
}
|
||
case m.keyMatch(m.keyBindings.toggleSidebar, msg, func(tea.KeyMsg) bool { return keyCtrl(msg, 'b') }) && canFireComposerGatedToggle(m.keyBindings.toggleSidebar, defaultToggleSidebarChord, m.composerValue() == ""):
|
||
// Ctrl+B collapses / restores the right context sidebar. The composer-empty
|
||
// requirement only applies when the binding resolves to the conflicting
|
||
// default Ctrl+B chord (unset, or explicitly configured to the same
|
||
// chord), which readline navigation (move-to-beginning-of-line) also
|
||
// claims while typing; a binding that resolves to a genuinely different
|
||
// chord still fires mid-type. Only acts when
|
||
// the sidebar would otherwise be on screen (managed mode, wide enough,
|
||
// real conversation) so it's a no-op — not a confusing notice — on the
|
||
// home screen or a narrow terminal. Hiding reflows the chat to full
|
||
// width, so mirror the width-change bookkeeping (re-wrap the streaming
|
||
// fade, resize the composer) the WindowSizeMsg path does.
|
||
if !m.transcriptDetailed && m.noBlockingModal() && m.sidebarToggleAllowed() {
|
||
// Just show/hide — no transcript notice. The reflow IS the feedback,
|
||
// and emitting a line every toggle piled up noise in the chat.
|
||
m.sidebarHidden = !m.sidebarHidden
|
||
m.lineAges = nil
|
||
m.input.SetWidth(maxInt(20, m.chatColumnWidth()-14))
|
||
return m, nil
|
||
}
|
||
case keyCtrl(msg, '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()
|
||
// Editing the filter changes which row is highlighted; keep the
|
||
// theme preview in sync with it (no-op for other pickers).
|
||
m.previewSelectedTheme()
|
||
return m, nil
|
||
}
|
||
// On an empty composer, Backspace removes the last attachment chip
|
||
// ([Image #N] / [Doc #N]) so you can drop one you don't need without
|
||
// clearing them all. With text present it deletes a character as usual.
|
||
if m.composerValue() == "" {
|
||
if next, removed := m.removeLastAttachment(); removed {
|
||
return next, nil
|
||
}
|
||
}
|
||
case keyIs(msg, tea.KeyTab):
|
||
if m.transcriptDetailed {
|
||
return m, nil
|
||
}
|
||
if m.pendingPermission != nil {
|
||
return m.movePermissionCursor(1), nil
|
||
}
|
||
if m.pendingAskUser != nil {
|
||
return m.moveAskUserTab(1), nil
|
||
}
|
||
if m.providerWizard != nil {
|
||
m.burstCount = 0
|
||
return m.handleProviderWizardKey(msg)
|
||
}
|
||
if m.mcpAddWizard != nil {
|
||
m.burstCount = 0
|
||
return m.handleMCPAddWizardKey(msg)
|
||
}
|
||
if m.mcpManager != nil {
|
||
m.burstCount = 0
|
||
return m.handleMCPManagerKey(msg)
|
||
}
|
||
if m.picker == nil && m.suggestionsActive() {
|
||
m.moveSuggestion(1)
|
||
return m, nil
|
||
}
|
||
case keyIs(msg, tea.KeyPgUp):
|
||
m = m.clearHover()
|
||
return m.scrollChat(m.chatPageScrollLines()), nil
|
||
case keyIs(msg, tea.KeyPgDown):
|
||
m = m.clearHover()
|
||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||
case keyShift(msg) && keyIs(msg, tea.KeyUp):
|
||
// Shift+Up scrolls the transcript up one line. Must be checked before
|
||
// plain KeyUp so shifted arrows aren't consumed by the composer-path.
|
||
if m.transcriptDetailed {
|
||
return m, nil
|
||
}
|
||
// Suggestions keep Shift+arrows for the composer path (unchanged); plain
|
||
// ↑/↓ and Ctrl+P/N still move the palette via moveModalSelection.
|
||
if m.suggestionsActive() {
|
||
break
|
||
}
|
||
if next, cmd, ok := m.moveModalSelection(-1); ok {
|
||
return next, cmd
|
||
}
|
||
if m.composerValue() != "" {
|
||
break // let the input handle multiline navigation
|
||
}
|
||
m = m.clearHover()
|
||
return m.scrollChat(1), nil
|
||
case keyShift(msg) && keyIs(msg, tea.KeyDown):
|
||
// Shift+Down scrolls the transcript down one line. Must be checked
|
||
// before plain KeyDown so shifted arrows aren't consumed.
|
||
if m.transcriptDetailed {
|
||
return m, nil
|
||
}
|
||
if m.suggestionsActive() {
|
||
break
|
||
}
|
||
if next, cmd, ok := m.moveModalSelection(1); ok {
|
||
return next, cmd
|
||
}
|
||
if m.composerValue() != "" {
|
||
break // let the input handle multiline navigation
|
||
}
|
||
m = m.clearHover()
|
||
return m.scrollChat(-1), nil
|
||
case keyIs(msg, tea.KeyDown):
|
||
if m.transcriptDetailed {
|
||
m = m.clearHover()
|
||
return m.scrollChat(-1), nil
|
||
}
|
||
if next, cmd, ok := m.moveModalSelection(1); ok {
|
||
return next, cmd
|
||
}
|
||
if next, ok := m.moveComposerVisualCursor(1); ok {
|
||
return next, nil
|
||
}
|
||
if m.historyRecallActive() {
|
||
return m.recallHistory(1), nil
|
||
}
|
||
case keyIs(msg, tea.KeyUp):
|
||
// ArrowUp exits subchat view (returns to main chat).
|
||
if m.subchat.active {
|
||
m.chatScrollOffset = m.subchat.exit()
|
||
m = m.clearHover() // bodyY numbering differs between subchat and the parent transcript
|
||
return m, nil
|
||
}
|
||
if m.transcriptDetailed {
|
||
m = m.clearHover()
|
||
return m.scrollChat(1), nil
|
||
}
|
||
if next, cmd, ok := m.moveModalSelection(-1); ok {
|
||
return next, cmd
|
||
}
|
||
if next, ok := m.moveComposerVisualCursor(-1); ok {
|
||
return next, nil
|
||
}
|
||
// A queued message takes ↑ priority over history recall: pop it back
|
||
// into the composer for editing before it sends on the next turn.
|
||
if m.hasQueuedMessage() && m.pendingSpecReview == nil {
|
||
return m.popQueuedMessageForEdit(), nil
|
||
}
|
||
if m.historyRecallActive() {
|
||
return m.recallHistory(-1), nil
|
||
}
|
||
case keyCtrl(msg, 'u'):
|
||
// Ctrl+U scrolls up half a page, or moves the cursor up in
|
||
// permission/ask-user prompts. Falls through to the active modal
|
||
// (wizard, etc.) when none of the above are in focus.
|
||
if m.transcriptDetailed {
|
||
return m, nil
|
||
}
|
||
if m.pendingPermission != nil {
|
||
return m.movePermissionCursor(-1), nil
|
||
}
|
||
if m.pendingAskUser != nil {
|
||
return m.moveAskUserCursor(-1), nil
|
||
}
|
||
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
|
||
break
|
||
}
|
||
if m.composerValue() != "" {
|
||
break // let the input handle its own Ctrl+U (delete-to-bol)
|
||
}
|
||
m = m.clearHover()
|
||
return m.scrollChat(m.chatPageScrollLines()), nil
|
||
case keyCtrl(msg, 'd'):
|
||
// Ctrl+D scrolls down half a page, or moves the cursor down in
|
||
// permission/ask-user prompts. Falls through to the active modal
|
||
// when none of the above are in focus.
|
||
if m.transcriptDetailed {
|
||
return m, nil
|
||
}
|
||
if m.pendingPermission != nil {
|
||
return m.movePermissionCursor(1), nil
|
||
}
|
||
if m.pendingAskUser != nil {
|
||
return m.moveAskUserCursor(1), nil
|
||
}
|
||
if m.providerWizard != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || m.pendingSpecReview != nil {
|
||
break
|
||
}
|
||
if m.composerValue() != "" {
|
||
break // let the input handle its own Ctrl+D (delete-next-char)
|
||
}
|
||
m = m.clearHover()
|
||
return m.scrollChat(-m.chatPageScrollLines()), nil
|
||
}
|
||
if m.transcriptDetailed {
|
||
return m, nil
|
||
}
|
||
if m.pendingAskUser != nil {
|
||
_, state, ok := m.pendingAskUser.activeQuestion()
|
||
if !ok {
|
||
return m, nil // Confirm tab: ignore stray keys
|
||
}
|
||
// In picker mode a printable keystroke means the user wants to type their
|
||
// own answer, so flip into free-text first instead of letting the text
|
||
// accumulate invisibly and then be discarded when Enter picks an option.
|
||
if !state.typing && keyPrintable(msg) {
|
||
state.typing = true
|
||
m.input.SetValue("")
|
||
}
|
||
if state.typing {
|
||
var cmd tea.Cmd
|
||
m.input, cmd = m.input.Update(msg)
|
||
return m, cmd
|
||
}
|
||
return m, nil // picker mode: non-navigation keys do nothing
|
||
}
|
||
if m.pendingSpecReview != nil {
|
||
m.burstCount = 0
|
||
return m.handleSpecReviewKey(msg)
|
||
}
|
||
if m.pendingPermission != nil {
|
||
// 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))
|
||
// Filtering changes the highlighted row; keep the theme preview in
|
||
// sync with it (no-op for other pickers).
|
||
m.previewSelectedTheme()
|
||
}
|
||
return m, nil
|
||
}
|
||
if next, ok := m.applyComposerKey(msg); ok {
|
||
return next, nil
|
||
}
|
||
if m.composerActive && strings.Contains(m.composer.text, "\n") {
|
||
return m, nil
|
||
}
|
||
// The key fell through to the text input: let it update, then refresh the
|
||
// autocomplete match list from the new value.
|
||
var cmd tea.Cmd
|
||
m.input, cmd = m.input.Update(msg)
|
||
m.resetComposerFromInput()
|
||
m.recomputeSuggestions()
|
||
return m, cmd
|
||
case tea.FocusMsg:
|
||
m.terminalFocused = true
|
||
// Sync the caret with focus immediately: leaving it to the next
|
||
// composerBlinkMsg tick can keep it hidden for up to a tick after the
|
||
// terminal regains focus (and, on blur below, leave it visible in an
|
||
// unfocused terminal for the same window).
|
||
m.composerCursorVisible = true
|
||
if m.notifier != nil {
|
||
m.notifier.SetFocused(true)
|
||
}
|
||
return m, nil
|
||
case tea.BlurMsg:
|
||
m.terminalFocused = false
|
||
m.composerCursorVisible = false
|
||
if m.notifier != nil {
|
||
m.notifier.SetFocused(false)
|
||
}
|
||
return m, nil
|
||
case toolCallStreamStartMsg:
|
||
if msg.runID != m.activeRunID {
|
||
return m, nil
|
||
}
|
||
// A new tool call opened — reset the live "writing" block to it.
|
||
m.streamCallID = msg.id
|
||
m.streamCallName = msg.name
|
||
m.streamCallDecoder = newStreamingDecoder()
|
||
return m, nil
|
||
case toolCallStreamDeltaMsg:
|
||
if msg.runID != m.activeRunID || msg.id != m.streamCallID || m.streamCallDecoder == nil {
|
||
return m, nil
|
||
}
|
||
m.streamCallDecoder.feed(msg.fragment)
|
||
// A streamed tool-call argument (e.g. a file's contents in write_file) is
|
||
// real generated output: count it toward the live token estimate so the
|
||
// "↑ N tok" pulse climbs during a long write, and bump lastStreamActivity so
|
||
// the quiet-generation hint stays clear of an actively-streaming provider.
|
||
m.turnStreamedRunes += utf8.RuneCountInString(msg.fragment)
|
||
m.lastStreamActivity = m.now()
|
||
return m, nil
|
||
case agentTextMsg:
|
||
if msg.runID != m.activeRunID {
|
||
return m, nil
|
||
}
|
||
// Streaming text means any in-progress tool call has finished — clear the
|
||
// live "writing" block so it doesn't linger over new prose.
|
||
m.clearStreamingToolCall()
|
||
m.streamingText = append(m.streamingText, msg.delta...)
|
||
m.turnStreamedRunes += utf8.RuneCountInString(msg.delta)
|
||
// recordStreamingDelta appends a time.Time to lineAges for every
|
||
// newline in the delta and bumps lastStreamActivity. It also
|
||
// re-stamps the in-progress last entry so the line that's still
|
||
// being filled stays visibly fresh.
|
||
m.recordStreamingDelta(msg.delta)
|
||
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 an active sidebar
|
||
// holding agents: their cool ripple animation needs the phase to keep
|
||
// advancing even when no run is pending, so the tick loop stays alive while
|
||
// sidebarHasAgents() holds (and stops the moment the agents/sidebar clear).
|
||
if !m.pending && !m.compactInFlight && !m.doctorInFlight {
|
||
// The tick also keeps advancing while the aimlapi.com onboarding sub-flow is
|
||
// busy (its progress screen is spinner-only), even though no agent run is in
|
||
// flight, so its shared MiniDot spinner keeps animating.
|
||
//
|
||
// Return the FPS-throttled tick that Update hands back — NOT m.spinner.Tick,
|
||
// which fires immediately and busy-loops the frame at event-loop speed. That
|
||
// makes the glyph spin far too fast (and burns CPU) on screens that sit here
|
||
// for a while, e.g. the aimlapi checkout wait. The active-run path below
|
||
// already does this; this keeps idle animation at the same cadence.
|
||
if (m.sidebarHasAgents() || m.aimlapiOnboardAnimating()) && !m.reducedMotion {
|
||
var cmd tea.Cmd
|
||
m.spinner, cmd = m.spinner.Update(msg)
|
||
m.spinnerPhase++
|
||
m.spinnerTicking = true
|
||
return m, cmd
|
||
}
|
||
m.spinnerTicking = false
|
||
return m, nil
|
||
}
|
||
m.spinnerTicking = true
|
||
var cmd tea.Cmd
|
||
m.spinner, cmd = m.spinner.Update(msg)
|
||
// Advance the shared ripple phase in lock-step with the spinner glyph;
|
||
// frozen under reduced motion so the colour wave stops with the glyph.
|
||
if !m.reducedMotion {
|
||
m.spinnerPhase++
|
||
}
|
||
if m.compactInFlight {
|
||
if !m.reducedMotion {
|
||
m.compactFrame++ // frozen frame under reduced motion -> static ring
|
||
}
|
||
m = m.setCompactStatusRow(m.compactText(true))
|
||
}
|
||
if m.doctorInFlight {
|
||
if !m.reducedMotion {
|
||
m.doctorFrame++
|
||
}
|
||
m = m.setDoctorStatusRow(m.doctorConnectivityRunningText())
|
||
}
|
||
return m, cmd
|
||
case streamingFadeTickMsg:
|
||
// The fade's own tick (separate from the spinner so a slower
|
||
// cadence is enough). Short-circuits when fadeActive is false,
|
||
// which is how the ticker stops cleanly at stream end: the
|
||
// agentResponseMsg handler sets fadeActive = false, and the
|
||
// next tick that lands after that point returns nil here.
|
||
if !m.fadeActive {
|
||
return m, nil
|
||
}
|
||
return m, streamingFadeTick()
|
||
case 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.width = msg.Width
|
||
m.height = msg.Height
|
||
// A resize re-wraps content at a new width, shifting every row's bodyY;
|
||
// a stale transcript-hover target could coincidentally land on an
|
||
// unrelated clickable row (same reasoning as clearHover's other callers).
|
||
m = m.clearHover()
|
||
// Reset the streaming-text fade state. A width change can re-wrap
|
||
// the in-progress text into a different number of visual lines,
|
||
// which invalidates the per-line age mapping. The next delta
|
||
// will reseed lineAges and restart the tick.
|
||
m.lineAges = nil
|
||
m.lastStreamActivity = m.now()
|
||
// Size the composer so long input scrolls horizontally with the cursor
|
||
// visible instead of being clipped invisibly past the right edge.
|
||
m.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
|
||
// The title bar prints once into native scrollback when the inline
|
||
// renderer is active. In alt-screen mode it stays pinned inside View.
|
||
if !m.altScreen && !m.headerPrinted && msg.Width > 0 {
|
||
m.headerPrinted = true
|
||
m.flushQueue = append(m.flushQueue, m.titleBar(chatWidth(msg.Width)))
|
||
}
|
||
// A resumed/idle session may already hold sidebar agents now that geometry
|
||
// (and thus sidebarActive) is known; kick the ripple tick loop if so. No-op
|
||
// when the loop is already running or there is nothing to animate.
|
||
return m, m.ensureSpinnerTick()
|
||
case permissionRequestMsg:
|
||
// The agent goroutine that raised this request is BLOCKED waiting on the
|
||
// decision callback, so every branch below must resolve it exactly once —
|
||
// or store it in pendingPermission, which resolves it on the user's reply.
|
||
// Dropping a request without resolving parks the run forever (the reported
|
||
// "stuck for 33 minutes" deadlock): the agent waits on a decision channel
|
||
// nothing will ever signal, with no visible prompt and no network activity.
|
||
if msg.runID != m.activeRunID {
|
||
// A superseded/stale run: unblock its parked goroutine now rather than
|
||
// relying on that run's context cancel to fire first.
|
||
if msg.decide != nil {
|
||
msg.decide(agent.PermissionDecision{Action: agent.PermissionDecisionCancel, Reason: "run superseded"})
|
||
}
|
||
return m, nil
|
||
}
|
||
if msg.request.Action != agent.PermissionActionPrompt {
|
||
// Not a user-facing prompt (e.g. a sandbox-allowed command that still
|
||
// blocked because it requested additional permissions). The UI has
|
||
// nothing to ask, so resolve immediately and FAIL CLOSED — never
|
||
// silently grant access that was never surfaced to the user. (The agent
|
||
// now marks such elevation requests as prompts, so in practice this is a
|
||
// defensive backstop; matches the ACP handler's fail-closed contract.)
|
||
if msg.decide != nil {
|
||
msg.decide(autoResolvedPermissionDecision(msg.request.Action))
|
||
}
|
||
return m, nil
|
||
}
|
||
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.usageModelID, event)
|
||
for _, row := range usageRows {
|
||
m.transcript = appendTranscriptRow(m.transcript, row)
|
||
}
|
||
}
|
||
delete(m.liveUsageCounts, msg.runID)
|
||
// Events are persisted into the session the run was recording into AT
|
||
// CANCEL TIME — the active session may have changed since (/resume),
|
||
// and writing there would contaminate its log with checkpoint payloads
|
||
// whose blobs live under the original session. appendSessionEvents*
|
||
// only returns rows for persist FAILURES; surface them so a failed
|
||
// checkpoint/tool flush (which would silently degrade /rewind) is
|
||
// visible rather than swallowed.
|
||
var flushRows []transcriptRow
|
||
events := flushableSessionEvents(msg.sessionEvents)
|
||
if flushSessionID == m.activeSession.SessionID {
|
||
m, flushRows = m.appendSessionEvents(events)
|
||
} else {
|
||
flushRows = m.appendSessionEventsTo(flushSessionID, events)
|
||
}
|
||
for _, row := range flushRows {
|
||
m.transcript = appendTranscriptRow(m.transcript, row)
|
||
}
|
||
// A Ctrl+C during an in-flight run defers its quit until the run's
|
||
// checkpoint session events have been flushed (above). Now that the
|
||
// last pending flush is drained, fire the deferred quit.
|
||
if m.exiting && len(m.flushRunIDs) == 0 {
|
||
return m.quit()
|
||
}
|
||
}
|
||
return m, nil
|
||
}
|
||
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
|
||
m.pending = false
|
||
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
|
||
// 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.plan.frozenAt = m.now() // freeze the plan clock while idle (no run in flight)
|
||
// A fully successful turn means the task is done. Weaker models often
|
||
// forget the final update_plan, leaving the panel stuck mid-progress;
|
||
// reconcile it to complete here. Read pendingAskUser/pendingPermission
|
||
// BEFORE the reset below clears them, and skip spec-draft reviews — those
|
||
// are legitimate mid-plan err==nil yields where the plan is NOT done.
|
||
if msg.err == nil && msg.specReview == nil &&
|
||
m.pendingAskUser == nil && m.pendingPermission == nil {
|
||
m.plan.completeRemaining(m.now())
|
||
}
|
||
m.pendingPermission = nil
|
||
if m.pendingAskUser != nil && m.pendingAskUser.answer != nil {
|
||
m.pendingAskUser.answer(nil)
|
||
}
|
||
m.pendingAskUser = nil
|
||
liveUsageCount := m.liveUsageCounts[msg.runID]
|
||
for index, event := range msg.usageEvents {
|
||
if index < liveUsageCount {
|
||
continue
|
||
}
|
||
var usageRows []transcriptRow
|
||
m, usageRows = m.recordUsageEvent(msg.usageModelID, event)
|
||
for _, row := range usageRows {
|
||
m.transcript = appendTranscriptRow(m.transcript, row)
|
||
}
|
||
}
|
||
delete(m.liveUsageCounts, msg.runID)
|
||
var sessionRows []transcriptRow
|
||
m, sessionRows = m.appendSessionEvents(msg.sessionEvents)
|
||
for _, row := range sessionRows {
|
||
m.transcript = appendTranscriptRow(m.transcript, row)
|
||
}
|
||
for _, row := range msg.rows {
|
||
if row.kind == rowReasoning {
|
||
m.streamingReasoning = ""
|
||
m.streamingReasoningExpanded = false
|
||
}
|
||
m.transcript = appendTranscriptRow(m.transcript, row)
|
||
}
|
||
if msg.err != nil {
|
||
// A failed turn has no final answer row to supersede the streamed
|
||
// text the user already watched — keep the partial answer instead of
|
||
// letting it vanish from history.
|
||
if row, ok := reasoningTranscriptRow("", msg.runID, m.streamingReasoning); ok {
|
||
m.transcript = appendTranscriptRow(m.transcript, row)
|
||
}
|
||
if text := strings.TrimRight(m.streamingTextString(), "\n"); strings.TrimSpace(text) != "" {
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
|
||
}
|
||
// The error row terminates the turn, so it carries the done-line
|
||
// metadata a final assistant row would have carried. A recognized
|
||
// provider failure (auth/rate-limit/connectivity/…) also carries a
|
||
// one-line next step so the user isn't left staring at a raw blob.
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
|
||
kind: rowError,
|
||
text: msg.err.Error(),
|
||
hint: errhint.TUIHint(msg.err),
|
||
final: true,
|
||
turnTools: msg.turnTools,
|
||
turnElapsed: msg.turnElapsed,
|
||
})
|
||
}
|
||
if m.runCompletionWarning != nil {
|
||
if notice := strings.TrimSpace(m.runCompletionWarning()); notice != "" {
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: notice})
|
||
}
|
||
}
|
||
m.streamingText = nil
|
||
m.streamingReasoning = ""
|
||
m.streamingReasoningExpanded = false
|
||
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 goalCmd tea.Cmd
|
||
if msg.goalAware && !hadQueuedMessage && msg.specReview == nil {
|
||
next, goalCmd = next.launchGoalContinuationIfReady()
|
||
}
|
||
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, 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 ollamaContextWindowDiscoveredMsg:
|
||
if msg.err == nil && msg.contextWindow > 0 {
|
||
if m.ollamaContextWindowByModel == nil {
|
||
m.ollamaContextWindowByModel = map[string]int{}
|
||
}
|
||
m.ollamaContextWindowByModel[msg.modelName] = msg.contextWindow
|
||
}
|
||
return m, nil
|
||
case mcpCommandResultMsg:
|
||
return m.applyMCPCommandResultMessage(msg), nil
|
||
}
|
||
|
||
var cmd tea.Cmd
|
||
m.input, cmd = m.input.Update(msg)
|
||
return m, cmd
|
||
}
|
||
|
||
func (m model) View() tea.View {
|
||
var content string
|
||
if m.setup.visible {
|
||
content = m.setupView(chatWidth(m.width))
|
||
} else if m.helpOverlay || m.leaderHelpOverlay || !m.transcriptDetailed {
|
||
// When helpOverlay / leaderHelpOverlay is active the panel is composited into
|
||
// the normal transcript view as a true overlay (scrim + vertical centering), matching
|
||
// how the suggestion picker / provider wizard / pickers are drawn.
|
||
content = m.transcriptView()
|
||
} else {
|
||
content = m.detailedTranscriptView()
|
||
}
|
||
|
||
view := tea.NewView(content)
|
||
view.AltScreen = m.altScreen
|
||
// Paint the whole frame with the active theme's surface. Zero never paints the
|
||
// terminal's own canvas, so without this a theme's text falls on the terminal
|
||
// background — fine when they share polarity, but a light theme's dark text on a
|
||
// dark terminal (or vice versa) is invisible, and a color theme never shows its
|
||
// real surface. Painting the panel makes every theme self-contained and legible
|
||
// on any terminal, and fills the transparent popup interiors (e.g. the /theme
|
||
// picker) too. Alt-screen only, so inline output never leaves a painted
|
||
// background behind in the user's scrollback after exit.
|
||
if m.altScreen {
|
||
view.BackgroundColor = zeroTheme.bgPanel
|
||
}
|
||
// Always requested, independent of the notifier: the composer cursor's
|
||
// focus/blink behavior (composerBlinkMsg above) needs tea.FocusMsg/BlurMsg
|
||
// regardless of notification config. A standard, widely supported DEC
|
||
// private mode (CSI ?1004h) that unsupported terminals silently ignore.
|
||
view.ReportFocus = true
|
||
// Voice mode's Space-hold gesture needs key-release events (Kitty protocol).
|
||
// Request them only while voice mode is on — the renderer re-sends the request
|
||
// only when the value changes, so gating this costs nothing (§10).
|
||
view.KeyboardEnhancements.ReportEventTypes = m.dictation.voiceModeEnabled
|
||
if m.wantsMouseCapture() {
|
||
if isRunningUnderPRoot() {
|
||
// Under PRoot the AllMotion (1003) sequence doesn't work
|
||
// reliably, breaking touch-gesture scrolling. Fall back to
|
||
// CellMotion which still delivers wheel events, clicks, and
|
||
// drag — the only thing lost is hover-highlighting.
|
||
view.MouseMode = tea.MouseModeCellMotion
|
||
} else {
|
||
// AllMotion (not CellMotion) is required for hover highlighting:
|
||
// it reports cursor movement even with no button pressed.
|
||
// CellMotion only reports motion while a button is held (drag) —
|
||
// see bubbletea's MouseMode docs. AllMotion has marginally worse
|
||
// terminal compatibility but is well supported by the terminals
|
||
// this app targets; the existing 15ms mouse-event throttle
|
||
// (mouseEventThrottleInterval) already bounds the redraw rate
|
||
// from the extra motion events.
|
||
view.MouseMode = tea.MouseModeAllMotion
|
||
}
|
||
}
|
||
return view
|
||
}
|
||
|
||
// transcriptEmpty reports whether the chat surface has no real content yet
|
||
// (only the welcome row), which is when the empty state renders.
|
||
func (m model) transcriptEmpty() bool {
|
||
for _, row := range m.transcript {
|
||
if row.kind != rowWelcome {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// transcriptView renders the visible chat surface: in inline mode this is the
|
||
// live tail not yet settled into native scrollback; in alt-screen mode it is
|
||
// the managed conversation view. Streaming/modal blocks and composer chrome are
|
||
// always rendered here.
|
||
func (m model) transcriptView() string {
|
||
// Two-column layout: in alt-screen managed mode on a wide-enough terminal,
|
||
// the chat renders into a left column and a context sidebar (FILES / PLAN /
|
||
// tokens) into a right column. The chat is rendered by the existing scroll
|
||
// engine at the reduced column width via a model copy, then joined with the
|
||
// sidebar row-by-row. The subchat drill-in keeps its own single-column view.
|
||
if m.sidebarActive() && !m.subchat.active {
|
||
return m.twoColumnTranscriptView()
|
||
}
|
||
|
||
width := chatWidth(m.width)
|
||
|
||
// Subchat drill-in: when active, show the child session's transcript with
|
||
// a nav bar instead of the main chat.
|
||
if m.subchat.active {
|
||
navBar := renderSubchatNavBar(m.subchat.childSessionTitle, width)
|
||
childBodyItems := m.transcriptBodyItemsFromRows(m.subchat.childRows, width)
|
||
footer := m.footerView(width)
|
||
if m.altScreen && m.height > 0 {
|
||
return m.scrollableTranscriptItemsView(navBar, childBodyItems, footer, width, "")
|
||
}
|
||
bodyLayout := layoutTranscriptBodyItems(childBodyItems)
|
||
body := navBar + "\n\n" + bodyLayout.String()
|
||
return body + footer
|
||
}
|
||
|
||
helpOverlayContent := ""
|
||
if m.helpOverlay {
|
||
helpOverlayContent = m.renderKeybindingHelpOverlay(width)
|
||
}
|
||
leaderHelpOverlayContent := ""
|
||
if m.leaderHelpOverlay {
|
||
leaderHelpOverlayContent = m.renderLeaderHelpOverlay(width)
|
||
}
|
||
|
||
suggestionOverlay := m.suggestionOverlay(width)
|
||
providerOverlay := m.providerWizardOverlay(width)
|
||
mcpAddOverlay := m.mcpAddWizardOverlay(width)
|
||
mcpOverlay := m.mcpManagerOverlay(width)
|
||
pickerOverlay := m.pickerOverlay(width)
|
||
sttKeyOverlay := m.sttKeyPromptOverlay(width)
|
||
viewportOverlay := ""
|
||
switch {
|
||
case sttKeyOverlay != "":
|
||
viewportOverlay = sttKeyOverlay
|
||
case helpOverlayContent != "":
|
||
viewportOverlay = helpOverlayContent
|
||
case leaderHelpOverlayContent != "":
|
||
viewportOverlay = leaderHelpOverlayContent
|
||
case providerOverlay != "":
|
||
viewportOverlay = providerOverlay
|
||
case mcpAddOverlay != "":
|
||
viewportOverlay = mcpAddOverlay
|
||
case mcpOverlay != "":
|
||
viewportOverlay = mcpOverlay
|
||
case pickerOverlay != "":
|
||
viewportOverlay = pickerOverlay
|
||
case suggestionOverlay != "":
|
||
viewportOverlay = suggestionOverlay
|
||
}
|
||
emptyOverlay := ""
|
||
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
|
||
emptyOverlay = viewportOverlay
|
||
}
|
||
bodyItems := m.transcriptBodyItems(width, emptyOverlay, false)
|
||
|
||
footer := m.footerView(width)
|
||
|
||
overlayForViewport := viewportOverlay
|
||
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
|
||
overlayForViewport = ""
|
||
}
|
||
|
||
// Plan panel renders inline in the transcript body (as a transcript row),
|
||
// not pinned at the top. It appears above the specialist cards like a
|
||
// chat message, the way todo/plan updates render inline.
|
||
if m.altScreen && m.height > 0 {
|
||
header := m.pinnedTitleBar(width)
|
||
return m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport)
|
||
}
|
||
|
||
bodyLayout := layoutTranscriptBodyItems(bodyItems)
|
||
body := bodyLayout.String()
|
||
if overlayForViewport != "" {
|
||
body += "\n" + overlayForViewport + "\n"
|
||
}
|
||
return body + footer
|
||
}
|
||
|
||
// twoColumnTranscriptView renders the alt-screen chat into a left column and
|
||
// the context sidebar (FILES / PLAN / tokens) into a right column. The chat is
|
||
// produced by the existing scroll engine at the reduced chat-column width (via
|
||
// chatColumnWidth, which every frame/geometry caller already routes through),
|
||
// yielding exactly m.height lines at the column width; the sidebar block is
|
||
// built to the same height and joined row-by-row. Overlays/wizards never reach
|
||
// here — sidebarActive() returns false while any is up, falling back to the
|
||
// single-column path. Caller guarantees sidebarActive() && !subchat.active.
|
||
func (m model) twoColumnTranscriptView() string {
|
||
chatW := m.chatColumnWidth()
|
||
sidebarW := sidebarWidth(m.width)
|
||
|
||
width := chatW
|
||
|
||
suggestionOverlay := m.suggestionOverlay(width)
|
||
bodyItems := m.transcriptBodyItems(width, "", false)
|
||
footer := m.footerView(width)
|
||
overlayForViewport := suggestionOverlay
|
||
if m.transcriptEmpty() && !m.pending {
|
||
overlayForViewport = ""
|
||
}
|
||
|
||
header := m.pinnedTitleBar(width)
|
||
chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport))
|
||
|
||
sidebar := m.renderContextSidebar(sidebarW, len(chatBlock))
|
||
rows := joinColumns(chatBlock, sidebar, chatW, sidebarW)
|
||
return strings.Join(rows, "\n")
|
||
}
|
||
|
||
func (m model) titleBarInTranscriptBody() bool {
|
||
return !m.altScreen && !m.headerPrinted
|
||
}
|
||
|
||
func (m model) pinnedTitleBar(width int) string {
|
||
if !m.altScreen || m.height <= 0 {
|
||
return ""
|
||
}
|
||
// The file drill-in replaces the title bar with its nav line (path + key
|
||
// hints). Both are exactly one line, and every frame computation routes
|
||
// through here, so the swap never desyncs the viewport geometry.
|
||
if m.fileView.active {
|
||
return m.fileViewNavBar(width)
|
||
}
|
||
return m.titleBar(width)
|
||
}
|
||
|
||
func (m model) footerView(width int) string {
|
||
var footer strings.Builder
|
||
if m.renamePrompt != nil {
|
||
footer.WriteString(m.sessionRenamePromptView(width))
|
||
footer.WriteString("\n")
|
||
footer.WriteString(m.statusLine(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.statusLine(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.statusLine(width))
|
||
return footer.String()
|
||
}
|
||
// Pinned plan panel: sits directly above the composer so it stays visible
|
||
// while the transcript scrolls underneath (a streaming turn no longer pushes
|
||
// the plan off-screen). Budgeted to at most a third of the screen height; a
|
||
// taller plan collapses to a one-line summary so the composer always stays
|
||
// on screen. Skipped in the subchat drill-in: m.plan belongs to the PARENT
|
||
// run, not the subagent/swarm child session being viewed there, so pinning it
|
||
// above that composer would show unrelated state.
|
||
if !m.subchat.active {
|
||
if plan := m.renderPinnedPlanPanel(width, m.pinnedPlanMaxHeight()); plan != "" {
|
||
footer.WriteString(plan)
|
||
footer.WriteString("\n")
|
||
}
|
||
}
|
||
// The row above the composer: transient copy feedback takes priority; otherwise
|
||
// a faint idle affordance — discoverable key hints on the left, a jump-to-bottom
|
||
// cue on the right when scrolled up. Always one line (blank when nothing shows),
|
||
// so the footer height is unchanged.
|
||
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
|
||
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
|
||
} else if 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))
|
||
if hint := m.composerDescriptionHint(width); hint != "" {
|
||
footer.WriteString("\n")
|
||
footer.WriteString(hint)
|
||
}
|
||
footer.WriteString("\n")
|
||
footer.WriteString(m.statusLine(width))
|
||
return footer.String()
|
||
}
|
||
|
||
// composerIdleHint returns a faint one-line key-shortcut hint shown above the
|
||
// composer on an empty, idle prompt, so the chord bindings are discoverable
|
||
// without opening the ? overlay. Empty while typing, during a run, in the
|
||
// full-screen transcript, or under any modal/overlay so it never competes for
|
||
// attention. Width-tiered so a narrow terminal only shows the essential pointer.
|
||
func (m model) composerIdleHint() string {
|
||
// Leader-pending is always shown (even mid-type) so the user knows the next
|
||
// key is a chord, not composer input.
|
||
if m.leaderPending {
|
||
return zeroTheme.faint.Render("Ctrl+X — await shortcut (m model · p provider · ? list · Esc cancel)")
|
||
}
|
||
// Managed (alt-screen) mode only: inline mode prints to native scrollback where
|
||
// this footer row isn't a stable surface. Hidden while typing, during a run, in
|
||
// the full-screen transcript, or under any modal/overlay.
|
||
if !m.altScreen || m.pending || m.composerValue() != "" || !m.noBlockingModal() ||
|
||
m.subchat.active || m.suggestionsActive() || m.transcriptDetailed {
|
||
return ""
|
||
}
|
||
sidebarKey := labelOr(m.keyBindings.toggleSidebar, "Ctrl+B")
|
||
detailKey := labelOr(m.keyBindings.toggleDetailed, "Ctrl+O")
|
||
mouseKey := labelOr(m.keyBindings.toggleMouse, "Ctrl+E")
|
||
|
||
var hint string
|
||
switch widthTier(m.width) {
|
||
case tierTiny:
|
||
return "" // too cramped for a hint
|
||
case tierNarrow:
|
||
hint = "? shortcuts"
|
||
case tierMedium:
|
||
parts := []string{"? shortcuts", "Ctrl+X cmds"}
|
||
if m.sidebarAvailable() {
|
||
parts = append(parts, sidebarKey+" sidebar")
|
||
}
|
||
hint = strings.Join(parts, " · ")
|
||
default:
|
||
parts := []string{"? shortcuts", "Ctrl+X cmds"}
|
||
if m.sidebarAvailable() {
|
||
parts = append(parts, sidebarKey+" sidebar")
|
||
}
|
||
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))
|
||
}
|
||
|
||
// pinnedPlanMaxHeight is the line budget for the pinned plan panel: at most a
|
||
// third of the screen, so even a long plan can't crowd out the transcript or
|
||
// the composer. Beyond this the panel collapses to its one-line summary. Falls
|
||
// back to a generous cap when the height isn't known yet (unmeasured/headless).
|
||
func (m model) pinnedPlanMaxHeight() int {
|
||
if m.height <= 0 {
|
||
return 12
|
||
}
|
||
budget := m.height / 3
|
||
if budget < 3 {
|
||
budget = 3
|
||
}
|
||
return budget
|
||
}
|
||
|
||
type tuiRect struct {
|
||
x int
|
||
y int
|
||
width int
|
||
height int
|
||
}
|
||
|
||
func (r tuiRect) contains(x int, y int) bool {
|
||
return x >= r.x && y >= r.y && x < r.x+r.width && y < r.y+r.height
|
||
}
|
||
|
||
func (r tuiRect) local(x int, y int) (int, int, bool) {
|
||
if !r.contains(x, y) {
|
||
return 0, 0, false
|
||
}
|
||
return x - r.x, y - r.y, true
|
||
}
|
||
|
||
type transcriptFrameLayout struct {
|
||
width int
|
||
height int
|
||
headerRect tuiRect
|
||
bodyRect tuiRect
|
||
footerRect tuiRect
|
||
composerRect tuiRect
|
||
statusRect tuiRect
|
||
headerLines []string
|
||
bodyHeight int
|
||
footerLines []string
|
||
fullFooterLines []string
|
||
footerClip int
|
||
}
|
||
|
||
func (m model) scrollableTranscriptFrame(header string, footer string) transcriptFrameLayout {
|
||
headerLines := viewLines(header)
|
||
fullFooterLines := viewLines(footer)
|
||
footerLines := append([]string(nil), fullFooterLines...)
|
||
|
||
maxFooterLines := maxInt(0, m.height-1)
|
||
if len(footerLines) > maxFooterLines {
|
||
footerLines = footerLines[len(footerLines)-maxFooterLines:]
|
||
}
|
||
if len(headerLines)+len(footerLines) >= m.height {
|
||
maxHeaderLines := maxInt(0, m.height-len(footerLines)-1)
|
||
if len(headerLines) > maxHeaderLines {
|
||
headerLines = headerLines[:maxHeaderLines]
|
||
}
|
||
}
|
||
if len(headerLines)+len(footerLines) >= m.height {
|
||
maxFooterLines = maxInt(0, m.height-len(headerLines)-1)
|
||
if len(footerLines) > maxFooterLines {
|
||
footerLines = footerLines[len(footerLines)-maxFooterLines:]
|
||
}
|
||
}
|
||
|
||
bodyHeight := m.height - len(headerLines) - len(footerLines)
|
||
if bodyHeight < 1 {
|
||
bodyHeight = 1
|
||
}
|
||
width := m.chatColumnWidth()
|
||
footerTop := len(headerLines) + bodyHeight
|
||
frame := transcriptFrameLayout{
|
||
width: width,
|
||
height: m.height,
|
||
headerRect: tuiRect{width: width, height: len(headerLines)},
|
||
bodyRect: tuiRect{y: len(headerLines), width: width, height: bodyHeight},
|
||
footerRect: tuiRect{y: footerTop, width: width, height: len(footerLines)},
|
||
headerLines: headerLines,
|
||
bodyHeight: bodyHeight,
|
||
footerLines: footerLines,
|
||
fullFooterLines: fullFooterLines,
|
||
footerClip: maxInt(0, len(fullFooterLines)-len(footerLines)),
|
||
}
|
||
frame.composerRect = frame.footerSubrect(viewLines(m.composerBox(width)))
|
||
if len(fullFooterLines) > 0 {
|
||
frame.statusRect = frame.footerLineRect(len(fullFooterLines) - 1)
|
||
}
|
||
return frame
|
||
}
|
||
|
||
func (f transcriptFrameLayout) footerSubrect(sequence []string) tuiRect {
|
||
if len(sequence) == 0 || len(f.footerLines) == 0 {
|
||
return tuiRect{}
|
||
}
|
||
top := lineSequenceIndex(f.fullFooterLines, sequence)
|
||
if top < 0 {
|
||
return tuiRect{}
|
||
}
|
||
visibleTop := maxInt(top, f.footerClip)
|
||
visibleBottom := minInt(top+len(sequence), f.footerClip+len(f.footerLines))
|
||
if visibleTop >= visibleBottom {
|
||
return tuiRect{}
|
||
}
|
||
return tuiRect{
|
||
y: f.footerRect.y + visibleTop - f.footerClip,
|
||
width: f.width,
|
||
height: visibleBottom - visibleTop,
|
||
}
|
||
}
|
||
|
||
func (f transcriptFrameLayout) footerLineRect(line int) tuiRect {
|
||
if line < f.footerClip || line >= f.footerClip+len(f.footerLines) {
|
||
return tuiRect{}
|
||
}
|
||
return tuiRect{
|
||
y: f.footerRect.y + line - f.footerClip,
|
||
width: f.width,
|
||
height: 1,
|
||
}
|
||
}
|
||
|
||
func (m model) scrollableTranscriptItemsView(header string, items []transcriptBodyItem, footer string, width int, overlay string) string {
|
||
frame := m.scrollableTranscriptFrame(header, footer)
|
||
metrics := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||
window := transcriptViewportForLayout(metrics, frame, m.chatScrollOffset).window()
|
||
body := layoutVisibleTranscriptBodyItems(items, metrics, window)
|
||
|
||
return m.renderScrollableTranscriptWindow(frame, body.lines, window, width, overlay)
|
||
}
|
||
|
||
func (m model) renderScrollableTranscriptWindow(frame transcriptFrameLayout, bodyWindow []string, window transcriptViewportWindow, width int, overlay string) string {
|
||
for len(bodyWindow) < window.height {
|
||
bodyWindow = append(bodyWindow, "")
|
||
}
|
||
bodyWindow = overlayViewportLines(bodyWindow, overlay, width)
|
||
|
||
lines := make([]string, 0, len(frame.headerLines)+len(bodyWindow)+len(frame.footerLines))
|
||
lines = append(lines, frame.headerLines...)
|
||
lines = append(lines, bodyWindow...)
|
||
lines = append(lines, frame.footerLines...)
|
||
for index, line := range lines {
|
||
lines[index] = fitStyledLine(line, width)
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func overlayViewportLines(lines []string, overlay string, width int) []string {
|
||
if strings.TrimSpace(overlay) == "" || len(lines) == 0 {
|
||
return lines
|
||
}
|
||
overlayLines := viewLines(overlay)
|
||
if len(overlayLines) == 0 {
|
||
return lines
|
||
}
|
||
left, overlayLines, overlayWidth := normalizeOverlayBlock(overlayLines, width)
|
||
if overlayWidth <= 0 {
|
||
return lines
|
||
}
|
||
// Scrim: dim the whole transcript backdrop so a floating overlay (slash-command
|
||
// palette, picker, wizard) clearly stands out instead of blending into the live
|
||
// chat behind it. Header and composer are rendered separately and stay bright.
|
||
for index := range lines {
|
||
lines[index] = scrimViewportLine(lines[index], width)
|
||
}
|
||
start := maxInt(0, (len(lines)-len(overlayLines))/2)
|
||
for offset, line := range overlayLines {
|
||
target := start + offset
|
||
if target >= len(lines) {
|
||
break
|
||
}
|
||
lines[target] = overlayViewportLine(lines[target], line, left, overlayWidth, width)
|
||
}
|
||
return lines
|
||
}
|
||
|
||
// scrimViewportLine dims one backdrop line: it strips the line's own colors and
|
||
// re-renders the text faint, so the dimmed transcript recedes behind the overlay.
|
||
// Blank lines are left untouched.
|
||
func scrimViewportLine(line string, width int) string {
|
||
plain := ansi.Strip(line)
|
||
if strings.TrimSpace(plain) == "" {
|
||
return line
|
||
}
|
||
return zeroTheme.faint.Render(plain)
|
||
}
|
||
|
||
func normalizeOverlayBlock(lines []string, width int) (int, []string, int) {
|
||
left := -1
|
||
for _, line := range lines {
|
||
if strings.TrimSpace(ansi.Strip(line)) == "" {
|
||
continue
|
||
}
|
||
spaces := leadingPlainSpaces(line)
|
||
if left < 0 || spaces < left {
|
||
left = spaces
|
||
}
|
||
}
|
||
if left < 0 {
|
||
left = 0
|
||
}
|
||
left = minInt(left, maxInt(0, width-1))
|
||
|
||
trimmed := make([]string, 0, len(lines))
|
||
blockWidth := 0
|
||
for _, line := range lines {
|
||
if left > 0 && len(line) >= left {
|
||
line = line[left:]
|
||
}
|
||
trimmed = append(trimmed, line)
|
||
blockWidth = maxInt(blockWidth, lipgloss.Width(line))
|
||
}
|
||
blockWidth = minInt(blockWidth, maxInt(0, width-left))
|
||
return left, trimmed, blockWidth
|
||
}
|
||
|
||
func leadingPlainSpaces(line string) int {
|
||
spaces := 0
|
||
for spaces < len(line) && line[spaces] == ' ' {
|
||
spaces++
|
||
}
|
||
return spaces
|
||
}
|
||
|
||
func overlayViewportLine(base string, overlay string, left int, overlayWidth int, width int) string {
|
||
if width <= 0 {
|
||
return ""
|
||
}
|
||
left = clampInt(left, 0, width)
|
||
overlayWidth = minInt(overlayWidth, width-left)
|
||
rightStart := minInt(width, left+overlayWidth)
|
||
|
||
base = fitStyledLine(base, width)
|
||
prefix := padStyledLine(ansi.Cut(base, 0, left), left)
|
||
panel := padStyledLine(overlay, overlayWidth)
|
||
suffix := padStyledLine(ansi.Cut(base, rightStart, width), width-rightStart)
|
||
return prefix + panel + suffix
|
||
}
|
||
|
||
func padStyledLine(line string, width int) string {
|
||
if width <= 0 {
|
||
return ""
|
||
}
|
||
line = fitStyledLine(line, width)
|
||
if pad := width - lipgloss.Width(line); pad > 0 {
|
||
line += strings.Repeat(" ", pad)
|
||
}
|
||
return line
|
||
}
|
||
|
||
func viewLines(value string) []string {
|
||
if value == "" {
|
||
return nil
|
||
}
|
||
return strings.Split(strings.TrimSuffix(value, "\n"), "\n")
|
||
}
|
||
|
||
func (m model) scrollChat(delta int) model {
|
||
if !m.altScreen || delta == 0 {
|
||
return m
|
||
}
|
||
viewport, ok := m.chatTranscriptViewport()
|
||
if !ok {
|
||
return m
|
||
}
|
||
m.chatScrollOffset = viewport.scroll(delta).offset
|
||
if m.chatScrollOffset == 0 {
|
||
m.chatBodyLines = 0
|
||
}
|
||
return m
|
||
}
|
||
|
||
func (m model) chatScrollMetrics() (int, int) {
|
||
viewport, ok := m.chatTranscriptViewport()
|
||
if !ok {
|
||
return 0, 0
|
||
}
|
||
return viewport.totalLines, viewport.maxOffset()
|
||
}
|
||
|
||
func (m model) chatTranscriptViewport() (transcriptViewport, bool) {
|
||
if !m.altScreen || m.height <= 0 {
|
||
return transcriptViewport{}, false
|
||
}
|
||
width := m.chatColumnWidth()
|
||
if m.transcriptDetailed {
|
||
items := m.transcriptBodyItems(width, "", true)
|
||
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||
header := detailedTranscriptHeader(width) + "\n" + zeroTheme.line.Render(strings.Repeat("-", width))
|
||
footer := m.detailedTranscriptFooter(width)
|
||
frame := m.scrollableTranscriptFrame(header, footer)
|
||
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
|
||
}
|
||
items := m.transcriptBodyItems(width, "", false)
|
||
body := measureTranscriptBodyItems(items, m.transcriptBodyHeights)
|
||
frame := m.scrollableTranscriptFrame(m.pinnedTitleBar(width), m.footerView(width))
|
||
return transcriptViewportForLayout(body, frame, m.chatScrollOffset), true
|
||
}
|
||
|
||
// syncChatScroll pins the viewport to what the user is reading. The scroll offset
|
||
// is measured from the bottom, so when the transcript grows (streaming) the window
|
||
// would otherwise follow the new bottom and drag the user off their spot. While
|
||
// the user has scrolled up, shift the offset by however many lines the body changed
|
||
// so the absolute view holds; at the bottom (offset 0) it follows normally. Only the
|
||
// scrolled-up path renders the body, so the common case stays cheap.
|
||
func (m model) syncChatScroll() model {
|
||
if !m.altScreen || m.chatScrollOffset <= 0 {
|
||
// At the bottom (or inline mode): follow the tail; reset the pin baseline.
|
||
m.chatBodyLines = 0
|
||
return m
|
||
}
|
||
current, maxOffset := m.chatScrollMetrics()
|
||
m.chatScrollOffset = clampInt(m.chatScrollOffset, 0, maxOffset)
|
||
if m.chatScrollOffset <= 0 {
|
||
m.chatBodyLines = 0
|
||
return m
|
||
}
|
||
if m.chatBodyLines == 0 {
|
||
// Just scrolled up: establish the baseline, no adjustment this frame.
|
||
m.chatBodyLines = current
|
||
return m
|
||
}
|
||
// Shift by the signed delta so the absolute view holds whether the body grew
|
||
// (streaming appended lines) or shrank (a tool card collapsed, transcript
|
||
// cleared). Clamp at zero so a large shrink lands the user back at the tail
|
||
// rather than underflowing past it.
|
||
m.chatScrollOffset = clampInt(m.chatScrollOffset+current-m.chatBodyLines, 0, maxOffset)
|
||
m.chatBodyLines = current
|
||
return m
|
||
}
|
||
|
||
func (m model) chatPageScrollLines() int {
|
||
if m.height <= 0 {
|
||
return 10
|
||
}
|
||
return maxInt(3, m.height-8)
|
||
}
|
||
|
||
// interimBlock renders the live assistant text while a turn streams. It uses
|
||
// the same lightweight markdown renderer as completed assistant rows, so
|
||
// tables and simple formatting stabilize as soon as enough tokens arrive.
|
||
// Before the first delta arrives it falls back to the spinner so the surface
|
||
// still shows liveness. The cursor needs no ticker — it appears exactly while
|
||
// pending.
|
||
// liveReasoningBodyCap caps an EXPANDED live ("Thinking…") reasoning block to
|
||
// roughly half the screen so it doesn't fill the terminal and its clickable
|
||
// toggle header stays on-screen. Returns 0 (no cap) when the height is unknown.
|
||
func (m model) liveReasoningBodyCap() int {
|
||
if m.height <= 0 {
|
||
return 0
|
||
}
|
||
return maxInt(6, m.height/2)
|
||
}
|
||
|
||
func (m model) interimBlock(width int) string {
|
||
text := strings.TrimRight(m.streamingTextString(), "\n")
|
||
reasoning := strings.TrimRight(m.streamingReasoning, "\n")
|
||
blocks := []string{}
|
||
if strings.TrimSpace(reasoning) != "" {
|
||
blocks = append(blocks, renderReasoningBlock(reasoning, m.streamingReasoningExpanded, width, true, 0, m.liveReasoningBodyCap()))
|
||
}
|
||
if strings.TrimSpace(text) == "" {
|
||
if writing := m.streamingToolCallView(width); writing != "" {
|
||
blocks = append(blocks, writing)
|
||
}
|
||
blocks = append(blocks, m.workingStatusLine())
|
||
// During a long think the reasoning block is collapsed to just its header;
|
||
// show a live tail of the streaming reasoning beneath the working line so
|
||
// the screen keeps changing (never looks stuck) and the user can see WHAT
|
||
// the model is reasoning about. Skipped when expanded (the full body shows).
|
||
if reasoning != "" && !m.streamingReasoningExpanded {
|
||
blocks = append(blocks, reasoningPreviewLines(reasoning, width)...)
|
||
}
|
||
return strings.Join(blocks, "\n")
|
||
}
|
||
// Live streaming block: prose streams normally, but an open fenced code block
|
||
// is buffered until its closing fence arrives so the code appears as one
|
||
// highlighted block instead of recoloring token-by-token.
|
||
lines := renderStreamingAssistantMarkdownText(text, assistantMeasure(width), width)
|
||
for index, line := range lines {
|
||
// styleStreamingLine fades plain prose but leaves already-highlighted
|
||
// markdown/code lines alone, so live colors match the committed row.
|
||
lines[index] = m.styleStreamingLine(line, index, len(lines))
|
||
}
|
||
lines = m.appendStreamingCursor(lines, width)
|
||
blocks = append(blocks, strings.Join(lines, "\n"))
|
||
// Live preview of a file currently being written, so a long write_file/edit
|
||
// shows the code streaming in rather than looking frozen.
|
||
if writing := m.streamingToolCallView(width); writing != "" {
|
||
blocks = append(blocks, writing)
|
||
}
|
||
// Always show the live working line (spinner + verb + elapsed) BELOW the
|
||
// streamed text so an upstream stall keeps animating, never a frozen screen.
|
||
blocks = append(blocks, m.workingStatusLine())
|
||
return strings.Join(blocks, "\n")
|
||
}
|
||
|
||
// workingStatusLine renders the live "working" indicator shown on every pending
|
||
// render: an animated spinner, the rotating working verb, and the elapsed time.
|
||
// It is shown even once partial text has streamed so an upstream stall never
|
||
// looks like a frozen terminal — the spinner tick (~80ms, time-based) drives the
|
||
// re-render, so the elapsed clock keeps advancing for ANY provider/model even
|
||
// when no stream data arrives.
|
||
// spinnerGlyph is the liveness glyph every renderer should use instead of
|
||
// m.spinner.View() directly: the animated frame normally, or a steady dot under
|
||
// reduced motion. The caller applies its own color; liveness is preserved by the
|
||
// advancing elapsed timer, so the static glyph never reads as frozen.
|
||
func (m model) spinnerGlyph() string {
|
||
if m.reducedMotion {
|
||
return "•"
|
||
}
|
||
return m.spinner.View()
|
||
}
|
||
|
||
// workingActivity labels what the agent is doing right now for the working
|
||
// status line: "writing" while the final answer streams, otherwise "thinking"
|
||
// (reasoning, waiting on the model, or a tool in flight). Cheap and robust — no
|
||
// transcript scan — so it can't misreport on a long, output-less step.
|
||
func (m model) workingActivity() string {
|
||
if strings.TrimSpace(m.streamingTextString()) != "" {
|
||
return "writing"
|
||
}
|
||
return "thinking"
|
||
}
|
||
|
||
// toolCardSuppressedInTranscript reports tools whose transcript card is redundant
|
||
// because a dedicated UI surface already shows their state: Task (its specialist
|
||
// card) and update_plan (the pinned plan panel + PLAN sidebar). Their session
|
||
// events are still recorded; only the visible card is skipped.
|
||
func toolCardSuppressedInTranscript(name string) bool {
|
||
return name == "Task" || name == "update_plan"
|
||
}
|
||
|
||
func (m model) workingStatusLine() string {
|
||
// Cosine ripple FX: "Working" breathes through a cold-to-warm theme ramp, the
|
||
// wave moving one character per spinner tick (shared m.spinnerPhase clock). A
|
||
// 6-char wavelength fits the 7-letter word so a full oscillation is visible.
|
||
// Under reduced motion the phase is frozen, so this renders a static gradient.
|
||
working := rippleText("Working", ripplePalette(), m.spinnerPhase, 6)
|
||
line := zeroTheme.accent.Render(m.spinnerGlyph()) + " " + working
|
||
// Phase label so a long, output-less step reads as live progress rather than a
|
||
// frozen screen: "writing" while the answer streams, "thinking" otherwise
|
||
// (reasoning, waiting on the model, or running a tool).
|
||
line += zeroTheme.faint.Render(" · " + m.workingActivity())
|
||
if !m.turnStartedAt.IsZero() {
|
||
line += zeroTheme.faint.Render(" · " + formatWorkingElapsed(m.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. Only on the working line when the
|
||
// context sidebar isn't showing it — the sidebar's ACTIVITY pulse carries it
|
||
// whenever the sidebar is up, so it never appears in both places at once.
|
||
if !m.sidebarActive() {
|
||
if hint := m.quietGenerationHint(); hint != "" {
|
||
line += zeroTheme.amber.Render(" · " + hint)
|
||
}
|
||
}
|
||
// A second line carries live plan progress (how far along + the current step)
|
||
// so a long working stretch shows the task advancing without consulting the
|
||
// sidebar. Replaces the old per-call update_plan transcript cards. Empty when
|
||
// there is no active plan.
|
||
if planLine := m.workingPlanLine(); planLine != "" {
|
||
line += "\n" + planLine
|
||
}
|
||
return line
|
||
}
|
||
|
||
// workingPlanLine is the optional second line under the working indicator: the
|
||
// plan's done/total and the step currently in progress. Empty when there is no
|
||
// plan or the plan is already complete.
|
||
func (m model) workingPlanLine() string {
|
||
if m.plan.isEmpty() || m.plan.isComplete() {
|
||
return ""
|
||
}
|
||
total := len(m.plan.steps)
|
||
done := 0
|
||
for _, step := range m.plan.steps {
|
||
if step.status == "completed" || step.status == "failed" {
|
||
done++
|
||
}
|
||
}
|
||
text := fmt.Sprintf("· plan %d/%d", done, total)
|
||
if current := truncateStep(currentStepContent(m.plan.steps), 48); current != "" {
|
||
text += " · " + current
|
||
}
|
||
return " " + zeroTheme.faint.Render(text)
|
||
}
|
||
|
||
// workingTokenIndicator renders a live "↑ <n> tok" estimate of the tokens
|
||
// generated so far in the current turn, so the working line keeps moving while
|
||
// the model reasons and writes. It is shown for the whole turn — starting at
|
||
// "↑ 0 tok" before the first delta and climbing — so the counter never blinks
|
||
// out. Providers only report exact usage when a step finishes, so this estimates
|
||
// from the streamed reasoning+answer length at the usual ~4 characters per
|
||
// token; turnStreamedRunes accumulates across the whole turn (it survives the
|
||
// per-segment buffer clears), giving a monotonic climb that resets on the next
|
||
// turn.
|
||
func (m model) workingTokenIndicator() string {
|
||
tokens := m.turnStreamedRunes / 4
|
||
if m.turnStreamedRunes > 0 && tokens < 1 {
|
||
tokens = 1
|
||
}
|
||
return "↑ " + humanCount(tokens) + " tok"
|
||
}
|
||
|
||
// quietWorkingHint is how long the stream must be silent (no streamed text,
|
||
// reasoning, or tool-call output) during an active turn before the working line
|
||
// calls out that it's still generating — so a provider that buffers a big tool
|
||
// call (instead of streaming the file as it's written) doesn't read as stuck.
|
||
const quietWorkingHint = 8 * time.Second
|
||
|
||
// quietGenerationHint returns a "still generating…" cue with an advancing
|
||
// quiet-timer when the active turn has produced no streamed output for a while,
|
||
// else "". The advancing number is itself the liveness signal.
|
||
//
|
||
// Past half the provider's idle timeout, the cue escalates to name what's
|
||
// actually happening and when Zero will act on its own: a heartbeating-but-
|
||
// silent stream (observed on chatgpt/gpt-5.x and ollama reasoning models,
|
||
// see providerio.ErrStreamStalled) is bounded by the content-stall watchdog at
|
||
// providerio.ContentStallTimeout(idle), but until it fires this exact same
|
||
// plain "still generating… Xs" text is indistinguishable from a genuine hang —
|
||
// the ticking number was the only signal, and it looks identical whether real
|
||
// (if slow) content is coming or nothing ever will.
|
||
func (m model) quietGenerationHint() string {
|
||
if m.activeRunID == 0 || 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)
|
||
}
|
||
innerWidth := maxInt(1, width-4)
|
||
content := m.composerLine(innerWidth)
|
||
lines := strings.Split(content, "\n")
|
||
|
||
rendered := make([]string, 0, len(lines)+3)
|
||
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", width-2)+"╮"))
|
||
// Attachment chips ([Image #1] …) render INSIDE the box, above the input line,
|
||
// instead of as a separate row above the box.
|
||
if chips := renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments); chips != "" {
|
||
fitted := fitStyledLine(zeroTheme.muted.Render(chips), innerWidth)
|
||
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
|
||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
|
||
}
|
||
for _, line := range lines {
|
||
fitted := fitStyledLine(line, innerWidth)
|
||
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
|
||
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
|
||
}
|
||
rendered = append(rendered, m.composerDividerLine(width))
|
||
return strings.Join(rendered, "\n")
|
||
}
|
||
|
||
// composerDescriptionHint returns the description line that sits below the
|
||
// composer box, claude-code style, when the input is a single unambiguous
|
||
// slash command. Returns "" when the user is mid-prompt, the palette is closed,
|
||
// or more than one command matches. Slash commands only; the @file palette
|
||
// already shows its rows. The inline argument hint ([low|medium|...]) is
|
||
// unchanged and continues to render inside the composer box.
|
||
func (m model) composerDescriptionHint(width int) string {
|
||
if width < 8 {
|
||
return ""
|
||
}
|
||
if m.suggestionsAreFiles {
|
||
return ""
|
||
}
|
||
if !m.commandPaletteOpen || len(m.suggestions) != 1 {
|
||
return ""
|
||
}
|
||
if m.suggestionIdx != 0 {
|
||
return ""
|
||
}
|
||
value := strings.TrimSpace(m.input.Value())
|
||
if !strings.HasPrefix(value, "/") || strings.ContainsAny(value, " \t\n") {
|
||
return ""
|
||
}
|
||
suggestion := m.suggestions[0]
|
||
desc := strings.TrimSpace(suggestion.Desc)
|
||
if desc == "" {
|
||
return ""
|
||
}
|
||
return fitStyledLine(zeroTheme.muted.Render(desc), width)
|
||
}
|
||
|
||
// startsTurn reports whether a row begins a new conversational turn and therefore
|
||
// gets a blank line of separation above it (tool rows stay grouped together).
|
||
func startsTurn(kind rowKind) bool {
|
||
switch kind {
|
||
case rowUser, rowAssistant, rowSystem, rowError:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// isToolCardKind reports whether a row renders as a tool card (a running call or
|
||
// its collapsed result). Used to add one blank line between consecutive tool
|
||
// cards in a turn. Specialist cards are excluded — they own their own grouping
|
||
// (summary line + injected spacing) and must not be double-spaced.
|
||
func isToolCardKind(kind rowKind) bool {
|
||
return kind == rowToolCall || kind == rowToolResult
|
||
}
|
||
|
||
func needsSeparatorBeforeToolCard(previous rowKind, current rowKind) bool {
|
||
if !isToolCardKind(current) {
|
||
return false
|
||
}
|
||
return isToolCardKind(previous) || previous == rowAssistant || previous == rowUser
|
||
}
|
||
|
||
func shouldRuleBeforeTurn(previous rowKind, current rowKind) bool {
|
||
return current == rowAssistant && isToolCardKind(previous)
|
||
}
|
||
|
||
func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||
if m.pendingPermission == nil {
|
||
return m, nil
|
||
}
|
||
key := strings.ToLower(msg.String())
|
||
for _, option := range permissionOptions(m.pendingPermission.request) {
|
||
if option.hotkey == key {
|
||
return m.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()
|
||
return m, nil
|
||
}
|
||
|
||
func permissionDecisionReason(decision permissionDecision) string {
|
||
switch decision {
|
||
case permissionDecisionAllow:
|
||
return "approved in TUI"
|
||
case permissionDecisionAllowStrict:
|
||
return "approved with model review request in TUI"
|
||
case permissionDecisionAllowForSession:
|
||
return "approved for this session in TUI"
|
||
case permissionDecisionAllowPrefix:
|
||
return "approved command prefix for this session in TUI"
|
||
case permissionDecisionAlwaysAllowPrefix:
|
||
return "persistently approved command prefix in TUI"
|
||
case permissionDecisionAlwaysAllow:
|
||
return "persistently approved in TUI"
|
||
case permissionDecisionCancel:
|
||
return "cancelled in TUI"
|
||
case permissionDecisionDeny:
|
||
return "denied in TUI"
|
||
default:
|
||
return "denied in TUI"
|
||
}
|
||
}
|
||
|
||
// choosePicker applies the highlighted picker item through the same handler the
|
||
// typed command would have used, appends the resulting status text, and closes
|
||
// the picker. Behavior is identical to running "/model <id>" or "/effort <v>".
|
||
func (m model) choosePicker() (tea.Model, tea.Cmd) {
|
||
if m.modelPickerIsLoading() {
|
||
return m, nil
|
||
}
|
||
picker := m.picker
|
||
if picker != nil && picker.kind == pickerModel {
|
||
m.clearModelPickerLoadState()
|
||
}
|
||
m.picker = nil
|
||
if picker == nil {
|
||
return m, nil
|
||
}
|
||
item, ok := picker.current()
|
||
if !ok {
|
||
if picker.kind == pickerTheme {
|
||
// No selectable row (e.g. the filter matched nothing): undo any live
|
||
// preview so the palette matches the committed m.themeMode.
|
||
m.restoreCommittedTheme()
|
||
}
|
||
return m, nil
|
||
}
|
||
var cmd tea.Cmd
|
||
switch picker.kind {
|
||
case pickerModel:
|
||
text := ""
|
||
owner := strings.TrimSpace(item.OwnerProvider)
|
||
_, ownerIsSavedProvider := m.savedProviderByName(owner)
|
||
if owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) && ownerIsSavedProvider {
|
||
// A model from another saved provider: switch provider + model together.
|
||
m, text, _, cmd = m.switchProviderModel(owner, item.Value)
|
||
} else {
|
||
// OwnerProvider is blank, matches the active provider, or (registry-fallback
|
||
// / stale-history rows) doesn't resolve to any saved provider: apply against
|
||
// the active provider instead of attempting an unresolvable provider switch.
|
||
m, text = m.handleModelCommand(item.Value)
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
case pickerEffort:
|
||
text := ""
|
||
m, text = m.handleEffortCommand(item.Value)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
case pickerSession:
|
||
// item.Value is the chosen session id; handleResumeCommand hydrates it and
|
||
// rebuilds the transcript (returning "" on success, an error note on failure).
|
||
text := ""
|
||
m, text = m.handleResumeCommand(item.Value)
|
||
if text != "" {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
}
|
||
case pickerSkill:
|
||
// Fill the composer with "/name " so the user adds their request before
|
||
// submitting (a bare second Enter runs it without one); names the slash
|
||
// path cannot reach run immediately instead.
|
||
m, cmd = m.chooseSkillFromPicker(item)
|
||
case pickerSTTModel:
|
||
// Selecting the local engine with no model (and auto-download available)
|
||
// chains into the variant-download picker instead of finalizing.
|
||
if next, fetchCmd, opened := m.maybeOpenSTTDownloadPicker(item.Value); opened {
|
||
return next, fetchCmd
|
||
}
|
||
text := ""
|
||
m, text = m.handleSTTModelSelection(item.Value)
|
||
if text != "" { // empty when a key prompt opened instead of finalizing
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
}
|
||
case pickerSTTDownload:
|
||
return m.handleSTTDownloadSelection(item.Value)
|
||
case pickerTheme:
|
||
// The hovered palette is already live from the preview; handleThemeCommand
|
||
// records the choice (m.themeMode) and re-applies it, and reports the switch.
|
||
text := ""
|
||
m, text = m.handleThemeCommand(item.Value)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
if m.themeMode == themeAuto {
|
||
// Re-probe the terminal background so a committed `auto` re-detects
|
||
// light/dark instead of reusing the preview's reading — mirrors the
|
||
// text /theme dispatch (M17).
|
||
return m, tea.RequestBackgroundColor
|
||
}
|
||
}
|
||
return m, cmd
|
||
}
|
||
|
||
func (m model) chooseSuggestion() (tea.Model, tea.Cmd) {
|
||
if !m.suggestionsActive() || len(m.suggestions) == 0 {
|
||
return m, nil
|
||
}
|
||
wasFiles := m.suggestionsAreFiles
|
||
wasDirectory := m.selectedSuggestionIsDirectory()
|
||
requiresInput := m.selectedCommandSuggestionRequiresInput()
|
||
next := m.completeSuggestion()
|
||
if !wasFiles {
|
||
next.resetComposerFromInput()
|
||
}
|
||
if wasFiles && wasDirectory {
|
||
next.recomputeSuggestions()
|
||
return next, nil
|
||
}
|
||
if !wasFiles {
|
||
if requiresInput {
|
||
return next, nil
|
||
}
|
||
return next.handleSubmit()
|
||
}
|
||
return next, nil
|
||
}
|
||
|
||
func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||
input := m.composerValue()
|
||
// A drag-dropped image/PDF path that reached the composer (e.g. inserted as
|
||
// text) attaches instead of being parsed as an unknown "/…" command.
|
||
if path, ok := droppedAttachmentPath(input, m.cwd); ok {
|
||
m = m.handleImageCommand(path)
|
||
m.clearComposer()
|
||
m.clearSuggestions()
|
||
return m, nil
|
||
}
|
||
command := parseCommand(input)
|
||
// A pending /clear or /quit leave-confirmation is armed for the immediately-next
|
||
// repeat of that same command only; any other submission — including one queued
|
||
// or deferred by the early returns below — disarms it. Runs before those returns
|
||
// so an interposed prompt can't be skipped over and leave the gate falsely armed.
|
||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||
m.loopLeavePrompt = commandEmpty
|
||
}
|
||
// While exiting (Ctrl+C waiting on the cancelled run's checkpoint flush) a
|
||
// new run must not start: the deferred tea.Quit would abort it mid-flight
|
||
// and orphan its checkpoint blobs — the exact loss flushRunIDs prevents.
|
||
if command.kind == commandPrompt && m.exiting {
|
||
return m, nil
|
||
}
|
||
if command.kind == commandPrompt && m.pending {
|
||
return m.queueMessage(command.text), nil
|
||
}
|
||
if command.kind == commandPrompt && m.compactInFlight {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendSystem,
|
||
text: "Compact\nstatus: warning\nCompaction is running. Your next prompt will use the compacted context when this finishes.",
|
||
})
|
||
return m, nil
|
||
}
|
||
m.rememberInput(input)
|
||
m.clearComposer()
|
||
m.clearSuggestions()
|
||
// Snap the viewport back to the bottom for a real submission, but not for an
|
||
// empty Enter (a no-op) — that would yank the user away from wherever they
|
||
// had scrolled without anything actually being submitted.
|
||
if command.kind != commandEmpty {
|
||
m.chatScrollOffset = 0
|
||
}
|
||
|
||
return m.dispatchCommand(command)
|
||
}
|
||
|
||
// dispatchCommand runs a parsed slash/prompt command after submit/leader
|
||
// preamble (history, composer clear, leave-prompt disarm) has already run.
|
||
func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||
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
|
||
}
|
||
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 commandExit:
|
||
// Closing the session stops its foreground loops mid-task; warn once so a
|
||
// token-spending loop isn't ended by reflex.
|
||
if m.loopActive() && m.loopLeavePrompt != commandExit {
|
||
m.loopLeavePrompt = commandExit
|
||
m = m.appendLoopSystem(m.loopFooterSummary() + " active — closing the session stops them. Run /exit again to confirm, or /loop stop all first.")
|
||
return m, nil
|
||
}
|
||
m.loopLeavePrompt = commandEmpty
|
||
// /exit gets the same protection as Ctrl+C: cancel any in-flight run and
|
||
// defer the quit until its checkpoint session events flush — quitting
|
||
// immediately would orphan the blobs and break /rewind.
|
||
m.cancelRun()
|
||
m.exiting = true
|
||
if len(m.flushRunIDs) > 0 {
|
||
return m, nil
|
||
}
|
||
return m.quit()
|
||
case commandTools:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
|
||
return m, nil
|
||
case commandSkills:
|
||
// With skills installed, /skills opens a searchable picker (like /model);
|
||
// the text card remains only as the no-skills install hint.
|
||
if picker := m.newSkillPicker(); picker != nil {
|
||
m.picker = picker
|
||
return m, nil
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.skillsText()})
|
||
return m, nil
|
||
case commandMCP:
|
||
if strings.TrimSpace(command.text) == "" {
|
||
return m.openMCPManager(), nil
|
||
}
|
||
return m.startMCPTranscriptCommand(command.text)
|
||
case commandPermissions:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.permissionsText()})
|
||
return m, nil
|
||
case commandPS:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.backgroundTerminalsText()})
|
||
return m, nil
|
||
case commandStop:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.stopBackgroundTerminalsText(command.text)})
|
||
return m, nil
|
||
case commandSandboxSetup:
|
||
return m.startSandboxSetupCommand(command.text)
|
||
case commandProvider:
|
||
arg := strings.ToLower(strings.TrimSpace(command.text))
|
||
if arg == "" || arg == "add" {
|
||
if m.pending {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
|
||
return m, nil
|
||
}
|
||
// Bare /provider opens the list-first manager over the saved
|
||
// providers; /provider add jumps straight into the add wizard.
|
||
if arg == "add" {
|
||
m.providerWizard = m.newProviderWizard()
|
||
m.clearSuggestions()
|
||
return m, nil
|
||
}
|
||
return m.openProviderManager()
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
|
||
return m, nil
|
||
case commandModel:
|
||
if strings.TrimSpace(command.text) == "" {
|
||
if m.pending {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
|
||
return m, nil
|
||
}
|
||
next, cmd := m.openModelPicker()
|
||
if next.picker != nil {
|
||
return next, cmd
|
||
}
|
||
}
|
||
text := ""
|
||
m, text = m.handleModelCommand(command.text)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandSTTModel:
|
||
if m.pending {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
|
||
return m, nil
|
||
}
|
||
return m.openSTTModelPicker()
|
||
case commandVoice:
|
||
return m.toggleVoiceMode()
|
||
case commandContext:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()})
|
||
return m, nil
|
||
case commandConfig:
|
||
if arg := strings.ToLower(strings.TrimSpace(command.text)); arg != "" {
|
||
var text string
|
||
m, text = m.handleConfigCommand(arg)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.configText()})
|
||
return m, nil
|
||
case commandDebug:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.debugText()})
|
||
return m, nil
|
||
case commandPlan:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.planText()})
|
||
return m, nil
|
||
case commandDoctor:
|
||
return m.startDoctorCommand(command.text)
|
||
case commandSearch:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.searchText(command.text)})
|
||
return m, nil
|
||
case commandResume:
|
||
if m.pending {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendError,
|
||
text: "Cannot resume sessions while a run is active.",
|
||
})
|
||
return m, nil
|
||
}
|
||
// Bare `/resume` opens an interactive session picker (like /model & /provider);
|
||
// `/resume <id>` and `/resume latest` still resolve directly. The picker falls
|
||
// back to the text path when there is nothing to resume.
|
||
if strings.TrimSpace(command.text) == "" {
|
||
if next, ok := m.openSessionPicker(); ok {
|
||
return next, nil
|
||
}
|
||
}
|
||
text := ""
|
||
m, text = m.handleResumeCommand(command.text)
|
||
if strings.HasPrefix(text, sessionsCardsPrefix) {
|
||
// The list payload renders as stacked session cards, not a note.
|
||
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
|
||
kind: rowSystem,
|
||
tool: "sessions",
|
||
text: strings.TrimPrefix(text, sessionsCardsPrefix),
|
||
})
|
||
} else if text != "" {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
}
|
||
return m, nil
|
||
case 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
|
||
}
|
||
}
|
||
text := ""
|
||
m, text = m.handleEffortCommand(command.text)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandStyle:
|
||
text := ""
|
||
m, text = m.handleStyleCommand(command.text)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandSelfCorrect:
|
||
text := ""
|
||
m, text = m.handleSelfCorrectCommand(command.text)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandTurns:
|
||
// Changing the budget mid-run would mutate the inherited ZERO_MAX_TURNS env
|
||
// that sub-agents spawned later in THIS run read, making the run's budget
|
||
// inconsistent. Require an idle session (the new budget applies next run).
|
||
if m.pending && strings.TrimSpace(command.text) != "" {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Turns\nFinish or stop the current run before changing the tool-turn budget."})
|
||
return m, nil
|
||
}
|
||
text := ""
|
||
m, text = m.handleTurnsCommand(command.text)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandProfile:
|
||
// Same idle-session rule as /turns: switching the profile mutates the
|
||
// turn budget (and its ZERO_MAX_TURNS propagation), so a change needs
|
||
// an idle session; bare /profile (status) is always allowed.
|
||
if m.pending && strings.TrimSpace(command.text) != "" && !strings.EqualFold(strings.TrimSpace(command.text), "status") {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Profile\nFinish or stop the current run before switching the execution profile."})
|
||
return m, nil
|
||
}
|
||
text := ""
|
||
m, text = m.handleProfileCommand(command.text)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
return m, nil
|
||
case commandTheme:
|
||
// Bare `/theme` opens the popup picker (live preview on move, apply on
|
||
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
|
||
// (or `/theme list`) still runs the text handler directly.
|
||
if strings.TrimSpace(command.text) == "" {
|
||
m.picker = m.newThemePicker()
|
||
return m, nil
|
||
}
|
||
text := ""
|
||
m, text = m.handleThemeCommand(command.text)
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||
if m.themeMode == themeAuto {
|
||
// Re-probe the terminal background so /theme auto re-detects light/dark
|
||
// instead of reusing a stale reading from startup; the BackgroundColorMsg
|
||
// handler re-applies the auto palette with the fresh result (M17).
|
||
return m, tea.RequestBackgroundColor
|
||
}
|
||
return m, nil
|
||
case commandImage:
|
||
m = m.handleImageCommand(command.text)
|
||
return m, nil
|
||
case commandAddDir:
|
||
m = m.handleAddDirCommand(command.text)
|
||
return m, nil
|
||
case commandUnknown:
|
||
// A "/name" not in the builtin registry may be a user-defined command
|
||
// from .zero/commands/<name>.md — expand its template and run it as a
|
||
// normal prompt before reporting "unknown".
|
||
if next, cmd, handled := m.handleUserCommand(command.text); handled {
|
||
return next, cmd
|
||
}
|
||
// Then an installed skill: "/<skill-name> [args]" runs the skill directly
|
||
// (deterministic invocation, vs waiting for the model to pull it in).
|
||
if next, cmd, handled := m.handleSkillCommand(command.text); handled {
|
||
return next, cmd
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendError,
|
||
text: "unknown command: " + command.text,
|
||
})
|
||
return m, nil
|
||
case commandBash:
|
||
cmdText := strings.TrimSpace(command.text)
|
||
if cmdText == "" {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Usage: !<shell command>"})
|
||
return m, nil
|
||
}
|
||
// A "!cmd" shell escape runs OUTSIDE the agent sandbox, so gate it behind
|
||
// the explicit unsafe permission mode. In auto/ask mode it is not executed;
|
||
// the user is told how to enable it. This keeps a sandbox-bypassing exec
|
||
// from running without a deliberate safety posture.
|
||
if m.permissionMode != agent.PermissionModeUnsafe {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendSystem,
|
||
text: "Shell escape (!) is disabled in " + string(m.permissionMode) + " mode — it bypasses the sandbox. Relaunch with --skip-permissions-unsafe to run shell commands directly.",
|
||
})
|
||
return m, nil
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "$ " + cmdText})
|
||
return m, runBashEscape(m.cwd, cmdText)
|
||
case commandRetry:
|
||
// /retry launches a run, so it needs the same guards a normal prompt gets:
|
||
// never start one while exiting (would strand the shutdown flush) or during
|
||
// compaction (would race compactResultMsg's wholesale rewrite of
|
||
// transcript/sessionEvents and silently drop events).
|
||
if m.exiting {
|
||
return m, nil
|
||
}
|
||
if m.pending {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Retry\ncannot retry while a run is in progress."})
|
||
return m, nil
|
||
}
|
||
if m.compactInFlight {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendSystem,
|
||
text: "Retry\nstatus: warning\nCompaction is running. Retry once it finishes.",
|
||
})
|
||
return m, nil
|
||
}
|
||
if strings.TrimSpace(m.lastPrompt) == "" {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Retry\nno previous prompt to resend."})
|
||
return m, nil
|
||
}
|
||
// Re-stage the attachments the last prompt carried so launchPrompt rebuilds
|
||
// an identical request (document preamble + images + vision re-check). Without
|
||
// this the queues are empty and /retry would resend a text-only prompt,
|
||
// silently dropping the image/PDF context and answering a different task.
|
||
m.pendingImages = m.lastImages
|
||
m.pendingImageLabels = m.lastImageLabels
|
||
m.pendingDocuments = m.lastDocuments
|
||
return m.launchPrompt(m.lastPrompt)
|
||
case commandEdit:
|
||
if strings.TrimSpace(m.lastPrompt) == "" {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Edit\nno previous prompt to recall."})
|
||
return m, nil
|
||
}
|
||
// Re-stage the remembered attachments alongside the recalled text so an
|
||
// edited resend carries the same image/PDF context — the reappearing chip
|
||
// row is the visible confirmation. Without this, editing a vision- or
|
||
// document-backed prompt would silently submit a text-only version and
|
||
// answer a different task (the same gap /retry guards against).
|
||
m.pendingImages = m.lastImages
|
||
m.pendingImageLabels = m.lastImageLabels
|
||
m.pendingDocuments = m.lastDocuments
|
||
m.input.SetValue(m.lastPrompt)
|
||
return m, nil
|
||
case commandCopy:
|
||
text := m.lastAssistantAnswer()
|
||
if strings.TrimSpace(text) == "" {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Copy\nno answer to copy yet."})
|
||
return m, nil
|
||
}
|
||
return m, copyTranscriptSelectionCmd(text)
|
||
case commandExport:
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.handleExportCommand(command.text)})
|
||
return m, nil
|
||
case commandPrompt:
|
||
if intent, ok := detectMCPSetupIntent(command.text); ok {
|
||
return m.openMCPAddWizardFromIntent(intent), nil
|
||
}
|
||
return m.launchPrompt(command.text)
|
||
default:
|
||
return m, nil
|
||
}
|
||
}
|
||
|
||
// executeSlash runs a builtin slash command as if the user typed it and pressed
|
||
// Enter, without clearing the composer draft. Used by Ctrl+X leader chords so a
|
||
// mid-type prompt is preserved while /model (etc.) still opens.
|
||
func (m model) executeSlash(input string) (tea.Model, tea.Cmd) {
|
||
command := parseCommand(input)
|
||
if command.kind == commandEmpty || command.kind == commandPrompt {
|
||
return m, nil
|
||
}
|
||
if m.loopLeavePrompt != commandEmpty && command.kind != m.loopLeavePrompt {
|
||
m.loopLeavePrompt = commandEmpty
|
||
}
|
||
m.rememberInput(input)
|
||
m.clearSuggestions()
|
||
m.chatScrollOffset = 0
|
||
return m.dispatchCommand(command)
|
||
}
|
||
|
||
// launchPrompt starts a normal agent turn from text already accepted by the
|
||
// composer. Queued prompts use this path too, so session and image behavior
|
||
// stays identical to immediate submissions.
|
||
func (m model) launchPrompt(prompt string) (model, tea.Cmd) {
|
||
// Remember the verbatim prompt (before specialist/document expansion) so /retry
|
||
// and /edit can act on exactly what the user submitted. Snapshot the staged
|
||
// attachments too: launchPrompt clears the pending queues below, so /retry
|
||
// re-stages these to resend an identical vision/PDF-backed request rather than
|
||
// a degraded text-only one.
|
||
m.lastPrompt = prompt
|
||
m.lastImages = m.pendingImages
|
||
m.lastImageLabels = m.pendingImageLabels
|
||
m.lastDocuments = m.pendingDocuments
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendUser, text: prompt})
|
||
if m.provider == nil {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendAssistant,
|
||
text: "No provider configured. Run `zero setup` (guided) or `zero auth` (OAuth) from a shell, or set a provider API key env var, then relaunch.",
|
||
})
|
||
return m, nil
|
||
}
|
||
// A leading "@specialist <task>" is expanded into an explicit Task-delegation
|
||
// directive for the agent only; the transcript above keeps the user's verbatim
|
||
// "@mention". Non-mentions and mid-message "@file" references are unchanged.
|
||
if expanded, ok := expandSpecialistMention(prompt, m.agentOptions.Specialists); ok {
|
||
prompt = expanded
|
||
}
|
||
// Prepend any staged PDF document text as a model-facing preamble. The
|
||
// visible transcript above keeps the user's clean prompt; the agent (and the
|
||
// recorded session, for resume fidelity) sees the document text first.
|
||
if preamble := m.consumePendingDocuments(); preamble != "" {
|
||
prompt = preamble + prompt
|
||
}
|
||
var err error
|
||
m, err = m.ensureActiveSession(prompt)
|
||
if err != nil {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendError,
|
||
text: "session create error: " + err.Error(),
|
||
})
|
||
} else {
|
||
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)
|
||
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
|
||
"role": "user",
|
||
"content": prompt,
|
||
})
|
||
if err != nil {
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendError,
|
||
text: "session record error: " + err.Error(),
|
||
})
|
||
}
|
||
prompt = agentPrompt
|
||
}
|
||
// Re-check vision support against the CURRENT effective model at submit
|
||
// time, not just at /image attach time: the user may have attached on a
|
||
// vision model and then /model-switched to a non-vision one. If the active
|
||
// model can't accept images, drop them (with an inline notice mirroring
|
||
// exec's drop+warn wording) rather than sending them to a model that
|
||
// rejects them. Pending state is cleared either way below.
|
||
turnImages := m.pendingImages
|
||
if len(turnImages) > 0 && !m.modelSupportsVisionTUI() {
|
||
name := m.modelName
|
||
if name == "" {
|
||
name = "the active model"
|
||
}
|
||
m.transcript = reduceTranscript(m.transcript, transcriptAction{
|
||
kind: actionAppendSystem,
|
||
text: fmt.Sprintf("Model %s does not support image input; ignoring %d image(s).", name, len(turnImages)),
|
||
})
|
||
turnImages = nil
|
||
}
|
||
m.pendingImages = nil
|
||
m.pendingImageLabels = nil
|
||
runCtx, cancel := context.WithCancel(m.ctx)
|
||
m = m.beginRun(cancel)
|
||
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, turnImages), m.spinner.Tick)
|
||
}
|
||
|
||
// beginRun stamps the shared run-start state for a new agent turn: a fresh run
|
||
// ID, the cancel func, pending = true, the turn-start timestamp (the source for
|
||
// the working status line's live elapsed clock), and a reset working-verb
|
||
// rotation so the brand word shows first. Centralized so every launch path
|
||
// (normal prompt + spec draft/impl) keeps these in sync — a missing
|
||
// turnStartedAt previously dropped the elapsed timer on spec-mode runs.
|
||
func (m model) beginRun(cancel context.CancelFunc) model {
|
||
m = m.cancelIdleRecap()
|
||
if m.prepareRunCompletionWarning != nil {
|
||
m.prepareRunCompletionWarning()
|
||
}
|
||
m.runID++
|
||
m.activeRunID = m.runID
|
||
m.runCancel = cancel
|
||
m.pending = true
|
||
// Clear per-run tracking state so stale specialists and plans from the
|
||
// previous turn don't bleed into the new one.
|
||
m.specialists.clear()
|
||
m.plan.clear()
|
||
m.stepWork = nil
|
||
m.stepNarration = nil
|
||
m.stepExplanation = nil
|
||
m.planDetailOpen = false
|
||
m.planDetailGen++ // invalidate any in-flight step-explanation from the prior run
|
||
// A new run clears the sidebar's content (plan/agents), so the user's Ctrl+B
|
||
// hide was for the OLD context — reset it so the new run's sidebar isn't
|
||
// suppressed by a stale preference.
|
||
m.sidebarHidden = false
|
||
m.turnStartedAt = m.now()
|
||
m.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 an active sidebar holds agents to animate but the loop is not
|
||
// already running (e.g. a resumed session whose swarm members exist before any
|
||
// run started this process). It returns nil — issuing no second timer — when the
|
||
// loop is already alive, when reduced motion is set, or when there is nothing to
|
||
// animate, so an idle plain session schedules no timer.
|
||
func (m *model) ensureSpinnerTick() tea.Cmd {
|
||
if m.spinnerTicking || m.reducedMotion {
|
||
return nil
|
||
}
|
||
if !m.sidebarHasAgents() && !m.aimlapiOnboardAnimating() {
|
||
return nil
|
||
}
|
||
m.spinnerTicking = true
|
||
return m.spinner.Tick
|
||
}
|
||
|
||
func (m model) launchQueuedMessageIfReady() (model, tea.Cmd) {
|
||
if !m.hasQueuedMessage() || m.pending || m.exiting || m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil {
|
||
return m, nil
|
||
}
|
||
prompt := m.queuedMessage
|
||
m.queuedMessage = ""
|
||
return m.launchPrompt(prompt)
|
||
}
|
||
|
||
// historyRecallActive reports whether ↑/↓ should navigate previously submitted
|
||
// inputs: history exists and no modal surface owns the arrow keys.
|
||
func (m model) historyRecallActive() bool {
|
||
return len(m.inputHistory) > 0 &&
|
||
m.pendingAskUser == nil && m.pendingPermission == nil && m.pendingSpecReview == nil
|
||
}
|
||
|
||
// recallHistory steps through submitted inputs (-1 = older, +1 = newer),
|
||
// stashing the in-progress draft so stepping back past the newest recalled
|
||
// entry restores whatever was being typed.
|
||
func (m model) recallHistory(direction int) model {
|
||
if m.historyIdx == len(m.inputHistory) {
|
||
if direction > 0 {
|
||
return m
|
||
}
|
||
m.historyDraft = m.composerValue()
|
||
}
|
||
next := clamp(m.historyIdx+direction, 0, len(m.inputHistory))
|
||
if next == m.historyIdx {
|
||
return m
|
||
}
|
||
m.historyIdx = next
|
||
if next == len(m.inputHistory) {
|
||
m.input.SetValue(m.historyDraft)
|
||
} else {
|
||
m.input.SetValue(m.inputHistory[next])
|
||
}
|
||
m.input.CursorEnd()
|
||
m.resetComposerFromInput()
|
||
m.recomputeSuggestions()
|
||
return m
|
||
}
|
||
|
||
// rememberInput records a submitted composer value for ↑ recall and resets the
|
||
// navigation cursor past the newest entry.
|
||
func (m *model) rememberInput(value string) {
|
||
trimmed := strings.TrimSpace(value)
|
||
if trimmed != "" && (len(m.inputHistory) == 0 || m.inputHistory[len(m.inputHistory)-1] != trimmed) {
|
||
m.inputHistory = append(m.inputHistory, trimmed)
|
||
}
|
||
m.historyIdx = len(m.inputHistory)
|
||
m.historyDraft = ""
|
||
}
|
||
|
||
func (m *model) cancelRun() {
|
||
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.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
|
||
var specReview *pendingSpecReviewPrompt
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
options.PermissionMode = m.permissionMode
|
||
if runOptions.permissionMode != "" {
|
||
options.PermissionMode = runOptions.permissionMode
|
||
}
|
||
if runOptions.systemPrompt != "" {
|
||
options.SystemPrompt = runOptions.systemPrompt
|
||
}
|
||
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.ResponseStyle = m.responseStyle
|
||
options.Cwd = m.cwd
|
||
options.Images = images
|
||
if m.captureRunImages != nil {
|
||
m.captureRunImages(images)
|
||
}
|
||
// Enable agent-loop compaction sized to the active model's context window.
|
||
// AgentContextWindow applies a positive fallback for unknown/custom models so
|
||
// compaction (proactive + reactive) is enabled for every model, not just
|
||
// catalogued ones.
|
||
options.ContextWindow = modelregistry.AgentContextWindow(m.modelContextWindow(m.modelName))
|
||
|
||
// Post-edit self-correction is on by default in the TUI but kept FAST: it
|
||
// runs LSP diagnostics over the changed files only — cheap, change-scoped,
|
||
// and a no-op when no language server is installed. The project test plan
|
||
// (`go test ./...`, whole-repo) is NOT run per edit by default — that would
|
||
// add the full suite's latency to every turn and let a pre-existing failure
|
||
// hijack the agent — so the test half is opt-in via `/selfcorrect on`
|
||
// (m.selfCorrectTests). The spec-draft (planning) path never wires it,
|
||
// matching exec; the per-turn lsp.Manager is torn down when this run
|
||
// returns; auto-fix vs report-only follows the active permission mode.
|
||
if !runOptions.specDraft && options.Cwd != "" {
|
||
// Prefer the session-long manager (kept warm across prompts). Only when it
|
||
// is absent — e.g. cwd was unknown at construction, or a test built the
|
||
// model directly — fall back to a per-run manager that is shut down here.
|
||
lspManager := m.lspManager
|
||
if lspManager == nil {
|
||
lspManager = lsp.NewManager(options.Cwd)
|
||
defer func() {
|
||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
_ = lspManager.Shutdown(shutdownCtx)
|
||
}()
|
||
}
|
||
options.SelfCorrect = agent.NewSelfCorrector(options.Cwd, agent.NewLSPDiagnosticsChecker(lspManager), agent.NewProjectVerifier(options.Cwd), agent.SelfCorrectConfig{
|
||
Enabled: true,
|
||
IncludeTests: m.selfCorrectTests,
|
||
IncludeLSP: true,
|
||
Autonomy: selfCorrectAutonomyForMode(options.PermissionMode),
|
||
})
|
||
// Background post-edit diagnostics: the loop checks files changed by
|
||
// edit_file/write_file off the tool-call path and nudges the model with
|
||
// any errors before its next request. Shares the run's lazy manager.
|
||
options.FileDiagnostics = agent.NewFileDiagnostics(lspManager, options.Cwd)
|
||
}
|
||
|
||
// Some providers synthesize tool-call ids that repeat within a run (e.g.
|
||
// Gemini restarts its gemini_tool_N numbering on every provider turn).
|
||
// Transcript rows need distinct ids for dedup and call→result collapse,
|
||
// so repeats get an ordinal suffix; session payloads keep the provider's
|
||
// original ids.
|
||
callSeq := map[string]int{}
|
||
reasoningText := ""
|
||
reasoningSeq := 0
|
||
var reasoningStarted time.Time
|
||
var reasoningLast time.Time
|
||
flushReasoning := func(closedAt time.Time) {
|
||
if row, ok := reasoningTranscriptRow(fmt.Sprintf("reasoning_%d", reasoningSeq+1), runID, reasoningText); ok {
|
||
if !reasoningStarted.IsZero() {
|
||
if closedAt.IsZero() {
|
||
closedAt = reasoningLast
|
||
}
|
||
if !reasoningLast.IsZero() && closedAt.Before(reasoningLast) {
|
||
closedAt = reasoningLast
|
||
}
|
||
if elapsed := closedAt.Sub(reasoningStarted); elapsed > 0 {
|
||
row.turnElapsed = elapsed
|
||
}
|
||
}
|
||
reasoningSeq++
|
||
rows = append(rows, row)
|
||
m.sendAgentRow(runID, row)
|
||
}
|
||
reasoningText = ""
|
||
reasoningStarted = time.Time{}
|
||
reasoningLast = time.Time{}
|
||
}
|
||
|
||
onText := options.OnText
|
||
options.OnText = func(delta string) {
|
||
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,
|
||
}
|
||
// A Task delegation is shown by the specialist card below, and update_plan
|
||
// is shown by the pinned plan panel + PLAN sidebar, so skip both redundant
|
||
// transcript cards — the dedicated UI supersedes them.
|
||
if !toolCardSuppressedInTranscript(call.Name) {
|
||
rows = append(rows, row)
|
||
m.sendAgentRow(runID, row)
|
||
}
|
||
// Track specialist delegation: when the Task tool is called, register
|
||
// the specialist start so the specialist card + task table can show
|
||
// live status. The child session ID is not known yet (it's created
|
||
// inside the executor), so we use the tool call ID as a temporary
|
||
// key and reconcile on the result.
|
||
if call.Name == "Task" {
|
||
name, desc := parseTaskCallArgs(call.Arguments)
|
||
if m.runtimeMessageSink != nil {
|
||
m.runtimeMessageSink(specialistStartMsg{
|
||
runID: runID,
|
||
name: name,
|
||
description: desc,
|
||
childSessionID: call.ID,
|
||
})
|
||
}
|
||
}
|
||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||
Type: sessions.EventToolCall,
|
||
Payload: map[string]any{
|
||
"id": call.ID,
|
||
"name": call.Name,
|
||
"arguments": call.Arguments,
|
||
},
|
||
})
|
||
// Snapshot before-state of files this call will mutate, NOW (before the
|
||
// mutation runs), then batch the checkpoint event IN ORDER with the other
|
||
// session events so the recorded sequence matches execution (recording it
|
||
// out-of-band would reorder it ahead of the batched tool_call/result).
|
||
// SnapshotForCheckpoint writes the blobs; the batched event referencing
|
||
// them is flushed at end-of-run AND on cancel (flushRunIDs), so the blobs
|
||
// never stay orphaned — see its contract in internal/sessions.
|
||
if m.sessionStore != nil && m.activeSession.SessionID != "" {
|
||
var args map[string]any
|
||
if call.Arguments != "" {
|
||
_ = json.Unmarshal([]byte(call.Arguments), &args)
|
||
}
|
||
if targets := tools.MutationTargets(m.cwd, call.Name, args); len(targets) > 0 {
|
||
if payload, ok := m.sessionStore.SnapshotForCheckpoint(m.activeSession.SessionID, m.cwd, call.Name, targets); ok {
|
||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||
Type: sessions.EventSessionCheckpoint,
|
||
Payload: payload,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
if onToolCall != nil {
|
||
onToolCall(call)
|
||
}
|
||
}
|
||
|
||
options.OnToolProgress = func(toolCallID string, event streamjson.Event) {
|
||
if event.Type == streamjson.EventToolCall && m.runtimeMessageSink != nil {
|
||
m.runtimeMessageSink(specialistProgressMsg{
|
||
runID: runID,
|
||
toolCallID: toolCallID,
|
||
toolName: event.Name,
|
||
detail: toolCallSummary(event),
|
||
})
|
||
}
|
||
}
|
||
|
||
onToolResult := options.OnToolResult
|
||
options.OnToolResult = func(result agent.ToolResult) {
|
||
if runOptions.specDraft {
|
||
if info, ok := tuiSpecReviewFromToolResult(result, m.activeSession.SessionID); ok {
|
||
specReview = &info
|
||
}
|
||
}
|
||
row := transcriptRow{
|
||
kind: rowToolResult,
|
||
id: effectiveToolRowID(result.ToolCallID, callSeq[result.ToolCallID]),
|
||
text: toolResultRowText(result),
|
||
tool: result.Name,
|
||
status: result.Status,
|
||
detail: toolResultDetail(result),
|
||
meta: result.Meta,
|
||
runID: runID,
|
||
changedFiles: result.ChangedFiles,
|
||
changeSummaries: result.ChangeSummaries,
|
||
}
|
||
// A Task result is shown by the specialist card, and update_plan by the
|
||
// plan panel/sidebar, so skip both redundant transcript rows.
|
||
if !toolCardSuppressedInTranscript(result.Name) {
|
||
rows = append(rows, row)
|
||
m.sendAgentRow(runID, row)
|
||
}
|
||
// Sync the sticky plan panel when update_plan runs.
|
||
if result.Name == "update_plan" && m.registry != nil {
|
||
if planTool, ok := m.registry.Get("update_plan"); ok {
|
||
if reader, ok := planTool.(interface{ CurrentPlan() []tools.PlanItem }); ok {
|
||
if m.runtimeMessageSink != nil {
|
||
m.runtimeMessageSink(planUpdateMsg{runID: runID, items: reader.CurrentPlan()})
|
||
}
|
||
}
|
||
}
|
||
}
|
||
toolPayload := map[string]any{
|
||
"toolCallId": result.ToolCallID,
|
||
"name": result.Name,
|
||
"status": string(result.Status),
|
||
"output": result.Output,
|
||
}
|
||
if result.Redacted {
|
||
toolPayload["redacted"] = true
|
||
}
|
||
if len(result.Meta) > 0 {
|
||
toolPayload["meta"] = result.Meta
|
||
}
|
||
if len(result.ChangedFiles) > 0 {
|
||
toolPayload["changedFiles"] = result.ChangedFiles
|
||
}
|
||
if len(result.ChangeSummaries) > 0 {
|
||
toolPayload["changeSummaries"] = result.ChangeSummaries
|
||
}
|
||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||
Type: sessions.EventToolResult,
|
||
Payload: toolPayload,
|
||
})
|
||
// Complete specialist tracking when the Task tool returns.
|
||
if result.Name == "Task" {
|
||
status := specialistCompleted
|
||
if result.Status == tools.StatusError {
|
||
status = specialistError
|
||
}
|
||
childSessionID := result.ToolCallID
|
||
if sid, ok := result.Meta["session_id"]; ok && sid != "" {
|
||
childSessionID = sid
|
||
}
|
||
if m.runtimeMessageSink != nil {
|
||
m.runtimeMessageSink(specialistCompleteMsg{
|
||
runID: runID,
|
||
toolCallID: result.ToolCallID,
|
||
childSessionID: childSessionID,
|
||
status: status,
|
||
errorMsg: result.Output,
|
||
})
|
||
}
|
||
}
|
||
// swarm_collect carries task_id -> session_id for completed members, so
|
||
// the AGENTS sidebar rows can drill into a member's session like a
|
||
// specialist card.
|
||
if result.Name == "swarm_collect" && len(result.Meta) > 0 && m.runtimeMessageSink != nil {
|
||
m.runtimeMessageSink(swarmSessionsMsg{runID: runID, sessions: result.Meta})
|
||
}
|
||
if onToolResult != nil {
|
||
onToolResult(result)
|
||
}
|
||
}
|
||
|
||
onPermission := options.OnPermission
|
||
options.OnPermission = func(event agent.PermissionEvent) {
|
||
// The audit event is recorded for every call so the session log stays
|
||
// complete; the visible row is only emitted when the event carries
|
||
// user-facing information (a real prompt, a denial, an explicit durable
|
||
// grant), not for silent auto-approvals.
|
||
if permissionEventIsNoteworthy(event) {
|
||
row := permissionTranscriptRow(event)
|
||
row.runID = runID
|
||
rows = append(rows, row)
|
||
m.sendAgentRow(runID, row)
|
||
}
|
||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||
Type: tuiPermissionEventType(event),
|
||
Payload: event,
|
||
})
|
||
if onPermission != nil {
|
||
onPermission(event)
|
||
}
|
||
}
|
||
|
||
onUsage := options.OnUsage
|
||
options.OnUsage = func(event zeroruntime.Usage) {
|
||
usageEvents = append(usageEvents, event)
|
||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||
Type: sessions.EventUsage,
|
||
Payload: usage.EventUsagePayload(event),
|
||
})
|
||
m.sendAgentUsage(runID, usageModelID, event)
|
||
if onUsage != nil {
|
||
onUsage(event)
|
||
}
|
||
}
|
||
|
||
result, err := agent.Run(runCtx, prompt, m.provider, options)
|
||
if err != nil {
|
||
flushReasoning(m.now())
|
||
sessionEvents = append(sessionEvents, pendingSessionEvent{
|
||
Type: sessions.EventError,
|
||
Payload: map[string]any{"message": err.Error()},
|
||
})
|
||
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, 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, 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, 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, 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 {
|
||
if result.Status != tools.StatusError && strings.TrimSpace(result.Display.Preview) != "" {
|
||
return result.Display.Preview
|
||
}
|
||
return result.Output
|
||
}
|
||
|
||
func toolResultRowText(result agent.ToolResult) string {
|
||
status := result.Status
|
||
if status == "" {
|
||
status = tools.StatusOK
|
||
}
|
||
return fmt.Sprintf("tool result: %s %s %s", result.Name, status, truncateTUIOutput(result.Output, tuiToolOutputLimit))
|
||
}
|