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}