v0.3.68 a98ca1a

btw_dialog.go RawHistoryFolder

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

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 477

↑↓ moveenter openesc close