v0.1.58 25a4caf

btw_dialog.go RawHistoryFolder

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

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