detached 2d8ec25

Fix tool calls in btw side chats

commit
2d8ec253db7ad08e843253ed71a198e56f997435
author
patriceckhart · · 2026-07-23 16:22 UTC
parent
6af4e0f
signature
unsigned
refs
tag: v0.3.3
6 files · +419 -84
M README.md +1 -1
@@ -295,7 +295,7 @@ Jumping is non-destructive. The transcript is untouched, the viewport just scrol
295295
296296 Opens a side-chat overlay with the full main session as frozen context, so you can ask quick clarifying questions ("does asyncio.gather() catch exceptions?", "btw the bundle budget is 10MB", "what's the default fetch timeout?") without bloating the main thread.
297297
298-Each question fires a one-off model call against `system + main transcript + side-chat history so far`. Responses render in the overlay and stay there. When you press `esc` to close, **nothing** has been added to the main session and subsequent main-thread turns don't re-read any of the side-chat exchanges, keeping the running context window lean.
298+Each question runs an isolated agent turn against `system + main transcript + side-chat history so far`. The side chat has the same tools and guards as the main chat, including per-tool confirmation when `--no-yolo` is active. Responses, tool calls, and tool results stay in the overlay. When you press `esc` to close, **nothing** has been added to the main session and subsequent main-thread turns don't re-read any of the side-chat exchanges, keeping the running context window lean.
299299
300300 ```
301301 /btw # open the overlay, type questions interactively
@@ -295,7 +295,7 @@ Jumping is non-destructive. The transcript is untouched, the viewport just scrol
295 295
296 Opens a side-chat overlay with the full main session as frozen context, so you can ask quick clarifying questions ("does asyncio.gather() catch exceptions?", "btw the bundle budget is 10MB", "what's the default fetch timeout?") without bloating the main thread.296 Opens a side-chat overlay with the full main session as frozen context, so you can ask quick clarifying questions ("does asyncio.gather() catch exceptions?", "btw the bundle budget is 10MB", "what's the default fetch timeout?") without bloating the main thread.
297 297
298-Each question fires a one-off model call against `system + main transcript + side-chat history so far`. Responses render in the overlay and stay there. When you press `esc` to close, **nothing** has been added to the main session and subsequent main-thread turns don't re-read any of the side-chat exchanges, keeping the running context window lean.298+Each question runs an isolated agent turn against `system + main transcript + side-chat history so far`. The side chat has the same tools and guards as the main chat, including per-tool confirmation when `--no-yolo` is active. Responses, tool calls, and tool results stay in the overlay. When you press `esc` to close, **nothing** has been added to the main session and subsequent main-thread turns don't re-read any of the side-chat exchanges, keeping the running context window lean.
299 299
300 ```300 ```
301 /btw # open the overlay, type questions interactively301 /btw # open the overlay, type questions interactively
M packages/agent/modes/btw_dialog.go +152 -79
@@ -5,7 +5,6 @@ import (
55 "fmt"
66 "strings"
77 "sync"
8- "time"
98
109 "github.com/patriceckhart/zot/packages/core"
1110 "github.com/patriceckhart/zot/packages/provider"
@@ -18,11 +17,12 @@ import (
1817 type btwTurn struct {
1918 User string
2019 Assistant string
20+ Tools []tui.ToolCallView
2121 Err string
2222 }
2323
2424 // 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
25+// user's question, runs an isolated agent turn against the live
2626 // snapshot of the main session plus any prior side-chat turns,
2727 // renders the assistant reply through the markdown pipeline, and
2828 // keeps the main transcript completely untouched.
@@ -46,19 +46,15 @@ type btwDialog struct {
4646 // completed main turn).
4747 spin *spinner
4848
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
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
5453
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
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
6258
6359 // cwd is the working directory used to resolve relative paths
6460 // when the user presses Tab on a path-like token in the side-
@@ -96,18 +92,48 @@ func (d *btwDialog) Loading() bool {
9692 return d.active && d.loading
9793 }
9894
95+// SetToolPreview attaches the side-effect-free confirmation preview to the
96+// matching side-chat tool box before execution begins.
97+func (d *btwDialog) SetToolPreview(id, summary, content string) {
98+ if d == nil || id == "" {
99+ return
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
108+ }
109+ }
110+}
111+
112+// ToggleToolExpansion expands or collapses long tool results in the side chat.
113+func (d *btwDialog) ToggleToolExpansion() {
114+ if d == nil {
115+ return
116+ }
117+ d.mu.Lock()
118+ defer d.mu.Unlock()
119+ if d.toolView != nil {
120+ d.toolView.ExpandAll = !d.toolView.ExpandAll
121+ }
122+}
123+
99124 // Open enters the side chat. agent supplies the live transcript and
100125 // system prompt, plus the underlying provider client to use for the
101126 // one-off completion. seed is an optional first question that gets
102127 // auto-submitted (so /btw <text> starts a conversation right away).
103128 // invalidate, if non-nil, is called after each state change so the
104129 // host redraw loop can pick up the update without polling.
105-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, lineInput bool, invalidate func()) {
130+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {
106131 d.mu.Lock()
107132 d.active = true
108133 d.theme = th
109134 d.compactMode = compactMode
110135 d.lineInput = lineInput
136+ d.toolView = &tui.View{Theme: th, CompactMode: compactMode, FlatTools: flatTools}
111137 d.turns = nil
112138 d.loading = false
113139 d.cancel = nil
@@ -116,10 +142,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
116142 prompt = ""
117143 }
118144 d.editor = tui.NewEditor(prompt)
119- d.frozenSystem = system
120- d.frozenMsgs = agent.Messages()
121- d.client = agent.Client
122- d.model = model
145+ d.sideAgent = newBtwAgent(agent, system, model)
123146 d.cwd = cwd
124147 d.mu.Unlock()
125148
@@ -138,8 +161,8 @@ func (d *btwDialog) Close() {
138161 d.loading = false
139162 cancel := d.cancel
140163 d.cancel = nil
141- d.frozenMsgs = nil
142- d.client = nil
164+ d.sideAgent = nil
165+ d.toolView = nil
143166 d.mu.Unlock()
144167 if cancel != nil {
145168 cancel()
@@ -224,91 +247,127 @@ func (d *btwDialog) submit(invalidate func()) {
224247 d.turns = append(d.turns, btwTurn{User: question})
225248 turnIdx := len(d.turns) - 1
226249
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-
251250 ctx, cancel := context.WithCancel(context.Background())
252251 d.cancel = cancel
253- client := d.client
254- model := d.model
255- system := d.frozenSystem
252+ agent := d.sideAgent
256253 d.mu.Unlock()
257254
258255 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())
256+ err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {
257+ d.handleAgentEvent(turnIdx, ev)
268258 if invalidate != nil {
269259 invalidate()
270260 }
271- return
261+ })
262+ errMsg := ""
263+ if err != nil && ctx.Err() == nil {
264+ errMsg = err.Error()
265+ }
266+ d.completeTurn(turnIdx, errMsg)
267+ if invalidate != nil {
268+ invalidate()
272269 }
270+ }()
271+}
272+
273+func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
274+ agent := core.NewAgent(main.Client, model, system, main.Tools)
275+ agent.MaxSteps = main.MaxSteps
276+ agent.Reasoning = main.Reasoning
277+ agent.Temperature = main.Temperature
278+ agent.MaxTokens = main.MaxTokens
279+ agent.BeforeToolExecute = main.BeforeToolExecute
280+ agent.BeforeTurn = main.BeforeTurn
281+ agent.BeforeAssistantMessage = main.BeforeAssistantMessage
282+ agent.MaxRetries = main.MaxRetries
283+ agent.RetryBaseDelay = main.RetryBaseDelay
284+ agent.OnEvent = main.OnEvent
285+ agent.SetMessages(main.Messages())
286+ return agent
287+}
273288
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
289+func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) {
290+ d.mu.Lock()
291+ defer d.mu.Unlock()
292+ if turnIdx < 0 || turnIdx >= len(d.turns) {
293+ return
294+ }
295+ turn := &d.turns[turnIdx]
296+ switch e := ev.(type) {
297+ case core.EvTextDelta:
298+ turn.Assistant += e.Delta
299+ case core.EvToolUseStart:
300+ turn.Tools = append(turn.Tools, tui.ToolCallView{ID: e.ID, Name: e.Name, Streaming: true})
301+ case core.EvToolUseArgs:
302+ if tool := findBtwTool(turn, e.ID); tool != nil {
303+ tool.RawJSONBuf += e.Delta
304+ if path, ok, _ := tui.ExtractPartialStringField(tool.RawJSONBuf, "path"); ok {
305+ tool.LivePath = path
306+ } else if path, ok, _ := tui.ExtractPartialStringField(tool.RawJSONBuf, "file_path"); ok {
307+ tool.LivePath = path
308+ }
309+ }
310+ case core.EvToolUseEnd:
311+ if tool := findBtwTool(turn, e.ID); tool != nil {
312+ tool.Streaming = false
313+ }
314+ case core.EvToolCall:
315+ tool := findBtwTool(turn, e.ID)
316+ if tool == nil {
317+ turn.Tools = append(turn.Tools, tui.ToolCallView{ID: e.ID, Name: e.Name})
318+ tool = &turn.Tools[len(turn.Tools)-1]
319+ }
320+ tool.Name = e.Name
321+ tool.Args = tui.ShortArgs(e.Name, e.Args)
322+ if tool.RawJSONBuf == "" {
323+ tool.RawJSONBuf = string(e.Args)
324+ }
325+ tool.Streaming = false
326+ case core.EvToolResult:
327+ if tool := findBtwTool(turn, e.ID); tool != nil {
328+ tool.Done = true
329+ tool.Error = e.Result.IsError
330+ var text strings.Builder
331+ for _, content := range e.Result.Content {
332+ if block, ok := content.(provider.TextBlock); ok {
333+ if text.Len() > 0 {
334+ text.WriteString("\n")
335+ }
336+ text.WriteString(block.Text)
283337 }
284338 }
339+ tool.Result = text.String()
285340 }
341+ }
342+}
286343
287- errMsg := ""
288- if finalErr != nil && ctx.Err() == nil {
289- errMsg = finalErr.Error()
344+func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView {
345+ for j := range turn.Tools {
346+ if turn.Tools[j].ID == id {
347+ return &turn.Tools[j]
290348 }
291- d.completeTurn(turnIdx, reply.String(), errMsg)
292- if invalidate != nil {
293- invalidate()
294- }
295- }()
349+ }
350+ return nil
296351 }
297352
298-// completeTurn fills in the assistant text or error for the turn at
299-// idx and clears the loading state.
300-func (d *btwDialog) completeTurn(idx int, assistant, errMsg string) {
353+// completeTurn records an error, if any, and clears the loading state.
354+func (d *btwDialog) completeTurn(idx int, errMsg string) {
301355 d.mu.Lock()
302356 defer d.mu.Unlock()
303357 if idx < 0 || idx >= len(d.turns) {
304358 return
305359 }
306- d.turns[idx].Assistant = assistant
307360 d.turns[idx].Err = errMsg
308361 d.loading = false
309362 d.cancel = nil
310363 }
311364
365+func renderBtwConfirmation(th tui.Theme, width int, btw *btwDialog, confirm *confirmDialog) []string {
366+ rows := padDialogFrame(btw.Render(th, width))
367+ rows = append(rows, "")
368+ return append(rows, padDialogFrame(confirm.Render(th, width))...)
369+}
370+
312371 // Render returns the side-chat panel lines. Called every frame
313372 // while active.
314373 func (d *btwDialog) Render(th tui.Theme, width int) []string {
@@ -328,6 +387,14 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
328387 for _, t := range d.turns {
329388 out = append(out, "")
330389 out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...)
390+ if len(t.Tools) > 0 {
391+ // Match the main transcript's inter-message separator between
392+ // the user bubble and the first tool box.
393+ out = append(out, "")
394+ }
395+ for _, tool := range t.Tools {
396+ out = append(out, d.toolView.RenderToolCall(tool, width)...)
397+ }
331398 if t.Assistant != "" {
332399 out = append(out, "")
333400 out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)
@@ -397,6 +464,12 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
397464 for _, t := range d.turns {
398465 editorOffset++ // blank
399466 editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))
467+ if len(t.Tools) > 0 {
468+ editorOffset++ // main transcript's user-to-tool separator
469+ }
470+ for _, tool := range t.Tools {
471+ editorOffset += len(d.toolView.RenderToolCall(tool, width))
472+ }
400473 if t.Assistant != "" {
401474 editorOffset++ // blank
402475 editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))
@@ -5,7 +5,6 @@ import (
5 "fmt"5 "fmt"
6 "strings"6 "strings"
7 "sync"7 "sync"
8- "time"
9 8
10 "github.com/patriceckhart/zot/packages/core"9 "github.com/patriceckhart/zot/packages/core"
11 "github.com/patriceckhart/zot/packages/provider"10 "github.com/patriceckhart/zot/packages/provider"
@@ -18,11 +17,12 @@ import (
18 type btwTurn struct {17 type btwTurn struct {
19 User string18 User string
20 Assistant string19 Assistant string
20+ Tools []tui.ToolCallView
21 Err string21 Err string
22 }22 }
23 23
24 // btwDialog is the side-chat overlay opened by /btw. It shows the24 // btwDialog is the side-chat overlay opened by /btw. It shows the
25-// user's question, runs a one-off model call against the live25+// user's question, runs an isolated agent turn against the live
26 // snapshot of the main session plus any prior side-chat turns,26 // snapshot of the main session plus any prior side-chat turns,
27 // renders the assistant reply through the markdown pipeline, and27 // renders the assistant reply through the markdown pipeline, and
28 // keeps the main transcript completely untouched.28 // keeps the main transcript completely untouched.
@@ -46,19 +46,15 @@ type btwDialog struct {
46 // completed main turn).46 // completed main turn).
47 spin *spinner47 spin *spinner
48 48
49- // Frozen at Open() time so the side-chat keeps a stable view of49+ // sideAgent owns an isolated transcript seeded from the main
50- // the main thread even if a turn happens to land on the main50+ // conversation. It uses the same tools and runtime hooks as the
51- // agent while the dialog is open (rare but possible).51+ // main agent, but has no persistence callbacks.
52- frozenSystem string52+ sideAgent *core.Agent
53- frozenMsgs []provider.Message
54 53
55- // Provider details captured at open time; used by send() to54+ // Theme and toolView are cached so render() uses the same tool-call
56- // build the request without going back through the agent.55+ // presentation and collapse behavior as the main transcript.
57- client provider.Client56+ theme tui.Theme
58- model string57+ toolView *tui.View
59-
60- // Theme cached so render() doesn't need it threaded through.
61- theme tui.Theme
62 58
63 // cwd is the working directory used to resolve relative paths59 // cwd is the working directory used to resolve relative paths
64 // when the user presses Tab on a path-like token in the side-60 // when the user presses Tab on a path-like token in the side-
@@ -96,18 +92,48 @@ func (d *btwDialog) Loading() bool {
96 return d.active && d.loading92 return d.active && d.loading
97 }93 }
98 94
95+// SetToolPreview attaches the side-effect-free confirmation preview to the
96+// matching side-chat tool box before execution begins.
97+func (d *btwDialog) SetToolPreview(id, summary, content string) {
98+ if d == nil || id == "" {
99+ return
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
108+ }
109+ }
110+}
111+
112+// ToggleToolExpansion expands or collapses long tool results in the side chat.
113+func (d *btwDialog) ToggleToolExpansion() {
114+ if d == nil {
115+ return
116+ }
117+ d.mu.Lock()
118+ defer d.mu.Unlock()
119+ if d.toolView != nil {
120+ d.toolView.ExpandAll = !d.toolView.ExpandAll
121+ }
122+}
123+
99 // Open enters the side chat. agent supplies the live transcript and124 // Open enters the side chat. agent supplies the live transcript and
100 // system prompt, plus the underlying provider client to use for the125 // system prompt, plus the underlying provider client to use for the
101 // one-off completion. seed is an optional first question that gets126 // one-off completion. seed is an optional first question that gets
102 // auto-submitted (so /btw <text> starts a conversation right away).127 // auto-submitted (so /btw <text> starts a conversation right away).
103 // invalidate, if non-nil, is called after each state change so the128 // invalidate, if non-nil, is called after each state change so the
104 // host redraw loop can pick up the update without polling.129 // host redraw loop can pick up the update without polling.
105-func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode bool, lineInput bool, invalidate func()) {130+func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, seed string, compactMode, flatTools bool, lineInput bool, invalidate func()) {
106 d.mu.Lock()131 d.mu.Lock()
107 d.active = true132 d.active = true
108 d.theme = th133 d.theme = th
109 d.compactMode = compactMode134 d.compactMode = compactMode
110 d.lineInput = lineInput135 d.lineInput = lineInput
136+ d.toolView = &tui.View{Theme: th, CompactMode: compactMode, FlatTools: flatTools}
111 d.turns = nil137 d.turns = nil
112 d.loading = false138 d.loading = false
113 d.cancel = nil139 d.cancel = nil
@@ -116,10 +142,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se
116 prompt = ""142 prompt = ""
117 }143 }
118 d.editor = tui.NewEditor(prompt)144 d.editor = tui.NewEditor(prompt)
119- d.frozenSystem = system145+ d.sideAgent = newBtwAgent(agent, system, model)
120- d.frozenMsgs = agent.Messages()
121- d.client = agent.Client
122- d.model = model
123 d.cwd = cwd146 d.cwd = cwd
124 d.mu.Unlock()147 d.mu.Unlock()
125 148
@@ -138,8 +161,8 @@ func (d *btwDialog) Close() {
138 d.loading = false161 d.loading = false
139 cancel := d.cancel162 cancel := d.cancel
140 d.cancel = nil163 d.cancel = nil
141- d.frozenMsgs = nil164+ d.sideAgent = nil
142- d.client = nil165+ d.toolView = nil
143 d.mu.Unlock()166 d.mu.Unlock()
144 if cancel != nil {167 if cancel != nil {
145 cancel()168 cancel()
@@ -224,91 +247,127 @@ func (d *btwDialog) submit(invalidate func()) {
224 d.turns = append(d.turns, btwTurn{User: question})247 d.turns = append(d.turns, btwTurn{User: question})
225 turnIdx := len(d.turns) - 1248 turnIdx := len(d.turns) - 1
226 249
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())250 ctx, cancel := context.WithCancel(context.Background())
252 d.cancel = cancel251 d.cancel = cancel
253- client := d.client252+ agent := d.sideAgent
254- model := d.model
255- system := d.frozenSystem
256 d.mu.Unlock()253 d.mu.Unlock()
257 254
258 go func() {255 go func() {
259- req := provider.Request{256+ err := agent.Prompt(ctx, question, nil, func(ev core.AgentEvent) {
260- Model: model,257+ d.handleAgentEvent(turnIdx, ev)
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 {258 if invalidate != nil {
269 invalidate()259 invalidate()
270 }260 }
271- return261+ })
262+ errMsg := ""
263+ if err != nil && ctx.Err() == nil {
264+ errMsg = err.Error()
265+ }
266+ d.completeTurn(turnIdx, errMsg)
267+ if invalidate != nil {
268+ invalidate()
272 }269 }
270+ }()
271+}
272+
273+func newBtwAgent(main *core.Agent, system, model string) *core.Agent {
274+ agent := core.NewAgent(main.Client, model, system, main.Tools)
275+ agent.MaxSteps = main.MaxSteps
276+ agent.Reasoning = main.Reasoning
277+ agent.Temperature = main.Temperature
278+ agent.MaxTokens = main.MaxTokens
279+ agent.BeforeToolExecute = main.BeforeToolExecute
280+ agent.BeforeTurn = main.BeforeTurn
281+ agent.BeforeAssistantMessage = main.BeforeAssistantMessage
282+ agent.MaxRetries = main.MaxRetries
283+ agent.RetryBaseDelay = main.RetryBaseDelay
284+ agent.OnEvent = main.OnEvent
285+ agent.SetMessages(main.Messages())
286+ return agent
287+}
273 288
274- var reply strings.Builder289+func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) {
275- var finalErr error290+ d.mu.Lock()
276- for ev := range stream {291+ defer d.mu.Unlock()
277- switch e := ev.(type) {292+ if turnIdx < 0 || turnIdx >= len(d.turns) {
278- case provider.EventTextDelta:293+ return
279- reply.WriteString(e.Delta)294+ }
280- case provider.EventDone:295+ turn := &d.turns[turnIdx]
281- if e.Err != nil {296+ switch e := ev.(type) {
282- finalErr = e.Err297+ case core.EvTextDelta:
298+ turn.Assistant += e.Delta
299+ case core.EvToolUseStart:
300+ turn.Tools = append(turn.Tools, tui.ToolCallView{ID: e.ID, Name: e.Name, Streaming: true})
301+ case core.EvToolUseArgs:
302+ if tool := findBtwTool(turn, e.ID); tool != nil {
303+ tool.RawJSONBuf += e.Delta
304+ if path, ok, _ := tui.ExtractPartialStringField(tool.RawJSONBuf, "path"); ok {
305+ tool.LivePath = path
306+ } else if path, ok, _ := tui.ExtractPartialStringField(tool.RawJSONBuf, "file_path"); ok {
307+ tool.LivePath = path
308+ }
309+ }
310+ case core.EvToolUseEnd:
311+ if tool := findBtwTool(turn, e.ID); tool != nil {
312+ tool.Streaming = false
313+ }
314+ case core.EvToolCall:
315+ tool := findBtwTool(turn, e.ID)
316+ if tool == nil {
317+ turn.Tools = append(turn.Tools, tui.ToolCallView{ID: e.ID, Name: e.Name})
318+ tool = &turn.Tools[len(turn.Tools)-1]
319+ }
320+ tool.Name = e.Name
321+ tool.Args = tui.ShortArgs(e.Name, e.Args)
322+ if tool.RawJSONBuf == "" {
323+ tool.RawJSONBuf = string(e.Args)
324+ }
325+ tool.Streaming = false
326+ case core.EvToolResult:
327+ if tool := findBtwTool(turn, e.ID); tool != nil {
328+ tool.Done = true
329+ tool.Error = e.Result.IsError
330+ var text strings.Builder
331+ for _, content := range e.Result.Content {
332+ if block, ok := content.(provider.TextBlock); ok {
333+ if text.Len() > 0 {
334+ text.WriteString("\n")
335+ }
336+ text.WriteString(block.Text)
283 }337 }
284 }338 }
339+ tool.Result = text.String()
285 }340 }
341+ }
342+}
286 343
287- errMsg := ""344+func findBtwTool(turn *btwTurn, id string) *tui.ToolCallView {
288- if finalErr != nil && ctx.Err() == nil {345+ for j := range turn.Tools {
289- errMsg = finalErr.Error()346+ if turn.Tools[j].ID == id {
347+ return &turn.Tools[j]
290 }348 }
291- d.completeTurn(turnIdx, reply.String(), errMsg)349+ }
292- if invalidate != nil {350+ return nil
293- invalidate()
294- }
295- }()
296 }351 }
297 352
298-// completeTurn fills in the assistant text or error for the turn at353+// completeTurn records an error, if any, and clears the loading state.
299-// idx and clears the loading state.354+func (d *btwDialog) completeTurn(idx int, errMsg string) {
300-func (d *btwDialog) completeTurn(idx int, assistant, errMsg string) {
301 d.mu.Lock()355 d.mu.Lock()
302 defer d.mu.Unlock()356 defer d.mu.Unlock()
303 if idx < 0 || idx >= len(d.turns) {357 if idx < 0 || idx >= len(d.turns) {
304 return358 return
305 }359 }
306- d.turns[idx].Assistant = assistant
307 d.turns[idx].Err = errMsg360 d.turns[idx].Err = errMsg
308 d.loading = false361 d.loading = false
309 d.cancel = nil362 d.cancel = nil
310 }363 }
311 364
365+func renderBtwConfirmation(th tui.Theme, width int, btw *btwDialog, confirm *confirmDialog) []string {
366+ rows := padDialogFrame(btw.Render(th, width))
367+ rows = append(rows, "")
368+ return append(rows, padDialogFrame(confirm.Render(th, width))...)
369+}
370+
312 // Render returns the side-chat panel lines. Called every frame371 // Render returns the side-chat panel lines. Called every frame
313 // while active.372 // while active.
314 func (d *btwDialog) Render(th tui.Theme, width int) []string {373 func (d *btwDialog) Render(th tui.Theme, width int) []string {
@@ -328,6 +387,14 @@ func (d *btwDialog) Render(th tui.Theme, width int) []string {
328 for _, t := range d.turns {387 for _, t := range d.turns {
329 out = append(out, "")388 out = append(out, "")
330 out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...)389 out = append(out, btwUserBubbleRows(th, t.User, width-2, d.compactMode)...)
390+ if len(t.Tools) > 0 {
391+ // Match the main transcript's inter-message separator between
392+ // the user bubble and the first tool box.
393+ out = append(out, "")
394+ }
395+ for _, tool := range t.Tools {
396+ out = append(out, d.toolView.RenderToolCall(tool, width)...)
397+ }
331 if t.Assistant != "" {398 if t.Assistant != "" {
332 out = append(out, "")399 out = append(out, "")
333 out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)400 out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...)
@@ -397,6 +464,12 @@ func (d *btwDialog) CursorPos(width int) (row, col int) {
397 for _, t := range d.turns {464 for _, t := range d.turns {
398 editorOffset++ // blank465 editorOffset++ // blank
399 editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))466 editorOffset += len(btwUserBubbleRows(d.theme, t.User, width-2, d.compactMode))
467+ if len(t.Tools) > 0 {
468+ editorOffset++ // main transcript's user-to-tool separator
469+ }
470+ for _, tool := range t.Tools {
471+ editorOffset += len(d.toolView.RenderToolCall(tool, width))
472+ }
400 if t.Assistant != "" {473 if t.Assistant != "" {
401 editorOffset++ // blank474 editorOffset++ // blank
402 editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))475 editorOffset += len(renderDialogMarkdownRows(t.Assistant, d.theme, width))
A packages/agent/modes/btw_dialog_test.go +203 -0
@@ -0,0 +1,203 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+ "github.com/patriceckhart/zot/packages/tui"
15+)
16+
17+type btwToolTestClient struct {
18+ mu sync.Mutex
19+ requests []provider.Request
20+ done chan struct{}
21+}
22+
23+func (c *btwToolTestClient) Name() string { return "btw-test" }
24+
25+func (c *btwToolTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
26+ c.mu.Lock()
27+ c.requests = append(c.requests, req)
28+ call := len(c.requests)
29+ c.mu.Unlock()
30+
31+ out := make(chan provider.Event, 4)
32+ go func() {
33+ defer close(out)
34+ if call == 1 {
35+ out <- provider.EventToolStart{ID: "tool-1", Name: "echo"}
36+ out <- provider.EventToolArgs{ID: "tool-1", Delta: `{"text":"hello"}`}
37+ out <- provider.EventToolEnd{ID: "tool-1"}
38+ out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
39+ Role: provider.RoleAssistant,
40+ Content: []provider.Content{provider.ToolCallBlock{
41+ ID: "tool-1", Name: "echo", Arguments: json.RawMessage(`{"text":"hello"}`),
42+ }},
43+ }}
44+ return
45+ }
46+ out <- provider.EventTextDelta{Delta: "tool finished"}
47+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
48+ Role: provider.RoleAssistant,
49+ Content: []provider.Content{
50+ provider.TextBlock{Text: "tool finished"},
51+ },
52+ }}
53+ close(c.done)
54+ }()
55+ return out, nil
56+}
57+
58+type btwEchoTool struct {
59+ runs atomic.Int32
60+}
61+
62+func (t *btwEchoTool) Name() string { return "echo" }
63+func (t *btwEchoTool) Description() string { return "echo text" }
64+func (t *btwEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
65+func (t *btwEchoTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
66+ t.runs.Add(1)
67+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "hello"}}}, nil
68+}
69+
70+func TestBtwToolExpansionUsesSideChatView(t *testing.T) {
71+ result := strings.Repeat("output line\n", tui.ToolCollapseLines+10)
72+ d := &btwDialog{
73+ active: true,
74+ toolView: &tui.View{Theme: tui.Dark},
75+ turns: []btwTurn{{
76+ User: "show output",
77+ Tools: []tui.ToolCallView{{
78+ ID: "tool-1", Name: "bash", Result: result, Done: true,
79+ }},
80+ }},
81+ editor: tui.NewEditor(""),
82+ }
83+
84+ if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
85+ t.Fatalf("long /btw tool result was not initially collapsed:\n%s", rendered)
86+ }
87+ d.ToggleToolExpansion()
88+ if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
89+ t.Fatalf("long /btw tool result remained collapsed after expansion:\n%s", rendered)
90+ }
91+}
92+
93+func TestBtwCtrlOTogglesSideViewDuringConfirmation(t *testing.T) {
94+ resp := make(chan core.ConfirmDecision, 1)
95+ confirm := newConfirmDialog()
96+ confirm.Enqueue(&confirmRequest{toolName: "bash", resp: resp})
97+ btw := &btwDialog{active: true, toolView: &tui.View{}}
98+ i := &Interactive{
99+ view: &tui.View{},
100+ btwDialog: btw,
101+ confirmDialog: confirm,
102+ dirty: make(chan struct{}, 1),
103+ }
104+
105+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
106+ if !btw.toolView.ExpandAll {
107+ t.Fatal("ctrl+o did not expand /btw tool output during confirmation")
108+ }
109+ if i.view.ExpandAll {
110+ t.Fatal("ctrl+o expanded the main view instead of the active /btw view")
111+ }
112+ if !confirm.Active() {
113+ t.Fatal("ctrl+o closed the confirmation dialog")
114+ }
115+ select {
116+ case decision := <-resp:
117+ t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
118+ default:
119+ }
120+}
121+
122+func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) {
123+ client := &btwToolTestClient{done: make(chan struct{})}
124+ tool := &btwEchoTool{}
125+ main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
126+ main.SetMessages([]provider.Message{{
127+ Role: provider.RoleUser,
128+ Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},
129+ }})
130+ var policyCalls atomic.Int32
131+ main.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
132+ policyCalls.Add(1)
133+ return true, "", nil
134+ }
135+
136+ d := newBtwDialog()
137+ d.Open(tui.Theme{}, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
138+
139+ select {
140+ case <-client.done:
141+ case <-time.After(2 * time.Second):
142+ t.Fatal("timed out waiting for /btw tool turn")
143+ }
144+ deadline := time.Now().Add(2 * time.Second)
145+ for d.Loading() && time.Now().Before(deadline) {
146+ time.Sleep(time.Millisecond)
147+ }
148+ if d.Loading() {
149+ t.Fatal("/btw remained loading after the agent turn completed")
150+ }
151+
152+ if got := tool.runs.Load(); got != 1 {
153+ t.Fatalf("tool executions = %d, want 1", got)
154+ }
155+ if got := policyCalls.Load(); got != 1 {
156+ t.Fatalf("main agent tool-policy calls = %d, want 1", got)
157+ }
158+
159+ client.mu.Lock()
160+ requests := append([]provider.Request(nil), client.requests...)
161+ client.mu.Unlock()
162+ if len(requests) != 2 {
163+ t.Fatalf("provider requests = %d, want 2", len(requests))
164+ }
165+ if len(requests[0].Tools) != 1 || requests[0].Tools[0].Name != "echo" {
166+ t.Fatalf("first /btw request tools = %#v, want echo", requests[0].Tools)
167+ }
168+ if got := len(main.Messages()); got != 1 {
169+ t.Fatalf("main transcript messages = %d, want frozen length 1", got)
170+ }
171+
172+ d.mu.Lock()
173+ if len(d.turns) != 1 {
174+ t.Fatalf("side-chat turns = %d, want 1", len(d.turns))
175+ }
176+ turn := d.turns[0]
177+ if turn.Assistant != "tool finished" {
178+ t.Fatalf("assistant = %q, want %q", turn.Assistant, "tool finished")
179+ }
180+ if len(turn.Tools) != 1 || !turn.Tools[0].Done || turn.Tools[0].Error {
181+ t.Fatalf("rendered tool state = %#v, want one successful completed tool", turn.Tools)
182+ }
183+ d.mu.Unlock()
184+
185+ rows := d.Render(tui.Theme{}, 80)
186+ rendered := strings.Join(rows, "\n")
187+ if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") {
188+ t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered)
189+ }
190+ toolRow := -1
191+ for idx, row := range rows {
192+ if strings.Contains(row, "┌─") {
193+ toolRow = idx
194+ break
195+ }
196+ }
197+ if toolRow < 1 || rows[toolRow-1] != "" {
198+ t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered)
199+ }
200+ if cursorRow, _ := d.CursorPos(80); cursorRow != len(rows)-3 {
201+ t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
202+ }
203+}
@@ -0,0 +1,203 @@
1+package modes
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "strings"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/patriceckhart/zot/packages/core"
13+ "github.com/patriceckhart/zot/packages/provider"
14+ "github.com/patriceckhart/zot/packages/tui"
15+)
16+
17+type btwToolTestClient struct {
18+ mu sync.Mutex
19+ requests []provider.Request
20+ done chan struct{}
21+}
22+
23+func (c *btwToolTestClient) Name() string { return "btw-test" }
24+
25+func (c *btwToolTestClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) {
26+ c.mu.Lock()
27+ c.requests = append(c.requests, req)
28+ call := len(c.requests)
29+ c.mu.Unlock()
30+
31+ out := make(chan provider.Event, 4)
32+ go func() {
33+ defer close(out)
34+ if call == 1 {
35+ out <- provider.EventToolStart{ID: "tool-1", Name: "echo"}
36+ out <- provider.EventToolArgs{ID: "tool-1", Delta: `{"text":"hello"}`}
37+ out <- provider.EventToolEnd{ID: "tool-1"}
38+ out <- provider.EventDone{Stop: provider.StopToolUse, Message: provider.Message{
39+ Role: provider.RoleAssistant,
40+ Content: []provider.Content{provider.ToolCallBlock{
41+ ID: "tool-1", Name: "echo", Arguments: json.RawMessage(`{"text":"hello"}`),
42+ }},
43+ }}
44+ return
45+ }
46+ out <- provider.EventTextDelta{Delta: "tool finished"}
47+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
48+ Role: provider.RoleAssistant,
49+ Content: []provider.Content{
50+ provider.TextBlock{Text: "tool finished"},
51+ },
52+ }}
53+ close(c.done)
54+ }()
55+ return out, nil
56+}
57+
58+type btwEchoTool struct {
59+ runs atomic.Int32
60+}
61+
62+func (t *btwEchoTool) Name() string { return "echo" }
63+func (t *btwEchoTool) Description() string { return "echo text" }
64+func (t *btwEchoTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
65+func (t *btwEchoTool) Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error) {
66+ t.runs.Add(1)
67+ return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "hello"}}}, nil
68+}
69+
70+func TestBtwToolExpansionUsesSideChatView(t *testing.T) {
71+ result := strings.Repeat("output line\n", tui.ToolCollapseLines+10)
72+ d := &btwDialog{
73+ active: true,
74+ toolView: &tui.View{Theme: tui.Dark},
75+ turns: []btwTurn{{
76+ User: "show output",
77+ Tools: []tui.ToolCallView{{
78+ ID: "tool-1", Name: "bash", Result: result, Done: true,
79+ }},
80+ }},
81+ editor: tui.NewEditor(""),
82+ }
83+
84+ if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); !strings.Contains(rendered, "ctrl+o to expand") {
85+ t.Fatalf("long /btw tool result was not initially collapsed:\n%s", rendered)
86+ }
87+ d.ToggleToolExpansion()
88+ if rendered := strings.Join(d.Render(tui.Dark, 80), "\n"); strings.Contains(rendered, "ctrl+o to expand") {
89+ t.Fatalf("long /btw tool result remained collapsed after expansion:\n%s", rendered)
90+ }
91+}
92+
93+func TestBtwCtrlOTogglesSideViewDuringConfirmation(t *testing.T) {
94+ resp := make(chan core.ConfirmDecision, 1)
95+ confirm := newConfirmDialog()
96+ confirm.Enqueue(&confirmRequest{toolName: "bash", resp: resp})
97+ btw := &btwDialog{active: true, toolView: &tui.View{}}
98+ i := &Interactive{
99+ view: &tui.View{},
100+ btwDialog: btw,
101+ confirmDialog: confirm,
102+ dirty: make(chan struct{}, 1),
103+ }
104+
105+ i.handleKey(context.Background(), tui.Key{Kind: tui.KeyCtrlO})
106+ if !btw.toolView.ExpandAll {
107+ t.Fatal("ctrl+o did not expand /btw tool output during confirmation")
108+ }
109+ if i.view.ExpandAll {
110+ t.Fatal("ctrl+o expanded the main view instead of the active /btw view")
111+ }
112+ if !confirm.Active() {
113+ t.Fatal("ctrl+o closed the confirmation dialog")
114+ }
115+ select {
116+ case decision := <-resp:
117+ t.Fatalf("ctrl+o unexpectedly answered confirmation: %+v", decision)
118+ default:
119+ }
120+}
121+
122+func TestBtwDialogRunsToolsWithMainAgentPolicyInIsolatedTranscript(t *testing.T) {
123+ client := &btwToolTestClient{done: make(chan struct{})}
124+ tool := &btwEchoTool{}
125+ main := core.NewAgent(client, "main-model", "main-system", core.Registry{"echo": tool})
126+ main.SetMessages([]provider.Message{{
127+ Role: provider.RoleUser,
128+ Content: []provider.Content{provider.TextBlock{Text: "frozen context"}},
129+ }})
130+ var policyCalls atomic.Int32
131+ main.BeforeToolExecute = func(provider.ToolCallBlock) (bool, string, json.RawMessage) {
132+ policyCalls.Add(1)
133+ return true, "", nil
134+ }
135+
136+ d := newBtwDialog()
137+ d.Open(tui.Theme{}, main, main.System, main.Model, t.TempDir(), "use the echo tool", false, false, true, nil)
138+
139+ select {
140+ case <-client.done:
141+ case <-time.After(2 * time.Second):
142+ t.Fatal("timed out waiting for /btw tool turn")
143+ }
144+ deadline := time.Now().Add(2 * time.Second)
145+ for d.Loading() && time.Now().Before(deadline) {
146+ time.Sleep(time.Millisecond)
147+ }
148+ if d.Loading() {
149+ t.Fatal("/btw remained loading after the agent turn completed")
150+ }
151+
152+ if got := tool.runs.Load(); got != 1 {
153+ t.Fatalf("tool executions = %d, want 1", got)
154+ }
155+ if got := policyCalls.Load(); got != 1 {
156+ t.Fatalf("main agent tool-policy calls = %d, want 1", got)
157+ }
158+
159+ client.mu.Lock()
160+ requests := append([]provider.Request(nil), client.requests...)
161+ client.mu.Unlock()
162+ if len(requests) != 2 {
163+ t.Fatalf("provider requests = %d, want 2", len(requests))
164+ }
165+ if len(requests[0].Tools) != 1 || requests[0].Tools[0].Name != "echo" {
166+ t.Fatalf("first /btw request tools = %#v, want echo", requests[0].Tools)
167+ }
168+ if got := len(main.Messages()); got != 1 {
169+ t.Fatalf("main transcript messages = %d, want frozen length 1", got)
170+ }
171+
172+ d.mu.Lock()
173+ if len(d.turns) != 1 {
174+ t.Fatalf("side-chat turns = %d, want 1", len(d.turns))
175+ }
176+ turn := d.turns[0]
177+ if turn.Assistant != "tool finished" {
178+ t.Fatalf("assistant = %q, want %q", turn.Assistant, "tool finished")
179+ }
180+ if len(turn.Tools) != 1 || !turn.Tools[0].Done || turn.Tools[0].Error {
181+ t.Fatalf("rendered tool state = %#v, want one successful completed tool", turn.Tools)
182+ }
183+ d.mu.Unlock()
184+
185+ rows := d.Render(tui.Theme{}, 80)
186+ rendered := strings.Join(rows, "\n")
187+ if !strings.Contains(rendered, "┌─") || !strings.Contains(rendered, "echo") || !strings.Contains(rendered, "└─") {
188+ t.Fatalf("/btw did not use the standard bordered tool renderer:\n%s", rendered)
189+ }
190+ toolRow := -1
191+ for idx, row := range rows {
192+ if strings.Contains(row, "┌─") {
193+ toolRow = idx
194+ break
195+ }
196+ }
197+ if toolRow < 1 || rows[toolRow-1] != "" {
198+ t.Fatalf("/btw tool box is not separated from the user bubble by a blank row:\n%s", rendered)
199+ }
200+ if cursorRow, _ := d.CursorPos(80); cursorRow != len(rows)-3 {
201+ t.Fatalf("cursor row = %d, want editor row %d after tool spacing change", cursorRow, len(rows)-3)
202+ }
203+}
M packages/agent/modes/confirm_dialog_test.go +21 -0
@@ -14,10 +14,19 @@ import (
1414
1515 func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
1616 dialog := newConfirmDialog()
17+ btw := &btwDialog{
18+ active: true,
19+ toolView: &tui.View{Theme: tui.Dark},
20+ turns: []btwTurn{{Tools: []tui.ToolCallView{{
21+ ID: "call-1", Name: "edit",
22+ }}}},
23+ editor: tui.NewEditor(""),
24+ }
1725 i := &Interactive{
1826 toolCalls: map[string]*tui.ToolCallView{
1927 "call-1": {ID: "call-1", Name: "edit"},
2028 },
29+ btwDialog: btw,
2130 confirmDialog: dialog,
2231 dirty: make(chan struct{}, 1),
2332 }
@@ -44,6 +53,18 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
4453 if preview != "-old\n+new\n" {
4554 t.Fatalf("tool preview = %q", preview)
4655 }
56+ btw.mu.Lock()
57+ btwPreview := btw.turns[0].Tools[0].Preview
58+ btw.mu.Unlock()
59+ if btwPreview != "-old\n+new\n" {
60+ t.Fatalf("/btw tool preview = %q", btwPreview)
61+ }
62+ combined := strings.Join(renderBtwConfirmation(tui.Dark, 80, btw, dialog), "\n")
63+ for _, want := range []string{"btw - side chat", "old", "new", "confirm tool call"} {
64+ if !strings.Contains(combined, want) {
65+ t.Fatalf("combined /btw confirmation missing %q:\n%s", want, combined)
66+ }
67+ }
4768
4869 dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
4970 select {
@@ -14,10 +14,19 @@ import (
14 14
15 func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {15 func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
16 dialog := newConfirmDialog()16 dialog := newConfirmDialog()
17+ btw := &btwDialog{
18+ active: true,
19+ toolView: &tui.View{Theme: tui.Dark},
20+ turns: []btwTurn{{Tools: []tui.ToolCallView{{
21+ ID: "call-1", Name: "edit",
22+ }}}},
23+ editor: tui.NewEditor(""),
24+ }
17 i := &Interactive{25 i := &Interactive{
18 toolCalls: map[string]*tui.ToolCallView{26 toolCalls: map[string]*tui.ToolCallView{
19 "call-1": {ID: "call-1", Name: "edit"},27 "call-1": {ID: "call-1", Name: "edit"},
20 },28 },
29+ btwDialog: btw,
21 confirmDialog: dialog,30 confirmDialog: dialog,
22 dirty: make(chan struct{}, 1),31 dirty: make(chan struct{}, 1),
23 }32 }
@@ -44,6 +53,18 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) {
44 if preview != "-old\n+new\n" {53 if preview != "-old\n+new\n" {
45 t.Fatalf("tool preview = %q", preview)54 t.Fatalf("tool preview = %q", preview)
46 }55 }
56+ btw.mu.Lock()
57+ btwPreview := btw.turns[0].Tools[0].Preview
58+ btw.mu.Unlock()
59+ if btwPreview != "-old\n+new\n" {
60+ t.Fatalf("/btw tool preview = %q", btwPreview)
61+ }
62+ combined := strings.Join(renderBtwConfirmation(tui.Dark, 80, btw, dialog), "\n")
63+ for _, want := range []string{"btw - side chat", "old", "new", "confirm tool call"} {
64+ if !strings.Contains(combined, want) {
65+ t.Fatalf("combined /btw confirmation missing %q:\n%s", want, combined)
66+ }
67+ }
47 68
48 dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})69 dialog.HandleKey(tui.Key{Kind: tui.KeyEnter})
49 select {70 select {
M packages/agent/modes/interactive.go +32 -4
@@ -1145,14 +1145,21 @@ func (i *Interactive) redraw() {
11451145 dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
11461146 case i.jumpDialog.Active():
11471147 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
1148+ case i.confirmDialog.Active():
1149+ if i.btwDialog.Active() {
1150+ // Keep the side-chat transcript and its live tool preview visible
1151+ // above confirmation, matching how the main transcript remains
1152+ // visible while --no-yolo waits for a decision.
1153+ dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)
1154+ } else {
1155+ dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
1156+ }
11481157 case i.btwDialog.Active():
11491158 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
11501159 case i.skillsDialog.Active():
11511160 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
11521161 case i.changelogDialog.Active():
11531162 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
1154- case i.confirmDialog.Active():
1155- dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
11561163 case i.logoutDialog.Active():
11571164 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
11581165 case i.telegramDialog.Active():
@@ -1849,6 +1856,16 @@ func (i *Interactive) toggleToolExpansion() {
18491856 i.setToolExpansion(expanded)
18501857 }
18511858
1859+func (i *Interactive) toggleBtwToolExpansion() {
1860+ i.btwDialog.ToggleToolExpansion()
1861+ i.mu.Lock()
1862+ if i.rend != nil {
1863+ i.rend.Clear()
1864+ }
1865+ i.mu.Unlock()
1866+ i.invalidate()
1867+}
1868+
18521869 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18531870 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
18541871 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
@@ -1872,7 +1889,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18721889 // user can expand or collapse a tool preview before deciding.
18731890 if i.confirmDialog.Active() {
18741891 if k.Kind == tui.KeyCtrlO {
1875- i.toggleToolExpansion()
1892+ if i.btwDialog != nil && i.btwDialog.Active() {
1893+ i.toggleBtwToolExpansion()
1894+ } else {
1895+ i.toggleToolExpansion()
1896+ }
18761897 return false
18771898 }
18781899 i.confirmDialog.HandleKey(k)
@@ -2086,6 +2107,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
20862107 i.invalidate()
20872108 return false
20882109 }
2110+ if k.Kind == tui.KeyCtrlO {
2111+ i.toggleBtwToolExpansion()
2112+ return false
2113+ }
20892114 i.btwDialog.HandleKey(k, i.invalidate)
20902115 return false
20912116 }
@@ -4495,7 +4520,7 @@ func (i *Interactive) openBtwDialog(args []string) {
44954520 return
44964521 }
44974522 seed := strings.TrimSpace(strings.Join(args, " "))
4498- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
4523+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
44994524 i.invalidate()
45004525 }
45014526
@@ -5628,6 +5653,9 @@ func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.Confi
56285653 tc.Preview = call.Content
56295654 }
56305655 i.mu.Unlock()
5656+ if i.btwDialog != nil && i.btwDialog.Active() {
5657+ i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
5658+ }
56315659 }
56325660 i.confirmDialog.Enqueue(&confirmRequest{
56335661 toolName: call.Name,
@@ -1145,14 +1145,21 @@ func (i *Interactive) redraw() {
1145 dialog = i.swarmDialog.Render(i.cfg.Theme, cols)1145 dialog = i.swarmDialog.Render(i.cfg.Theme, cols)
1146 case i.jumpDialog.Active():1146 case i.jumpDialog.Active():
1147 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)1147 dialog = i.jumpDialog.Render(i.cfg.Theme, cols)
1148+ case i.confirmDialog.Active():
1149+ if i.btwDialog.Active() {
1150+ // Keep the side-chat transcript and its live tool preview visible
1151+ // above confirmation, matching how the main transcript remains
1152+ // visible while --no-yolo waits for a decision.
1153+ dialog = renderBtwConfirmation(i.cfg.Theme, cols, i.btwDialog, i.confirmDialog)
1154+ } else {
1155+ dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
1156+ }
1148 case i.btwDialog.Active():1157 case i.btwDialog.Active():
1149 dialog = i.btwDialog.Render(i.cfg.Theme, cols)1158 dialog = i.btwDialog.Render(i.cfg.Theme, cols)
1150 case i.skillsDialog.Active():1159 case i.skillsDialog.Active():
1151 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)1160 dialog = i.skillsDialog.Render(i.cfg.Theme, cols)
1152 case i.changelogDialog.Active():1161 case i.changelogDialog.Active():
1153 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)1162 dialog = i.changelogDialog.Render(i.cfg.Theme, cols)
1154- case i.confirmDialog.Active():
1155- dialog = i.confirmDialog.Render(i.cfg.Theme, cols)
1156 case i.logoutDialog.Active():1163 case i.logoutDialog.Active():
1157 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)1164 dialog = i.logoutDialog.Render(i.cfg.Theme, cols)
1158 case i.telegramDialog.Active():1165 case i.telegramDialog.Active():
@@ -1849,6 +1856,16 @@ func (i *Interactive) toggleToolExpansion() {
1849 i.setToolExpansion(expanded)1856 i.setToolExpansion(expanded)
1850 }1857 }
1851 1858
1859+func (i *Interactive) toggleBtwToolExpansion() {
1860+ i.btwDialog.ToggleToolExpansion()
1861+ i.mu.Lock()
1862+ if i.rend != nil {
1863+ i.rend.Clear()
1864+ }
1865+ i.mu.Unlock()
1866+ i.invalidate()
1867+}
1868+
1852 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {1869 func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1853 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so1870 // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so
1854 // pressing ctrl+c then typing then ctrl+c much later doesn't quit1871 // pressing ctrl+c then typing then ctrl+c much later doesn't quit
@@ -1872,7 +1889,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1872 // user can expand or collapse a tool preview before deciding.1889 // user can expand or collapse a tool preview before deciding.
1873 if i.confirmDialog.Active() {1890 if i.confirmDialog.Active() {
1874 if k.Kind == tui.KeyCtrlO {1891 if k.Kind == tui.KeyCtrlO {
1875- i.toggleToolExpansion()1892+ if i.btwDialog != nil && i.btwDialog.Active() {
1893+ i.toggleBtwToolExpansion()
1894+ } else {
1895+ i.toggleToolExpansion()
1896+ }
1876 return false1897 return false
1877 }1898 }
1878 i.confirmDialog.HandleKey(k)1899 i.confirmDialog.HandleKey(k)
@@ -2086,6 +2107,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
2086 i.invalidate()2107 i.invalidate()
2087 return false2108 return false
2088 }2109 }
2110+ if k.Kind == tui.KeyCtrlO {
2111+ i.toggleBtwToolExpansion()
2112+ return false
2113+ }
2089 i.btwDialog.HandleKey(k, i.invalidate)2114 i.btwDialog.HandleKey(k, i.invalidate)
2090 return false2115 return false
2091 }2116 }
@@ -4495,7 +4520,7 @@ func (i *Interactive) openBtwDialog(args []string) {
4495 return4520 return
4496 }4521 }
4497 seed := strings.TrimSpace(strings.Join(args, " "))4522 seed := strings.TrimSpace(strings.Join(args, " "))
4498- i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)4523+ i.btwDialog.Open(i.cfg.Theme, i.agent, i.agent.System, i.cfg.Model, i.cfg.CWD, seed, i.compactModeEnabled(), i.cfg.FlatTools, tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines, i.invalidate)
4499 i.invalidate()4524 i.invalidate()
4500 }4525 }
4501 4526
@@ -5628,6 +5653,9 @@ func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.Confi
5628 tc.Preview = call.Content5653 tc.Preview = call.Content
5629 }5654 }
5630 i.mu.Unlock()5655 i.mu.Unlock()
5656+ if i.btwDialog != nil && i.btwDialog.Active() {
5657+ i.btwDialog.SetToolPreview(call.ID, call.Summary, call.Content)
5658+ }
5631 }5659 }
5632 i.confirmDialog.Enqueue(&confirmRequest{5660 i.confirmDialog.Enqueue(&confirmRequest{
5633 toolName: call.Name,5661 toolName: call.Name,
M packages/tui/view.go +10 -0
@@ -802,6 +802,16 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
802802 return lines
803803 }
804804
805+// RenderToolCall renders one live or completed tool call using the same
806+// presentation as tool calls in the main transcript. Dialogs with isolated
807+// agent loops use this to avoid maintaining a second tool renderer.
808+func (v *View) RenderToolCall(tc ToolCallView, width int) []string {
809+ if v.liveBodyHigh == nil {
810+ v.liveBodyHigh = make(map[string]int)
811+ }
812+ return v.renderToolCall(tc, width)
813+}
814+
805815 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
806816 var lines []string
807817
@@ -802,6 +802,16 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
802 return lines802 return lines
803 }803 }
804 804
805+// RenderToolCall renders one live or completed tool call using the same
806+// presentation as tool calls in the main transcript. Dialogs with isolated
807+// agent loops use this to avoid maintaining a second tool renderer.
808+func (v *View) RenderToolCall(tc ToolCallView, width int) []string {
809+ if v.liveBodyHigh == nil {
810+ v.liveBodyHigh = make(map[string]int)
811+ }
812+ return v.renderToolCall(tc, width)
813+}
814+
805 func (v *View) renderToolCall(tc ToolCallView, width int) []string {815 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
806 var lines []string816 var lines []string
807 817

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close