v0.0.42 ac6d556

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// Open enters the side chat. agent supplies the live transcript and
77// system prompt, plus the underlying provider client to use for the
78// one-off completion. seed is an optional first question that gets
79// auto-submitted (so /btw <text> behaves like the reference).
80func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, seed string) {
81	d.mu.Lock()
82	d.active = true
83	d.theme = th
84	d.turns = nil
85	d.loading = false
86	d.cancel = nil
87	d.editor = tui.NewEditor(th.FG256(th.Accent, "▌ "))
88	d.frozenSystem = system
89	d.frozenMsgs = agent.Messages()
90	d.client = agent.Client
91	d.model = model
92	d.mu.Unlock()
93
94	if seed = strings.TrimSpace(seed); seed != "" {
95		d.editor.SetValue(seed)
96		d.submit()
97	}
98}
99
100// Close hides the dialog. Cancels any in-flight request.
101func (d *btwDialog) Close() {
102	d.mu.Lock()
103	d.active = false
104	d.turns = nil
105	d.editor = nil
106	d.loading = false
107	cancel := d.cancel
108	d.cancel = nil
109	d.frozenMsgs = nil
110	d.client = nil
111	d.mu.Unlock()
112	if cancel != nil {
113		cancel()
114	}
115}
116
117// HandleKey routes a keypress to the dialog. Returns true if the
118// dialog wants the event consumed (always true while active, except
119// for the special closing case where the caller may want to signal
120// the parent).
121func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
122	if !d.Active() {
123		return false
124	}
125	switch k.Kind {
126	case tui.KeyEsc:
127		// First esc: cancel an in-flight call. Subsequent esc closes.
128		d.mu.Lock()
129		busy := d.loading
130		cancel := d.cancel
131		d.mu.Unlock()
132		if busy && cancel != nil {
133			cancel()
134			return false
135		}
136		d.Close()
137		invalidate()
138		return true
139	}
140
141	d.mu.Lock()
142	editor := d.editor
143	loading := d.loading
144	d.mu.Unlock()
145	if editor == nil {
146		return false
147	}
148	// Don't accept new submissions while a call is in flight; arrow
149	// keys / scrolling still flow through to the editor for caret
150	// movement and history.
151	submitted := editor.HandleKey(k)
152	invalidate()
153	if submitted && !loading {
154		d.submit()
155	}
156	return false
157}
158
159// submit fires the LLM call for the current input and, on success,
160// appends a new turn to d.turns.
161func (d *btwDialog) submit() {
162	d.mu.Lock()
163	if d.editor == nil || d.loading {
164		d.mu.Unlock()
165		return
166	}
167	question := strings.TrimSpace(d.editor.Value())
168	if question == "" {
169		d.mu.Unlock()
170		return
171	}
172	d.editor.Clear()
173	d.loading = true
174	if d.spin == nil {
175		d.spin = newSpinner()
176	}
177	d.spin.Start()
178	d.turns = append(d.turns, btwTurn{User: question})
179	turnIdx := len(d.turns) - 1
180
181	// Build the request: system + frozen main transcript + every
182	// prior side-chat turn (user + assistant) + this question.
183	msgs := append([]provider.Message(nil), d.frozenMsgs...)
184	for i, t := range d.turns {
185		msgs = append(msgs, provider.Message{
186			Role: provider.RoleUser,
187			Content: []provider.Content{
188				provider.TextBlock{Text: t.User},
189			},
190			Time: time.Now(),
191		})
192		// Only completed turns contribute an assistant reply; the
193		// in-flight one (turnIdx) hasn't got one yet.
194		if i < turnIdx && t.Assistant != "" {
195			msgs = append(msgs, provider.Message{
196				Role: provider.RoleAssistant,
197				Content: []provider.Content{
198					provider.TextBlock{Text: t.Assistant},
199				},
200				Time: time.Now(),
201			})
202		}
203	}
204
205	ctx, cancel := context.WithCancel(context.Background())
206	d.cancel = cancel
207	client := d.client
208	model := d.model
209	system := d.frozenSystem
210	d.mu.Unlock()
211
212	go func() {
213		req := provider.Request{
214			Model:    model,
215			System:   system,
216			Messages: msgs,
217			// No tools: side chat is conversational, not agentic.
218		}
219		stream, err := client.Stream(ctx, req)
220		if err != nil {
221			d.completeTurn(turnIdx, "", err.Error())
222			return
223		}
224
225		var reply strings.Builder
226		var finalErr error
227		for ev := range stream {
228			switch e := ev.(type) {
229			case provider.EventTextDelta:
230				reply.WriteString(e.Delta)
231			case provider.EventDone:
232				if e.Err != nil {
233					finalErr = e.Err
234				}
235			}
236		}
237
238		errMsg := ""
239		if finalErr != nil && ctx.Err() == nil {
240			errMsg = finalErr.Error()
241		}
242		d.completeTurn(turnIdx, reply.String(), errMsg)
243	}()
244}
245
246// completeTurn fills in the assistant text or error for the turn at
247// idx and clears the loading state.
248func (d *btwDialog) completeTurn(idx int, assistant, errMsg string) {
249	d.mu.Lock()
250	defer d.mu.Unlock()
251	if idx < 0 || idx >= len(d.turns) {
252		return
253	}
254	d.turns[idx].Assistant = assistant
255	d.turns[idx].Err = errMsg
256	d.loading = false
257	d.cancel = nil
258}
259
260// Render returns the side-chat panel lines. Called every frame
261// while active.
262func (d *btwDialog) Render(th tui.Theme, width int) []string {
263	d.mu.Lock()
264	defer d.mu.Unlock()
265	if !d.active {
266		return nil
267	}
268
269	var out []string
270	out = append(out, frameHeaderColor(th, "btw — side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
271
272	if len(d.turns) == 0 && !d.loading {
273		out = append(out, "  "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))
274	}
275
276	for _, t := range d.turns {
277		out = append(out, "")
278		out = append(out, "  "+th.FG256(th.User, "▌ you"))
279		for _, line := range strings.Split(t.User, "\n") {
280			out = append(out, "    "+th.FG256(th.Muted, line))
281		}
282		if t.Assistant != "" {
283			out = append(out, "")
284			out = append(out, "  "+th.FG256(th.Assistant, "▌ zot"))
285			md := tui.RenderMarkdown(t.Assistant, th, width-4)
286			for _, line := range strings.Split(md, "\n") {
287				out = append(out, "    "+line)
288			}
289		}
290		if t.Err != "" {
291			out = append(out, "    "+th.FG256(th.Error, "✖ "+t.Err))
292		}
293	}
294
295	if d.loading && d.spin != nil {
296		out = append(out, "")
297		// Match the main chat busy prefix shape: spinner glyph,
298		// rotating funny-line, elapsed seconds, then a muted hint
299		// that esc cancels.
300		prefix := fmt.Sprintf("%s %s · %s",
301			th.FG256(th.Spinner, d.spin.Frame()),
302			th.FG256(th.Spinner, d.spin.Message()),
303			th.FG256(th.Muted, d.spin.Elapsed().String()),
304		)
305		out = append(out, "  "+prefix+"  "+th.FG256(th.Muted, "(esc cancels)"))
306	}
307
308	out = append(out, "")
309	if d.editor != nil {
310		edLines, _, _ := d.editor.Render(width)
311		for _, l := range edLines {
312			// Indent the editor body so it lines up with the side-chat
313			// content column. Editor's prompt already includes a left
314			// marker, so just two cells of pad.
315			out = append(out, "  "+l)
316		}
317	}
318	out = append(out, frameRuleColor(th, width, th.Accent))
319	return out
320}
321
322// CursorRow / CursorCol report where the dialog wants the terminal
323// cursor placed within its render output, so the parent can position
324// the actual terminal cursor on the editor input. Returns (-1, -1)
325// when the dialog isn't active or has no editor.
326func (d *btwDialog) CursorPos(width int) (row, col int) {
327	d.mu.Lock()
328	defer d.mu.Unlock()
329	if !d.active || d.editor == nil {
330		return -1, -1
331	}
332	// Reproduce render's structure to find where the editor sits:
333	// header (1) + (turns_section_lines) + (loading?) + blank (1) + editor
334	editorOffset := 1 // header
335	if len(d.turns) == 0 && !d.loading {
336		editorOffset++ // muted "ask anything..." line
337	}
338	for _, t := range d.turns {
339		editorOffset++ // blank
340		editorOffset++ // "you" header
341		editorOffset += len(strings.Split(t.User, "\n"))
342		if t.Assistant != "" {
343			editorOffset++ // blank
344			editorOffset++ // "zot" header
345			editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
346		}
347		if t.Err != "" {
348			editorOffset++
349		}
350	}
351	if d.loading {
352		editorOffset++ // blank
353		editorOffset++ // spinner line
354	}
355	editorOffset++ // pre-editor blank
356	_, eRow, eCol := d.editor.Render(width - 2)
357	return editorOffset + eRow, eCol + 2 /* matches render indent */
358}
359
360// errMessage is a tiny helper for the future when we want to surface
361// retryable failures in a styled way.
362func errMessage(err error) string {
363	if err == nil {
364		return ""
365	}
366	return fmt.Sprintf("error: %s", err.Error())
367}

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