Files
zero/internal/tui/model.go
KRATOS e0dd67a4da Wire LSP diagnostics into self-correct (exec + TUI) (#191)
* Wire LSP diagnostics into headless --self-correct

newExecSelfCorrector now backs the --self-correct loop with both halves: the
workspace test plan AND an lsp.Manager-based diagnostics checker (IncludeLSP=true).
The manager is lazy (Manager.Check degrades a missing language server to nil,nil),
and a deferred cleanup shuts it down at run end, terminating any spawned sessions.

This revives the internal/lsp client (44 -> 3 unreachable funcs); it was dead
because the only consumer passed a nil checker with IncludeLSP=false.

Add TestManagerCheckRealGopls: drives NewManager -> Check against a real gopls and
asserts a real diagnostic; skips when gopls is absent, so it stays CI-safe.

* Wire self-correct into the interactive TUI (on by default)

runAgentWithOptions now builds a SelfCorrector for every turn, so post-edit
verification (workspace test plan + LSP diagnostics) runs by default in the TUI.
The spec-draft planning path is excluded, matching exec. A per-turn lsp.Manager
is created and torn down when the run returns. Auto-fix vs report-only follows
the active permission mode (unsafe->high, auto->medium, ask/other->low) via
selfCorrectAutonomyForMode.

* Keep TUI self-correct fast by default; gate the test half behind /selfcorrect

The TUI default now runs only the LSP-diagnostics half of self-correct — cheap and
scoped to the changed files — so it never adds the whole-repo test plan's latency to a
turn (and a pre-existing failure can't hijack the agent). The project test plan is opt-in
per session via a new /selfcorrect [on|off] command (alias /sc); IncludeTests reads
m.selfCorrectTests, default false. exec keeps both halves.

* Remove dead session.didClose from lsp

session.didClose sent a textDocument/didClose notification but was never called (the
manager only closes documents via Shutdown), so it was unreachable even from tests and
flagged by staticcheck U1000. Removing it takes internal/lsp to 0 truly-dead funcs and 0
U1000 findings; the 2 remaining unreachable-from-main funcs (Available, URIToPath) are
public, test-covered API.

* Align /selfcorrect help with accepted arguments

The usage/description advertised only [on|off] but the handler also accepts status,
list, tests, full, and lsp. Surface the primary verbs (on|off|status) and clarify the
on/off behavior so command discovery matches the handler (CodeRabbit).

* Make /selfcorrect usage strings match the handler exactly

Both the command definition and the invalid-argument error message now list the full
accepted set [status|on|off|tests|full|lsp]; the redundant 'list' alias is dropped so docs
and handler agree exactly. Addresses Vasanth's review (the error-message usage at
session_controls.go was still [on|off]) and CodeRabbit's help-text finding.

* Clarify /selfcorrect off semantics in help text

Reword the description so 'off/lsp' reads as 'disable tests, LSP-only' rather than the
ambiguous 'return to LSP-only', which could be misread as disabling the feature entirely
(CodeRabbit).
2026-06-14 10:56:53 +05:30

2480 lines
81 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"unicode"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"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/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
const tuiToolOutputLimit = 240
const defaultResponseStyle = "balanced"
const chatWheelScrollLines = 3
type model struct {
ctx context.Context
cwd string
userConfigPath string
gitBranch string
providerName string
modelName string
providerProfile config.ProviderProfile
provider zeroruntime.Provider
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
discoverProviderModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
registry *tools.Registry
sessionStore *sessions.Store
sandboxStore *sandbox.GrantStore
mcpConfig config.MCPConfig
mcpPermissionStore *internalmcp.PermissionStore
mcpTokenStore *internalmcp.TokenStore
mcpCommand func(context.Context, []string) MCPCommandResult
mcpViewStateCache MCPViewState
mcpViewStateReady bool
mcpCommandSeq int
mcpCommandCancel context.CancelFunc
activeSession sessions.Metadata
sessionEvents []sessions.Event
usageTracker *usage.Tracker
sessionCompactor SessionCompactor
runtimeMessageSink func(tea.Msg)
agentOptions agent.Options
notifier *notify.Notifier
permissionMode agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
responseStyle string
compactRequests int
compactInFlight bool
compactFrame int
lastCompactResult *CompactResult
lastCompactError string
unpricedRequests int
unpricedTokens int
transcript []transcriptRow
transcriptDetailed bool
input textinput.Model
composer composerState
composerActive bool
composerPastePreviews []composerPastePreview
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
pending bool
queuedMessage string
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
pendingPermission *pendingPermissionPrompt
pendingAskUser *pendingAskUserPrompt
pendingSpecReview *pendingSpecReviewPrompt
width int
height int
now func() time.Time
chatScrollOffset int
// Flush-frontier state (see flush.go). In inline mode, transcript[:flushed]
// is already in native scrollback; in alt-screen mode this frontier stays
// idle so history cannot reveal prior shell output.
// flushedAny gates the first turn-separator blank line; flushQueue/
// printInFlight serialize ordered scrollback prints; headerPrinted records
// the one-time title-bar print at startup.
flushed int
flushedAny bool
flushQueue []string
printInFlight bool
headerPrinted bool
// Composer input history (shell-style ↑/↓ recall of submitted inputs).
// historyIdx == len(inputHistory) means "not navigating"; historyDraft
// preserves whatever was typed before recall started.
inputHistory []string
historyIdx int
historyDraft string
streamingText string // live assistant text for the current segment
// 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
lastMouseSelection mouseSelectionTarget
mouseCapture bool
transcriptSelection transcriptSelectionState
copyStatus string
copyStatusSeq int
// picker, when non-nil, is an open interactive selector overlay (/model,
// /effort, /mode 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
modelPickerLoading bool
modelPickerLoadingProviderID string
modelPickerLoadError string
modelPickerLiveProviderID string
modelPickerLiveModels []providermodeldiscovery.Model
// pendingImages holds image attachments staged by /image for the next user
// turn; pendingImageLabels are their display names (base(path)) for the chip
// row. Both are cleared after a prompt is submitted (or /image clear). nil =
// no attachments = today's text-only behavior exactly.
pendingImages []zeroruntime.ImageBlock
pendingImageLabels []string
// pendingDocuments holds PDF text layers staged by /image for the next user
// turn; the text is prepended to the prompt as a preamble at submit time and
// the slice is cleared (or by /image clear). nil = no documents staged.
pendingDocuments []pendingDocument
// captureRunImages, when set, is invoked with the images a run is launched
// with. Nil in production; used by tests to assert image threading without a
// real provider round-trip.
captureRunImages func([]zeroruntime.ImageBlock)
}
type agentTextMsg struct {
runID int
delta string
}
type agentResponseMsg struct {
runID int
rows []transcriptRow
usageEvents []zeroruntime.Usage
usageModelID string
sessionEvents []pendingSessionEvent
specReview *pendingSpecReviewPrompt
err error
// Done-line metadata for the error path, where no final assistant row
// carries it (the success path marks the row itself).
turnTools int
turnElapsed time.Duration
}
type agentRowMsg struct {
runID int
row transcriptRow
}
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 permissionDecision = agent.PermissionDecisionAction
const (
permissionDecisionAllow permissionDecision = agent.PermissionDecisionAllow
permissionDecisionDeny permissionDecision = agent.PermissionDecisionDeny
permissionDecisionAlwaysAllow permissionDecision = agent.PermissionDecisionAlwaysAllow
)
type permissionRequestMsg struct {
runID int
request agent.PermissionRequest
decide func(agent.PermissionDecision)
}
type pendingPermissionPrompt struct {
request agent.PermissionRequest
decide func(agent.PermissionDecision)
}
// 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. Answers are collected
// one question at a time; once every question has an answer (or the user cancels)
// the answer callback is invoked exactly once.
type pendingAskUserPrompt struct {
request agent.AskUserRequest
answer func([]string)
index int
answers []string
}
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
}
}
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
usageTracker := options.UsageTracker
if usageTracker == nil {
usageTracker = usage.NewTracker(usage.TrackerOptions{})
}
permissionMode := options.PermissionMode
if permissionMode == "" {
permissionMode = options.AgentOptions.PermissionMode
}
if permissionMode == "" {
permissionMode = agent.PermissionModeAuto
}
input := textinput.New()
input.Prompt = "❯ "
input.PromptStyle = zeroTheme.userPrompt
input.TextStyle = zeroTheme.ink
input.PlaceholderStyle = zeroTheme.faint
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), spinner.WithStyle(zeroTheme.accent))
notifier := notify.New(os.Stderr, notify.Config{
Mode: notify.Mode(strings.TrimSpace(options.Notify.Mode)),
FocusMode: notify.FocusMode(strings.TrimSpace(options.Notify.FocusMode)),
})
notifier.SetFocused(true)
m := model{
ctx: ctx,
cwd: cwd,
userConfigPath: options.UserConfigPath,
gitBranch: gitBranch(cwd),
providerName: options.ProviderName,
modelName: options.ModelName,
providerProfile: options.ProviderProfile,
favoriteModels: favoriteModelSet(options.FavoriteModels),
provider: options.Provider,
newProvider: options.NewProvider,
discoverProviderModels: options.DiscoverProviderModels,
registry: registry,
sessionStore: sessionStore,
sandboxStore: sandboxStore,
mcpConfig: options.MCPConfig,
mcpPermissionStore: options.MCPPermissionStore,
mcpTokenStore: options.MCPTokenStore,
mcpCommand: options.MCPCommand,
agentOptions: options.AgentOptions,
sessionCompactor: options.SessionCompactor,
runtimeMessageSink: options.RuntimeMessageSink,
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
usageTracker: usageTracker,
transcript: initialTranscript(),
input: input,
spinner: runSpinner,
now: time.Now,
notifier: notifier,
altScreen: options.AltScreen,
setup: newSetupState(options.Setup),
setupSave: options.Setup.Save,
}
m.refreshMCPViewState()
return m
}
const (
composerPlaceholder = "describe a task for zero…"
composerMaxVisibleLines = 4
)
func (m model) Init() tea.Cmd {
return textinput.Blink
}
// Update routes every message through updateModel, then advances the flush
// frontier for inline rendering. Alt-screen runs keep rows in the managed view
// instead of printing into terminal scrollback (see flush.go).
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if _, ok := msg.(flushedMsg); ok {
m.printInFlight = false
return m.drainFlushQueue()
}
next, cmd := m.updateModel(msg)
nm, ok := next.(model)
if !ok {
return next, cmd
}
nm, mouseCmd := nm.syncMouseCapture()
nm, flushCmd := nm.settleTranscript()
return nm, batchCommands(cmd, mouseCmd, flushCmd)
}
func batchCommands(cmds ...tea.Cmd) tea.Cmd {
filtered := make([]tea.Cmd, 0, len(cmds))
for _, cmd := range cmds {
if cmd != nil {
filtered = append(filtered, cmd)
}
}
switch len(filtered) {
case 0:
return nil
case 1:
return filtered[0]
default:
return tea.Batch(filtered...)
}
}
func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.MouseMsg:
if m.setup.visible {
return m.handleSetupMouse(msg)
}
return m.handleMouse(msg)
case transcriptCopiedMsg:
m.transcriptSelection = transcriptSelectionState{}
m.copyStatusSeq++
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 tea.KeyMsg:
if m.setup.visible {
return m.handleSetupKey(msg)
}
m.transcriptSelection = transcriptSelectionState{}
m.clearMouseSelection()
switch msg.Type {
case tea.KeyCtrlC:
// cancelRun records the in-flight run into flushRunIDs and writes the
// "Run cancelled." marker, exactly like the Esc path. While ANY cancelled
// run is still flushing we must NOT quit yet: each cancelled goroutine
// returns its accumulated session events (including the
// EventSessionCheckpoint blobs it already wrote to disk before each
// mutating tool) in a final agentResponseMsg, and quitting now would drop
// that message, orphaning the checkpoints and breaking /rewind. This
// covers both a run cancelled BY this Ctrl+C and one cancelled by an
// earlier Esc whose flush hasn't landed (m.pending is already false then,
// but flushRunIDs is not empty). The agentResponseMsg handler fires
// tea.Quit once flushRunIDs drains.
m.cancelRun()
m.exiting = true
if len(m.flushRunIDs) > 0 {
return m, nil
}
return m, tea.Quit
case tea.KeyCtrlO:
return m.toggleDetailedTranscript(), nil
case tea.KeyEsc:
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
}
// An active questionnaire is cancelled (not the whole run): deliver
// whatever answers were collected so the agent loop unblocks and
// degrades to its best-assumption path.
if m.pendingAskUser != nil {
return m.resolveAskUser(true)
}
if m.pendingSpecReview != nil {
return m.cancelSpecReview()
}
if m.providerWizard != nil {
m.providerWizard = nil
return m, nil
}
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()
}
m.picker = nil
return m, nil
}
if m.suggestionsActive() {
return m.dismissSuggestions(), nil
}
if m.hasQueuedMessage() {
return m.clearQueuedMessage(), nil
}
m.clearComposer()
m.clearSuggestions()
if m.pending {
m.cancelRun()
}
return m, nil
case 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 {
return m, nil
}
if m.pendingAskUser != nil {
return m.submitAskUserAnswer()
}
if m.pendingSpecReview != nil {
return m, nil
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
return m.choosePicker()
}
if msg.Alt {
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()
}
return m.handleSubmit()
case tea.KeyShiftTab:
if m.transcriptDetailed {
return m, 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.pendingPermission == nil && m.pendingAskUser == nil && m.pendingSpecReview == nil && m.providerWizard == nil && m.mcpAddWizard == nil && m.mcpManager == nil && m.picker == nil {
m.permissionMode = nextPermissionMode(m.permissionMode)
return m, nil
}
case tea.KeyCtrlF:
if m.picker != nil && m.picker.kind == pickerModel {
if m.modelPickerIsLoading() {
return m, nil
}
return m.toggleModelFavorite(), nil
}
case tea.KeyBackspace, tea.KeyCtrlH:
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.deleteQueryRune()
return m, nil
}
case tea.KeyTab:
if m.transcriptDetailed {
return m, nil
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
return m.handleMCPManagerKey(msg)
}
if m.picker == nil && m.suggestionsActive() {
m.moveSuggestion(1)
return m, nil
}
case tea.KeyPgUp:
if m.transcriptDetailed {
return m, nil
}
return m.scrollChat(m.chatPageScrollLines()), nil
case tea.KeyPgDown:
if m.transcriptDetailed {
return m, nil
}
return m.scrollChat(-m.chatPageScrollLines()), nil
case tea.KeyDown:
if m.transcriptDetailed {
return m, nil
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.move(1)
return m, nil
}
if m.suggestionsActive() {
m.moveSuggestion(1)
return m, nil
}
if next, ok := m.moveComposerVisualCursor(1); ok {
return next, nil
}
if m.historyRecallActive() {
return m.recallHistory(1), nil
}
case tea.KeyUp:
if m.transcriptDetailed {
return m, nil
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
return m.handleMCPManagerKey(msg)
}
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.move(-1)
return m, nil
}
if m.suggestionsActive() {
m.moveSuggestion(-1)
return m, nil
}
if next, ok := m.moveComposerVisualCursor(-1); ok {
return next, nil
}
if m.historyRecallActive() {
return m.recallHistory(-1), nil
}
}
if m.transcriptDetailed {
return m, nil
}
if m.pendingAskUser != nil {
// While a questionnaire is active, all other keys feed the text input
// (the answer field); nothing else should react.
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
if m.pendingSpecReview != nil {
return m.handleSpecReviewKey(msg)
}
if m.pendingPermission != nil {
return m.handlePermissionKey(msg)
}
if m.providerWizard != nil {
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
return m.handleMCPAddWizardKey(msg)
}
if m.mcpManager != nil {
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 msg.Type == tea.KeyRunes {
m.picker.appendQuery(msg.Runes)
}
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:
if m.notifier != nil {
m.notifier.SetFocused(true)
}
return m, nil
case tea.BlurMsg:
if m.notifier != nil {
m.notifier.SetFocused(false)
}
return m, nil
case agentTextMsg:
if msg.runID != m.activeRunID {
return m, nil
}
m.streamingText += msg.delta
return m, nil
case spinner.TickMsg:
// Not forwarding the tick while idle stops the spinner's self-scheduling,
// so no timer fires between runs.
if !m.pending && !m.compactInFlight {
return m, nil
}
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
if m.compactInFlight {
m.compactFrame++
m = m.setCompactStatusRow(m.compactText(true))
}
return m, cmd
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Size the composer so long input scrolls horizontally with the cursor
// visible instead of being clipped invisibly past the right edge.
m.input.Width = maxInt(20, chatWidth(msg.Width)-14)
// The title bar prints once into native scrollback when the inline
// renderer is active. In alt-screen mode tea.Println is ignored, so the
// title stays managed inside View.
if !m.altScreen && !m.headerPrinted && msg.Width > 0 {
m.headerPrinted = true
m.flushQueue = append(m.flushQueue, m.titleBar(chatWidth(msg.Width)))
}
return m, nil
case permissionRequestMsg:
if msg.runID != m.activeRunID {
return m, nil
}
promptRow := permissionTranscriptRow(permissionEventFromRequest(msg.request))
promptRow.runID = msg.runID
m.transcript = appendTranscriptRow(m.transcript, promptRow)
if msg.request.Action == agent.PermissionActionPrompt {
m.pendingPermission = &pendingPermissionPrompt{
request: msg.request,
decide: msg.decide,
}
}
return m, nil
case askUserRequestMsg:
if msg.runID != m.activeRunID {
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,
answers: make([]string, 0, len(msg.request.Questions)),
}
m.clearComposer()
m.clearSuggestions()
return m, nil
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.
for _, event := range msg.usageEvents {
var usageRows []transcriptRow
m, usageRows = m.recordUsageEvent(msg.usageModelID, event)
for _, row := range usageRows {
m.transcript = appendTranscriptRow(m.transcript, row)
}
}
// 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, tea.Quit
}
}
return m, nil
}
m.pending = false
// 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.pendingPermission = nil
m.pendingAskUser = nil
for _, event := range msg.usageEvents {
var usageRows []transcriptRow
m, usageRows = m.recordUsageEvent(msg.usageModelID, event)
for _, row := range usageRows {
m.transcript = appendTranscriptRow(m.transcript, row)
}
}
var sessionRows []transcriptRow
m, sessionRows = m.appendSessionEvents(msg.sessionEvents)
for _, row := range sessionRows {
m.transcript = appendTranscriptRow(m.transcript, row)
}
for _, row := range msg.rows {
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 text := strings.TrimRight(m.streamingText, "\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.
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{
kind: rowError,
text: msg.err.Error(),
final: true,
turnTools: msg.turnTools,
turnElapsed: msg.turnElapsed,
})
}
m.streamingText = ""
if msg.specReview != nil {
m = m.activateSpecReview(*msg.specReview)
}
if m.notifier != nil {
m.notifier.Notify(notify.Completion, notify.DefaultMessage(notify.Completion))
}
return m.launchQueuedMessageIfReady()
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 agentRowMsg:
if msg.runID != m.activeRunID {
return m, nil
}
// 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 text := strings.TrimRight(m.streamingText, "\n"); strings.TrimSpace(text) != "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
}
m.streamingText = ""
}
m.transcript = appendTranscriptRow(m.transcript, msg.row)
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 modelPickerModelsDiscoveredMsg:
return m.applyModelPickerModelsDiscovered(msg), 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() string {
if m.setup.visible {
return m.setupView(chatWidth(m.width))
}
if m.transcriptDetailed {
return m.detailedTranscriptView()
}
return m.transcriptView()
}
// 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 {
width := chatWidth(m.width)
suggestionOverlay := m.suggestionOverlay(width)
providerOverlay := m.providerWizardOverlay(width)
mcpAddOverlay := m.mcpAddWizardOverlay(width)
mcpOverlay := m.mcpManagerOverlay(width)
pickerOverlay := m.pickerOverlay(width)
viewportOverlay := ""
switch {
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
}
body, _ := m.transcriptBody(width, emptyOverlay)
footer := m.footerView(width)
overlayForViewport := viewportOverlay
if m.transcriptEmpty() && !m.pending && viewportOverlay != "" {
overlayForViewport = ""
}
if m.altScreen && m.height > 0 {
return m.scrollableTranscriptView(body, footer, width, overlayForViewport)
}
if overlayForViewport != "" {
body += "\n" + overlayForViewport + "\n"
}
return body + footer
}
func (m model) footerView(width int) string {
var footer strings.Builder
footer.WriteString("\n")
if chips := renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments); chips != "" {
footer.WriteString(fitStyledLine(zeroTheme.muted.Render(chips), width))
footer.WriteString("\n")
}
if copyStatus := strings.TrimSpace(m.copyStatus); copyStatus != "" {
footer.WriteString(rightAlignedLine(zeroTheme.ink.Render(copyStatus), width))
footer.WriteString("\n")
}
footer.WriteString(m.composerBox(width))
if queued := renderQueuedMessagePreview(m.queuedMessage, width); queued != "" {
footer.WriteString("\n")
footer.WriteString(queued)
}
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
return footer.String()
}
func (m model) scrollableTranscriptView(body string, footer string, width int, overlay string) string {
bodyLines := viewLines(body)
footerLines := viewLines(footer)
maxFooterLines := maxInt(0, m.height-1)
if len(footerLines) > maxFooterLines {
footerLines = footerLines[len(footerLines)-maxFooterLines:]
}
available := m.height - len(footerLines)
if available < 1 {
available = 1
}
maxOffset := maxInt(0, len(bodyLines)-available)
offset := clamp(m.chatScrollOffset, 0, maxOffset)
start := maxInt(0, len(bodyLines)-available-offset)
end := minInt(len(bodyLines), start+available)
lines := make([]string, 0, available+len(footerLines))
if start < end {
lines = append(lines, bodyLines[start:end]...)
}
for len(lines) < available {
lines = append(lines, "")
}
lines = overlayViewportLines(lines, overlay, width)
lines = append(lines, 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
}
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
}
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
}
m.chatScrollOffset = maxInt(0, m.chatScrollOffset+delta)
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.
func (m model) interimBlock(width int) string {
text := strings.TrimRight(m.streamingText, "\n")
if strings.TrimSpace(text) == "" {
return m.spinner.View() + " " + zeroTheme.muted.Render("working…")
}
lines := renderAssistantMarkdownText(text, sayMeasure(width), width)
for index, line := range lines {
lines[index] = styleAssistantMarkdownLine(line, zeroTheme.sayText)
}
lines = appendStreamingCursor(lines, width)
return strings.Join(lines, "\n")
}
func appendStreamingCursor(lines []string, width int) []string {
cursor := zeroTheme.accent.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
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.Width = 0
return fitStyledLine(commandArgumentHintComposerLine(input, argumentHint), width)
}
previews := validComposerPastePreviews(state, m.composerPastePreviews)
displayState := composerDisplayStateForPastePreviews(state, previews)
return renderComposerInput(input, displayState, width)
}
type composerVisualLine struct {
first bool
start int
end int
}
func renderComposerInput(input textinput.Model, state composerState, width int) string {
state = normalizeComposerState(state)
if width <= 0 {
return ""
}
if state.text == "" {
return fitStyledLine(input.View(), width)
}
segments := composerWrappedVisualLines(input, state, width)
cursorLine := composerCursorVisualLine(segments, state.cursor)
if len(segments) > composerMaxVisibleLines {
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
}
}
lines := make([]string, 0, len(segments))
for index, segment := range segments {
lines = append(lines, fitStyledLine(renderComposerVisualLine(input, state, segment, index == cursorLine), width))
}
return strings.Join(lines, "\n")
}
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) string {
runes := []rune(state.text)
prefix := composerVisualLinePrefix(input, segment.first)
textStyle := input.TextStyle.Inline(true)
if !hasCursor {
return prefix + textStyle.Render(string(runes[segment.start:segment.end]))
}
offset := clamp(state.cursor-segment.start, 0, segment.end-segment.start)
cursorIndex := segment.start + offset
before := string(runes[segment.start:cursorIndex])
cursor := input.Cursor
if cursorIndex < segment.end {
cursor.SetChar(string(runes[cursorIndex]))
after := string(runes[cursorIndex+1 : segment.end])
return prefix + textStyle.Render(before) + cursor.View() + textStyle.Render(after)
}
cursor.SetChar(" ")
return prefix + textStyle.Render(before) + cursor.View()
}
func composerVisualLinePrefix(input textinput.Model, first bool) string {
if first {
return input.PromptStyle.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) string {
hintRunes := []rune(argumentHint)
if len(hintRunes) == 0 {
return input.View()
}
input.Cursor.TextStyle = zeroTheme.faint
input.Cursor.SetChar(string(hintRunes[0]))
displayValue := strings.TrimRightFunc(input.Value(), unicode.IsSpace)
return input.PromptStyle.Render(input.Prompt) +
input.TextStyle.Inline(true).Render(displayValue) +
zeroTheme.faint.Render(" ") +
input.Cursor.View() +
zeroTheme.faint.Render(string(hintRunes[1:]))
}
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)+2)
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", width-2)+"╮"))
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")
}
// 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
}
}
func (m model) handlePermissionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch strings.ToLower(msg.String()) {
case "a":
return m.resolvePermission(permissionDecisionAllow)
case "d":
return m.resolvePermission(permissionDecisionDeny)
case "y":
return m.resolvePermission(permissionDecisionAlwaysAllow)
default:
return m, nil
}
}
func (m model) resolvePermission(decision permissionDecision) (tea.Model, tea.Cmd) {
pending := m.pendingPermission
if pending == nil {
return m, nil
}
if pending.decide != nil {
pending.decide(agent.PermissionDecision{
Action: decision,
Reason: permissionDecisionReason(decision),
})
}
m.pendingPermission = nil
return m, nil
}
// submitAskUserAnswer records the answer to the current question and advances to
// the next one; once every question is answered it delivers the full answer set.
func (m model) submitAskUserAnswer() (tea.Model, tea.Cmd) {
pending := m.pendingAskUser
if pending == nil {
return m, nil
}
pending.answers = append(pending.answers, strings.TrimSpace(m.input.Value()))
pending.index++
m.input.SetValue("")
if pending.index >= len(pending.request.Questions) {
return m.resolveAskUser(false)
}
return m, nil
}
// resolveAskUser delivers the collected answers (padding to one-per-question when
// cancelled early) and clears the prompt. cancelled answers stay empty so the
// loop can degrade to its best-assumption path without deadlocking.
func (m model) resolveAskUser(cancelled bool) (tea.Model, tea.Cmd) {
pending := m.pendingAskUser
if pending == nil {
return m, nil
}
answers := pending.answers
if cancelled {
// Record the question currently on screen as unanswered too.
m.input.SetValue("")
}
for len(answers) < len(pending.request.Questions) {
answers = append(answers, "")
}
if pending.answer != nil {
pending.answer(answers)
}
m.pendingAskUser = nil
m.clearSuggestions()
return m, nil
}
func permissionDecisionReason(decision permissionDecision) string {
switch decision {
case permissionDecisionAllow:
return "approved in TUI"
case permissionDecisionAlwaysAllow:
return "persistently approved 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>", "/effort <v>",
// or "/mode <name>".
func (m model) choosePicker() (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
picker := m.picker
if picker != nil && picker.kind == pickerModel {
m.clearModelPickerLoadState()
}
m.picker = nil
if picker == nil {
return m, nil
}
item, ok := picker.current()
if !ok {
return m, nil
}
switch picker.kind {
case pickerModel:
text := ""
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 pickerMode:
text := ""
m, text = m.handleModeCommand(item.Value)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
}
return m, nil
}
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()
command := parseCommand(input)
// 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
}
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:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionClear})
// 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 ·")
return m, nil
case commandExit:
// /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, tea.Quit
case commandTools:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.toolsText()})
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 commandProvider:
if strings.TrimSpace(command.text) == "" {
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
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 commandMode:
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.newModePicker(); picker != nil {
m.picker = picker
return m, nil
}
}
text := ""
m, text = m.handleModeCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandContext:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.contextText()})
return m, nil
case commandConfig:
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:
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.doctorText()})
return m, nil
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
}
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 commandSpec:
return m.handleSpecCommand(command.text)
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 commandTheme:
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: shellOnlyCommandText(command.name),
})
return m, nil
case commandInputStyle:
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendSystem,
text: shellOnlyCommandText(command.name),
})
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:
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 commandPrompt:
if intent, ok := detectMCPSetupIntent(command.text); ok {
return m.openMCPAddWizardFromIntent(intent), nil
}
return m.launchPrompt(command.text)
default:
return m, nil
}
}
// 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) {
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.",
})
return m, nil
}
// Prepend any staged PDF document text as a model-facing preamble. The
// visible transcript above keeps the user's clean prompt; the agent (and the
// recorded session, for resume fidelity) sees the document text first.
if preamble := m.consumePendingDocuments(); preamble != "" {
prompt = preamble + prompt
}
var err error
m, err = m.ensureActiveSession(prompt)
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "session create error: " + err.Error(),
})
} else {
agentPrompt := m.sessionPrompt(prompt)
m, err = m.appendSessionEvent(sessions.EventMessage, map[string]any{
"role": "user",
"content": prompt,
})
if err != nil {
m.transcript = reduceTranscript(m.transcript, transcriptAction{
kind: actionAppendError,
text: "session record error: " + err.Error(),
})
}
prompt = agentPrompt
}
// Re-check vision support against the CURRENT effective model at submit
// time, not just at /image attach time: the user may have attached on a
// vision model and then /model-switched to a non-vision one. If the active
// model can't accept images, drop them (with an inline notice mirroring
// exec's drop+warn wording) rather than sending them to a model that
// rejects them. Pending state is cleared either way below.
turnImages := m.pendingImages
if len(turnImages) > 0 && !modelSupportsVisionTUI(m.modelName) {
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.runID++
m.activeRunID = m.runID
m.runCancel = cancel
m.pending = true
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, turnImages), m.spinner.Tick)
}
func (m model) launchQueuedMessageIfReady() (model, tea.Cmd) {
if !m.hasQueuedMessage() || m.pending || m.exiting || m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil {
return m, nil
}
prompt := m.queuedMessage
m.queuedMessage = ""
return m.launchPrompt(prompt)
}
// historyRecallActive reports whether ↑/↓ should navigate previously submitted
// inputs: history exists and no modal surface owns the arrow keys.
func (m model) historyRecallActive() bool {
return len(m.inputHistory) > 0 &&
m.pendingAskUser == nil && m.pendingPermission == nil && m.pendingSpecReview == nil
}
// recallHistory steps through submitted inputs (-1 = older, +1 = newer),
// stashing the in-progress draft so stepping back past the newest recalled
// entry restores whatever was being typed.
func (m model) recallHistory(direction int) model {
if m.historyIdx == len(m.inputHistory) {
if direction > 0 {
return m
}
m.historyDraft = m.composerValue()
}
next := clamp(m.historyIdx+direction, 0, len(m.inputHistory))
if next == m.historyIdx {
return m
}
m.historyIdx = next
if next == len(m.inputHistory) {
m.input.SetValue(m.historyDraft)
} else {
m.input.SetValue(m.inputHistory[next])
}
m.input.CursorEnd()
m.resetComposerFromInput()
m.recomputeSuggestions()
return m
}
// rememberInput records a submitted composer value for ↑ recall and resets the
// navigation cursor past the newest entry.
func (m *model) rememberInput(value string) {
trimmed := strings.TrimSpace(value)
if trimmed != "" && (len(m.inputHistory) == 0 || m.inputHistory[len(m.inputHistory)-1] != trimmed) {
m.inputHistory = append(m.inputHistory, trimmed)
}
m.historyIdx = len(m.inputHistory)
m.historyDraft = ""
}
func (m *model) cancelRun() {
if m.runCancel != nil {
m.runCancel()
}
// 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 text := strings.TrimRight(m.streamingText, "\n"); strings.TrimSpace(text) != "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowAssistant, text: text})
}
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, text: "Run cancelled."})
}
if m.pending && m.activeSession.SessionID != "" {
if next, err := (*m).appendSessionEvent(sessions.EventError, map[string]any{
"message": "Run cancelled.",
}); err == nil {
*m = next
}
}
m.pending = false
m.runCancel = nil
m.activeRunID = 0
m.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 = ""
}
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()
toolCalls := 0
rows := []transcriptRow{}
usageEvents := []zeroruntime.Usage{}
sessionEvents := []pendingSessionEvent{}
usageModelID := m.modelName
var specReview *pendingSpecReviewPrompt
options := m.agentOptions
options.Registry = m.registry
if runOptions.registry != nil {
options.Registry = runOptions.registry
}
options.PermissionMode = m.permissionMode
if runOptions.permissionMode != "" {
options.PermissionMode = runOptions.permissionMode
}
if runOptions.systemPrompt != "" {
options.SystemPrompt = runOptions.systemPrompt
}
options.SessionID = m.activeSession.SessionID
options.ProviderName = m.providerName
options.Model = m.modelName
options.ReasoningEffort = string(m.reasoningEffort)
options.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. An unknown/custom model resolves to 0, leaving compaction off.
options.ContextWindow = 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 != "" {
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),
})
}
onText := options.OnText
options.OnText = func(delta string) {
m.sendAgentText(runID, delta)
if onText != nil {
onText(delta)
}
}
onReasoning := options.OnReasoning
options.OnReasoning = func(delta string) {
m.sendAgentText(runID, delta)
if onReasoning != nil {
onReasoning(delta)
}
}
onPermissionRequest := options.OnPermissionRequest
options.OnPermissionRequest = func(ctx context.Context, request agent.PermissionRequest) (agent.PermissionDecision, error) {
if onPermissionRequest != nil {
return onPermissionRequest(ctx, request)
}
if m.runtimeMessageSink == nil {
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: "permission prompt unavailable"}, nil
}
if m.notifier != nil {
m.notifier.Notify(notify.AwaitingInput, notify.DefaultMessage(notify.AwaitingInput))
}
decisionCh := make(chan agent.PermissionDecision, 1)
m.sendPermissionRequest(runID, request, func(decision agent.PermissionDecision) {
select {
case decisionCh <- decision:
default:
}
})
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventPermissionRequest,
Payload: request,
})
select {
case decision := <-decisionCh:
if strings.TrimSpace(decision.Reason) == "" {
decision.Reason = permissionDecisionReason(permissionDecision(decision.Action))
}
return decision, nil
case <-ctx.Done():
return agent.PermissionDecision{Action: agent.PermissionDecisionDeny, Reason: ctx.Err().Error()}, ctx.Err()
}
}
onAskUser := options.OnAskUser
options.OnAskUser = func(ctx context.Context, request agent.AskUserRequest) (agent.AskUserResponse, error) {
if onAskUser != nil {
return onAskUser(ctx, request)
}
if m.runtimeMessageSink == nil {
// No interactive surface: let the loop degrade gracefully.
return agent.AskUserResponse{}, fmt.Errorf("ask_user prompt unavailable")
}
// Only notify when there is actually something to answer — a request
// with no questions auto-resolves without ever prompting the user.
if m.notifier != nil && len(request.Questions) > 0 {
m.notifier.Notify(notify.AwaitingInput, notify.DefaultMessage(notify.AwaitingInput))
}
answerCh := make(chan []string, 1)
m.sendAskUserRequest(runID, request, func(answers []string) {
select {
case answerCh <- answers:
default:
}
})
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventMessage,
Payload: askUserSessionPayload(request),
})
select {
case answers := <-answerCh:
// Persist the answers next to the question event so the exchange
// is complete on /resume; rehydration renders them as a system note.
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventMessage,
Payload: map[string]any{
"role": "ask_user_answers",
"toolCallId": request.ToolCallID,
"answers": answers,
},
})
return agent.AskUserResponse{Answers: answers}, nil
case <-ctx.Done():
return agent.AskUserResponse{}, ctx.Err()
}
}
// 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{}
onToolCall := options.OnToolCall
options.OnToolCall = func(call agent.ToolCall) {
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,
}
rows = append(rows, row)
m.sendAgentRow(runID, row)
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)
}
}
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: result.Output,
runID: runID,
}
rows = append(rows, row)
m.sendAgentRow(runID, row)
toolPayload := map[string]any{
"toolCallId": result.ToolCallID,
"name": result.Name,
"status": string(result.Status),
"output": result.Output,
}
if result.Redacted {
toolPayload["redacted"] = true
}
if len(result.Meta) > 0 {
toolPayload["meta"] = result.Meta
}
if len(result.ChangedFiles) > 0 {
toolPayload["changedFiles"] = result.ChangedFiles
}
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventToolResult,
Payload: toolPayload,
})
if onToolResult != nil {
onToolResult(result)
}
}
onPermission := options.OnPermission
options.OnPermission = func(event agent.PermissionEvent) {
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: map[string]any{
"promptTokens": event.EffectiveInputTokens(),
"completionTokens": event.EffectiveOutputTokens(),
"totalTokens": event.TotalTokens(),
},
})
if onUsage != nil {
onUsage(event)
}
}
result, err := agent.Run(runCtx, prompt, m.provider, options)
if err != nil {
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
if runOptions.specDraft {
if result.StopReason != agent.StopReasonSpecReviewRequired || specReview == nil || specReview.SpecID == "" || specReview.SpecFilePath == "" {
err := fmt.Errorf("spec draft ended without submit_spec")
sessionEvents = append(sessionEvents, pendingSessionEvent{
Type: sessions.EventError,
Payload: map[string]any{"message": err.Error()},
})
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, err: err, turnTools: toolCalls, turnElapsed: m.now().Sub(started)}
}
return agentResponseMsg{runID: runID, rows: rows, usageEvents: usageEvents, usageModelID: usageModelID, sessionEvents: sessionEvents, specReview: specReview}
}
rows = append(rows, transcriptRow{
kind: rowAssistant,
text: result.FinalAnswer,
final: true,
turnTools: toolCalls,
turnElapsed: m.now().Sub(started),
})
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}
}
}
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})
}
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 {
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})
}
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))
}