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