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. It only injects a blank row after the frame
382 // header when Render did not already put one there. With existing
383 // turns or a loading spinner, Render's first body row is already
384 // blank, so counting an extra pad row would place the cursor one
385 // row too low.
386 editorOffset := 1 // header
387 if len(d.turns) == 0 && !d.loading {
388 editorOffset++ // padDialogFrame's post-header blank
389 editorOffset++ // muted "ask anything..." line
390 }
391 for _, t := range d.turns {
392 editorOffset++ // blank
393 editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2))
394 if t.Assistant != "" {
395 editorOffset++ // blank
396 editorOffset += len(strings.Split(tui.RenderMarkdown(t.Assistant, d.theme, width-4), "\n"))
397 }
398 if t.Err != "" {
399 editorOffset++
400 }
401 }
402 if d.loading {
403 editorOffset++ // blank
404 editorOffset++ // spinner line
405 }
406 editorOffset++ // pre-editor blank
407 _, eRow, eCol := d.editor.Render(width - 2)
408 return editorOffset + eRow, eCol + 2 /* matches render indent */
409}
410
411// btwUserBubbleRows renders a user message inside the /btw dialog
412// using the same bubble layout the main chat uses (full-width tinted
413// panel, left-edge ▌ bar, padding rows above and below). The frame
414// padding is the caller's job; bubbleWidth is the available row
415// width inside the frame.
416func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int) []string {
417 const leftGutter = 0
418 const rightGutter = 2
419 innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
420 if innerWidth < 1 {
421 innerWidth = 1
422 }
423 bar := th.BG(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))
424 row := func(content string) string {
425 inner := strings.Repeat(" ", leftGutter) + content
426 return " " + bar + th.UserBubble(inner, bubbleWidth-2)
427 }
428 var bubble []string
429 for _, l := range strings.Split(text, "\n") {
430 for _, w := range tui.WrapANSILine(l, innerWidth) {
431 bubble = append(bubble, row(w))
432 }
433 }
434 if len(bubble) == 0 {
435 return nil
436 }
437 out := make([]string, 0, len(bubble)+2)
438 out = append(out, row(""))
439 out = append(out, bubble...)
440 out = append(out, row(""))
441 return out
442}
443
444// errMessage is a tiny helper for the future when we want to surface
445// retryable failures in a styled way.
446func errMessage(err error) string {
447 if err == nil {
448 return ""
449 }
450 return fmt.Sprintf("error: %s", err.Error())
451}