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