v0.21.0 cd9706d

interactive_events.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"strings"
6	"time"
7
8	"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 := tools.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_search from the moment the bridge is
236		// attached, including while its startup handshake is in flight.
237		delete(tools, "web_search")
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		delete(registry, "web_search")
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}

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