tui: render tool calls as boxes instead of horizontal rules
Each tool block now renders as a labelled box: \u250c\u2500 bash ls -la \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2502 $ ls -la \u2502 \u2502 total ... \u2502 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 instead of the open top/bottom horizontal rules used previously. The tool name + short args are embedded in the top edge so each call has a self-contained frame; body lines get vertical edges with a 1-cell inner gutter; image escapes are passed through un-bordered so the graphics layer isn't smeared. Each tool_result message owns its complete box (top + body + bottom). When the model batches multiple tool_use blocks in one assistant message, the assistant message emits no box pieces \u2014 each result renders its own adjacent box, looking up the call's label from a new toolCallLabels map. This avoids the prior bug where two tool_use blocks in a row produced two unclosed top edges with no body between them. The streaming live overlay (renderToolCall) draws the same top/body/bottom directly so an in-flight write/edit looks the same as the finalised transcript form. Body lines have their first 2 leading-indent cells stripped so content lands at a consistent column inside the box rather than drifting right. Inter-message blanks come from Build()'s natural separator; no extra blanks are emitted around boxes, so two consecutive boxes are separated by exactly one blank row.
- commit
- bd213e52bdc2828c23ffafb7cdf9d875e44baa78
- author
- patriceckhart · · 2026-04-27 12:56 UTC
- parent
- 1056bfc
- signature
- unsigned
1 file · +243 -54
M internal/tui/view.go +243 -54
| @@ -79,7 +79,12 @@ type View struct { | |||
| 79 | 79 | // Used by renderNumberedFile to draw a line-number gutter over | |
| 80 | 80 | // raw (unnumbered) file content the model receives. Rebuilt on | |
| 81 | 81 | // each Build(). | |
| 82 | - | toolStartLines map[string]int | |
| 82 | + | toolStartLines map[string]int | |
| 83 | + | // toolCallLabels maps tool_use_id to the box label (tool name + | |
| 84 | + | // short args) for the call, so a tool_result message can render | |
| 85 | + | // the box top edge without having to look back at the assistant | |
| 86 | + | // message that originated the call. Rebuilt on each Build(). | |
| 87 | + | toolCallLabels map[string]string | |
| 83 | 88 | Streaming string // current assistant text delta | |
| 84 | 89 | StreamingActive bool | |
| 85 | 90 | ToolCalls []ToolCallView // tool calls in flight or completed | |
| @@ -314,6 +319,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 314 | 319 | func (v *View) refreshToolPaths() { | |
| 315 | 320 | v.toolPaths = map[string]string{} | |
| 316 | 321 | v.toolStartLines = map[string]int{} | |
| 322 | + | v.toolCallLabels = map[string]string{} | |
| 317 | 323 | for _, m := range v.Messages { | |
| 318 | 324 | for _, c := range m.Content { | |
| 319 | 325 | if tc, ok := c.(provider.ToolCallBlock); ok { | |
| @@ -323,6 +329,7 @@ func (v *View) refreshToolPaths() { | |||
| 323 | 329 | if off := offsetFromToolArgs(tc.Arguments); off >= 1 { | |
| 324 | 330 | v.toolStartLines[tc.ID] = off | |
| 325 | 331 | } | |
| 332 | + | v.toolCallLabels[tc.ID] = tc.Name + " " + ShortArgs(tc.Name, tc.Arguments) | |
| 326 | 333 | } | |
| 327 | 334 | } | |
| 328 | 335 | } | |
| @@ -509,12 +516,12 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 509 | 516 | } | |
| 510 | 517 | } | |
| 511 | 518 | case provider.ToolCallBlock: | |
| 512 | - | // Rule above the tool header frames the call as a | |
| 513 | - | // self-contained block separating it from the | |
| 514 | - | // assistant prose above. The matching closing rule | |
| 515 | - | // is emitted at the end of the tool-role message. | |
| 516 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | |
| 517 | - | lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, ""+b.Name+" "+ShortArgs(b.Name, b.Arguments))) | |
| 519 | + | // The whole box (top + body + bottom) is rendered by | |
| 520 | + | // the matching tool_result message so a single | |
| 521 | + | // assistant message that batches multiple tool_use | |
| 522 | + | // blocks doesn't produce a stack of unclosed top | |
| 523 | + | // edges. Each tool result owns its own box. | |
| 524 | + | _ = b | |
| 518 | 525 | } | |
| 519 | 526 | } | |
| 520 | 527 | case provider.RoleTool: | |
| @@ -534,18 +541,33 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 534 | 541 | startLine = s | |
| 535 | 542 | } | |
| 536 | 543 | } | |
| 537 | - | // Render the body directly under the tool-call | |
| 538 | - | // header (no "result" sub-header). Errors keep a | |
| 539 | - | // one-line header so they're distinguishable from | |
| 540 | - | // successful output. A closing rule below the body | |
| 541 | - | // pairs with the opening rule emitted above the | |
| 542 | - | // tool-call header in the assistant message, | |
| 543 | - | // framing the whole tool block. | |
| 544 | + | label := "" | |
| 545 | + | if v.toolCallLabels != nil { | |
| 546 | + | label = v.toolCallLabels[tr.CallID] | |
| 547 | + | } | |
| 548 | + | // Each tool result owns a complete box: top edge with | |
| 549 | + | // the call's label, body rows wrapped in vertical | |
| 550 | + | // edges, bottom edge to close. The label is looked up | |
| 551 | + | // from the matching ToolCallBlock so multiple calls | |
| 552 | + | // in one assistant message render as N adjacent boxes | |
| 553 | + | // instead of stacking unclosed top edges. | |
| 554 | + | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 544 | 555 | if tr.IsError { | |
| 545 | - | lines = append(lines, v.Theme.FG256(color, " error")) | |
| 556 | + | lines = append(lines, toolBoxSide(v.Theme, v.Theme.FG256(color, " error"), width)) | |
| 557 | + | } | |
| 558 | + | for _, body := range v.renderToolResultContent(tr.Content, width, color, path, startLine) { | |
| 559 | + | // Image escapes paint into a graphics layer that | |
| 560 | + | // doesn't share the text grid; wrapping such a row | |
| 561 | + | // in │ … │ produces visible artefacts on iTerm / | |
| 562 | + | // Kitty, so leave image rows un-bordered. The | |
| 563 | + | // surrounding lines still close the box top + bottom. | |
| 564 | + | if hasImageEscapeLine(body) { | |
| 565 | + | lines = append(lines, body) | |
| 566 | + | continue | |
| 567 | + | } | |
| 568 | + | lines = append(lines, toolBoxSide(v.Theme, body, width)) | |
| 546 | 569 | } | |
| 547 | - | lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...) | |
| 548 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | |
| 570 | + | lines = append(lines, toolBoxBottom(v.Theme, width)) | |
| 549 | 571 | } | |
| 550 | 572 | } | |
| 551 | 573 | } | |
| @@ -555,52 +577,63 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 555 | 577 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |
| 556 | 578 | var lines []string | |
| 557 | 579 | ||
| 558 | - | // Header. While the call is still streaming, prefer the live path | |
| 559 | - | // extracted from the partial args so the user sees the target | |
| 560 | - | // file as soon as it's known, even before the full JSON arrived. | |
| 580 | + | // Header label. While the call is still streaming, prefer the | |
| 581 | + | // live path extracted from the partial args so the user sees | |
| 582 | + | // the target file as soon as it's known, even before the full | |
| 583 | + | // JSON arrived. | |
| 561 | 584 | arg := tc.Args | |
| 562 | 585 | if arg == "" && tc.LivePath != "" { | |
| 563 | 586 | arg = tc.LivePath | |
| 564 | 587 | } | |
| 565 | - | // Match the transcript-side framing: tool headers sit indented four | |
| 566 | - | // cells under the assistant body column. The live overlay used to skip | |
| 567 | - | // the indent which made streaming tools jump back to column 0. | |
| 568 | - | head := " " + v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg) | |
| 569 | - | ||
| 570 | - | // Live streaming body: pulled out of the partial JSON buffer for | |
| 571 | - | // tools whose interesting content is a string field (currently | |
| 572 | - | // write's `content` and edit's `new_text` chunks). The body is | |
| 573 | - | // already framed by wrapLiveBody with top+bottom rules, so we | |
| 574 | - | // don't add the extra toolBlockRule around it — that would | |
| 575 | - | // produce four rules per streaming block, with a visible doubled | |
| 576 | - | // line at the bottom. | |
| 588 | + | label := tc.Name + " " + arg | |
| 589 | + | ||
| 590 | + | // Every box is preceded by a blank line so it doesn't sit | |
| 591 | + | // flush against the prose / previous tool block above it. | |
| 592 | + | // Matches the transcript-side framing in renderMessage. | |
| 593 | + | ||
| 594 | + | // Streaming body (write/edit): top edge with the label, body | |
| 595 | + | // rows wrapped with vertical edges, bottom edge to close the | |
| 596 | + | // box. When the call finalises, the live overlay disappears | |
| 597 | + | // the same frame the transcript renders the closed box, so | |
| 598 | + | // there's no visible hop. | |
| 577 | 599 | if tc.Streaming && tc.Result == "" { | |
| 578 | - | lines = append(lines, head) | |
| 600 | + | lines = append(lines, "") | |
| 601 | + | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 579 | 602 | if body := v.renderLiveToolBody(tc, width); len(body) > 0 { | |
| 580 | 603 | lines = append(lines, body...) | |
| 581 | 604 | } | |
| 605 | + | lines = append(lines, toolBoxBottom(v.Theme, width)) | |
| 582 | 606 | return lines | |
| 583 | 607 | } | |
| 584 | 608 | ||
| 585 | - | // Finished tool call: frame the whole block with opening + | |
| 586 | - | // closing rules so it stands apart from surrounding assistant | |
| 587 | - | // prose. Matches the transcript-side framing in renderMessage. | |
| 588 | - | // When the result is empty, show just the header without rules | |
| 589 | - | // to avoid blank spacing for no-output tool calls. | |
| 609 | + | // Finished tool call with no body: just the labelled top edge | |
| 610 | + | // directly closed by the bottom. Avoids a blank interior row | |
| 611 | + | // for no-output tools. | |
| 590 | 612 | if tc.Result == "" { | |
| 591 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | |
| 592 | - | lines = append(lines, head) | |
| 613 | + | lines = append(lines, "") | |
| 614 | + | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 615 | + | lines = append(lines, toolBoxBottom(v.Theme, width)) | |
| 593 | 616 | return lines | |
| 594 | 617 | } | |
| 595 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | |
| 596 | - | lines = append(lines, head) | |
| 618 | + | ||
| 619 | + | // Finished tool call with a result body. Top edge embeds the | |
| 620 | + | // label; body lines go through toolBoxSide so each row gets | |
| 621 | + | // vertical edges; bottom edge closes the box. | |
| 622 | + | lines = append(lines, "") | |
| 623 | + | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |
| 597 | 624 | color := v.Theme.ToolOut | |
| 598 | 625 | if tc.Error { | |
| 599 | 626 | color = v.Theme.Error | |
| 600 | 627 | } | |
| 601 | 628 | body := toolResultBlock(v.Theme, tc.Result, width, color) | |
| 602 | - | lines = append(lines, v.collapseToolBody(body, false)...) | |
| 603 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | |
| 629 | + | for _, l := range v.collapseToolBody(body, false) { | |
| 630 | + | if hasImageEscapeLine(l) { | |
| 631 | + | lines = append(lines, l) | |
| 632 | + | continue | |
| 633 | + | } | |
| 634 | + | lines = append(lines, toolBoxSide(v.Theme, l, width)) | |
| 635 | + | } | |
| 636 | + | lines = append(lines, toolBoxBottom(v.Theme, width)) | |
| 604 | 637 | return lines | |
| 605 | 638 | } | |
| 606 | 639 | ||
| @@ -637,18 +670,21 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||
| 637 | 670 | return nil | |
| 638 | 671 | } | |
| 639 | 672 | ||
| 640 | - | // wrapLiveBody wraps a list of content lines with the standard | |
| 641 | - | // wrapLiveBody wraps a list of content lines with the standard | |
| 642 | - | // tool-result rules (top + bottom), collapsing to the preview | |
| 643 | - | // height if the body is tall. Shared between write and edit | |
| 644 | - | // streaming. | |
| 673 | + | // wrapLiveBody returns the streaming body content as a list of | |
| 674 | + | // box-side rows: each line wrapped in │ … │ with right padding so | |
| 675 | + | // the closing edge sits at column width-1. The caller (renderToolCall) | |
| 676 | + | // supplies the surrounding top/bottom edges so the live overlay | |
| 677 | + | // renders as a closed box matching the finalised transcript form. | |
| 645 | 678 | func (v *View) wrapLiveBody(body []string, width int) []string { | |
| 646 | 679 | body = v.collapseToolBody(body, false) | |
| 647 | - | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | |
| 648 | - | out := make([]string, 0, len(body)+2) | |
| 649 | - | out = append(out, rule) | |
| 650 | - | out = append(out, body...) | |
| 651 | - | out = append(out, rule) | |
| 680 | + | out := make([]string, 0, len(body)) | |
| 681 | + | for _, l := range body { | |
| 682 | + | if hasImageEscapeLine(l) { | |
| 683 | + | out = append(out, l) | |
| 684 | + | continue | |
| 685 | + | } | |
| 686 | + | out = append(out, toolBoxSide(v.Theme, l, width)) | |
| 687 | + | } | |
| 652 | 688 | return out | |
| 653 | 689 | } | |
| 654 | 690 | ||
| @@ -666,6 +702,159 @@ func toolBlockRule(th Theme, width int) string { | |||
| 666 | 702 | return th.FG256(th.Muted, strings.Repeat("─", w)) | |
| 667 | 703 | } | |
| 668 | 704 | ||
| 705 | + | // toolBoxInnerPad is the number of blank cells kept between a box | |
| 706 | + | // edge (┌, │, └) and the content inside it. One cell of breathing | |
| 707 | + | // room makes the label read as part of the box rather than fused to | |
| 708 | + | // the corner, and gives body lines a tiny gutter from the left side. | |
| 709 | + | const toolBoxInnerPad = 1 | |
| 710 | + | ||
| 711 | + | // toolBoxTop renders the labelled top edge of a tool block: | |
| 712 | + | // | |
| 713 | + | // ┌─ bash xcrun simctl list devices ─────────────────────────────┐ | |
| 714 | + | // | |
| 715 | + | // The label is the tool name + short args (the same string previously | |
| 716 | + | // shown on its own row beneath the rule). Padding fills the remainder | |
| 717 | + | // of the line so the right corner sits at column width-1, matching the | |
| 718 | + | // closing edge. | |
| 719 | + | func toolBoxTop(th Theme, label string, width int) string { | |
| 720 | + | w := width | |
| 721 | + | if w < 12 { | |
| 722 | + | w = 12 | |
| 723 | + | } | |
| 724 | + | innerPad := strings.Repeat(" ", toolBoxInnerPad) | |
| 725 | + | // "┌─" + innerPad + " " + label + " " + innerPad = used; pad | |
| 726 | + | // with ─ to width-1, then "┐". | |
| 727 | + | prefix := "┌─" + innerPad + " " | |
| 728 | + | suffix := " " + innerPad | |
| 729 | + | used := visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) | |
| 730 | + | fill := w - used - 1 // -1 for the closing corner | |
| 731 | + | if fill < 0 { | |
| 732 | + | // Label overflows; truncate with an ellipsis so the right | |
| 733 | + | // corner still lands on the right edge. Rare on real | |
| 734 | + | // terminals (the chat column is usually wide enough). | |
| 735 | + | over := -fill | |
| 736 | + | runes := []rune(label) | |
| 737 | + | if over+1 < len(runes) { | |
| 738 | + | label = string(runes[:len(runes)-over-1]) + "…" | |
| 739 | + | } else { | |
| 740 | + | label = "…" | |
| 741 | + | } | |
| 742 | + | used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) | |
| 743 | + | fill = w - used - 1 | |
| 744 | + | if fill < 0 { | |
| 745 | + | fill = 0 | |
| 746 | + | } | |
| 747 | + | } | |
| 748 | + | line := prefix + label + suffix + strings.Repeat("─", fill) + "┐" | |
| 749 | + | return th.FG256(th.Muted, line) | |
| 750 | + | } | |
| 751 | + | ||
| 752 | + | // toolBoxBottom renders the bottom edge of a tool block: | |
| 753 | + | // | |
| 754 | + | // └─────────────────────────────────────────────────────────────────────────────┘ | |
| 755 | + | // | |
| 756 | + | // Spans the same width as toolBoxTop so the corners line up. | |
| 757 | + | func toolBoxBottom(th Theme, width int) string { | |
| 758 | + | w := width | |
| 759 | + | if w < 12 { | |
| 760 | + | w = 12 | |
| 761 | + | } | |
| 762 | + | line := "└" + strings.Repeat("─", w-2) + "┘" | |
| 763 | + | return th.FG256(th.Muted, line) | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or | |
| 767 | + | // iTerm2 (\x1b]1337;File=) inline-image escape. Such rows draw into | |
| 768 | + | // the terminal's graphics layer and shouldn't be wrapped with text | |
| 769 | + | // box edges. | |
| 770 | + | func hasImageEscapeLine(s string) bool { | |
| 771 | + | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | |
| 772 | + | } | |
| 773 | + | ||
| 774 | + | // toolBoxBodyTrimLeft is the number of leading literal spaces stripped | |
| 775 | + | // from each body line as it enters toolBoxSide. Body renderers | |
| 776 | + | // (renderToolText, renderRawFile, the diff/bash helpers, ...) all | |
| 777 | + | // emit a 4-cell indent so the content aligns with the assistant body | |
| 778 | + | // column when there's no box around it. Inside a box the box edge + | |
| 779 | + | // inner pad already supply most of that gutter, so we drop a couple | |
| 780 | + | // of cells from the body's own indent to keep the content from | |
| 781 | + | // drifting too far right. | |
| 782 | + | const toolBoxBodyTrimLeft = 2 | |
| 783 | + | ||
| 784 | + | // toolBoxSide wraps a single body line with vertical box edges: | |
| 785 | + | // | |
| 786 | + | // │ foo bar baz │ | |
| 787 | + | // | |
| 788 | + | // The middle preserves the line verbatim (including any ANSI escapes) | |
| 789 | + | // after stripping toolBoxBodyTrimLeft leading literal spaces. One cell | |
| 790 | + | // of inner padding sits on each side so the content breathes from the | |
| 791 | + | // edges; the right padding fills to column width-1. | |
| 792 | + | func toolBoxSide(th Theme, line string, width int) string { | |
| 793 | + | w := width | |
| 794 | + | if w < 12 { | |
| 795 | + | w = 12 | |
| 796 | + | } | |
| 797 | + | left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad) | |
| 798 | + | right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│") | |
| 799 | + | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | |
| 800 | + | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |
| 801 | + | cur := visibleWidth(line) | |
| 802 | + | if cur > inner { | |
| 803 | + | // Last-resort truncation. Strips ANSI styling at the cut | |
| 804 | + | // point; rare path so the simpler implementation is fine. | |
| 805 | + | line = stripANSI(line) | |
| 806 | + | runes := []rune(line) | |
| 807 | + | if len(runes) > inner { | |
| 808 | + | line = string(runes[:inner]) | |
| 809 | + | } | |
| 810 | + | cur = visibleWidth(line) | |
| 811 | + | } | |
| 812 | + | pad := inner - cur | |
| 813 | + | if pad < 0 { | |
| 814 | + | pad = 0 | |
| 815 | + | } | |
| 816 | + | return left + line + strings.Repeat(" ", pad) + right | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | // trimLeadingSpaces removes up to n literal space characters from the | |
| 820 | + | // start of s without touching anything else. ANSI CSI escapes that | |
| 821 | + | // appear before the spaces are skipped over (so a coloured indent | |
| 822 | + | // like "\x1b[2m text" still loses its first n cells of indent). | |
| 823 | + | // Stops as soon as it hits a non-space character. | |
| 824 | + | func trimLeadingSpaces(s string, n int) string { | |
| 825 | + | if n <= 0 || s == "" { | |
| 826 | + | return s | |
| 827 | + | } | |
| 828 | + | var b strings.Builder | |
| 829 | + | b.Grow(len(s)) | |
| 830 | + | trimmed := 0 | |
| 831 | + | i := 0 | |
| 832 | + | for i < len(s) { | |
| 833 | + | // Pass through any ANSI escape sequence verbatim. | |
| 834 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { | |
| 835 | + | end := i + 2 | |
| 836 | + | for end < len(s) { | |
| 837 | + | c := s[end] | |
| 838 | + | end++ | |
| 839 | + | if c >= 0x40 && c <= 0x7e { | |
| 840 | + | break | |
| 841 | + | } | |
| 842 | + | } | |
| 843 | + | b.WriteString(s[i:end]) | |
| 844 | + | i = end | |
| 845 | + | continue | |
| 846 | + | } | |
| 847 | + | if trimmed < n && s[i] == ' ' { | |
| 848 | + | trimmed++ | |
| 849 | + | i++ | |
| 850 | + | continue | |
| 851 | + | } | |
| 852 | + | b.WriteString(s[i:]) | |
| 853 | + | return b.String() | |
| 854 | + | } | |
| 855 | + | return b.String() | |
| 856 | + | } | |
| 857 | + | ||
| 669 | 858 | // renderToolResultContent renders the body of a tool result block. | |
| 670 | 859 | // Text blocks get the usual rules-wrapped treatment; text that looks | |
| 671 | 860 | // like a unified diff gets +/- coloring. Image blocks are rendered | |
| @@ -79,7 +79,12 @@ type View struct { | |||||
| 79 | // Used by renderNumberedFile to draw a line-number gutter over | 79 | // Used by renderNumberedFile to draw a line-number gutter over | ||
| 80 | // raw (unnumbered) file content the model receives. Rebuilt on | 80 | // raw (unnumbered) file content the model receives. Rebuilt on | ||
| 81 | // each Build(). | 81 | // each Build(). | ||
| 82 | - | toolStartLines map[string]int | 82 | + | toolStartLines map[string]int |
| 83 | + | // toolCallLabels maps tool_use_id to the box label (tool name + | |||
| 84 | + | // short args) for the call, so a tool_result message can render | |||
| 85 | + | // the box top edge without having to look back at the assistant | |||
| 86 | + | // message that originated the call. Rebuilt on each Build(). | |||
| 87 | + | toolCallLabels map[string]string | |||
| 83 | Streaming string // current assistant text delta | 88 | Streaming string // current assistant text delta | ||
| 84 | StreamingActive bool | 89 | StreamingActive bool | ||
| 85 | ToolCalls []ToolCallView // tool calls in flight or completed | 90 | ToolCalls []ToolCallView // tool calls in flight or completed | ||
| @@ -314,6 +319,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 314 | func (v *View) refreshToolPaths() { | 319 | func (v *View) refreshToolPaths() { | ||
| 315 | v.toolPaths = map[string]string{} | 320 | v.toolPaths = map[string]string{} | ||
| 316 | v.toolStartLines = map[string]int{} | 321 | v.toolStartLines = map[string]int{} | ||
| 322 | + | v.toolCallLabels = map[string]string{} | |||
| 317 | for _, m := range v.Messages { | 323 | for _, m := range v.Messages { | ||
| 318 | for _, c := range m.Content { | 324 | for _, c := range m.Content { | ||
| 319 | if tc, ok := c.(provider.ToolCallBlock); ok { | 325 | if tc, ok := c.(provider.ToolCallBlock); ok { | ||
| @@ -323,6 +329,7 @@ func (v *View) refreshToolPaths() { | |||||
| 323 | if off := offsetFromToolArgs(tc.Arguments); off >= 1 { | 329 | if off := offsetFromToolArgs(tc.Arguments); off >= 1 { | ||
| 324 | v.toolStartLines[tc.ID] = off | 330 | v.toolStartLines[tc.ID] = off | ||
| 325 | } | 331 | } | ||
| 332 | + | v.toolCallLabels[tc.ID] = tc.Name + " " + ShortArgs(tc.Name, tc.Arguments) | |||
| 326 | } | 333 | } | ||
| 327 | } | 334 | } | ||
| 328 | } | 335 | } | ||
| @@ -509,12 +516,12 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 509 | } | 516 | } | ||
| 510 | } | 517 | } | ||
| 511 | case provider.ToolCallBlock: | 518 | case provider.ToolCallBlock: | ||
| 512 | - | // Rule above the tool header frames the call as a | 519 | + | // The whole box (top + body + bottom) is rendered by |
| 513 | - | // self-contained block separating it from the | 520 | + | // the matching tool_result message so a single |
| 514 | - | // assistant prose above. The matching closing rule | 521 | + | // assistant message that batches multiple tool_use |
| 515 | - | // is emitted at the end of the tool-role message. | 522 | + | // blocks doesn't produce a stack of unclosed top |
| 516 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | 523 | + | // edges. Each tool result owns its own box. |
| 517 | - | lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, ""+b.Name+" "+ShortArgs(b.Name, b.Arguments))) | 524 | + | _ = b |
| 518 | } | 525 | } | ||
| 519 | } | 526 | } | ||
| 520 | case provider.RoleTool: | 527 | case provider.RoleTool: | ||
| @@ -534,18 +541,33 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 534 | startLine = s | 541 | startLine = s | ||
| 535 | } | 542 | } | ||
| 536 | } | 543 | } | ||
| 537 | - | // Render the body directly under the tool-call | 544 | + | label := "" |
| 538 | - | // header (no "result" sub-header). Errors keep a | 545 | + | if v.toolCallLabels != nil { |
| 539 | - | // one-line header so they're distinguishable from | 546 | + | label = v.toolCallLabels[tr.CallID] |
| 540 | - | // successful output. A closing rule below the body | 547 | + | } |
| 541 | - | // pairs with the opening rule emitted above the | 548 | + | // Each tool result owns a complete box: top edge with |
| 542 | - | // tool-call header in the assistant message, | 549 | + | // the call's label, body rows wrapped in vertical |
| 543 | - | // framing the whole tool block. | 550 | + | // edges, bottom edge to close. The label is looked up |
| 551 | + | // from the matching ToolCallBlock so multiple calls | |||
| 552 | + | // in one assistant message render as N adjacent boxes | |||
| 553 | + | // instead of stacking unclosed top edges. | |||
| 554 | + | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |||
| 544 | if tr.IsError { | 555 | if tr.IsError { | ||
| 545 | - | lines = append(lines, v.Theme.FG256(color, " error")) | 556 | + | lines = append(lines, toolBoxSide(v.Theme, v.Theme.FG256(color, " error"), width)) |
| 557 | + | } | |||
| 558 | + | for _, body := range v.renderToolResultContent(tr.Content, width, color, path, startLine) { | |||
| 559 | + | // Image escapes paint into a graphics layer that | |||
| 560 | + | // doesn't share the text grid; wrapping such a row | |||
| 561 | + | // in │ … │ produces visible artefacts on iTerm / | |||
| 562 | + | // Kitty, so leave image rows un-bordered. The | |||
| 563 | + | // surrounding lines still close the box top + bottom. | |||
| 564 | + | if hasImageEscapeLine(body) { | |||
| 565 | + | lines = append(lines, body) | |||
| 566 | + | continue | |||
| 567 | + | } | |||
| 568 | + | lines = append(lines, toolBoxSide(v.Theme, body, width)) | |||
| 546 | } | 569 | } | ||
| 547 | - | lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...) | 570 | + | lines = append(lines, toolBoxBottom(v.Theme, width)) |
| 548 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | |||
| 549 | } | 571 | } | ||
| 550 | } | 572 | } | ||
| 551 | } | 573 | } | ||
| @@ -555,52 +577,63 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||||
| 555 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | 577 | func (v *View) renderToolCall(tc ToolCallView, width int) []string { | ||
| 556 | var lines []string | 578 | var lines []string | ||
| 557 | 579 | ||||
| 558 | - | // Header. While the call is still streaming, prefer the live path | 580 | + | // Header label. While the call is still streaming, prefer the |
| 559 | - | // extracted from the partial args so the user sees the target | 581 | + | // live path extracted from the partial args so the user sees |
| 560 | - | // file as soon as it's known, even before the full JSON arrived. | 582 | + | // the target file as soon as it's known, even before the full |
| 583 | + | // JSON arrived. | |||
| 561 | arg := tc.Args | 584 | arg := tc.Args | ||
| 562 | if arg == "" && tc.LivePath != "" { | 585 | if arg == "" && tc.LivePath != "" { | ||
| 563 | arg = tc.LivePath | 586 | arg = tc.LivePath | ||
| 564 | } | 587 | } | ||
| 565 | - | // Match the transcript-side framing: tool headers sit indented four | 588 | + | label := tc.Name + " " + arg |
| 566 | - | // cells under the assistant body column. The live overlay used to skip | 589 | + | |
| 567 | - | // the indent which made streaming tools jump back to column 0. | 590 | + | // Every box is preceded by a blank line so it doesn't sit |
| 568 | - | head := " " + v.Theme.FG256(v.Theme.Tool, ""+tc.Name+" "+arg) | 591 | + | // flush against the prose / previous tool block above it. |
| 569 | - | 592 | + | // Matches the transcript-side framing in renderMessage. | |
| 570 | - | // Live streaming body: pulled out of the partial JSON buffer for | 593 | + | |
| 571 | - | // tools whose interesting content is a string field (currently | 594 | + | // Streaming body (write/edit): top edge with the label, body |
| 572 | - | // write's `content` and edit's `new_text` chunks). The body is | 595 | + | // rows wrapped with vertical edges, bottom edge to close the |
| 573 | - | // already framed by wrapLiveBody with top+bottom rules, so we | 596 | + | // box. When the call finalises, the live overlay disappears |
| 574 | - | // don't add the extra toolBlockRule around it — that would | 597 | + | // the same frame the transcript renders the closed box, so |
| 575 | - | // produce four rules per streaming block, with a visible doubled | 598 | + | // there's no visible hop. |
| 576 | - | // line at the bottom. | |||
| 577 | if tc.Streaming && tc.Result == "" { | 599 | if tc.Streaming && tc.Result == "" { | ||
| 578 | - | lines = append(lines, head) | 600 | + | lines = append(lines, "") |
| 601 | + | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |||
| 579 | if body := v.renderLiveToolBody(tc, width); len(body) > 0 { | 602 | if body := v.renderLiveToolBody(tc, width); len(body) > 0 { | ||
| 580 | lines = append(lines, body...) | 603 | lines = append(lines, body...) | ||
| 581 | } | 604 | } | ||
| 605 | + | lines = append(lines, toolBoxBottom(v.Theme, width)) | |||
| 582 | return lines | 606 | return lines | ||
| 583 | } | 607 | } | ||
| 584 | 608 | ||||
| 585 | - | // Finished tool call: frame the whole block with opening + | 609 | + | // Finished tool call with no body: just the labelled top edge |
| 586 | - | // closing rules so it stands apart from surrounding assistant | 610 | + | // directly closed by the bottom. Avoids a blank interior row |
| 587 | - | // prose. Matches the transcript-side framing in renderMessage. | 611 | + | // for no-output tools. |
| 588 | - | // When the result is empty, show just the header without rules | |||
| 589 | - | // to avoid blank spacing for no-output tool calls. | |||
| 590 | if tc.Result == "" { | 612 | if tc.Result == "" { | ||
| 591 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | 613 | + | lines = append(lines, "") |
| 592 | - | lines = append(lines, head) | 614 | + | lines = append(lines, toolBoxTop(v.Theme, label, width)) |
| 615 | + | lines = append(lines, toolBoxBottom(v.Theme, width)) | |||
| 593 | return lines | 616 | return lines | ||
| 594 | } | 617 | } | ||
| 595 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | 618 | + | |
| 596 | - | lines = append(lines, head) | 619 | + | // Finished tool call with a result body. Top edge embeds the |
| 620 | + | // label; body lines go through toolBoxSide so each row gets | |||
| 621 | + | // vertical edges; bottom edge closes the box. | |||
| 622 | + | lines = append(lines, "") | |||
| 623 | + | lines = append(lines, toolBoxTop(v.Theme, label, width)) | |||
| 597 | color := v.Theme.ToolOut | 624 | color := v.Theme.ToolOut | ||
| 598 | if tc.Error { | 625 | if tc.Error { | ||
| 599 | color = v.Theme.Error | 626 | color = v.Theme.Error | ||
| 600 | } | 627 | } | ||
| 601 | body := toolResultBlock(v.Theme, tc.Result, width, color) | 628 | body := toolResultBlock(v.Theme, tc.Result, width, color) | ||
| 602 | - | lines = append(lines, v.collapseToolBody(body, false)...) | 629 | + | for _, l := range v.collapseToolBody(body, false) { |
| 603 | - | lines = append(lines, toolBlockRule(v.Theme, width)) | 630 | + | if hasImageEscapeLine(l) { |
| 631 | + | lines = append(lines, l) | |||
| 632 | + | continue | |||
| 633 | + | } | |||
| 634 | + | lines = append(lines, toolBoxSide(v.Theme, l, width)) | |||
| 635 | + | } | |||
| 636 | + | lines = append(lines, toolBoxBottom(v.Theme, width)) | |||
| 604 | return lines | 637 | return lines | ||
| 605 | } | 638 | } | ||
| 606 | 639 | ||||
| @@ -637,18 +670,21 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { | |||||
| 637 | return nil | 670 | return nil | ||
| 638 | } | 671 | } | ||
| 639 | 672 | ||||
| 640 | - | // wrapLiveBody wraps a list of content lines with the standard | 673 | + | // wrapLiveBody returns the streaming body content as a list of |
| 641 | - | // wrapLiveBody wraps a list of content lines with the standard | 674 | + | // box-side rows: each line wrapped in │ … │ with right padding so |
| 642 | - | // tool-result rules (top + bottom), collapsing to the preview | 675 | + | // the closing edge sits at column width-1. The caller (renderToolCall) |
| 643 | - | // height if the body is tall. Shared between write and edit | 676 | + | // supplies the surrounding top/bottom edges so the live overlay |
| 644 | - | // streaming. | 677 | + | // renders as a closed box matching the finalised transcript form. |
| 645 | func (v *View) wrapLiveBody(body []string, width int) []string { | 678 | func (v *View) wrapLiveBody(body []string, width int) []string { | ||
| 646 | body = v.collapseToolBody(body, false) | 679 | body = v.collapseToolBody(body, false) | ||
| 647 | - | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | 680 | + | out := make([]string, 0, len(body)) |
| 648 | - | out := make([]string, 0, len(body)+2) | 681 | + | for _, l := range body { |
| 649 | - | out = append(out, rule) | 682 | + | if hasImageEscapeLine(l) { |
| 650 | - | out = append(out, body...) | 683 | + | out = append(out, l) |
| 651 | - | out = append(out, rule) | 684 | + | continue |
| 685 | + | } | |||
| 686 | + | out = append(out, toolBoxSide(v.Theme, l, width)) | |||
| 687 | + | } | |||
| 652 | return out | 688 | return out | ||
| 653 | } | 689 | } | ||
| 654 | 690 | ||||
| @@ -666,6 +702,159 @@ func toolBlockRule(th Theme, width int) string { | |||||
| 666 | return th.FG256(th.Muted, strings.Repeat("─", w)) | 702 | return th.FG256(th.Muted, strings.Repeat("─", w)) | ||
| 667 | } | 703 | } | ||
| 668 | 704 | ||||
| 705 | + | // toolBoxInnerPad is the number of blank cells kept between a box | |||
| 706 | + | // edge (┌, │, └) and the content inside it. One cell of breathing | |||
| 707 | + | // room makes the label read as part of the box rather than fused to | |||
| 708 | + | // the corner, and gives body lines a tiny gutter from the left side. | |||
| 709 | + | const toolBoxInnerPad = 1 | |||
| 710 | + | ||||
| 711 | + | // toolBoxTop renders the labelled top edge of a tool block: | |||
| 712 | + | // | |||
| 713 | + | // ┌─ bash xcrun simctl list devices ─────────────────────────────┐ | |||
| 714 | + | // | |||
| 715 | + | // The label is the tool name + short args (the same string previously | |||
| 716 | + | // shown on its own row beneath the rule). Padding fills the remainder | |||
| 717 | + | // of the line so the right corner sits at column width-1, matching the | |||
| 718 | + | // closing edge. | |||
| 719 | + | func toolBoxTop(th Theme, label string, width int) string { | |||
| 720 | + | w := width | |||
| 721 | + | if w < 12 { | |||
| 722 | + | w = 12 | |||
| 723 | + | } | |||
| 724 | + | innerPad := strings.Repeat(" ", toolBoxInnerPad) | |||
| 725 | + | // "┌─" + innerPad + " " + label + " " + innerPad = used; pad | |||
| 726 | + | // with ─ to width-1, then "┐". | |||
| 727 | + | prefix := "┌─" + innerPad + " " | |||
| 728 | + | suffix := " " + innerPad | |||
| 729 | + | used := visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) | |||
| 730 | + | fill := w - used - 1 // -1 for the closing corner | |||
| 731 | + | if fill < 0 { | |||
| 732 | + | // Label overflows; truncate with an ellipsis so the right | |||
| 733 | + | // corner still lands on the right edge. Rare on real | |||
| 734 | + | // terminals (the chat column is usually wide enough). | |||
| 735 | + | over := -fill | |||
| 736 | + | runes := []rune(label) | |||
| 737 | + | if over+1 < len(runes) { | |||
| 738 | + | label = string(runes[:len(runes)-over-1]) + "…" | |||
| 739 | + | } else { | |||
| 740 | + | label = "…" | |||
| 741 | + | } | |||
| 742 | + | used = visibleWidth(prefix) + visibleWidth(label) + visibleWidth(suffix) | |||
| 743 | + | fill = w - used - 1 | |||
| 744 | + | if fill < 0 { | |||
| 745 | + | fill = 0 | |||
| 746 | + | } | |||
| 747 | + | } | |||
| 748 | + | line := prefix + label + suffix + strings.Repeat("─", fill) + "┐" | |||
| 749 | + | return th.FG256(th.Muted, line) | |||
| 750 | + | } | |||
| 751 | + | ||||
| 752 | + | // toolBoxBottom renders the bottom edge of a tool block: | |||
| 753 | + | // | |||
| 754 | + | // └─────────────────────────────────────────────────────────────────────────────┘ | |||
| 755 | + | // | |||
| 756 | + | // Spans the same width as toolBoxTop so the corners line up. | |||
| 757 | + | func toolBoxBottom(th Theme, width int) string { | |||
| 758 | + | w := width | |||
| 759 | + | if w < 12 { | |||
| 760 | + | w = 12 | |||
| 761 | + | } | |||
| 762 | + | line := "└" + strings.Repeat("─", w-2) + "┘" | |||
| 763 | + | return th.FG256(th.Muted, line) | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | // hasImageEscapeLine reports whether s contains a Kitty (\x1b_G) or | |||
| 767 | + | // iTerm2 (\x1b]1337;File=) inline-image escape. Such rows draw into | |||
| 768 | + | // the terminal's graphics layer and shouldn't be wrapped with text | |||
| 769 | + | // box edges. | |||
| 770 | + | func hasImageEscapeLine(s string) bool { | |||
| 771 | + | return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G") | |||
| 772 | + | } | |||
| 773 | + | ||||
| 774 | + | // toolBoxBodyTrimLeft is the number of leading literal spaces stripped | |||
| 775 | + | // from each body line as it enters toolBoxSide. Body renderers | |||
| 776 | + | // (renderToolText, renderRawFile, the diff/bash helpers, ...) all | |||
| 777 | + | // emit a 4-cell indent so the content aligns with the assistant body | |||
| 778 | + | // column when there's no box around it. Inside a box the box edge + | |||
| 779 | + | // inner pad already supply most of that gutter, so we drop a couple | |||
| 780 | + | // of cells from the body's own indent to keep the content from | |||
| 781 | + | // drifting too far right. | |||
| 782 | + | const toolBoxBodyTrimLeft = 2 | |||
| 783 | + | ||||
| 784 | + | // toolBoxSide wraps a single body line with vertical box edges: | |||
| 785 | + | // | |||
| 786 | + | // │ foo bar baz │ | |||
| 787 | + | // | |||
| 788 | + | // The middle preserves the line verbatim (including any ANSI escapes) | |||
| 789 | + | // after stripping toolBoxBodyTrimLeft leading literal spaces. One cell | |||
| 790 | + | // of inner padding sits on each side so the content breathes from the | |||
| 791 | + | // edges; the right padding fills to column width-1. | |||
| 792 | + | func toolBoxSide(th Theme, line string, width int) string { | |||
| 793 | + | w := width | |||
| 794 | + | if w < 12 { | |||
| 795 | + | w = 12 | |||
| 796 | + | } | |||
| 797 | + | left := th.FG256(th.Muted, "│") + strings.Repeat(" ", toolBoxInnerPad) | |||
| 798 | + | right := strings.Repeat(" ", toolBoxInnerPad) + th.FG256(th.Muted, "│") | |||
| 799 | + | inner := w - 2 - 2*toolBoxInnerPad // available between the two pads | |||
| 800 | + | line = trimLeadingSpaces(line, toolBoxBodyTrimLeft) | |||
| 801 | + | cur := visibleWidth(line) | |||
| 802 | + | if cur > inner { | |||
| 803 | + | // Last-resort truncation. Strips ANSI styling at the cut | |||
| 804 | + | // point; rare path so the simpler implementation is fine. | |||
| 805 | + | line = stripANSI(line) | |||
| 806 | + | runes := []rune(line) | |||
| 807 | + | if len(runes) > inner { | |||
| 808 | + | line = string(runes[:inner]) | |||
| 809 | + | } | |||
| 810 | + | cur = visibleWidth(line) | |||
| 811 | + | } | |||
| 812 | + | pad := inner - cur | |||
| 813 | + | if pad < 0 { | |||
| 814 | + | pad = 0 | |||
| 815 | + | } | |||
| 816 | + | return left + line + strings.Repeat(" ", pad) + right | |||
| 817 | + | } | |||
| 818 | + | ||||
| 819 | + | // trimLeadingSpaces removes up to n literal space characters from the | |||
| 820 | + | // start of s without touching anything else. ANSI CSI escapes that | |||
| 821 | + | // appear before the spaces are skipped over (so a coloured indent | |||
| 822 | + | // like "\x1b[2m text" still loses its first n cells of indent). | |||
| 823 | + | // Stops as soon as it hits a non-space character. | |||
| 824 | + | func trimLeadingSpaces(s string, n int) string { | |||
| 825 | + | if n <= 0 || s == "" { | |||
| 826 | + | return s | |||
| 827 | + | } | |||
| 828 | + | var b strings.Builder | |||
| 829 | + | b.Grow(len(s)) | |||
| 830 | + | trimmed := 0 | |||
| 831 | + | i := 0 | |||
| 832 | + | for i < len(s) { | |||
| 833 | + | // Pass through any ANSI escape sequence verbatim. | |||
| 834 | + | if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '[' { | |||
| 835 | + | end := i + 2 | |||
| 836 | + | for end < len(s) { | |||
| 837 | + | c := s[end] | |||
| 838 | + | end++ | |||
| 839 | + | if c >= 0x40 && c <= 0x7e { | |||
| 840 | + | break | |||
| 841 | + | } | |||
| 842 | + | } | |||
| 843 | + | b.WriteString(s[i:end]) | |||
| 844 | + | i = end | |||
| 845 | + | continue | |||
| 846 | + | } | |||
| 847 | + | if trimmed < n && s[i] == ' ' { | |||
| 848 | + | trimmed++ | |||
| 849 | + | i++ | |||
| 850 | + | continue | |||
| 851 | + | } | |||
| 852 | + | b.WriteString(s[i:]) | |||
| 853 | + | return b.String() | |||
| 854 | + | } | |||
| 855 | + | return b.String() | |||
| 856 | + | } | |||
| 857 | + | ||||
| 669 | // renderToolResultContent renders the body of a tool result block. | 858 | // renderToolResultContent renders the body of a tool result block. | ||
| 670 | // Text blocks get the usual rules-wrapped treatment; text that looks | 859 | // Text blocks get the usual rules-wrapped treatment; text that looks | ||
| 671 | // like a unified diff gets +/- coloring. Image blocks are rendered | 860 | // like a unified diff gets +/- coloring. Image blocks are rendered | ||