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 | |||
| 295 | 295 | ||
| 296 | 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 | 301 | /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 interactively | 301 | /btw # open the overlay, type questions interactively | ||
M packages/agent/modes/btw_dialog.go +152 -79
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | - | "time" | |
| 9 | 8 | ||
| 10 | 9 | "github.com/patriceckhart/zot/packages/core" | |
| 11 | 10 | "github.com/patriceckhart/zot/packages/provider" | |
| @@ -18,11 +17,12 @@ import ( | |||
| 18 | 17 | type btwTurn struct { | |
| 19 | 18 | User string | |
| 20 | 19 | Assistant string | |
| 20 | + | Tools []tui.ToolCallView | |
| 21 | 21 | Err string | |
| 22 | 22 | } | |
| 23 | 23 | ||
| 24 | 24 | // btwDialog is the side-chat overlay opened by /btw. It shows the | |
| 25 | - | // user's question, runs a one-off model call against the live | |
| 25 | + | // user's question, runs an isolated agent turn against the live | |
| 26 | 26 | // snapshot of the main session plus any prior side-chat turns, | |
| 27 | 27 | // renders the assistant reply through the markdown pipeline, and | |
| 28 | 28 | // keeps the main transcript completely untouched. | |
| @@ -46,19 +46,15 @@ type btwDialog struct { | |||
| 46 | 46 | // completed main turn). | |
| 47 | 47 | spin *spinner | |
| 48 | 48 | ||
| 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 | |
| 54 | 53 | ||
| 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 | |
| 62 | 58 | ||
| 63 | 59 | // cwd is the working directory used to resolve relative paths | |
| 64 | 60 | // when the user presses Tab on a path-like token in the side- | |
| @@ -96,18 +92,48 @@ func (d *btwDialog) Loading() bool { | |||
| 96 | 92 | 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 | 124 | // Open enters the side chat. agent supplies the live transcript and | |
| 100 | 125 | // system prompt, plus the underlying provider client to use for the | |
| 101 | 126 | // one-off completion. seed is an optional first question that gets | |
| 102 | 127 | // auto-submitted (so /btw <text> starts a conversation right away). | |
| 103 | 128 | // invalidate, if non-nil, is called after each state change so the | |
| 104 | 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 | 131 | d.mu.Lock() | |
| 107 | 132 | d.active = true | |
| 108 | 133 | d.theme = th | |
| 109 | 134 | d.compactMode = compactMode | |
| 110 | 135 | d.lineInput = lineInput | |
| 136 | + | d.toolView = &tui.View{Theme: th, CompactMode: compactMode, FlatTools: flatTools} | |
| 111 | 137 | d.turns = nil | |
| 112 | 138 | d.loading = false | |
| 113 | 139 | d.cancel = nil | |
| @@ -116,10 +142,7 @@ func (d *btwDialog) Open(th tui.Theme, agent *core.Agent, system, model, cwd, se | |||
| 116 | 142 | prompt = "" | |
| 117 | 143 | } | |
| 118 | 144 | 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) | |
| 123 | 146 | d.cwd = cwd | |
| 124 | 147 | d.mu.Unlock() | |
| 125 | 148 | ||
| @@ -138,8 +161,8 @@ func (d *btwDialog) Close() { | |||
| 138 | 161 | d.loading = false | |
| 139 | 162 | cancel := d.cancel | |
| 140 | 163 | d.cancel = nil | |
| 141 | - | d.frozenMsgs = nil | |
| 142 | - | d.client = nil | |
| 164 | + | d.sideAgent = nil | |
| 165 | + | d.toolView = nil | |
| 143 | 166 | d.mu.Unlock() | |
| 144 | 167 | if cancel != nil { | |
| 145 | 168 | cancel() | |
| @@ -224,91 +247,127 @@ func (d *btwDialog) submit(invalidate func()) { | |||
| 224 | 247 | d.turns = append(d.turns, btwTurn{User: question}) | |
| 225 | 248 | 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 | 250 | ctx, cancel := context.WithCancel(context.Background()) | |
| 252 | 251 | d.cancel = cancel | |
| 253 | - | client := d.client | |
| 254 | - | model := d.model | |
| 255 | - | system := d.frozenSystem | |
| 252 | + | agent := d.sideAgent | |
| 256 | 253 | d.mu.Unlock() | |
| 257 | 254 | ||
| 258 | 255 | 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) | |
| 268 | 258 | if invalidate != nil { | |
| 269 | 259 | invalidate() | |
| 270 | 260 | } | |
| 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() | |
| 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.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) | |
| 283 | 337 | } | |
| 284 | 338 | } | |
| 339 | + | tool.Result = text.String() | |
| 285 | 340 | } | |
| 341 | + | } | |
| 342 | + | } | |
| 286 | 343 | ||
| 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] | |
| 290 | 348 | } | |
| 291 | - | d.completeTurn(turnIdx, reply.String(), errMsg) | |
| 292 | - | if invalidate != nil { | |
| 293 | - | invalidate() | |
| 294 | - | } | |
| 295 | - | }() | |
| 349 | + | } | |
| 350 | + | return nil | |
| 296 | 351 | } | |
| 297 | 352 | ||
| 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) { | |
| 301 | 355 | d.mu.Lock() | |
| 302 | 356 | defer d.mu.Unlock() | |
| 303 | 357 | if idx < 0 || idx >= len(d.turns) { | |
| 304 | 358 | return | |
| 305 | 359 | } | |
| 306 | - | d.turns[idx].Assistant = assistant | |
| 307 | 360 | d.turns[idx].Err = errMsg | |
| 308 | 361 | d.loading = false | |
| 309 | 362 | 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 | 371 | // Render returns the side-chat panel lines. Called every frame | |
| 313 | 372 | // while active. | |
| 314 | 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 | 387 | for _, t := range d.turns { | |
| 329 | 388 | out = append(out, "") | |
| 330 | 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 | 398 | if t.Assistant != "" { | |
| 332 | 399 | out = append(out, "") | |
| 333 | 400 | out = append(out, renderDialogMarkdownRows(t.Assistant, th, width)...) | |
| @@ -397,6 +464,12 @@ func (d *btwDialog) CursorPos(width int) (row, col int) { | |||
| 397 | 464 | for _, t := range d.turns { | |
| 398 | 465 | editorOffset++ // blank | |
| 399 | 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 | 473 | if t.Assistant != "" { | |
| 401 | 474 | editorOffset++ // blank | |
| 402 | 475 | 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 string | 18 | User string | ||
| 20 | Assistant string | 19 | Assistant string | ||
| 20 | + | Tools []tui.ToolCallView | |||
| 21 | Err string | 21 | Err string | ||
| 22 | } | 22 | } | ||
| 23 | 23 | ||||
| 24 | // btwDialog is the side-chat overlay opened by /btw. It shows the | 24 | // btwDialog is the side-chat overlay opened by /btw. It shows the | ||
| 25 | - | // user's question, runs a one-off model call against the live | 25 | + | // 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, and | 27 | // 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 *spinner | 47 | spin *spinner | ||
| 48 | 48 | ||||
| 49 | - | // Frozen at Open() time so the side-chat keeps a stable view of | 49 | + | // sideAgent owns an isolated transcript seeded from the main |
| 50 | - | // the main thread even if a turn happens to land on the main | 50 | + | // 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 string | 52 | + | sideAgent *core.Agent |
| 53 | - | frozenMsgs []provider.Message | |||
| 54 | 53 | ||||
| 55 | - | // Provider details captured at open time; used by send() to | 54 | + | // 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.Client | 56 | + | theme tui.Theme |
| 58 | - | model string | 57 | + | 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 paths | 59 | // 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.loading | 92 | 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 and | 124 | // Open enters the side chat. agent supplies the live transcript and | ||
| 100 | // system prompt, plus the underlying provider client to use for the | 125 | // system prompt, plus the underlying provider client to use for the | ||
| 101 | // one-off completion. seed is an optional first question that gets | 126 | // 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 the | 128 | // 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 = true | 132 | d.active = true | ||
| 108 | d.theme = th | 133 | d.theme = th | ||
| 109 | d.compactMode = compactMode | 134 | d.compactMode = compactMode | ||
| 110 | d.lineInput = lineInput | 135 | d.lineInput = lineInput | ||
| 136 | + | d.toolView = &tui.View{Theme: th, CompactMode: compactMode, FlatTools: flatTools} | |||
| 111 | d.turns = nil | 137 | d.turns = nil | ||
| 112 | d.loading = false | 138 | d.loading = false | ||
| 113 | d.cancel = nil | 139 | 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 = system | 145 | + | d.sideAgent = newBtwAgent(agent, system, model) |
| 120 | - | d.frozenMsgs = agent.Messages() | |||
| 121 | - | d.client = agent.Client | |||
| 122 | - | d.model = model | |||
| 123 | d.cwd = cwd | 146 | d.cwd = cwd | ||
| 124 | d.mu.Unlock() | 147 | d.mu.Unlock() | ||
| 125 | 148 | ||||
| @@ -138,8 +161,8 @@ func (d *btwDialog) Close() { | |||||
| 138 | d.loading = false | 161 | d.loading = false | ||
| 139 | cancel := d.cancel | 162 | cancel := d.cancel | ||
| 140 | d.cancel = nil | 163 | d.cancel = nil | ||
| 141 | - | d.frozenMsgs = nil | 164 | + | d.sideAgent = nil |
| 142 | - | d.client = nil | 165 | + | 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) - 1 | 248 | 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 = cancel | 251 | d.cancel = cancel | ||
| 253 | - | client := d.client | 252 | + | 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 | - | 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() | |||
| 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.Builder | 289 | + | func (d *btwDialog) handleAgentEvent(turnIdx int, ev core.AgentEvent) { |
| 275 | - | var finalErr error | 290 | + | 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.Err | 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) | |||
| 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 at | 353 | + | // 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 | return | 358 | return | ||
| 305 | } | 359 | } | ||
| 306 | - | d.turns[idx].Assistant = assistant | |||
| 307 | d.turns[idx].Err = errMsg | 360 | d.turns[idx].Err = errMsg | ||
| 308 | d.loading = false | 361 | d.loading = false | ||
| 309 | d.cancel = nil | 362 | 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 frame | 371 | // 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++ // blank | 465 | 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++ // blank | 474 | 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 ( | |||
| 14 | 14 | ||
| 15 | 15 | func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |
| 16 | 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 | 25 | i := &Interactive{ | |
| 18 | 26 | toolCalls: map[string]*tui.ToolCallView{ | |
| 19 | 27 | "call-1": {ID: "call-1", Name: "edit"}, | |
| 20 | 28 | }, | |
| 29 | + | btwDialog: btw, | |
| 21 | 30 | confirmDialog: dialog, | |
| 22 | 31 | dirty: make(chan struct{}, 1), | |
| 23 | 32 | } | |
| @@ -44,6 +53,18 @@ func TestConfirmToolCallAttachesDiffBeforeDecision(t *testing.T) { | |||
| 44 | 53 | if preview != "-old\n+new\n" { | |
| 45 | 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 | 69 | dialog.HandleKey(tui.Key{Kind: tui.KeyEnter}) | |
| 49 | 70 | 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() { | |||
| 1145 | 1145 | dialog = i.swarmDialog.Render(i.cfg.Theme, cols) | |
| 1146 | 1146 | case i.jumpDialog.Active(): | |
| 1147 | 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 | 1157 | case i.btwDialog.Active(): | |
| 1149 | 1158 | dialog = i.btwDialog.Render(i.cfg.Theme, cols) | |
| 1150 | 1159 | case i.skillsDialog.Active(): | |
| 1151 | 1160 | dialog = i.skillsDialog.Render(i.cfg.Theme, cols) | |
| 1152 | 1161 | case i.changelogDialog.Active(): | |
| 1153 | 1162 | dialog = i.changelogDialog.Render(i.cfg.Theme, cols) | |
| 1154 | - | case i.confirmDialog.Active(): | |
| 1155 | - | dialog = i.confirmDialog.Render(i.cfg.Theme, cols) | |
| 1156 | 1163 | case i.logoutDialog.Active(): | |
| 1157 | 1164 | dialog = i.logoutDialog.Render(i.cfg.Theme, cols) | |
| 1158 | 1165 | case i.telegramDialog.Active(): | |
| @@ -1849,6 +1856,16 @@ func (i *Interactive) toggleToolExpansion() { | |||
| 1849 | 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 | 1869 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 1853 | 1870 | // Any key that isn't ctrl+c invalidates an armed ctrl+c-exit, so | |
| 1854 | 1871 | // 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 | 1889 | // user can expand or collapse a tool preview before deciding. | |
| 1873 | 1890 | if i.confirmDialog.Active() { | |
| 1874 | 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 | 1897 | return false | |
| 1877 | 1898 | } | |
| 1878 | 1899 | i.confirmDialog.HandleKey(k) | |
| @@ -2086,6 +2107,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 2086 | 2107 | i.invalidate() | |
| 2087 | 2108 | return false | |
| 2088 | 2109 | } | |
| 2110 | + | if k.Kind == tui.KeyCtrlO { | |
| 2111 | + | i.toggleBtwToolExpansion() | |
| 2112 | + | return false | |
| 2113 | + | } | |
| 2089 | 2114 | i.btwDialog.HandleKey(k, i.invalidate) | |
| 2090 | 2115 | return false | |
| 2091 | 2116 | } | |
| @@ -4495,7 +4520,7 @@ func (i *Interactive) openBtwDialog(args []string) { | |||
| 4495 | 4520 | return | |
| 4496 | 4521 | } | |
| 4497 | 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 | 4524 | i.invalidate() | |
| 4500 | 4525 | } | |
| 4501 | 4526 | ||
| @@ -5628,6 +5653,9 @@ func (i *Interactive) ConfirmToolCall(call core.ToolCallConfirmation) core.Confi | |||
| 5628 | 5653 | tc.Preview = call.Content | |
| 5629 | 5654 | } | |
| 5630 | 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 | 5660 | i.confirmDialog.Enqueue(&confirmRequest{ | |
| 5633 | 5661 | 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, so | 1870 | // 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 quit | 1871 | // 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 false | 1897 | 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 false | 2108 | 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 false | 2115 | return false | ||
| 2091 | } | 2116 | } | ||
| @@ -4495,7 +4520,7 @@ func (i *Interactive) openBtwDialog(args []string) { | |||||
| 4495 | return | 4520 | 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.Content | 5653 | 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 | |||
| 802 | 802 | 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 | 815 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |
| 806 | 816 | var lines []string | |
| 807 | 817 | ||
| @@ -802,6 +802,16 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 802 | return lines | 802 | 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 []string | 816 | var lines []string | ||
| 807 | 817 | ||||