v0.33.0 653c635

interactive_events.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close