v0.50.1 a637010

btw_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"strings"
8	"sync"
9
10	"github.com/bnema/zut/packages/core"
11	"github.com/bnema/zut/packages/provider"
12	"github.com/bnema/zut/packages/tui"
13)
14
15// btwTurn is one user/assistant pair within a side chat. Kept
16// separate from the main transcript so closing the dialog leaves
17// the main session untouched.
18type btwTurn struct {
19	User      string
20	Assistant string
21	Tools     []tui.ToolCallView
22	Err       string
23}
24
25// btwDialog is the side-chat overlay opened by /btw. It shows the
26// user's question, runs an isolated agent turn against the live
27// snapshot of the main session plus any prior side-chat turns,
28// renders the assistant reply through the markdown pipeline, and
29// keeps the main transcript completely untouched.
30//
31// Cancellation: esc cancels an in-flight call when one is running,
32// otherwise closes the dialog.
33type btwDialog struct {
34	mu          sync.Mutex
35	active      bool
36	turns       []btwTurn
37	editor      *tui.Editor
38	loading     bool
39	compactMode bool
40	lineInput   bool
41	cancel      context.CancelFunc
42	generation  uint64
43	closeHook   func()
44
45	// spin drives the same braille animation as the main status bar. Its
46	// activity label is owned by this dialog under d.mu.
47	spin     *spinner
48	activity agentActivity
49
50	// sideAgent owns an isolated transcript seeded from the main
51	// conversation. It uses the same tools and runtime hooks as the
52	// main agent, but has no persistence callbacks.
53	sideAgent *core.Agent
54
55	// Theme and toolView are cached so render() uses the same tool-call
56	// presentation and collapse behavior as the main transcript.
57	theme    tui.Theme
58	toolView *tui.View
59
60	// cwd is the working directory used to resolve relative paths
61	// when the user presses Tab on a path-like token in the side-
62	// chat editor. Set by Open() from the host's cwd so the same
63	// path-completion that works in the main editor also works
64	// here.
65	cwd string
66}
67
68func newBtwDialog() *btwDialog {
69	return &btwDialog{}
70}
71
72func btwOrigin(generation uint64) string {
73	return fmt.Sprintf("btw:%d", generation)
74}
75
76func isBtwOrigin(origin string) bool {
77	return strings.HasPrefix(origin, "btw:")
78}
79
80func (d *btwDialog) setCloseHook(hook func()) {
81	if d == nil {
82		return
83	}
84	d.mu.Lock()
85	d.closeHook = hook
86	d.mu.Unlock()
87}
88
89// Active reports whether the dialog is visible and consuming keys.
90func (d *btwDialog) Active() bool {
91	if d == nil {
92		return false
93	}
94	d.mu.Lock()
95	defer d.mu.Unlock()
96	return d.active
97}
98
99// Loading reports whether the dialog is currently awaiting a
100// model response (and therefore rendering an animated spinner).
101// Used by the host to decide whether a periodic redraw is worth
102// triggering; when false and the user is just typing, we can
103// skip the tick and let the terminal drive the cursor blink.
104func (d *btwDialog) Loading() bool {
105	if d == nil {
106		return false
107	}
108	d.mu.Lock()
109	defer d.mu.Unlock()
110	return d.active && d.loading
111}
112
113// enqueueToolConfirmation attaches the side-effect-free confirmation preview
114// to the matching side-chat tool box and queues its prompt while d.mu is
115// held. Keeping both actions together prevents Close from advancing the
116// generation between the ownership check and the confirmation enqueue.
117func (d *btwDialog) enqueueToolConfirmation(origin, id, summary, content string, enqueue func()) bool {
118	if d == nil || id == "" || enqueue == nil {
119		return false
120	}
121	d.mu.Lock()
122	defer d.mu.Unlock()
123	if !d.active || origin != btwOrigin(d.generation) {
124		return false
125	}
126	for turnIdx := range d.turns {
127		if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {
128			tool.Args = summary
129			tool.Preview = content
130			d.activity.activity = activity{kind: activityAwaitingConfirmation, tool: tool.Name}
131			enqueue()
132			return true
133		}
134	}
135	return false
136}
137
138// ToggleToolExpansion expands or collapses long tool results in the side chat.
139func (d *btwDialog) ToggleToolExpansion() {
140	if d == nil {
141		return
142	}
143	d.mu.Lock()
144	defer d.mu.Unlock()
145	if d.toolView != nil {
146		d.toolView.ExpandAll = !d.toolView.ExpandAll
147	}
148}
149
150func (d *btwDialog) setTheme(theme tui.Theme) {
151	if d == nil {
152		return
153	}
154	d.mu.Lock()
155	defer d.mu.Unlock()
156	d.theme = theme
157	if d.toolView != nil {
158		d.toolView.Theme = theme
159		d.toolView.InvalidateRenderCache()
160	}
161	if d.editor != nil {
162		d.editor.Prompt = theme.AccentBar(theme.Accent)
163	}
164	if d.spin != nil {
165		d.spin.Configure(theme)
166	}
167}
168
169// Open enters the side chat. agent supplies the live transcript and
170// system prompt, plus the underlying provider client to use for the
171// one-off completion. seed is an optional first question that gets
172// auto-submitted (so /btw <text> starts a conversation right away).
173// invalidate, if non-nil, is called after each state change so the
174// host redraw loop can pick up the update without polling.
175func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {
176	d.mu.Lock()
177	d.generation++
178	d.active = true
179	d.theme = th
180	d.compactMode = compactMode
181	d.lineInput = lineInput
182	d.toolView = &tui.View{Theme: th, CompactMode: compactMode, FlatTools: flatTools}
183	d.turns = nil
184	d.loading = false
185	d.cancel = nil
186	d.activity = agentActivity{}
187	prompt := th.AccentBar(th.Accent)
188	if lineInput {
189		prompt = ""
190	}
191	d.editor = tui.NewEditor(prompt)
192	d.sideAgent = newBtwAgent(agent, system, model, btwOrigin(d.generation))
193	d.cwd = cwd
194	d.mu.Unlock()
195
196	if seed = strings.TrimSpace(seed); seed != "" {
197		d.editor.SetValue(seed)
198		d.submit(invalidate)
199	}
200}
201
202// Close hides the dialog. Cancels any in-flight request.
203func (d *btwDialog) Close() {
204	d.mu.Lock()
205	d.generation++
206	d.active = false
207	d.turns = nil
208	d.editor = nil
209	d.loading = false
210	d.activity = agentActivity{}
211	cancel := d.cancel
212	d.cancel = nil
213	d.sideAgent = nil
214	d.toolView = nil
215	closeHook := d.closeHook
216	d.mu.Unlock()
217	if cancel != nil {
218		cancel()
219	}
220	if closeHook != nil {
221		closeHook()
222	}
223}
224
225// HandleKey routes a keypress to the dialog. Returns true if the
226// dialog wants the event consumed (always true while active, except
227// for the special closing case where the caller may want to signal
228// the parent).
229func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
230	if !d.Active() {
231		return false
232	}
233	switch k.Kind {
234	case tui.KeyEsc:
235		// First esc: cancel an in-flight call. Subsequent esc closes.
236		d.mu.Lock()
237		busy := d.loading
238		cancel := d.cancel
239		d.mu.Unlock()
240		if busy && cancel != nil {
241			cancel()
242			return false
243		}
244		d.Close()
245		invalidate()
246		return true
247	}
248
249	d.mu.Lock()
250	editor := d.editor
251	loading := d.loading
252	cwd := d.cwd
253	d.mu.Unlock()
254	if editor == nil {
255		return false
256	}
257	// Tab-complete a path-like token before the editor sees the key,
258	// matching the main editor's behaviour.
259	if k.Kind == tui.KeyTab {
260		if tryPathTabCompleteEditor(editor, cwd) {
261			invalidate()
262			return false
263		}
264	}
265	// Don't accept new submissions while a call is in flight; arrow
266	// keys / scrolling still flow through to the editor for caret
267	// movement and history.
268	submitted := editor.HandleKey(k)
269	invalidate()
270	if submitted && !loading {
271		d.submit(invalidate)
272	}
273	return false
274}
275
276// submit fires the LLM call for the current input and, on success,
277// appends a new turn to d.turns. invalidate is called every time
278// the turn's visible state changes (text delta, error, complete)
279// so the host redraw loop picks up the update without relying on
280// a periodic tick.
281func (d *btwDialog) submit(invalidate func()) {
282	d.mu.Lock()
283	if d.editor == nil || d.loading {
284		d.mu.Unlock()
285		return
286	}
287	question := strings.TrimSpace(d.editor.Value())
288	if question == "" {
289		d.mu.Unlock()
290		return
291	}
292	d.editor.Clear()
293	d.loading = true
294	if d.spin == nil {
295		d.spin = newSpinner(d.theme)
296	} else {
297		d.spin.Configure(d.theme)
298	}
299	d.spin.Start()
300	d.turns = append(d.turns, btwTurn{User: question})
301	turnIdx := len(d.turns) - 1
302	generation := d.generation
303
304	ctx, cancel := context.WithCancel(context.Background())
305	d.cancel = cancel
306	agent := d.sideAgent
307	providerName := ""
308	model := ""
309	if agent != nil {
310		model = agent.Model
311		if agent.Client != nil {
312			providerName = agent.Client.Name()
313		}
314	}
315	d.activity = newAgentActivity(providerName, model)
316	d.mu.Unlock()
317
318	go func() {
319		err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {
320			d.handleAgentEvent(turnIdx, generation, ev)
321			if invalidate != nil {
322				invalidate()
323			}
324		})
325		errMsg := ""
326		if err != nil && ctx.Err() == nil {
327			errMsg = err.Error()
328		}
329		d.completeTurn(turnIdx, generation, errMsg)
330		if invalidate != nil {
331			invalidate()
332		}
333	}()
334}
335
336func newBtwAgent(main *core.Agent, system, model, origin string) *core.Agent {
337	agent := core.NewAgent(main.Client, model, system, main.ToolsSnapshot())
338	agent.MaxSteps = main.MaxSteps
339	agent.DisableRepetitionGuard = main.DisableRepetitionGuard
340	agent.Reasoning = main.Reasoning
341	agent.Temperature = main.Temperature
342	agent.FastMode = main.FastModeEnabled()
343	agent.MaxTokens = main.MaxTokens
344	if beforeToolExecute := main.BeforeToolExecute; beforeToolExecute != nil {
345		agent.BeforeToolExecute = func(call provider.ToolCallBlock) (bool, string, json.RawMessage) {
346			call.Origin = origin
347			return beforeToolExecute(call)
348		}
349	}
350	agent.BeforeTurn = main.BeforeTurn
351	agent.BeforeAssistantMessage = main.BeforeAssistantMessage
352	agent.MaxRetries = main.MaxRetries
353	agent.RetryBaseDelay = main.RetryBaseDelay
354	agent.OnEvent = main.OnEvent
355	agent.SetMessages(main.Messages())
356	// The side agent shares the main registry (which contains plan) and the main
357	// transcript, so it must also carry the main plan: otherwise `show` answers
358	// empty and an indexed update fails on a checklist the transcript displays.
359	agent.SetPlan(main.CurrentPlan())
360	return agent
361}
362
363func (d *btwDialog) handleAgentEvent(turnIdx int, generation uint64, ev core.AgentEvent) {
364	d.mu.Lock()
365	defer d.mu.Unlock()
366	if !d.active || generation != d.generation || turnIdx < 0 || turnIdx >= len(d.turns) {
367		return
368	}
369	turn := &d.turns[turnIdx]
370	d.activity.apply(ev)
371	switch e := ev.(type) {
372	case core.EvTextDelta:
373		turn.Assistant += e.Delta
374	case core.EvToolUseStart:
375		turn.Tools = append(turn.Tools, tui.ToolCallView{ID: e.ID, Name: e.Name, Streaming: true})
376	case core.EvToolUseArgs:
377		if tool := findBtwTool(turn, e.ID); tool != nil {
378			tool.RawJSONBuf += e.Delta
379			if path, ok, _ := tui.ExtractPartialStringField(tool.RawJSONBuf, "path"); ok {
380				tool.LivePath = path
381			} else if path, ok, _ := tui.ExtractPartialStringField(tool.RawJSONBuf, "file_path"); ok {
382				tool.LivePath = path
383			}
384		}
385	case core.EvToolUseEnd:
386		if tool := findBtwTool(turn, e.ID); tool != nil {
387			tool.Streaming = false
388		}
389	case core.EvToolCall:
390		tool := findBtwTool(turn, e.ID)
391		if tool == nil {
392			turn.Tools = append(turn.Tools, tui.ToolCallView{ID: e.ID, Name: e.Name})
393			tool = &turn.Tools[len(turn.Tools)-1]
394		}
395		tool.Name = e.Name
396		tool.Args = tui.ShortArgs(e.Name, e.Args)
397		if tool.RawJSONBuf == "" {
398			tool.RawJSONBuf = string(e.Args)
399		}
400		tool.Streaming = false
401	case core.EvToolResult:
402		if tool := findBtwTool(turn, e.ID); tool != nil {
403			tool.Done = true
404			tool.Error = e.Result.IsError
405			var text strings.Builder
406			for _, content := range e.Result.Content {
407				if block, ok := content.(provider.TextBlock); ok {
408					if text.Len() > 0 {
409						text.WriteString("\n")
410					}
411					text.WriteString(block.Text)
412				}
413			}
414			tool.Result = text.String()
415		}
416	}
417}
418
419func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView {
420	for j := range turn.Tools {
421		if turn.Tools[j].ID == id {
422			return &turn.Tools[j]
423		}
424	}
425	return nil
426}
427
428// completeTurn records an error, if any, and clears the loading state.
429func (d *btwDialog) completeTurn(idx int, generation uint64, errMsg string) {
430	d.mu.Lock()
431	defer d.mu.Unlock()
432	if !d.active || generation != d.generation || idx < 0 || idx >= len(d.turns) {
433		return
434	}
435	d.turns[idx].Err = errMsg
436	d.loading = false
437	d.activity = agentActivity{}
438	d.cancel = nil
439}
440
441func renderBtwConfirmation(th tui.Theme, width int, btw *btwDialog, confirm *confirmDialog) []string {
442	rows := padDialogFrame(btw.Render(th, width))
443	rows = append(rows, "")
444	return append(rows, padDialogFrame(confirm.Render(th, width))...)
445}
446
447// Render returns the side-chat panel lines. Called every frame
448// while active.
449func (d *btwDialog) Render(th tui.Theme, width int) []string {
450	d.mu.Lock()
451	defer d.mu.Unlock()
452	if !d.active {
453		return nil
454	}
455
456	var out []string
457	out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
458
459	if len(d.turns) == 0 && !d.loading {
460		out = append(out, "  "+th.FGColor(th.Muted, "ask anything; replies stay private to this side chat."))
461	}
462
463	for _, t := range d.turns {
464		out = append(out, "")
465		out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...)
466		if len(t.Tools) > 0 {
467			// Match the main transcript's inter-message separator between
468			// the user bubble and the first tool box.
469			out = append(out, "")
470		}
471		for _, tool := range t.Tools {
472			out = append(out, d.toolView.RenderToolCall(tool, width)...)
473		}
474		if t.Assistant != "" {
475			out = append(out, "")
476			out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)
477		}
478		if t.Err != "" {
479			out = append(out, wrapDialogTextRows(th.FGColor(th.Error, "✖ "+t.Err), width)...)
480		}
481	}
482
483	if d.loading && d.spin != nil {
484		out = append(out, "")
485		label := d.activity.label()
486		if label == "" {
487			label = "Preparing request"
488		}
489		prefix := fmt.Sprintf("%s %s - %s",
490			th.FGColor(th.Assistant, d.spin.Frame()),
491			th.FGColor(th.Assistant, label),
492			th.FGColor(th.Muted, d.spin.Elapsed().String()),
493		)
494		out = append(out, "  "+prefix+"  "+th.FGColor(th.Muted, "(esc cancels)"))
495	}
496
497	out = append(out, "")
498	if d.editor != nil {
499		// Render at width-2 to match the two-cell left indent applied
500		// below. CursorPos uses the same width so the reported cursor
501		// column matches the wrapped layout shown on screen.
502		d.editor.Prompt = th.AccentBar(th.Accent)
503		if d.lineInput {
504			d.editor.Prompt = ""
505		}
506		edLines, _, _ := d.editor.Render(width - 2)
507		for _, l := range edLines {
508			// Indent the editor body so it lines up with the side-chat
509			// content column. Editor's prompt already includes a left
510			// marker, so just two cells of pad.
511			out = append(out, "  "+l)
512		}
513		out = append(out, "") // breathing room between editor and frame rule
514	}
515	out = append(out, frameRuleColor(th, width, th.Accent))
516	return out
517}
518
519// CursorRow / CursorCol report where the dialog wants the terminal
520// cursor placed within its render output, so the parent can position
521// the actual terminal cursor on the editor input. Returns (-1, -1)
522// when the dialog isn't active or has no editor.
523func (d *btwDialog) CursorPos(width int) (row, col int) {
524	d.mu.Lock()
525	defer d.mu.Unlock()
526	if !d.active || d.editor == nil {
527		return -1, -1
528	}
529	// Reproduce render's structure to find where the editor sits.
530	// Note: the parent (interactive.go) wraps every dialog with
531	// padDialogFrame. It only injects a blank row after the frame
532	// header when Render did not already put one there. With existing
533	// turns or a loading spinner, Render's first body row is already
534	// blank, so counting an extra pad row would place the cursor one
535	// row too low.
536	editorOffset := 1 // header
537	if len(d.turns) == 0 && !d.loading {
538		editorOffset++ // padDialogFrame's post-header blank
539		editorOffset++ // muted "ask anything..." line
540	}
541	for _, t := range d.turns {
542		editorOffset++ // blank
543		editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))
544		if len(t.Tools) > 0 {
545			editorOffset++ // main transcript's user-to-tool separator
546		}
547		for _, tool := range t.Tools {
548			editorOffset += len(d.toolView.RenderToolCall(tool, width))
549		}
550		if t.Assistant != "" {
551			editorOffset++ // blank
552			editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))
553		}
554		if t.Err != "" {
555			editorOffset += len(wrapDialogTextRows(d.theme.FGColor(d.theme.Error, "✖ "+t.Err), width))
556		}
557	}
558	if d.loading {
559		editorOffset++ // blank
560		editorOffset++ // spinner line
561	}
562	editorOffset++ // pre-editor blank
563	d.editor.Prompt = d.theme.AccentBar(d.theme.Accent)
564	if d.lineInput {
565		d.editor.Prompt = ""
566	}
567	_, eRow, eCol := d.editor.Render(width - 2)
568	return editorOffset + eRow, eCol + 2 /* matches render indent */
569}
570
571// btwUserBubbleRows renders a user message inside the /btw dialog
572// using the same bubble layout the main chat uses (full-width tinted
573// panel, left-edge ▌ bar, padding rows above and below). The frame
574// padding is the caller's job; bubbleWidth is the available row
575// width inside the frame.
576func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode bool) []string {
577	const leftGutter = 0
578	const rightGutter = 2
579	innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
580	if innerWidth < 1 {
581		innerWidth = 1
582	}
583	bar := th.BG(th.UserBubbleBG, th.FGColor(th.Accent, "▌ "))
584	row := func(content string) string {
585		inner := strings.Repeat(" ", leftGutter) + content
586		return "  " + bar + th.UserBubble(inner, bubbleWidth-2)
587	}
588	if compactMode {
589		innerWidth = bubbleWidth - 2
590		if innerWidth < 1 {
591			innerWidth = 1
592		}
593		row = func(content string) string {
594			return "  " + th.FGColor(th.Accent, "▌ ") + th.FGColor(th.Muted, content)
595		}
596	}
597	var bubble []string
598	for _, l := range strings.Split(text, "\n") {
599		for _, w := range tui.WrapANSILine(l, innerWidth) {
600			bubble = append(bubble, row(w))
601		}
602	}
603	if len(bubble) == 0 {
604		return nil
605	}
606	if compactMode {
607		return bubble
608	}
609	out := make([]string, 0, len(bubble)+2)
610	out = append(out, row(""))
611	out = append(out, bubble...)
612	out = append(out, row(""))
613	return out
614}

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