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