v0.2.53 c7f7c01

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

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