v0.44.0 efd47b3

interactive_events.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"strings"
8	"time"
9
10	toolspkg "github.com/bnema/zut/packages/agent/tools"
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13	"github.com/bnema/zut/packages/tui"
14)
15
16func eventAffectsPresentation(ev core.AgentEvent) bool {
17	switch ev.(type) {
18	case core.EvToolProgress:
19		return false
20	default:
21		return true
22	}
23}
24
25func planProgress(plan []core.PlanStep) (current, total int) {
26	total = len(plan)
27	for idx, step := range plan {
28		if step.Status == core.PlanInProgress {
29			return idx + 1, total
30		}
31	}
32	return 0, total
33}
34
35func (i *Interactive) bumpToolRevisionLocked(tc *tui.ToolCallView) {
36	i.toolRenderRevision++
37	if i.toolRenderRevision == 0 {
38		// Overflow is practically unreachable, but resetting the sequence must
39		// also drop old revisions before they can alias new frames.
40		if i.view != nil {
41			i.view.InvalidateRenderCache()
42		}
43		i.toolRenderRevision = 1
44	}
45	tc.Revision = i.toolRenderRevision
46}
47func (i *Interactive) handleEventForPresentation(ev core.AgentEvent) {
48	i.handleEvent(ev)
49	// Progress payloads are still delivered to the core sink and applied
50	// in order, but they have no interactive-visible representation.
51	if eventAffectsPresentation(ev) {
52		i.invalidate()
53	}
54}
55func (i *Interactive) handleEvent(ev core.AgentEvent) {
56	i.mu.Lock()
57	defer i.mu.Unlock()
58	i.activity.apply(ev)
59	switch e := ev.(type) {
60	case core.EvAssistantStart:
61		// Fires at the top of every model request, including follow-ups
62		// after tool use. The presenter anchors the transcript here so the
63		// reply is never shown both as live text and as a finished message.
64		i.startStreamLocked()
65		// Clear the live tool-call overlay. Any tools from the
66		// previous round are now fully folded into the transcript
67		// (assistant tool_use block + tool role message with the
68		// result), so keeping them in the overlay would duplicate
69		// them in the view — once inside the finalised transcript
70		// and once below the streaming block, with the streaming
71		// summary sandwiched in between. The next EvToolUseStart
72		// will populate fresh entries for this turn's tools.
73		i.toolCalls = map[string]*tui.ToolCallView{}
74		i.toolOrder = nil
75	case core.EvTextDelta:
76		// The pacer paints buffered text a few runes per tick so every
77		// provider has the same typewriter feel regardless of chunk size.
78		i.stream.Push(e.Delta)
79		i.wakeStreamPacer()
80	case core.EvAssistantMessage:
81		// Side effects read the final message, which is complete even
82		// while the pacer is still painting it.
83		i.assistantMessageSideEffects(e.Message)
84		// The presenter keeps the live text until the last rune is painted,
85		// then reveals the transcript in the same frame.
86		i.stream.Finish()
87	case core.EvToolUseStart:
88		// Live streaming: pre-create the view so the user sees the
89		// tool call being composed in real time. Any subsequent
90		// EvToolCall for the same ID updates the same struct (the
91		// final parsed args + name are already known here).
92		if _, exists := i.toolCalls[e.ID]; !exists {
93			tc := &tui.ToolCallView{
94				ID:        e.ID,
95				Name:      e.Name,
96				Streaming: true,
97			}
98			i.bumpToolRevisionLocked(tc)
99			i.toolCalls[e.ID] = tc
100			i.toolOrder = append(i.toolOrder, e.ID)
101			i.stream.Gate(e.ID)
102		}
103	case core.EvToolUseArgs:
104		if tc, ok := i.toolCalls[e.ID]; ok {
105			tc.RawJSONBuf += e.Delta
106			i.bumpToolRevisionLocked(tc)
107			// Refresh the live path as soon as it parses; used in
108			// the header (write /Users/example/Desktop/demo.ts)
109			// while the content is still streaming.
110			if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "path"); pok {
111				tc.LivePath = p
112			} else if p, pok, _ := tui.ExtractPartialStringField(tc.RawJSONBuf, "file_path"); pok {
113				tc.LivePath = p
114			}
115		}
116	case core.EvToolUseEnd:
117		if tc, ok := i.toolCalls[e.ID]; ok {
118			tc.Streaming = false
119			i.bumpToolRevisionLocked(tc)
120		}
121	case core.EvToolCall:
122		// If we already pre-created the view during streaming, just
123		// refresh the final Args summary. Otherwise create a new one
124		// (non-streaming providers or legacy paths).
125		if tc, ok := i.toolCalls[e.ID]; ok {
126			tc.Args = tui.ShortArgs(e.Name, e.Args)
127			i.bumpToolRevisionLocked(tc)
128			if tc.RawJSONBuf == "" {
129				tc.RawJSONBuf = string(e.Args)
130			}
131			tc.Streaming = false
132		} else {
133			tc := &tui.ToolCallView{
134				ID:         e.ID,
135				Name:       e.Name,
136				Args:       tui.ShortArgs(e.Name, e.Args),
137				RawJSONBuf: string(e.Args),
138			}
139			i.bumpToolRevisionLocked(tc)
140			i.toolCalls[e.ID] = tc
141			i.toolOrder = append(i.toolOrder, e.ID)
142			i.stream.Gate(e.ID)
143		}
144	case core.EvToolResult:
145		if tc, ok := i.toolCalls[e.ID]; ok {
146			tc.Done = true
147			tc.Error = e.Result.IsError
148			tc.Preview = ""
149			var text strings.Builder
150			for _, c := range e.Result.Content {
151				if tb, ok := c.(provider.TextBlock); ok {
152					if text.Len() > 0 {
153						text.WriteString("\n")
154					}
155					text.WriteString(tb.Text)
156				}
157			}
158			tc.Result = text.String()
159			i.bumpToolRevisionLocked(tc)
160		}
161		if update, ok := toolspkg.GoalUpdateFromResult(e.Result); ok {
162			if update.Status == core.GoalSuperseded && i.cfg.CurrentGoal != nil {
163				if current := i.cfg.CurrentGoal(); current != nil {
164					i.goalStatus = current.Status
165				}
166				// A superseding replacement owns its own lifecycle; a pending
167				// Esc return for the replaced goal must not fire.
168				i.interruptedGoalReturn = nil
169			} else if update.Status == core.GoalActive {
170				// Persistence runs before this event. Reflect a manager resume or
171				// newly active goal, but do not let a stale result undo a user pause.
172				if i.cfg.CurrentGoal != nil {
173					if current := i.cfg.CurrentGoal(); current != nil {
174						i.goalStatus = current.Status
175					}
176				} else if i.goalStatus != core.GoalPaused {
177					i.goalStatus = core.GoalActive
178				}
179			} else if i.goalStatus == core.GoalActive {
180				i.goalStatus = update.Status
181				// Paused and terminal goals must not inherit an Esc-armed return.
182				if update.Status == core.GoalPaused || update.Status == core.GoalDone || update.Status == core.GoalBlocked {
183					i.interruptedGoalReturn = nil
184				}
185			}
186		}
187	case core.EvPlanUpdate:
188		i.planCurrent, i.planTotal = planProgress(e.Update.Plan)
189		// Copy-on-write: the live view may still reference the previous map while
190		// a render reads it. Replacing the map instead of mutating it in place
191		// keeps every published map immutable, so no reader can race a map write.
192		next := make(map[string]core.PlanUpdate, len(i.planSnapshots)+1)
193		for id, update := range i.planSnapshots {
194			next[id] = update
195		}
196		next[e.CallID] = e.Update
197		i.planSnapshots = next
198		// A checklist can arrive after the tool-result message was first cached.
199		// Bumping the revision invalidates those rows through msgVisualKey.
200		i.planRevision++
201	case core.EvUsage:
202		i.cumUsage = e.Cumulative
203		if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 {
204			i.lastCtxInput = contextUsed
205		}
206	case core.EvTurnRecovery:
207		// One bounded core continuation fired because the turn ended
208		// without a final answer. Surface it once so the extended work
209		// does not read as a stall. The inner continuation is owned by
210		// core, so no goal or scheduler follow-up is started here.
211		i.extNotes = append(i.extNotes, "  "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Continuing: no final answer received."))
212	case core.EvRepetitionGuard:
213		switch e.Stage {
214		case core.RepetitionGuardWarning:
215			i.extNotes = append(i.extNotes, "  "+i.cfg.Theme.FGColor(i.cfg.Theme.Tool, "Repeated content detected; trying a different approach."))
216		case core.RepetitionGuardStopped:
217			i.statusErr = fmt.Sprintf("stopped a repetitive loop after %d identical attempts", e.Count)
218			i.statusOK = ""
219		}
220	case core.EvTurnEnd:
221		// Cancellation usually surfaces as a context error rather than
222		// StopAborted. Either way, stop typing at once: text that never
223		// reached the transcript must not keep painting and then vanish.
224		if e.Stop == provider.StopAborted || errors.Is(e.Err, context.Canceled) {
225			i.stream.Reset()
226			i.statusErr = ""
227			i.statusOK = "cancelled"
228			return
229		}
230		// A request can end without an assistant message (empty reply,
231		// extension suppression, provider error). Close the stream so the
232		// transcript anchor never outlives its request.
233		i.stream.Finish()
234		if e.Stop == provider.StopLength {
235			// The model hit its output-token cap mid-response, so the
236			// reply (often a long write/edit) is truncated. Surface it
237			// explicitly, otherwise the turn just ends and reads like
238			// the UI gave up. The agent already requests the model's
239			// full MaxOutput budget, so this means the response genuinely
240			// exceeded that ceiling; ask the user to continue.
241			i.statusErr = "response hit the model's output-token limit and was cut off, ask it to continue"
242			i.statusOK = ""
243			return
244		}
245		// Don't surface mid-loop stream errors as a red banner here.
246		// EvTurnEnd fires after every step in a multi-step tool loop,
247		// so a transient 503 / network blip would briefly paint a red
248		// banner over the still-streaming chat before the agent loop
249		// either retries or exits. The final error (if any) is set by
250		// startTurnWithImages once Prompt() returns, and recoverable
251		// failures are routed to the rescue picker instead — which
252		// keeps the chat clean while the agent is working.
253		_ = e.Err
254	}
255}
256func (i *Interactive) WebSearchPolicyGeneration() uint64 {
257	return i.webSearchPolicyGeneration.Load()
258}
259func (i *Interactive) ApplyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry) (core.Registry, bool) {
260	return i.applyAgentPromptConfig(ag, system, tools, 0, false)
261}
262func (i *Interactive) ApplyAgentPromptConfigAtWebSearchGeneration(ag *core.Agent, system string, tools core.Registry, generation uint64) (core.Registry, bool) {
263	return i.applyAgentPromptConfig(ag, system, tools, generation, true)
264}
265func (i *Interactive) applyAgentPromptConfig(ag *core.Agent, system string, tools core.Registry, generation uint64, checkGeneration bool) (core.Registry, bool) {
266	if ag == nil {
267		return nil, false
268	}
269	// Match the lock order used by dynamic tool mutations so a refresh
270	// cannot race a snapshot-and-replace operation that would reintroduce
271	// the old registry after the atomic prompt swap.
272	i.agentMu.Lock()
273	defer i.agentMu.Unlock()
274	i.mu.Lock()
275	defer i.mu.Unlock()
276	if i.agent != ag || checkGeneration && i.webSearchPolicyGeneration.Load() != generation {
277		return nil, false
278	}
279	if i.telegramBridge != nil {
280		// Telegram prompts arrive over an external messaging channel without a
281		// per-request confirmation surface. Keep the normal interactive
282		// registry from reintroducing web capabilities from the moment the
283		// bridge is attached, including while its startup handshake is in flight.
284		toolspkg.RemoveWebCapabilities(tools)
285	}
286	oldTools := ag.SetPromptConfig(system, tools)
287	_, webSearchAvailable := tools["web_search"]
288	i.setWebSearchAvailable(webSearchAvailable)
289	return oldTools, true
290}
291func (i *Interactive) prepareReplacementAgentLocked(ag *core.Agent) {
292	// This runs inside the replacement commit, never during candidate builds.
293	i.resetCodexUsageLocked()
294	// A draining stream is anchored in the old agent's transcript.
295	i.stream.Reset()
296	if ag == nil {
297		i.setWebSearchAvailable(false)
298		return
299	}
300	registry := ag.ToolsSnapshot()
301	if i.telegramBridge != nil {
302		// External Telegram prompts may arrive as soon as the replacement is
303		// published, so post-swap cleanup is too late.
304		toolspkg.RemoveWebCapabilities(registry)
305		ag.SetTools(registry)
306	}
307	_, webSearchAvailable := registry["web_search"]
308	i.setWebSearchAvailable(webSearchAvailable && i.telegramBridge == nil)
309}
310func (i *Interactive) DeferUntilIdle(fn func()) {
311	if fn == nil {
312		return
313	}
314	i.mu.Lock()
315	if i.busy {
316		i.pendingIdleWork = append(i.pendingIdleWork, fn)
317		i.mu.Unlock()
318		return
319	}
320	i.mu.Unlock()
321	fn()
322}
323func (i *Interactive) takePendingIdleWorkLocked() []func() {
324	work := i.pendingIdleWork
325	i.pendingIdleWork = nil
326	return work
327}
328func runPendingIdleWork(work []func()) {
329	for _, fn := range work {
330		if fn != nil {
331			fn()
332		}
333	}
334}
335func (i *Interactive) Agent() *core.Agent {
336	i.mu.Lock()
337	defer i.mu.Unlock()
338	return i.agent
339}
340func (i *Interactive) runReloadExt(ctx context.Context) {
341	if i.cfg.Extensions == nil {
342		i.mu.Lock()
343		i.statusErr = "no extension manager in this build"
344		i.mu.Unlock()
345		i.invalidate()
346		return
347	}
348	i.mu.Lock()
349	i.statusOK = "Reloading extensions..."
350	i.statusErr = ""
351	i.mu.Unlock()
352	i.invalidate()
353
354	go func() {
355		stats := i.cfg.Extensions.Reload(ctx, 2*time.Second)
356		msg, failed := formatReloadStatus(stats)
357		seq := i.setReloadStatus(msg, failed)
358		i.invalidate()
359		go i.dismissReloadStatus(ctx, seq, msg, failed)
360	}()
361}
362func (i *Interactive) Confirm(toolName string, preview string) core.ConfirmDecision {
363	return i.ConfirmToolCall(core.ToolCallConfirmation{Name: toolName, Summary: preview})
364}
365func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.ConfirmDecision {
366	resp := make(chan core.ConfirmDecision, 1)
367	if isBtwOrigin(call.Origin) {
368		req := &confirmRequest{
369			toolName:      call.Name,
370			preview:       call.Summary,
371			resp:          resp,
372			returnToChild: true,
373		}
374		if call.ID == "" || i.btwDialog == nil || !i.btwDialog.enqueueToolConfirmation(call.Origin, call.ID, call.Summary, call.Content, func() {
375			i.confirmDialog.Enqueue(req)
376		}) {
377			return core.ConfirmDecision{Allow: false, Reason: "side-chat tool call canceled"}
378		}
379		i.invalidate()
380		return <-resp
381	}
382	if call.ID != "" {
383		i.mu.Lock()
384		if tc, ok := i.toolCalls[call.ID]; ok {
385			tc.Args = call.Summary
386			tc.Preview = call.Content
387			i.activity.activity = activity{kind: activityAwaitingConfirmation, tool: call.Name, provider: i.cfg.Provider, model: i.cfg.Model}
388		}
389		i.mu.Unlock()
390	}
391	i.confirmDialog.Enqueue(&confirmRequest{
392		toolName: call.Name,
393		preview:  call.Summary,
394		resp:     resp,
395	})
396	i.invalidate()
397	return <-resp
398}

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