1package modes
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8 "time"
9
10 toolspkg "github.com/bnema/zut/packages/agent/tools"
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13 "github.com/bnema/zut/packages/tui"
14)
15
16func eventAffectsPresentation(ev core.AgentEvent) bool {
17 switch ev.(type) {
18 case core.EvToolProgress:
19 return false
20 default:
21 return true
22 }
23}
24
25func planProgress(plan []core.PlanStep) (current, total int) {
26 total = len(plan)
27 for idx, step := range plan {
28 if step.Status == core.PlanInProgress {
29 return idx + 1, total
30 }
31 }
32 return 0, total
33}
34
35func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
36 i.toolRenderRevision++
37 if i.toolRenderRevision == 0 {
38 // Overflow is practically unreachable, but resetting the sequence must
39 // also drop old revisions before they can alias new frames.
40 if i.view != nil {
41 i.view.InvalidateRenderCache()
42 }
43 i.toolRenderRevision = 1
44 }
45 tc.Revision = i.toolRenderRevision
46}
47func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
48 i.handleEvent(ev)
49 // Progress payloads are still delivered to the core sink and applied
50 // in order, but they have no interactive-visible representation.
51 if eventAffectsPresentation(ev) {
52 i.invalidate()
53 }
54}
55func (i *Interactive) handleEvent(ev core.AgentEvent) {
56 i.mu.Lock()
57 defer i.mu.Unlock()
58 i.activity.apply(ev)
59 switch e := ev.(type) {
60 case core.EvAssistantStart:
61 // Fires at the top of every model request, including follow-ups
62 // after tool use. The presenter anchors the transcript here so the
63 // reply is never shown both as live text and as a finished message.
64 i.startStreamLocked()
65 // Clear the live tool-call overlay. Any tools from the
66 // previous round are now fully folded into the transcript
67 // (assistant tool_use block + tool role message with the
68 // result), so keeping them in the overlay would duplicate
69 // them in the view — once inside the finalised transcript
70 // and once below the streaming block, with the streaming
71 // summary sandwiched in between. The next EvToolUseStart
72 // will populate fresh entries for this turn's tools.
73 i.toolCalls = map[string]*tui.ToolCallView{}
74 i.toolOrder = nil
75 case core.EvTextDelta:
76 // The pacer paints buffered text a few runes per tick so every
77 // provider has the same typewriter feel regardless of chunk size.
78 i.stream.Push(e.Delta)
79 i.wakeStreamPacer()
80 case core.EvAssistantMessage:
81 // Side effects read the final message, which is complete even
82 // while the pacer is still painting it.
83 i.assistantMessageSideEffects(e.Message)
84 // The presenter keeps the live text until the last rune is painted,
85 // then reveals the transcript in the same frame.
86 i.stream.Finish()
87 case core.EvToolUseStart:
88 // Live streaming: pre-create the view so the user sees the
89 // tool call being composed in real time. Any subsequent
90 // EvToolCall for the same ID updates the same struct (the
91 // final parsed args + name are already known here).
92 if _, exists := i.toolCalls[e.ID]; !exists {
93 tc := &tui.ToolCallView{
94 ID: e.ID,
95 Name: e.Name,
96 Streaming: true,
97 }
98 i.bumpToolRevisionLocked(tc)
99 i.toolCalls[e.ID] = tc
100 i.toolOrder = append(i.toolOrder, e.ID)
101 i.stream.Gate(e.ID)
102 }
103 case core.EvToolUseArgs:
104 if tc, ok := i.toolCalls[e.ID]; ok {
105 tc.RawJSONBuf += e.Delta
106 i.bumpToolRevisionLocked(tc)
107 // Refresh the live path as soon as it parses; used in
108 // the header (write /Users/example/Desktop/demo.ts)
109 // while the content is still streaming.
110 if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
111 tc.LivePath = p
112 } else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
113 tc.LivePath = p
114 }
115 }
116 case core.EvToolUseEnd:
117 if tc, ok := i.toolCalls[e.ID]; ok {
118 tc.Streaming = false
119 i.bumpToolRevisionLocked(tc)
120 }
121 case core.EvToolCall:
122 // If we already pre-created the view during streaming, just
123 // refresh the final Args summary. Otherwise create a new one
124 // (non-streaming providers or legacy paths).
125 if tc, ok := i.toolCalls[e.ID]; ok {
126 tc.Args = tui.ShortArgs(e.Name, e.Args)
127 i.bumpToolRevisionLocked(tc)
128 if tc.RawJSONBuf == "" {
129 tc.RawJSONBuf = string(e.Args)
130 }
131 tc.Streaming = false
132 } else {
133 tc := &tui.ToolCallView{
134 ID: e.ID,
135 Name: e.Name,
136 Args: tui.ShortArgs(e.Name, e.Args),
137 RawJSONBuf: string(e.Args),
138 }
139 i.bumpToolRevisionLocked(tc)
140 i.toolCalls[e.ID] = tc
141 i.toolOrder = append(i.toolOrder, e.ID)
142 i.stream.Gate(e.ID)
143 }
144 case core.EvToolResult:
145 if tc, ok := i.toolCalls[e.ID]; ok {
146 tc.Done = true
147 tc.Error = e.Result.IsError
148 tc.Preview = ""
149 var text strings.Builder
150 for _, c := range e.Result.Content {
151 if tb, ok := c.(provider.TextBlock); ok {
152 if text.Len() > 0 {
153 text.WriteString("\n")
154 }
155 text.WriteString(tb.Text)
156 }
157 }
158 tc.Result = text.String()
159 i.bumpToolRevisionLocked(tc)
160 }
161 if update, ok := toolspkg.GoalUpdateFromResult(e.Result); ok {
162 if update.Status == core.GoalSuperseded && i.cfg.CurrentGoal != nil {
163 if current := i.cfg.CurrentGoal(); current != nil {
164 i.goalStatus = current.Status
165 }
166 // A superseding replacement owns its own lifecycle; a pending
167 // Esc return for the replaced goal must not fire.
168 i.interruptedGoalReturn = nil
169 } else if update.Status == core.GoalActive {
170 // Persistence runs before this event. Reflect a manager resume or
171 // newly active goal, but do not let a stale result undo a user pause.
172 if i.cfg.CurrentGoal != nil {
173 if current := i.cfg.CurrentGoal(); current != nil {
174 i.goalStatus = current.Status
175 }
176 } else if i.goalStatus != core.GoalPaused {
177 i.goalStatus = core.GoalActive
178 }
179 } else if i.goalStatus == core.GoalActive {
180 i.goalStatus = update.Status
181 // Paused and terminal goals must not inherit an Esc-armed return.
182 if update.Status == core.GoalPaused || update.Status == core.GoalDone || update.Status == core.GoalBlocked {
183 i.interruptedGoalReturn = nil
184 }
185 }
186 }
187 case core.EvPlanUpdate:
188 i.planCurrent, i.planTotal = planProgress(e.Update.Plan)
189 // Copy-on-write: the live view may still reference the previous map while
190 // a render reads it. Replacing the map instead of mutating it in place
191 // keeps every published map immutable, so no reader can race a map write.
192 next := make(map[string]core.PlanUpdate, len(i.planSnapshots)+1)
193 for id, update := range i.planSnapshots {
194 next[id] = update
195 }
196 next[e.CallID] = e.Update
197 i.planSnapshots = next
198 // A checklist can arrive after the tool-result message was first cached.
199 // Bumping the revision invalidates those rows through msgVisualKey.
200 i.planRevision++
201 case core.EvUsage:
202 i.cumUsage = e.Cumulative
203 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
204 i.lastCtxInput = contextUsed
205 }
206 case core.EvTurnRecovery:
207 // One bounded core continuation fired because the turn ended
208 // without a final answer. Surface it once so the extended work
209 // does not read as a stall. The inner continuation is owned by
210 // core, so no goal or scheduler follow-up is started here.
211 i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Continuing: no final answer received."))
212 case core.EvRepetitionGuard:
213 switch e.Stage {
214 case core.RepetitionGuardWarning:
215 i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Repeated content detected; trying a different approach."))
216 case core.RepetitionGuardStopped:
217 i.statusErr = fmt.Sprintf("stopped a repetitive loop after %d identical attempts", e.Count)
218 i.statusOK = ""
219 }
220 case core.EvTurnEnd:
221 // Cancellation usually surfaces as a context error rather than
222 // StopAborted. Either way, stop typing at once: text that never
223 // reached the transcript must not keep painting and then vanish.
224 if e.Stop == provider.StopAborted || errors.Is(e.Err, context.Canceled) {
225 i.stream.Reset()
226 i.statusErr = ""
227 i.statusOK = "cancelled"
228 return
229 }
230 // A request can end without an assistant message (empty reply,
231 // extension suppression, provider error). Close the stream so the
232 // transcript anchor never outlives its request.
233 i.stream.Finish()
234 if e.Stop == provider.StopLength {
235 // The model hit its output-token cap mid-response, so the
236 // reply (often a long write/edit) is truncated. Surface it
237 // explicitly, otherwise the turn just ends and reads like
238 // the UI gave up. The agent already requests the model's
239 // full MaxOutput budget, so this means the response genuinely
240 // exceeded that ceiling; ask the user to continue.
241 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
242 i.statusOK = ""
243 return
244 }
245 // Don't surface mid-loop stream errors as a red banner here.
246 // EvTurnEnd fires after every step in a multi-step tool loop,
247 // so a transient 503 / network blip would briefly paint a red
248 // banner over the still-streaming chat before the agent loop
249 // either retries or exits. The final error (if any) is set by
250 // startTurnWithImages once Prompt() returns, and recoverable
251 // failures are routed to the rescue picker instead — which
252 // keeps the chat clean while the agent is working.
253 _ = e.Err
254 }
255}
256func (i *Interactive) WebSearchPolicyGeneration() uint64 {
257 return i.webSearchPolicyGeneration.Load()
258}
259func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
260 return i.applyAgentPromptConfig(ag, system, tools, 0, false)
261}
262func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
263 return i.applyAgentPromptConfig(ag, system, tools, generation, true)
264}
265func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
266 if ag == nil {
267 return nil, false
268 }
269 // Match the lock order used by dynamic tool mutations so a refresh
270 // cannot race a snapshot-and-replace operation that would reintroduce
271 // the old registry after the atomic prompt swap.
272 i.agentMu.Lock()
273 defer i.agentMu.Unlock()
274 i.mu.Lock()
275 defer i.mu.Unlock()
276 if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
277 return nil, false
278 }
279 if i.telegramBridge != nil {
280 // Telegram prompts arrive over an external messaging channel without a
281 // per-request confirmation surface. Keep the normal interactive
282 // registry from reintroducing web capabilities from the moment the
283 // bridge is attached, including while its startup handshake is in flight.
284 toolspkg.RemoveWebCapabilities(tools)
285 }
286 oldTools := ag.SetPromptConfig(system, tools)
287 _, webSearchAvailable := tools["web_search"]
288 i.setWebSearchAvailable(webSearchAvailable)
289 return oldTools, true
290}
291func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
292 // This runs inside the replacement commit, never during candidate builds.
293 i.resetCodexUsageLocked()
294 // A draining stream is anchored in the old agent's transcript.
295 i.stream.Reset()
296 if ag == nil {
297 i.setWebSearchAvailable(false)
298 return
299 }
300 registry := ag.ToolsSnapshot()
301 if i.telegramBridge != nil {
302 // External Telegram prompts may arrive as soon as the replacement is
303 // published, so post-swap cleanup is too late.
304 toolspkg.RemoveWebCapabilities(registry)
305 ag.SetTools(registry)
306 }
307 _, webSearchAvailable := registry["web_search"]
308 i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
309}
310func (i *Interactive) DeferUntilIdle(fn func()) {
311 if fn == nil {
312 return
313 }
314 i.mu.Lock()
315 if i.busy {
316 i.pendingIdleWork = append(i.pendingIdleWork, fn)
317 i.mu.Unlock()
318 return
319 }
320 i.mu.Unlock()
321 fn()
322}
323func (i *Interactive) takePendingIdleWorkLocked() []func() {
324 work := i.pendingIdleWork
325 i.pendingIdleWork = nil
326 return work
327}
328func runPendingIdleWork(work []func()) {
329 for _, fn := range work {
330 if fn != nil {
331 fn()
332 }
333 }
334}
335func (i *Interactive) Agent() *core.Agent {
336 i.mu.Lock()
337 defer i.mu.Unlock()
338 return i.agent
339}
340func (i *Interactive) runReloadExt(ctx context.Context) {
341 if i.cfg.Extensions == nil {
342 i.mu.Lock()
343 i.statusErr = "no extension manager in this build"
344 i.mu.Unlock()
345 i.invalidate()
346 return
347 }
348 i.mu.Lock()
349 i.statusOK = "Reloading extensions..."
350 i.statusErr = ""
351 i.mu.Unlock()
352 i.invalidate()
353
354 go func() {
355 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
356 msg, failed := formatReloadStatus(stats)
357 seq := i.setReloadStatus(msg, failed)
358 i.invalidate()
359 go i.dismissReloadStatus(ctx, seq, msg, failed)
360 }()
361}
362func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
363 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
364}
365func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
366 resp := make(chan core.ConfirmDecision, 1)
367 if isBtwOrigin(call.Origin) {
368 req := &confirmRequest{
369 toolName: call.Name,
370 preview: call.Summary,
371 resp: resp,
372 returnToChild: true,
373 }
374 if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
375 i.confirmDialog.Enqueue(req)
376 }) {
377 return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
378 }
379 i.invalidate()
380 return <-resp
381 }
382 if call.ID != "" {
383 i.mu.Lock()
384 if tc, ok := i.toolCalls[call.ID]; ok {
385 tc.Args = call.Summary
386 tc.Preview = call.Content
387 i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
388 }
389 i.mu.Unlock()
390 }
391 i.confirmDialog.Enqueue(&confirmRequest{
392 toolName: call.Name,
393 preview: call.Summary,
394 resp: resp,
395 })
396 i.invalidate()
397 return <-resp
398}