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 // A manager may advance a terminal goal to the next persisted
184 // goal in the same mission. Do not let a late tool result resume
185 // a goal explicitly paused by the user.
186 if i.goalStatus != core.GoalPaused {
187 i.goalStatus = core.GoalActive
188 }
189 } else if i.goalStatus == core.GoalActive {
190 i.goalStatus = update.Status
191 // Terminal update_goal transitions settle the mission; an Esc
192 // return for the settled goal is inert by identity, drop it.
193 if update.Status == core.GoalDone || update.Status == core.GoalBlocked {
194 i.interruptedGoalReturn = nil
195 }
196 }
197 }
198 case core.EvPlanUpdate:
199 i.planCurrent, i.planTotal = planProgress(e.Update.Plan)
200 case core.EvUsage:
201 i.cumUsage = e.Cumulative
202 if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
203 i.lastCtxInput = contextUsed
204 }
205 case core.EvTurnRecovery:
206 // One bounded core continuation fired because the turn ended
207 // without a final answer. Surface it once so the extended work
208 // does not read as a stall. The inner continuation is owned by
209 // core, so no goal or scheduler follow-up is started here.
210 i.extNotes = append(i.extNotes, " "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Continuing: no final answer received."))
211 case core.EvTurnEnd:
212 if e.Stop == provider.StopAborted {
213 i.resetStreamingStateLocked()
214 i.statusErr = ""
215 i.statusOK = "cancelled"
216 return
217 }
218 if e.Stop == provider.StopLength {
219 // The model hit its output-token cap mid-response, so the
220 // reply (often a long write/edit) is truncated. Surface it
221 // explicitly, otherwise the turn just ends and reads like
222 // the UI gave up. The agent already requests the model's
223 // full MaxOutput budget, so this means the response genuinely
224 // exceeded that ceiling; ask the user to continue.
225 i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
226 i.statusOK = ""
227 return
228 }
229 // Don't surface mid-loop stream errors as a red banner here.
230 // EvTurnEnd fires after every step in a multi-step tool loop,
231 // so a transient 503 / network blip would briefly paint a red
232 // banner over the still-streaming chat before the agent loop
233 // either retries or exits. The final error (if any) is set by
234 // startTurnWithImages once Prompt() returns, and recoverable
235 // failures are routed to the rescue picker instead — which
236 // keeps the chat clean while the agent is working.
237 _ = e.Err
238 }
239}
240func (i *Interactive) WebSearchPolicyGeneration() uint64 {
241 return i.webSearchPolicyGeneration.Load()
242}
243func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
244 return i.applyAgentPromptConfig(ag, system, tools, 0, false)
245}
246func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
247 return i.applyAgentPromptConfig(ag, system, tools, generation, true)
248}
249func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
250 if ag == nil {
251 return nil, false
252 }
253 // Match the lock order used by dynamic tool mutations so a refresh
254 // cannot race a snapshot-and-replace operation that would reintroduce
255 // the old registry after the atomic prompt swap.
256 i.agentMu.Lock()
257 defer i.agentMu.Unlock()
258 i.mu.Lock()
259 defer i.mu.Unlock()
260 if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
261 return nil, false
262 }
263 if i.telegramBridge != nil {
264 // Telegram prompts arrive over an external messaging channel without a
265 // per-request confirmation surface. Keep the normal interactive
266 // registry from reintroducing web capabilities from the moment the
267 // bridge is attached, including while its startup handshake is in flight.
268 toolspkg.RemoveWebCapabilities(tools)
269 }
270 oldTools := ag.SetPromptConfig(system, tools)
271 _, webSearchAvailable := tools["web_search"]
272 i.setWebSearchAvailable(webSearchAvailable)
273 return oldTools, true
274}
275func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
276 // This runs inside the replacement commit, never during candidate builds.
277 i.resetCodexUsageLocked()
278 if ag == nil {
279 i.setWebSearchAvailable(false)
280 return
281 }
282 registry := ag.ToolsSnapshot()
283 if i.telegramBridge != nil {
284 // External Telegram prompts may arrive as soon as the replacement is
285 // published, so post-swap cleanup is too late.
286 toolspkg.RemoveWebCapabilities(registry)
287 ag.SetTools(registry)
288 }
289 _, webSearchAvailable := registry["web_search"]
290 i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
291}
292func (i *Interactive) DeferUntilIdle(fn func()) {
293 if fn == nil {
294 return
295 }
296 i.mu.Lock()
297 if i.busy {
298 i.pendingIdleWork = append(i.pendingIdleWork, fn)
299 i.mu.Unlock()
300 return
301 }
302 i.mu.Unlock()
303 fn()
304}
305func (i *Interactive) takePendingIdleWorkLocked() []func() {
306 work := i.pendingIdleWork
307 i.pendingIdleWork = nil
308 return work
309}
310func runPendingIdleWork(work []func()) {
311 for _, fn := range work {
312 if fn != nil {
313 fn()
314 }
315 }
316}
317func (i *Interactive) Agent() *core.Agent {
318 i.mu.Lock()
319 defer i.mu.Unlock()
320 return i.agent
321}
322func (i *Interactive) runReloadExt(ctx context.Context) {
323 if i.cfg.Extensions == nil {
324 i.mu.Lock()
325 i.statusErr = "no extension manager in this build"
326 i.mu.Unlock()
327 i.invalidate()
328 return
329 }
330 i.mu.Lock()
331 i.statusOK = "Reloading extensions..."
332 i.statusErr = ""
333 i.mu.Unlock()
334 i.invalidate()
335
336 go func() {
337 stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
338 msg, failed := formatReloadStatus(stats)
339 seq := i.setReloadStatus(msg, failed)
340 i.invalidate()
341 go i.dismissReloadStatus(ctx, seq, msg, failed)
342 }()
343}
344func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
345 return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
346}
347func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
348 resp := make(chan core.ConfirmDecision, 1)
349 if isBtwOrigin(call.Origin) {
350 req := &confirmRequest{
351 toolName: call.Name,
352 preview: call.Summary,
353 resp: resp,
354 returnToChild: true,
355 }
356 if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
357 i.confirmDialog.Enqueue(req)
358 }) {
359 return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
360 }
361 i.invalidate()
362 return <-resp
363 }
364 if call.ID != "" {
365 i.mu.Lock()
366 if tc, ok := i.toolCalls[call.ID]; ok {
367 tc.Args = call.Summary
368 tc.Preview = call.Content
369 i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
370 }
371 i.mu.Unlock()
372 }
373 i.confirmDialog.Enqueue(&confirmRequest{
374 toolName: call.Name,
375 preview: call.Summary,
376 resp: resp,
377 })
378 i.invalidate()
379 return <-resp
380}