v0.2.39 8cd6818

btw_dialog.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"fmt"
6	"strings"
7	"sync"
8	"time"
9
10	"github.com/patriceckhart/zot/packages/core"
11	"github.com/patriceckhart/zot/packages/provider"
12	"github.com/patriceckhart/zot/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	Err       string
22}
23
24// btwDialog is the side-chat overlay opened by /btw. It shows the
25// user's question, runs a one-off model call 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	cancel  context.CancelFunc
39
40	// spin drives the same braille animation + rotating funny-line
41	// shown in the main status bar. Owned by the dialog so its clock
42	// is independent of the main spinner (so re-opening the dialog
43	// always starts fresh and the message doesn't carry over from a
44	// completed main turn).
45	spin *spinner
46
47	// Frozen at Open() time so the side-chat keeps a stable view of
48	// the main thread even if a turn happens to land on the main
49	// agent while the dialog is open (rare but possible).
50	frozenSystem string
51	frozenMsgs   []provider.Message
52
53	// Provider details captured at open time; used by send() to
54	// build the request without going back through the agent.
55	client provider.Client
56	model  string
57
58	// Theme cached so render() doesn't need it threaded through.
59	theme tui.Theme
60
61	// cwd is the working directory used to resolve relative paths
62	// when the user presses Tab on a path-like token in the side-
63	// chat editor. Set by Open() from the host's cwd so the same
64	// path-completion that works in the main editor also works
65	// here.
66	cwd string
67}
68
69func newBtwDialog() *btwDialog {
70	return &btwDialog{}
71}
72
73// Active reports whether the dialog is visible and consuming keys.
74func (d *btwDialog) Active() bool {
75	if d == nil {
76		return false
77	}
78	d.mu.Lock()
79	defer d.mu.Unlock()
80	return d.active
81}
82
83// Loading reports whether the dialog is currently awaiting a
84// model response (and therefore rendering an animated spinner).
85// Used by the host to decide whether a periodic redraw is worth
86// triggering; when false and the user is just typing, we can
87// skip the tick and let the terminal drive the cursor blink.
88func (d *btwDialog) Loading() bool {
89	if d == nil {
90		return false
91	}
92	d.mu.Lock()
93	defer d.mu.Unlock()
94	return d.active && d.loading
95}
96
97// Open enters the side chat. agent supplies the live transcript and
98// system prompt, plus the underlying provider client to use for the
99// one-off completion. seed is an optional first question that gets
100// auto-submitted (so /btw <text> starts a conversation right away).
101// invalidate, if non-nil, is called after each state change so the
102// host redraw loop can pick up the update without polling.
103func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, invalidate func()) {
104	d.mu.Lock()
105	d.active = true
106	d.theme = th
107	d.turns = nil
108	d.loading = false
109	d.cancel = nil
110	d.editor = tui.NewEditor(th.AccentBar(th.Accent))
111	d.frozenSystem = system
112	d.frozenMsgs = agent.Messages()
113	d.client = agent.Client
114	d.model = model
115	d.cwd = cwd
116	d.mu.Unlock()
117
118	if seed = strings.TrimSpace(seed); seed != "" {
119		d.editor.SetValue(seed)
120		d.submit(invalidate)
121	}
122}
123
124// Close hides the dialog. Cancels any in-flight request.
125func (d *btwDialog) Close() {
126	d.mu.Lock()
127	d.active = false
128	d.turns = nil
129	d.editor = nil
130	d.loading = false
131	cancel := d.cancel
132	d.cancel = nil
133	d.frozenMsgs = nil
134	d.client = nil
135	d.mu.Unlock()
136	if cancel != nil {
137		cancel()
138	}
139}
140
141// HandleKey routes a keypress to the dialog. Returns true if the
142// dialog wants the event consumed (always true while active, except
143// for the special closing case where the caller may want to signal
144// the parent).
145func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
146	if !d.Active() {
147		return false
148	}
149	switch k.Kind {
150	case tui.KeyEsc:
151		// First esc: cancel an in-flight call. Subsequent esc closes.
152		d.mu.Lock()
153		busy := d.loading
154		cancel := d.cancel
155		d.mu.Unlock()
156		if busy && cancel != nil {
157			cancel()
158			return false
159		}
160		d.Close()
161		invalidate()
162		return true
163	}
164
165	d.mu.Lock()
166	editor := d.editor
167	loading := d.loading
168	cwd := d.cwd
169	d.mu.Unlock()
170	if editor == nil {
171		return false
172	}
173	// Tab-complete a path-like token before the editor sees the key,
174	// matching the main editor's behaviour.
175	if k.Kind == tui.KeyTab {
176		if tryPathTabCompleteEditor(editor, cwd) {
177			invalidate()
178			return false
179		}
180	}
181	// Don't accept new submissions while a call is in flight; arrow
182	// keys / scrolling still flow through to the editor for caret
183	// movement and history.
184	submitted := editor.HandleKey(k)
185	invalidate()
186	if submitted && !loading {
187		d.submit(invalidate)
188	}
189	return false
190}
191
192// submit fires the LLM call for the current input and, on success,
193// appends a new turn to d.turns. invalidate is called every time
194// the turn's visible state changes (text delta, error, complete)
195// so the host redraw loop picks up the update without relying on
196// a periodic tick.
197func (d *btwDialog) submit(invalidate func()) {
198	d.mu.Lock()
199	if d.editor == nil || d.loading {
200		d.mu.Unlock()
201		return
202	}
203	question := strings.TrimSpace(d.editor.Value())
204	if question == "" {
205		d.mu.Unlock()
206		return
207	}
208	d.editor.Clear()
209	d.loading = true
210	if d.spin == nil {
211		d.spin = newSpinner(d.theme)
212	} else {
213		d.spin.Configure(d.theme)
214	}
215	d.spin.Start()
216	d.turns = append(d.turns, btwTurn{User: question})
217	turnIdx := len(d.turns) - 1
218
219	// Build the request: system + frozen main transcript + every
220	// prior side-chat turn (user + assistant) + this question.
221	msgs := append([]provider.Message(nil), d.frozenMsgs...)
222	for i, t := range d.turns {
223		msgs = append(msgs, provider.Message{
224			Role: provider.RoleUser,
225			Content: []provider.Content{
226				provider.TextBlock{Text: t.User},
227			},
228			Time: time.Now(),
229		})
230		// Only completed turns contribute an assistant reply; the
231		// in-flight one (turnIdx) hasn't got one yet.
232		if i < turnIdx && t.Assistant != "" {
233			msgs = append(msgs, provider.Message{
234				Role: provider.RoleAssistant,
235				Content: []provider.Content{
236					provider.TextBlock{Text: t.Assistant},
237				},
238				Time: time.Now(),
239			})
240		}
241	}
242
243	ctx, cancel := context.WithCancel(context.Background())
244	d.cancel = cancel
245	client := d.client
246	model := d.model
247	system := d.frozenSystem
248	d.mu.Unlock()
249
250	go func() {
251		req := provider.Request{
252			Model:    model,
253			System:   system,
254			Messages: msgs,
255			// No tools: side chat is conversational, not agentic.
256		}
257		stream, err := client.Stream(ctx, req)
258		if err != nil {
259			d.completeTurn(turnIdx, "", err.Error())
260			if invalidate != nil {
261				invalidate()
262			}
263			return
264		}
265
266		var reply strings.Builder
267		var finalErr error
268		for ev := range stream {
269			switch e := ev.(type) {
270			case provider.EventTextDelta:
271				reply.WriteString(e.Delta)
272			case provider.EventDone:
273				if e.Err != nil {
274					finalErr = e.Err
275				}
276			}
277		}
278
279		errMsg := ""
280		if finalErr != nil && ctx.Err() == nil {
281			errMsg = finalErr.Error()
282		}
283		d.completeTurn(turnIdx, reply.String(), errMsg)
284		if invalidate != nil {
285			invalidate()
286		}
287	}()
288}
289
290// completeTurn fills in the assistant text or error for the turn at
291// idx and clears the loading state.
292func (d *btwDialog) completeTurn(idx int, assistant, errMsg string) {
293	d.mu.Lock()
294	defer d.mu.Unlock()
295	if idx < 0 || idx >= len(d.turns) {
296		return
297	}
298	d.turns[idx].Assistant = assistant
299	d.turns[idx].Err = errMsg
300	d.loading = false
301	d.cancel = nil
302}
303
304// Render returns the side-chat panel lines. Called every frame
305// while active.
306func (d *btwDialog) Render(th tui.Theme, width int) []string {
307	d.mu.Lock()
308	defer d.mu.Unlock()
309	if !d.active {
310		return nil
311	}
312
313	var out []string
314	out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
315
316	if len(d.turns) == 0 && !d.loading {
317		out = append(out, "  "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))
318	}
319
320	for _, t := range d.turns {
321		out = append(out, "")
322		out = append(out, btwUserBubbleRows(th, t.User, width-2)...)
323		if t.Assistant != "" {
324			out = append(out, "")
325			md := tui.RenderMarkdown(t.Assistant, th, width-4)
326			for _, line := range strings.Split(md, "\n") {
327				if len(line) > 0 && line[0] == tui.FlushLeftSentinel {
328					line = line[1:]
329				}
330				out = append(out, "    "+line)
331			}
332		}
333		if t.Err != "" {
334			out = append(out, "    "+th.FG256(th.Error, "✖ "+t.Err))
335		}
336	}
337
338	if d.loading && d.spin != nil {
339		out = append(out, "")
340		// Match the main chat busy prefix shape: spinner glyph,
341		// rotating funny-line, elapsed seconds, then a muted hint
342		// that esc cancels.
343		prefix := fmt.Sprintf("%s %s - %s",
344			th.FG256(th.Assistant, d.spin.Frame()),
345			th.FG256(th.Assistant, d.spin.Message()),
346			th.FG256(th.Muted, d.spin.Elapsed().String()),
347		)
348		out = append(out, "  "+prefix+"  "+th.FG256(th.Muted, "(esc cancels)"))
349	}
350
351	out = append(out, "")
352	if d.editor != nil {
353		// Render at width-2 to match the two-cell left indent applied
354		// below. CursorPos uses the same width so the reported cursor
355		// column matches the wrapped layout shown on screen.
356		edLines, _, _ := d.editor.Render(width - 2)
357		for _, l := range edLines {
358			// Indent the editor body so it lines up with the side-chat
359			// content column. Editor's prompt already includes a left
360			// marker, so just two cells of pad.
361			out = append(out, "  "+l)
362		}
363		out = append(out, "") // breathing room between editor and frame rule
364	}
365	out = append(out, frameRuleColor(th, width, th.Accent))
366	return out
367}
368
369// CursorRow / CursorCol report where the dialog wants the terminal
370// cursor placed within its render output, so the parent can position
371// the actual terminal cursor on the editor input. Returns (-1, -1)
372// when the dialog isn't active or has no editor.
373func (d *btwDialog) CursorPos(width int) (row, col int) {
374	d.mu.Lock()
375	defer d.mu.Unlock()
376	if !d.active || d.editor == nil {
377		return -1, -1
378	}
379	// Reproduce render's structure to find where the editor sits.
380	// Note: the parent (interactive.go) wraps every dialog with
381	// padDialogFrame, which injects a blank row right after the
382	// frame header. We have to count that injected row here too;
383	// otherwise the reported cursor lands one row above the editor.
384	editorOffset := 1 // header
385	editorOffset++    // padDialogFrame's post-header blank
386	if len(d.turns) == 0 && !d.loading {
387		editorOffset++ // muted "ask anything..." line
388	}
389	for _, t := range d.turns {
390		editorOffset++ // blank
391		editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2))
392		if t.Assistant != "" {
393			editorOffset++ // blank
394			editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
395		}
396		if t.Err != "" {
397			editorOffset++
398		}
399	}
400	if d.loading {
401		editorOffset++ // blank
402		editorOffset++ // spinner line
403	}
404	editorOffset++ // pre-editor blank
405	_, eRow, eCol := d.editor.Render(width - 2)
406	return editorOffset + eRow, eCol + 2 /* matches render indent */
407}
408
409// btwUserBubbleRows renders a user message inside the /btw dialog
410// using the same bubble layout the main chat uses (full-width tinted
411// panel, left-edge ▌ bar, padding rows above and below). The frame
412// padding is the caller's job; bubbleWidth is the available row
413// width inside the frame.
414func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
415	const leftGutter = 0
416	const rightGutter = 2
417	innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
418	if innerWidth < 1 {
419		innerWidth = 1
420	}
421	bar := th.BG(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))
422	row := func(content string) string {
423		inner := strings.Repeat(" ", leftGutter) + content
424		return "  " + bar + th.UserBubble(inner, bubbleWidth-2)
425	}
426	var bubble []string
427	for _, l := range strings.Split(text, "\n") {
428		for _, w := range tui.WrapANSILine(l, innerWidth) {
429			bubble = append(bubble, row(w))
430		}
431	}
432	if len(bubble) == 0 {
433		return nil
434	}
435	out := make([]string, 0, len(bubble)+2)
436	out = append(out, row(""))
437	out = append(out, bubble...)
438	out = append(out, row(""))
439	return out
440}
441
442// errMessage is a tiny helper for the future when we want to surface
443// retryable failures in a styled way.
444func errMessage(err error) string {
445	if err == nil {
446		return ""
447	}
448	return fmt.Sprintf("error: %s", err.Error())
449}

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 2

Tags 477

↑↓ moveenter openesc close