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 out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)
334 }
335 if t.Err != "" {
336 out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+t.Err), width)...)
337 }
338 }
339
340 if d.loading && d.spin != nil {
341 out = append(out, "")
342 // Match the main chat busy prefix shape: spinner glyph,
343 // rotating funny-line, elapsed seconds, then a muted hint
344 // that esc cancels.
345 prefix := fmt.Sprintf("%s %s - %s",
346 th.FG256(th.Assistant, d.spin.Frame()),
347 th.FG256(th.Assistant, d.spin.Message()),
348 th.FG256(th.Muted, d.spin.Elapsed().String()),
349 )
350 out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)"))
351 }
352
353 out = append(out, "")
354 if d.editor != nil {
355 // Render at width-2 to match the two-cell left indent applied
356 // below. CursorPos uses the same width so the reported cursor
357 // column matches the wrapped layout shown on screen.
358 d.editor.Prompt = th.AccentBar(th.Accent)
359 if d.lineInput {
360 d.editor.Prompt = ""
361 }
362 edLines, _, _ := d.editor.Render(width - 2)
363 for _, l := range edLines {
364 // Indent the editor body so it lines up with the side-chat
365 // content column. Editor's prompt already includes a left
366 // marker, so just two cells of pad.
367 out = append(out, " "+l)
368 }
369 out = append(out, "") // breathing room between editor and frame rule
370 }
371 out = append(out, frameRuleColor(th, width, th.Accent))
372 return out
373}
374
375// CursorRow / CursorCol report where the dialog wants the terminal
376// cursor placed within its render output, so the parent can position
377// the actual terminal cursor on the editor input. Returns (-1, -1)
378// when the dialog isn't active or has no editor.
379func (d *btwDialog) CursorPos(width int) (row, col int) {
380 d.mu.Lock()
381 defer d.mu.Unlock()
382 if !d.active || d.editor == nil {
383 return -1, -1
384 }
385 // Reproduce render's structure to find where the editor sits.
386 // Note: the parent (interactive.go) wraps every dialog with
387 // padDialogFrame. It only injects a blank row after the frame
388 // header when Render did not already put one there. With existing
389 // turns or a loading spinner, Render's first body row is already
390 // blank, so counting an extra pad row would place the cursor one
391 // row too low.
392 editorOffset := 1 // header
393 if len(d.turns) == 0 && !d.loading {
394 editorOffset++ // padDialogFrame's post-header blank
395 editorOffset++ // muted "ask anything..." line
396 }
397 for _, t := range d.turns {
398 editorOffset++ // blank
399 editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))
400 if t.Assistant != "" {
401 editorOffset++ // blank
402 editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))
403 }
404 if t.Err != "" {
405 editorOffset += len(wrapDialogTextRows(d.theme.FG256(d.theme.Error, "✖ "+t.Err), width))
406 }
407 }
408 if d.loading {
409 editorOffset++ // blank
410 editorOffset++ // spinner line
411 }
412 editorOffset++ // pre-editor blank
413 d.editor.Prompt = d.theme.AccentBar(d.theme.Accent)
414 if d.lineInput {
415 d.editor.Prompt = ""
416 }
417 _, eRow, eCol := d.editor.Render(width - 2)
418 return editorOffset + eRow, eCol + 2 /* matches render indent */
419}
420
421// btwUserBubbleRows renders a user message inside the /btw dialog
422// using the same bubble layout the main chat uses (full-width tinted
423// panel, left-edge ▌ bar, padding rows above and below). The frame
424// padding is the caller's job; bubbleWidth is the available row
425// width inside the frame.
426func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode bool) []string {
427 const leftGutter = 0
428 const rightGutter = 2
429 innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
430 if innerWidth < 1 {
431 innerWidth = 1
432 }
433 bar := th.BG(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))
434 row := func(content string) string {
435 inner := strings.Repeat(" ", leftGutter) + content
436 return " " + bar + th.UserBubble(inner, bubbleWidth-2)
437 }
438 if compactMode {
439 innerWidth = bubbleWidth - 2
440 if innerWidth < 1 {
441 innerWidth = 1
442 }
443 row = func(content string) string {
444 return " " + th.FG256(th.Accent, "▌ ") + th.FG256(th.Muted, content)
445 }
446 }
447 var bubble []string
448 for _, l := range strings.Split(text, "\n") {
449 for _, w := range tui.WrapANSILine(l, innerWidth) {
450 bubble = append(bubble, row(w))
451 }
452 }
453 if len(bubble) == 0 {
454 return nil
455 }
456 if compactMode {
457 return bubble
458 }
459 out := make([]string, 0, len(bubble)+2)
460 out = append(out, row(""))
461 out = append(out, bubble...)
462 out = append(out, row(""))
463 return out
464}
465
466// errMessage is a tiny helper for the future when we want to surface
467// retryable failures in a styled way.
468func errMessage(err error) string {
469 if err == nil {
470 return ""
471 }
472 return fmt.Sprintf("error: %s", err.Error())
473}