1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8
9 "github.com/patriceckhart/zot/packages/core"
10 "github.com/patriceckhart/zot/packages/provider"
11 "github.com/patriceckhart/zot/packages/tui"
12)
13
14// btwTurn is one user/assistant pair within a side chat. Kept
15// separate from the main transcript so closing the dialog leaves
16// the main session untouched.
17type btwTurn struct {
18 User string
19 Assistant string
20 Tools []tui.ToolCallView
21 Err string
22}
23
24// btwDialog is the side-chat overlay opened by /btw. It shows the
25// user's question, runs an isolated agent turn 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 // sideAgent owns an isolated transcript seeded from the main
50 // conversation. It uses the same tools and runtime hooks as the
51 // main agent, but has no persistence callbacks.
52 sideAgent *core.Agent
53
54 // Theme and toolView are cached so render() uses the same tool-call
55 // presentation and collapse behavior as the main transcript.
56 theme tui.Theme
57 toolView *tui.View
58
59 // cwd is the working directory used to resolve relative paths
60 // when the user presses Tab on a path-like token in the side-
61 // chat editor. Set by Open() from the host's cwd so the same
62 // path-completion that works in the main editor also works
63 // here.
64 cwd string
65}
66
67func newBtwDialog() *btwDialog {
68 return &btwDialog{}
69}
70
71// Active reports whether the dialog is visible and consuming keys.
72func (d *btwDialog) Active() bool {
73 if d == nil {
74 return false
75 }
76 d.mu.Lock()
77 defer d.mu.Unlock()
78 return d.active
79}
80
81// Loading reports whether the dialog is currently awaiting a
82// model response (and therefore rendering an animated spinner).
83// Used by the host to decide whether a periodic redraw is worth
84// triggering; when false and the user is just typing, we can
85// skip the tick and let the terminal drive the cursor blink.
86func (d *btwDialog) Loading() bool {
87 if d == nil {
88 return false
89 }
90 d.mu.Lock()
91 defer d.mu.Unlock()
92 return d.active && d.loading
93}
94
95// SetToolPreview attaches the side-effect-free confirmation preview to the
96// matching side-chat tool box before execution begins.
97func (d *btwDialog) SetToolPreview(id, summary, content string) bool {
98 if d == nil || id == "" {
99 return false
100 }
101 d.mu.Lock()
102 defer d.mu.Unlock()
103 for turnIdx := range d.turns {
104 if tool := findBtwTool(&d.turns[turnIdx], id); tool != nil {
105 tool.Args = summary
106 tool.Preview = content
107 return true
108 }
109 }
110 return false
111}
112
113// ToggleToolExpansion expands or collapses long tool results in the side chat.
114func (d *btwDialog) ToggleToolExpansion() {
115 if d == nil {
116 return
117 }
118 d.mu.Lock()
119 defer d.mu.Unlock()
120 if d.toolView != nil {
121 d.toolView.ExpandAll = !d.toolView.ExpandAll
122 }
123}
124
125// Open enters the side chat. agent supplies the live transcript and
126// system prompt, plus the underlying provider client to use for the
127// one-off completion. seed is an optional first question that gets
128// auto-submitted (so /btw <text> starts a conversation right away).
129// invalidate, if non-nil, is called after each state change so the
130// host redraw loop can pick up the update without polling.
131func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {
132 d.mu.Lock()
133 d.active = true
134 d.theme = th
135 d.compactMode = compactMode
136 d.lineInput = lineInput
137 d.toolView = &tui.View{Theme: th, CompactMode: compactMode, FlatTools: flatTools}
138 d.turns = nil
139 d.loading = false
140 d.cancel = nil
141 prompt := th.AccentBar(th.Accent)
142 if lineInput {
143 prompt = ""
144 }
145 d.editor = tui.NewEditor(prompt)
146 d.sideAgent = newBtwAgent(agent, system, model)
147 d.cwd = cwd
148 d.mu.Unlock()
149
150 if seed = strings.TrimSpace(seed); seed != "" {
151 d.editor.SetValue(seed)
152 d.submit(invalidate)
153 }
154}
155
156// Close hides the dialog. Cancels any in-flight request.
157func (d *btwDialog) Close() {
158 d.mu.Lock()
159 d.active = false
160 d.turns = nil
161 d.editor = nil
162 d.loading = false
163 cancel := d.cancel
164 d.cancel = nil
165 d.sideAgent = nil
166 d.toolView = nil
167 d.mu.Unlock()
168 if cancel != nil {
169 cancel()
170 }
171}
172
173// HandleKey routes a keypress to the dialog. Returns true if the
174// dialog wants the event consumed (always true while active, except
175// for the special closing case where the caller may want to signal
176// the parent).
177func (d *btwDialog) HandleKey(k tui.Key, invalidate func()) (closed bool) {
178 if !d.Active() {
179 return false
180 }
181 switch k.Kind {
182 case tui.KeyEsc:
183 // First esc: cancel an in-flight call. Subsequent esc closes.
184 d.mu.Lock()
185 busy := d.loading
186 cancel := d.cancel
187 d.mu.Unlock()
188 if busy && cancel != nil {
189 cancel()
190 return false
191 }
192 d.Close()
193 invalidate()
194 return true
195 }
196
197 d.mu.Lock()
198 editor := d.editor
199 loading := d.loading
200 cwd := d.cwd
201 d.mu.Unlock()
202 if editor == nil {
203 return false
204 }
205 // Tab-complete a path-like token before the editor sees the key,
206 // matching the main editor's behaviour.
207 if k.Kind == tui.KeyTab {
208 if tryPathTabCompleteEditor(editor, cwd) {
209 invalidate()
210 return false
211 }
212 }
213 // Don't accept new submissions while a call is in flight; arrow
214 // keys / scrolling still flow through to the editor for caret
215 // movement and history.
216 submitted := editor.HandleKey(k)
217 invalidate()
218 if submitted && !loading {
219 d.submit(invalidate)
220 }
221 return false
222}
223
224// submit fires the LLM call for the current input and, on success,
225// appends a new turn to d.turns. invalidate is called every time
226// the turn's visible state changes (text delta, error, complete)
227// so the host redraw loop picks up the update without relying on
228// a periodic tick.
229func (d *btwDialog) submit(invalidate func()) {
230 d.mu.Lock()
231 if d.editor == nil || d.loading {
232 d.mu.Unlock()
233 return
234 }
235 question := strings.TrimSpace(d.editor.Value())
236 if question == "" {
237 d.mu.Unlock()
238 return
239 }
240 d.editor.Clear()
241 d.loading = true
242 if d.spin == nil {
243 d.spin = newSpinner(d.theme)
244 } else {
245 d.spin.Configure(d.theme)
246 }
247 d.spin.Start()
248 d.turns = append(d.turns, btwTurn{User: question})
249 turnIdx := len(d.turns) - 1
250
251 ctx, cancel := context.WithCancel(context.Background())
252 d.cancel = cancel
253 agent := d.sideAgent
254 d.mu.Unlock()
255
256 go func() {
257 err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {
258 d.handleAgentEvent(turnIdx, ev)
259 if invalidate != nil {
260 invalidate()
261 }
262 })
263 errMsg := ""
264 if err != nil && ctx.Err() == nil {
265 errMsg = err.Error()
266 }
267 d.completeTurn(turnIdx, errMsg)
268 if invalidate != nil {
269 invalidate()
270 }
271 }()
272}
273
274func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
275 agent := core.NewAgent(main.Client, model, system, main.Tools)
276 agent.MaxSteps = main.MaxSteps
277 agent.Reasoning = main.Reasoning
278 agent.Temperature = main.Temperature
279 agent.MaxTokens = main.MaxTokens
280 agent.BeforeToolExecute = main.BeforeToolExecute
281 agent.BeforeToolExecuteContext = main.BeforeToolExecuteContext
282 agent.BeforeTurn = main.BeforeTurn
283 agent.BeforeAssistantMessage = main.BeforeAssistantMessage
284 agent.MaxRetries = main.MaxRetries
285 agent.RetryBaseDelay = main.RetryBaseDelay
286 agent.OnEvent = main.OnEvent
287 agent.SetMessages(main.Messages())
288 return agent
289}
290
291func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) {
292 d.mu.Lock()
293 defer d.mu.Unlock()
294 if turnIdx < 0 || turnIdx >= len(d.turns) {
295 return
296 }
297 turn := &d.turns[turnIdx]
298 switch e := ev.(type) {
299 case core.EvTextDelta:
300 turn.Assistant += e.Delta
301 case core.EvToolUseStart:
302 turn.Tools = append(turn.Tools, tui.ToolCallView{ID: e.ID, Name: e.Name, Streaming: true})
303 case core.EvToolUseArgs:
304 if tool := findBtwTool(turn, e.ID); tool != nil {
305 tool.RawJSONBuf += e.Delta
306 if path, ok, _ := tui.ExtractPartialStringField(tool.RawJSONBuf, "path"); ok {
307 tool.LivePath = path
308 } else if path, ok, _ := tui.ExtractPartialStringField(tool.RawJSONBuf, "file_path"); ok {
309 tool.LivePath = path
310 }
311 }
312 case core.EvToolUseEnd:
313 if tool := findBtwTool(turn, e.ID); tool != nil {
314 tool.Streaming = false
315 }
316 case core.EvToolCall:
317 tool := findBtwTool(turn, e.ID)
318 if tool == nil {
319 turn.Tools = append(turn.Tools, tui.ToolCallView{ID: e.ID, Name: e.Name})
320 tool = &turn.Tools[len(turn.Tools)-1]
321 }
322 tool.Name = e.Name
323 tool.Args = tui.ShortArgs(e.Name, e.Args)
324 if tool.RawJSONBuf == "" {
325 tool.RawJSONBuf = string(e.Args)
326 }
327 tool.Streaming = false
328 case core.EvToolResult:
329 if tool := findBtwTool(turn, e.ID); tool != nil {
330 tool.Done = true
331 tool.Error = e.Result.IsError
332 var text strings.Builder
333 for _, content := range e.Result.Content {
334 if block, ok := content.(provider.TextBlock); ok {
335 if text.Len() > 0 {
336 text.WriteString("\n")
337 }
338 text.WriteString(block.Text)
339 }
340 }
341 tool.Result = text.String()
342 }
343 }
344}
345
346func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView {
347 for j := range turn.Tools {
348 if turn.Tools[j].ID == id {
349 return &turn.Tools[j]
350 }
351 }
352 return nil
353}
354
355// completeTurn records an error, if any, and clears the loading state.
356func (d *btwDialog) completeTurn(idx int, errMsg string) {
357 d.mu.Lock()
358 defer d.mu.Unlock()
359 if idx < 0 || idx >= len(d.turns) {
360 return
361 }
362 d.turns[idx].Err = errMsg
363 d.loading = false
364 d.cancel = nil
365}
366
367func renderBtwConfirmation(th tui.Theme, width int, btw *btwDialog, confirm *confirmDialog) []string {
368 rows := padDialogFrame(btw.Render(th, width))
369 rows = append(rows, "")
370 return append(rows, padDialogFrame(confirm.Render(th, width))...)
371}
372
373// Render returns the side-chat panel lines. Called every frame
374// while active.
375func (d *btwDialog) Render(th tui.Theme, width int) []string {
376 d.mu.Lock()
377 defer d.mu.Unlock()
378 if !d.active {
379 return nil
380 }
381
382 var out []string
383 out = append(out, frameHeaderColor(th, "btw - side chat (esc closes; nothing is added to the main thread)", width, th.Accent))
384
385 if len(d.turns) == 0 && !d.loading {
386 out = append(out, " "+th.FG256(th.Muted, "ask anything; replies stay private to this side chat."))
387 }
388
389 for _, t := range d.turns {
390 out = append(out, "")
391 out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...)
392 if len(t.Tools) > 0 {
393 // Match the main transcript's inter-message separator between
394 // the user bubble and the first tool box.
395 out = append(out, "")
396 }
397 for _, tool := range t.Tools {
398 out = append(out, d.toolView.RenderToolCall(tool, width)...)
399 }
400 if t.Assistant != "" {
401 out = append(out, "")
402 out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)
403 }
404 if t.Err != "" {
405 out = append(out, wrapDialogTextRows(th.FG256(th.Error, "✖ "+t.Err), width)...)
406 }
407 }
408
409 if d.loading && d.spin != nil {
410 out = append(out, "")
411 // Match the main chat busy prefix shape: spinner glyph,
412 // rotating funny-line, elapsed seconds, then a muted hint
413 // that esc cancels.
414 prefix := fmt.Sprintf("%s %s - %s",
415 th.FG256(th.Assistant, d.spin.Frame()),
416 th.FG256(th.Assistant, d.spin.Message()),
417 th.FG256(th.Muted, d.spin.Elapsed().String()),
418 )
419 out = append(out, " "+prefix+" "+th.FG256(th.Muted, "(esc cancels)"))
420 }
421
422 out = append(out, "")
423 if d.editor != nil {
424 // Render at width-2 to match the two-cell left indent applied
425 // below. CursorPos uses the same width so the reported cursor
426 // column matches the wrapped layout shown on screen.
427 d.editor.Prompt = th.AccentBar(th.Accent)
428 if d.lineInput {
429 d.editor.Prompt = ""
430 }
431 edLines, _, _ := d.editor.Render(width - 2)
432 for _, l := range edLines {
433 // Indent the editor body so it lines up with the side-chat
434 // content column. Editor's prompt already includes a left
435 // marker, so just two cells of pad.
436 out = append(out, " "+l)
437 }
438 out = append(out, "") // breathing room between editor and frame rule
439 }
440 out = append(out, frameRuleColor(th, width, th.Accent))
441 return out
442}
443
444// CursorRow / CursorCol report where the dialog wants the terminal
445// cursor placed within its render output, so the parent can position
446// the actual terminal cursor on the editor input. Returns (-1, -1)
447// when the dialog isn't active or has no editor.
448func (d *btwDialog) CursorPos(width int) (row, col int) {
449 d.mu.Lock()
450 defer d.mu.Unlock()
451 if !d.active || d.editor == nil {
452 return -1, -1
453 }
454 // Reproduce render's structure to find where the editor sits.
455 // Note: the parent (interactive.go) wraps every dialog with
456 // padDialogFrame. It only injects a blank row after the frame
457 // header when Render did not already put one there. With existing
458 // turns or a loading spinner, Render's first body row is already
459 // blank, so counting an extra pad row would place the cursor one
460 // row too low.
461 editorOffset := 1 // header
462 if len(d.turns) == 0 && !d.loading {
463 editorOffset++ // padDialogFrame's post-header blank
464 editorOffset++ // muted "ask anything..." line
465 }
466 for _, t := range d.turns {
467 editorOffset++ // blank
468 editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))
469 if len(t.Tools) > 0 {
470 editorOffset++ // main transcript's user-to-tool separator
471 }
472 for _, tool := range t.Tools {
473 editorOffset += len(d.toolView.RenderToolCall(tool, width))
474 }
475 if t.Assistant != "" {
476 editorOffset++ // blank
477 editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))
478 }
479 if t.Err != "" {
480 editorOffset += len(wrapDialogTextRows(d.theme.FG256(d.theme.Error, "✖ "+t.Err), width))
481 }
482 }
483 if d.loading {
484 editorOffset++ // blank
485 editorOffset++ // spinner line
486 }
487 editorOffset++ // pre-editor blank
488 d.editor.Prompt = d.theme.AccentBar(d.theme.Accent)
489 if d.lineInput {
490 d.editor.Prompt = ""
491 }
492 _, eRow, eCol := d.editor.Render(width - 2)
493 return editorOffset + eRow, eCol + 2 /* matches render indent */
494}
495
496// btwUserBubbleRows renders a user message inside the /btw dialog
497// using the same bubble layout the main chat uses (full-width tinted
498// panel, left-edge ▌ bar, padding rows above and below). The frame
499// padding is the caller's job; bubbleWidth is the available row
500// width inside the frame.
501func btwUserBubbleRows(th tui.Theme, text string, bubbleWidth int, compactMode bool) []string {
502 const leftGutter = 0
503 const rightGutter = 2
504 innerWidth := bubbleWidth - 2 - leftGutter - rightGutter // 2 = bar's two cells
505 if innerWidth < 1 {
506 innerWidth = 1
507 }
508 bar := th.BG(th.UserBubbleBG, th.FG256(th.Accent, "▌ "))
509 row := func(content string) string {
510 inner := strings.Repeat(" ", leftGutter) + content
511 return " " + bar + th.UserBubble(inner, bubbleWidth-2)
512 }
513 if compactMode {
514 innerWidth = bubbleWidth - 2
515 if innerWidth < 1 {
516 innerWidth = 1
517 }
518 row = func(content string) string {
519 return " " + th.FG256(th.Accent, "▌ ") + th.FG256(th.Muted, content)
520 }
521 }
522 var bubble []string
523 for _, l := range strings.Split(text, "\n") {
524 for _, w := range tui.WrapANSILine(l, innerWidth) {
525 bubble = append(bubble, row(w))
526 }
527 }
528 if len(bubble) == 0 {
529 return nil
530 }
531 if compactMode {
532 return bubble
533 }
534 out := make([]string, 0, len(bubble)+2)
535 out = append(out, row(""))
536 out = append(out, bubble...)
537 out = append(out, row(""))
538 return out
539}
540
541// errMessage is a tiny helper for the future when we want to surface
542// retryable failures in a styled way.
543func errMessage(err error) string {
544 if err == nil {
545 return ""
546 }
547 return fmt.Sprintf("error: %s", err.Error())
548}