detached bd213e5

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 {
7979 // Used by renderNumberedFile to draw a line-number gutter over
8080 // raw (unnumbered) file content the model receives. Rebuilt on
8181 // 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
8388 Streaming string // current assistant text delta
8489 StreamingActive bool
8590 ToolCalls []ToolCallView // tool calls in flight or completed
@@ -314,6 +319,7 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
314319 func (v *View) refreshToolPaths() {
315320 v.toolPaths = map[string]string{}
316321 v.toolStartLines = map[string]int{}
322+ v.toolCallLabels = map[string]string{}
317323 for _, m := range v.Messages {
318324 for _, c := range m.Content {
319325 if tc, ok := c.(provider.ToolCallBlock); ok {
@@ -323,6 +329,7 @@ func (v *View) refreshToolPaths() {
323329 if off := offsetFromToolArgs(tc.Arguments); off >= 1 {
324330 v.toolStartLines[tc.ID] = off
325331 }
332+ v.toolCallLabels[tc.ID] = tc.Name + " " + ShortArgs(tc.Name, tc.Arguments)
326333 }
327334 }
328335 }
@@ -509,12 +516,12 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
509516 }
510517 }
511518 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
518525 }
519526 }
520527 case provider.RoleTool:
@@ -534,18 +541,33 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
534541 startLine = s
535542 }
536543 }
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))
544555 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))
546569 }
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))
549571 }
550572 }
551573 }
@@ -555,52 +577,63 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
555577 func (v *View) renderToolCall(tc ToolCallView, width int) []string {
556578 var lines []string
557579
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.
561584 arg := tc.Args
562585 if arg == "" && tc.LivePath != "" {
563586 arg = tc.LivePath
564587 }
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.
577599 if tc.Streaming && tc.Result == "" {
578- lines = append(lines, head)
600+ lines = append(lines, "")
601+ lines = append(lines, toolBoxTop(v.Theme, label, width))
579602 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
580603 lines = append(lines, body...)
581604 }
605+ lines = append(lines, toolBoxBottom(v.Theme, width))
582606 return lines
583607 }
584608
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.
590612 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))
593616 return lines
594617 }
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))
597624 color := v.Theme.ToolOut
598625 if tc.Error {
599626 color = v.Theme.Error
600627 }
601628 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))
604637 return lines
605638 }
606639
@@ -637,18 +670,21 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
637670 return nil
638671 }
639672
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.
645678 func (v *View) wrapLiveBody(body []string, width int) []string {
646679 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+ }
652688 return out
653689 }
654690
@@ -666,6 +702,159 @@ func toolBlockRule(th Theme, width int) string {
666702 return th.FG256(th.Muted, strings.Repeat("─", w))
667703 }
668704
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+
669858 // renderToolResultContent renders the body of a tool result block.
670859 // Text blocks get the usual rules-wrapped treatment; text that looks
671860 // 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 over79 // Used by renderNumberedFile to draw a line-number gutter over
80 // raw (unnumbered) file content the model receives. Rebuilt on80 // raw (unnumbered) file content the model receives. Rebuilt on
81 // each Build().81 // each Build().
82- toolStartLines map[string]int82+ 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 delta88 Streaming string // current assistant text delta
84 StreamingActive bool89 StreamingActive bool
85 ToolCalls []ToolCallView // tool calls in flight or completed90 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] = off330 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 a519+ // The whole box (top + body + bottom) is rendered by
513- // self-contained block separating it from the520+ // the matching tool_result message so a single
514- // assistant prose above. The matching closing rule521+ // 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 = s541 startLine = s
535 }542 }
536 }543 }
537- // Render the body directly under the tool-call544+ label := ""
538- // header (no "result" sub-header). Errors keep a545+ if v.toolCallLabels != nil {
539- // one-line header so they're distinguishable from546+ label = v.toolCallLabels[tr.CallID]
540- // successful output. A closing rule below the body547+ }
541- // pairs with the opening rule emitted above the548+ // 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 []string578 var lines []string
557 579
558- // Header. While the call is still streaming, prefer the live path580+ // Header label. While the call is still streaming, prefer the
559- // extracted from the partial args so the user sees the target581+ // 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.Args584 arg := tc.Args
562 if arg == "" && tc.LivePath != "" {585 if arg == "" && tc.LivePath != "" {
563 arg = tc.LivePath586 arg = tc.LivePath
564 }587 }
565- // Match the transcript-side framing: tool headers sit indented four588+ label := tc.Name + " " + arg
566- // cells under the assistant body column. The live overlay used to skip589+
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 for593+
571- // tools whose interesting content is a string field (currently594+ // Streaming body (write/edit): top edge with the label, body
572- // write's `content` and edit's `new_text` chunks). The body is595+ // rows wrapped with vertical edges, bottom edge to close the
573- // already framed by wrapLiveBody with top+bottom rules, so we596+ // box. When the call finalises, the live overlay disappears
574- // don't add the extra toolBlockRule around it — that would597+ // the same frame the transcript renders the closed box, so
575- // produce four rules per streaming block, with a visible doubled598+ // 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 lines606 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 assistant610+ // 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 lines616 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.ToolOut624 color := v.Theme.ToolOut
598 if tc.Error {625 if tc.Error {
599 color = v.Theme.Error626 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 lines637 return lines
605 }638 }
606 639
@@ -637,18 +670,21 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
637 return nil670 return nil
638 }671 }
639 672
640-// wrapLiveBody wraps a list of content lines with the standard673+// wrapLiveBody returns the streaming body content as a list of
641-// wrapLiveBody wraps a list of content lines with the standard674+// box-side rows: each line wrapped in │ … │ with right padding so
642-// tool-result rules (top + bottom), collapsing to the preview675+// the closing edge sits at column width-1. The caller (renderToolCall)
643-// height if the body is tall. Shared between write and edit676+// 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 out688 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 looks859 // Text blocks get the usual rules-wrapped treatment; text that looks
671 // like a unified diff gets +/- coloring. Image blocks are rendered860 // like a unified diff gets +/- coloring. Image blocks are rendered

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close