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}