package modes import ( "context" "errors" "fmt" "strings" "time" toolspkg "github.com/bnema/zut/packages/agent/tools" "github.com/bnema/zut/packages/core" "github.com/bnema/zut/packages/provider" "github.com/bnema/zut/packages/tui" ) func eventAffectsPresentation(ev core.AgentEvent) bool { switch ev.(type) { case core.EvToolProgress: return false default: return true } } func planProgress(plan []core.PlanStep) (current, total int) { total = len(plan) for idx, step := range plan { if step.Status == core.PlanInProgress { return idx + 1, total } } return 0, total } func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) { i.toolRenderRevision++ if i.toolRenderRevision == 0 { // Overflow is practically unreachable, but resetting the sequence must // also drop old revisions before they can alias new frames. if i.view != nil { i.view.InvalidateRenderCache() } i.toolRenderRevision = 1 } tc.Revision = i.toolRenderRevision } func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) { i.handleEvent(ev) // Progress payloads are still delivered to the core sink and applied // in order, but they have no interactive-visible representation. if eventAffectsPresentation(ev) { i.invalidate() } } func (i *Interactive) handleEvent(ev core.AgentEvent) { i.mu.Lock() defer i.mu.Unlock() i.activity.apply(ev) switch e := ev.(type) { case core.EvAssistantStart: // Fires at the top of every model request, including follow-ups // after tool use. The presenter anchors the transcript here so the // reply is never shown both as live text and as a finished message. i.startStreamLocked() // Clear the live tool-call overlay. Any tools from the // previous round are now fully folded into the transcript // (assistant tool_use block + tool role message with the // result), so keeping them in the overlay would duplicate // them in the view — once inside the finalised transcript // and once below the streaming block, with the streaming // summary sandwiched in between. The next EvToolUseStart // will populate fresh entries for this turn's tools. i.toolCalls = map[string]*tui.ToolCallView{} i.toolOrder = nil case core.EvTextDelta: // The pacer paints buffered text a few runes per tick so every // provider has the same typewriter feel regardless of chunk size. i.stream.Push(e.Delta) i.wakeStreamPacer() case core.EvAssistantMessage: // Side effects read the final message, which is complete even // while the pacer is still painting it. i.assistantMessageSideEffects(e.Message) // The presenter keeps the live text until the last rune is painted, // then reveals the transcript in the same frame. i.stream.Finish() case core.EvToolUseStart: // Live streaming: pre-create the view so the user sees the // tool call being composed in real time. Any subsequent // EvToolCall for the same ID updates the same struct (the // final parsed args + name are already known here). if _, exists := i.toolCalls[e.ID]; !exists { tc := &tui.ToolCallView{ ID: e.ID, Name: e.Name, Streaming: true, } i.bumpToolRevisionLocked(tc) i.toolCalls[e.ID] = tc i.toolOrder = append(i.toolOrder, e.ID) i.stream.Gate(e.ID) } case core.EvToolUseArgs: if tc, ok := i.toolCalls[e.ID]; ok { tc.RawJSONBuf += e.Delta i.bumpToolRevisionLocked(tc) // Refresh the live path as soon as it parses; used in // the header (write /Users/example/Desktop/demo.ts) // while the content is still streaming. if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok { tc.LivePath = p } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok { tc.LivePath = p } } case core.EvToolUseEnd: if tc, ok := i.toolCalls[e.ID]; ok { tc.Streaming = false i.bumpToolRevisionLocked(tc) } case core.EvToolCall: // If we already pre-created the view during streaming, just // refresh the final Args summary. Otherwise create a new one // (non-streaming providers or legacy paths). if tc, ok := i.toolCalls[e.ID]; ok { tc.Args = tui.ShortArgs(e.Name, e.Args) i.bumpToolRevisionLocked(tc) if tc.RawJSONBuf == "" { tc.RawJSONBuf = string(e.Args) } tc.Streaming = false } else { tc := &tui.ToolCallView{ ID: e.ID, Name: e.Name, Args: tui.ShortArgs(e.Name, e.Args), RawJSONBuf: string(e.Args), } i.bumpToolRevisionLocked(tc) i.toolCalls[e.ID] = tc i.toolOrder = append(i.toolOrder, e.ID) i.stream.Gate(e.ID) } case core.EvToolResult: if tc, ok := i.toolCalls[e.ID]; ok { tc.Done = true tc.Error = e.Result.IsError tc.Preview = "" var text strings.Builder for _, c := range e.Result.Content { if tb, ok := c.(provider.TextBlock); ok { if text.Len() > 0 { text.WriteString("\n") } text.WriteString(tb.Text) } } tc.Result = text.String() i.bumpToolRevisionLocked(tc) } if update, ok := toolspkg.GoalUpdateFromResult(e.Result); ok { if update.Status == core.GoalSuperseded && i.cfg.CurrentGoal != nil { if current := i.cfg.CurrentGoal(); current != nil { i.goalStatus = current.Status } // A superseding replacement owns its own lifecycle; a pending // Esc return for the replaced goal must not fire. i.interruptedGoalReturn = nil } else if update.Status == core.GoalActive { // Persistence runs before this event. Reflect a manager resume or // newly active goal, but do not let a stale result undo a user pause. if i.cfg.CurrentGoal != nil { if current := i.cfg.CurrentGoal(); current != nil { i.goalStatus = current.Status } } else if i.goalStatus != core.GoalPaused { i.goalStatus = core.GoalActive } } else if i.goalStatus == core.GoalActive { i.goalStatus = update.Status // Paused and terminal goals must not inherit an Esc-armed return. if update.Status == core.GoalPaused || update.Status == core.GoalDone || update.Status == core.GoalBlocked { i.interruptedGoalReturn = nil } } } case core.EvPlanUpdate: i.planCurrent, i.planTotal = planProgress(e.Update.Plan) // Copy-on-write: the live view may still reference the previous map while // a render reads it. Replacing the map instead of mutating it in place // keeps every published map immutable, so no reader can race a map write. next := make(map[string]core.PlanUpdate, len(i.planSnapshots)+1) for id, update := range i.planSnapshots { next[id] = update } next[e.CallID] = e.Update i.planSnapshots = next // A checklist can arrive after the tool-result message was first cached. // Bumping the revision invalidates those rows through msgVisualKey. i.planRevision++ case core.EvUsage: i.cumUsage = e.Cumulative if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 { i.lastCtxInput = contextUsed } case core.EvTurnRecovery: // One bounded core continuation fired because the turn ended // without a final answer. Surface it once so the extended work // does not read as a stall. The inner continuation is owned by // core, so no goal or scheduler follow-up is started here. i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Continuing: no final answer received.")) case core.EvRepetitionGuard: switch e.Stage { case core.RepetitionGuardWarning: i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Repeated content detected; trying a different approach.")) case core.RepetitionGuardStopped: i.statusErr = fmt.Sprintf("stopped a repetitive loop after %d identical attempts", e.Count) i.statusOK = "" } case core.EvTurnEnd: // Cancellation usually surfaces as a context error rather than // StopAborted. Either way, stop typing at once: text that never // reached the transcript must not keep painting and then vanish. if e.Stop == provider.StopAborted || errors.Is(e.Err, context.Canceled) { i.stream.Reset() i.statusErr = "" i.statusOK = "cancelled" return } // A request can end without an assistant message (empty reply, // extension suppression, provider error). Close the stream so the // transcript anchor never outlives its request. i.stream.Finish() if e.Stop == provider.StopLength { // The model hit its output-token cap mid-response, so the // reply (often a long write/edit) is truncated. Surface it // explicitly, otherwise the turn just ends and reads like // the UI gave up. The agent already requests the model's // full MaxOutput budget, so this means the response genuinely // exceeded that ceiling; ask the user to continue. i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue" i.statusOK = "" return } // Don't surface mid-loop stream errors as a red banner here. // EvTurnEnd fires after every step in a multi-step tool loop, // so a transient 503 / network blip would briefly paint a red // banner over the still-streaming chat before the agent loop // either retries or exits. The final error (if any) is set by // startTurnWithImages once Prompt() returns, and recoverable // failures are routed to the rescue picker instead — which // keeps the chat clean while the agent is working. _ = e.Err } } func (i *Interactive) WebSearchPolicyGeneration() uint64 { return i.webSearchPolicyGeneration.Load() } func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) { return i.applyAgentPromptConfig(ag, system, tools, 0, false) } func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) { return i.applyAgentPromptConfig(ag, system, tools, generation, true) } func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) { if ag == nil { return nil, false } // Match the lock order used by dynamic tool mutations so a refresh // cannot race a snapshot-and-replace operation that would reintroduce // the old registry after the atomic prompt swap. i.agentMu.Lock() defer i.agentMu.Unlock() i.mu.Lock() defer i.mu.Unlock() if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation { return nil, false } if i.telegramBridge != nil { // Telegram prompts arrive over an external messaging channel without a // per-request confirmation surface. Keep the normal interactive // registry from reintroducing web capabilities from the moment the // bridge is attached, including while its startup handshake is in flight. toolspkg.RemoveWebCapabilities(tools) } oldTools := ag.SetPromptConfig(system, tools) _, webSearchAvailable := tools["web_search"] i.setWebSearchAvailable(webSearchAvailable) return oldTools, true } func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) { // This runs inside the replacement commit, never during candidate builds. i.resetCodexUsageLocked() // A draining stream is anchored in the old agent's transcript. i.stream.Reset() if ag == nil { i.setWebSearchAvailable(false) return } registry := ag.ToolsSnapshot() if i.telegramBridge != nil { // External Telegram prompts may arrive as soon as the replacement is // published, so post-swap cleanup is too late. toolspkg.RemoveWebCapabilities(registry) ag.SetTools(registry) } _, webSearchAvailable := registry["web_search"] i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil) } func (i *Interactive) DeferUntilIdle(fn func()) { if fn == nil { return } i.mu.Lock() if i.busy { i.pendingIdleWork = append(i.pendingIdleWork, fn) i.mu.Unlock() return } i.mu.Unlock() fn() } func (i *Interactive) takePendingIdleWorkLocked() []func() { work := i.pendingIdleWork i.pendingIdleWork = nil return work } func runPendingIdleWork(work []func()) { for _, fn := range work { if fn != nil { fn() } } } func (i *Interactive) Agent() *core.Agent { i.mu.Lock() defer i.mu.Unlock() return i.agent } func (i *Interactive) runReloadExt(ctx context.Context) { if i.cfg.Extensions == nil { i.mu.Lock() i.statusErr = "no extension manager in this build" i.mu.Unlock() i.invalidate() return } i.mu.Lock() i.statusOK = "Reloading extensions..." i.statusErr = "" i.mu.Unlock() i.invalidate() go func() { stats := i.cfg.Extensions.Reload(ctx, 2*time.Second) msg, failed := formatReloadStatus(stats) seq := i.setReloadStatus(msg, failed) i.invalidate() go i.dismissReloadStatus(ctx, seq, msg, failed) }() } func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision { return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview}) } func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision { resp := make(chan core.ConfirmDecision, 1) if isBtwOrigin(call.Origin) { req := &confirmRequest{ toolName: call.Name, preview: call.Summary, resp: resp, returnToChild: true, } if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() { i.confirmDialog.Enqueue(req) }) { return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"} } i.invalidate() return <-resp } if call.ID != "" { i.mu.Lock() if tc, ok := i.toolCalls[call.ID]; ok { tc.Args = call.Summary tc.Preview = call.Content i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model} } i.mu.Unlock() } i.confirmDialog.Enqueue(&confirmRequest{ toolName: call.Name, preview: call.Summary, resp: resp, }) i.invalidate() return <-resp }