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