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