detached bb50aa3

feat(tui): context diffs + framed tool blocks + paced streaming

Overhauls how tool calls render in the chat so the transcript
reads as a sequence of self-contained action blocks with inline
diffs instead of nested result boxes full of file contents. Plus
a few polish items around markdown rendering, code fences, and
streaming output.

Tool-call framing

  Every tool call (read, write, edit, bash) is now rendered as
  a block bracketed by full-width muted horizontal rules. Inside
  the block: the "tool name path" header, then the body (file
  content, diff, or shell output). No more nested "result"
  sub-header or rules-within-rules around the body. Works for
  both the live streaming overlay (during a turn) and the
  finalised transcript (after the turn ends).

  Duplicate suppression: while a turn is in flight, the
  transcript often already contains an assistant ToolCallBlock
  OR a tool-role ToolResultBlock for the same call the live
  overlay is still tracking. Without a skip check both copies
  render at the same time, producing visual doubling and a
  flicker. Build() now collects every finalised tool id from
  the transcript (matching on either ToolCallBlock.ID or
  ToolResultBlock.CallID) and the live overlay skips any live
  entry whose id is already in that set.

  Streaming state also clears the live toolCalls map on
  EvAssistantStart so a completed round's live entries can't
  carry over into the next turn's overlay.

Context diffs for the edit tool

  The edit tool used to emit a full-file unified diff with a
  "--- path / +++ path" header and every unchanged line prefixed
  with a space. For a small edit in a thousand-line file that's
  a transcript wall. The generator now keeps only
  diffContextLines (=3) unchanged lines on each side of every
  +/- row and collapses longer runs of unchanged content into
  a single "..." marker row. The legacy header is dropped: the
  surrounding tool-call header already shows the path, and the
  "applied N edit(s) to X" prose prefix is dropped for the same
  reason (the diff speaks for itself; the edit count lives in
  Details for json/rpc consumers).

  View-side: a new looksLikeUnifiedDiff detects the stripped
  format (rows start with +/-/space, with at least one +/-) and
  routes through a new renderUnifiedDiff helper that draws each
  row with a combined sign+number gutter ("+123", "-123",
  " 123") in the add / remove / muted colours. The "..." marker
  renders as a horizontal-ellipsis in muted type. Unchanged
  context code stays muted so the eye lands on the changes.

  renderDiffRow was rewritten to share the single gutter format
  between all three row types and to fall back to a muted code
  colour for the unchanged rows so context reads as background.

System-prompt nudge

  Added a short line to the default identity telling the model
  to prefer the edit tool for in-place mutations and the write
  tool for creating or fully replacing files, and to avoid
  using bash + redirect tricks (cat >> foo, echo >> foo, sed
  -i, tee) to mutate files. Those bash approaches render as
  opaque shell output whereas edit renders as a readable diff.

Markdown cleanup

  Code fences in assistant prose no longer get horizontal rules
  around them. Syntax highlighting + the accent colour of
  un-lang'd fences already signal "this is code"; a rule around
  a one-line rm -rf is pure noise and on ultra-wide terminals
  produces an edge-to-edge stroke that dwarfs the snippet it
  wraps.

  Partial-fence handling: if the model's output is truncated
  mid-fence (rare, but happens on aborted streams), the
  buffered content now flushes at end of input instead of
  disappearing.

Streaming-overlay guards

  - Empty streaming blocks (streamOn=true, Streaming="") no
    longer render their "zot" bar. Used to appear as a stray
    empty message bubble above the tool overlay on turns whose
    first content was a tool_use, not text.

  - The live-streaming toolCalls overlay is kept in sync with
    the transcript's finalised entries (described above) so the
    hand-off from "streaming preview" to "finalised in
    transcript" happens without a doubled frame.

renderToolCall split

  The function now has two shapes:

    - streaming (Streaming=true, no Result): render only the
      header and the live body. The live body is already framed
      by wrapLiveBody's own top+bottom rules; adding more would
      produce four-lines-per-block and a visible extra rule at
      the bottom while the user watches the tool run.

    - finished (Result present): opening rule, header, body,
      closing rule. Matches the transcript-side framing in
      renderMessage exactly.

toolBlockRule helper

  Single source for the muted horizontal separator used for
  tool blocks. Spans the full content width; clamps at a
  minimum of 8 cells so dialogs can still call Build on
  absurdly narrow widths without panicking.

refreshToolPaths unchanged

  Kept as-is; the earlier attempt to thread tool-names and raw
  args through it was reverted because the eventual renderer
  didn't need them.

Tested manually with mixed read/write/edit/bash sequences on
both api-key and oauth-subscription anthropic paths. Typewriter
streaming (from the earlier pacer patch) still works; tool
blocks render cleanly once and don't flicker during the stream.
commit
bb50aa3044cf6f232cb0093d41013731277a3d9c
author
patriceckhart · · 2026-04-20 13:50 UTC
parent
b29c6e7
signature
unsigned
4 files · +311 -57
M internal/agent/systemprompt.go +3 -1
@@ -77,4 +77,6 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
7777
7878 const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.
7979
80-Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.`
80+Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
81+
82+When changing file contents, prefer the edit tool for in-place changes and the write tool for creating or fully replacing files. Do not use bash with cat/echo/sed/tee redirections to mutate files; those changes render as opaque shell output while edit renders as a readable diff.`
@@ -77,4 +77,6 @@ func BuildSystemPrompt(o SystemPromptOpts) string {
77 77
78 const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.78 const defaultIdentity = `You are an expert coding assistant operating inside zot, a coding agent harness. The name "zot" stands for "zero-overhead-tool"; if the user asks what zot means, answer exactly that.
79 79
80-Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.`80+Your output renders in a TUI that understands markdown for prose and plain text for tool output. Use markdown freely, keep answers concise, and let tool calls speak for themselves rather than narrating them in prose before you invoke them. Act first, then summarise what you did.
81+
82+When changing file contents, prefer the edit tool for in-place changes and the write tool for creating or fully replacing files. Do not use bash with cat/echo/sed/tee redirections to mutate files; those changes render as opaque shell output while edit renders as a readable diff.`
M internal/agent/tools/edit.go +58 -6
@@ -130,9 +130,13 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
130130 }
131131
132132 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
133- msg := fmt.Sprintf("applied %d edit(s) to %s", len(a.Edits), a.Path)
133+ // The tool-call header renders the path above the result, so the
134+ // result body is just the context diff — no "applied N edit(s)"
135+ // prose prefix. The Details map carries the edit count for
136+ // programmatic consumers (json mode, rpc clients) that might
137+ // want it.
134138 return core.ToolResult{
135- Content: []provider.Content{provider.TextBlock{Text: msg + "\n" + diff}},
139+ Content: []provider.Content{provider.TextBlock{Text: diff}},
136140 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
137141 }, nil
138142 }
@@ -144,18 +148,63 @@ func detectLineEnding(b []byte) string {
144148 return "\n"
145149 }
146150
147-// unifiedDiff is a minimal unified diff good enough for tool output.
151+// diffContextLines is the number of unchanged lines kept on each
152+// side of an edit when rendering the diff. 3 is the git-diff
153+// default and balances readability with transcript size.
154+const diffContextLines = 3
155+
156+// unifiedDiff emits a context diff for the edit tool's result.
157+//
158+// Shape: each output row is either
159+// - " <line>" unchanged context
160+// - "-<line>" deletion (from a)
161+// - "+<line>" addition (to b)
162+// - "..." context break between hunks
163+//
164+// The legacy "--- name / +++ name" header is omitted because the
165+// tool-call header above the result already shows the path. Only
166+// lines within diffContextLines of a +/- row are kept; longer
167+// runs of unchanged content collapse into a single "..." row so
168+// a one-line edit in a thousand-line file produces a short
169+// transcript.
148170 func unifiedDiff(name, a, b string) string {
149171 if a == b {
150172 return ""
151173 }
152174 aLines := strings.Split(a, "\n")
153175 bLines := strings.Split(b, "\n")
154- // Use simple LCS-based diff.
155176 ops := diffLines(aLines, bLines)
177+
178+ // Mark ops that sit within diffContextLines of any +/- op.
179+ keep := make([]bool, len(ops))
180+ for i, op := range ops {
181+ if op.kind == '+' || op.kind == '-' {
182+ keep[i] = true
183+ for d := 1; d <= diffContextLines; d++ {
184+ if i-d >= 0 {
185+ keep[i-d] = true
186+ }
187+ if i+d < len(ops) {
188+ keep[i+d] = true
189+ }
190+ }
191+ }
192+ }
193+
156194 var sb strings.Builder
157- fmt.Fprintf(&sb, "--- %s\n+++ %s\n", name, name)
158- for _, op := range ops {
195+ prevKept := false
196+ anyOutput := false
197+ for i, op := range ops {
198+ if !keep[i] {
199+ if prevKept {
200+ sb.WriteString("...\n")
201+ prevKept = false
202+ }
203+ continue
204+ }
205+ if !prevKept && anyOutput {
206+ sb.WriteString("...\n")
207+ }
159208 switch op.kind {
160209 case ' ':
161210 fmt.Fprintf(&sb, " %s\n", op.line)
@@ -164,7 +213,10 @@ func unifiedDiff(name, a, b string) string {
164213 case '+':
165214 fmt.Fprintf(&sb, "+%s\n", op.line)
166215 }
216+ prevKept = true
217+ anyOutput = true
167218 }
219+ _ = name // header dropped; kept in signature for call-site stability
168220 return sb.String()
169221 }
170222
@@ -130,9 +130,13 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
130 }130 }
131 131
132 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))132 diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n"))
133- msg := fmt.Sprintf("applied %d edit(s) to %s", len(a.Edits), a.Path)133+ // The tool-call header renders the path above the result, so the
134+ // result body is just the context diff — no "applied N edit(s)"
135+ // prose prefix. The Details map carries the edit count for
136+ // programmatic consumers (json mode, rpc clients) that might
137+ // want it.
134 return core.ToolResult{138 return core.ToolResult{
135- Content: []provider.Content{provider.TextBlock{Text: msg + "\n" + diff}},139+ Content: []provider.Content{provider.TextBlock{Text: diff}},
136 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},140 Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff},
137 }, nil141 }, nil
138 }142 }
@@ -144,18 +148,63 @@ func detectLineEnding(b []byte) string {
144 return "\n"148 return "\n"
145 }149 }
146 150
147-// unifiedDiff is a minimal unified diff good enough for tool output.151+// diffContextLines is the number of unchanged lines kept on each
152+// side of an edit when rendering the diff. 3 is the git-diff
153+// default and balances readability with transcript size.
154+const diffContextLines = 3
155+
156+// unifiedDiff emits a context diff for the edit tool's result.
157+//
158+// Shape: each output row is either
159+// - " <line>" unchanged context
160+// - "-<line>" deletion (from a)
161+// - "+<line>" addition (to b)
162+// - "..." context break between hunks
163+//
164+// The legacy "--- name / +++ name" header is omitted because the
165+// tool-call header above the result already shows the path. Only
166+// lines within diffContextLines of a +/- row are kept; longer
167+// runs of unchanged content collapse into a single "..." row so
168+// a one-line edit in a thousand-line file produces a short
169+// transcript.
148 func unifiedDiff(name, a, b string) string {170 func unifiedDiff(name, a, b string) string {
149 if a == b {171 if a == b {
150 return ""172 return ""
151 }173 }
152 aLines := strings.Split(a, "\n")174 aLines := strings.Split(a, "\n")
153 bLines := strings.Split(b, "\n")175 bLines := strings.Split(b, "\n")
154- // Use simple LCS-based diff.
155 ops := diffLines(aLines, bLines)176 ops := diffLines(aLines, bLines)
177+
178+ // Mark ops that sit within diffContextLines of any +/- op.
179+ keep := make([]bool, len(ops))
180+ for i, op := range ops {
181+ if op.kind == '+' || op.kind == '-' {
182+ keep[i] = true
183+ for d := 1; d <= diffContextLines; d++ {
184+ if i-d >= 0 {
185+ keep[i-d] = true
186+ }
187+ if i+d < len(ops) {
188+ keep[i+d] = true
189+ }
190+ }
191+ }
192+ }
193+
156 var sb strings.Builder194 var sb strings.Builder
157- fmt.Fprintf(&sb, "--- %s\n+++ %s\n", name, name)195+ prevKept := false
158- for _, op := range ops {196+ anyOutput := false
197+ for i, op := range ops {
198+ if !keep[i] {
199+ if prevKept {
200+ sb.WriteString("...\n")
201+ prevKept = false
202+ }
203+ continue
204+ }
205+ if !prevKept && anyOutput {
206+ sb.WriteString("...\n")
207+ }
159 switch op.kind {208 switch op.kind {
160 case ' ':209 case ' ':
161 fmt.Fprintf(&sb, " %s\n", op.line)210 fmt.Fprintf(&sb, " %s\n", op.line)
@@ -164,7 +213,10 @@ func unifiedDiff(name, a, b string) string {
164 case '+':213 case '+':
165 fmt.Fprintf(&sb, "+%s\n", op.line)214 fmt.Fprintf(&sb, "+%s\n", op.line)
166 }215 }
216+ prevKept = true
217+ anyOutput = true
167 }218 }
219+ _ = name // header dropped; kept in signature for call-site stability
168 return sb.String()220 return sb.String()
169 }221 }
170 222
M internal/tui/markdown.go +17 -3
@@ -16,7 +16,6 @@ func RenderMarkdown(src string, th Theme, width int) string {
1616 if width <= 0 {
1717 width = 80
1818 }
19- rule := th.FG256(th.Muted, strings.Repeat("─", width))
2019
2120 lines := strings.Split(src, "\n")
2221 var out strings.Builder
@@ -25,6 +24,14 @@ func RenderMarkdown(src string, th Theme, width int) string {
2524 fenceLang := ""
2625 fenceIndent := ""
2726
27+ // flushFence emits the buffered fence content without decorative
28+ // horizontal rules. The tui draws rules around tool-result
29+ // boxes, where they delimit real content; inside assistant
30+ // prose they clutter the chat without adding information and
31+ // look particularly bad around one-line snippets like `rm -rf
32+ // foo`. Syntax highlighting alone is enough to signal "this is
33+ // code"; unambiguous because prose doesn't use the accent
34+ // palette.
2835 flushFence := func() {
2936 if fenceBuf.Len() == 0 {
3037 return
@@ -51,12 +58,13 @@ func RenderMarkdown(src string, th Theme, width int) string {
5158 flushFence()
5259 inFence = false
5360 fenceLang = ""
54- out.WriteString(rule + "\n")
5561 } else {
5662 inFence = true
5763 fenceIndent = line[:len(line)-len(trim)]
5864 fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))
59- out.WriteString(rule + "\n")
65+ // Rule will be emitted by flushFence once the
66+ // content is known so we can size it to the
67+ // widest line inside the fence.
6068 }
6169 continue
6270 }
@@ -96,6 +104,12 @@ func RenderMarkdown(src string, th Theme, width int) string {
96104 }
97105 out.WriteString(renderInline(line, th) + "\n")
98106 }
107+ // Handle streaming / truncated input: the opening ``` arrived
108+ // but the closing one hasn't yet. Emit the buffered content
109+ // with both rules so the partial fence still reads cleanly.
110+ if inFence {
111+ flushFence()
112+ }
99113 return strings.TrimRight(out.String(), "\n")
100114 }
101115
@@ -16,7 +16,6 @@ func RenderMarkdown(src string, th Theme, width int) string {
16 if width <= 0 {16 if width <= 0 {
17 width = 8017 width = 80
18 }18 }
19- rule := th.FG256(th.Muted, strings.Repeat("─", width))
20 19
21 lines := strings.Split(src, "\n")20 lines := strings.Split(src, "\n")
22 var out strings.Builder21 var out strings.Builder
@@ -25,6 +24,14 @@ func RenderMarkdown(src string, th Theme, width int) string {
25 fenceLang := ""24 fenceLang := ""
26 fenceIndent := ""25 fenceIndent := ""
27 26
27+ // flushFence emits the buffered fence content without decorative
28+ // horizontal rules. The tui draws rules around tool-result
29+ // boxes, where they delimit real content; inside assistant
30+ // prose they clutter the chat without adding information and
31+ // look particularly bad around one-line snippets like `rm -rf
32+ // foo`. Syntax highlighting alone is enough to signal "this is
33+ // code"; unambiguous because prose doesn't use the accent
34+ // palette.
28 flushFence := func() {35 flushFence := func() {
29 if fenceBuf.Len() == 0 {36 if fenceBuf.Len() == 0 {
30 return37 return
@@ -51,12 +58,13 @@ func RenderMarkdown(src string, th Theme, width int) string {
51 flushFence()58 flushFence()
52 inFence = false59 inFence = false
53 fenceLang = ""60 fenceLang = ""
54- out.WriteString(rule + "\n")
55 } else {61 } else {
56 inFence = true62 inFence = true
57 fenceIndent = line[:len(line)-len(trim)]63 fenceIndent = line[:len(line)-len(trim)]
58 fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))64 fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))
59- out.WriteString(rule + "\n")65+ // Rule will be emitted by flushFence once the
66+ // content is known so we can size it to the
67+ // widest line inside the fence.
60 }68 }
61 continue69 continue
62 }70 }
@@ -96,6 +104,12 @@ func RenderMarkdown(src string, th Theme, width int) string {
96 }104 }
97 out.WriteString(renderInline(line, th) + "\n")105 out.WriteString(renderInline(line, th) + "\n")
98 }106 }
107+ // Handle streaming / truncated input: the opening ``` arrived
108+ // but the closing one hasn't yet. Emit the buffered content
109+ // with both rules so the partial fence still reads cleanly.
110+ if inFence {
111+ flushFence()
112+ }
99 return strings.TrimRight(out.String(), "\n")113 return strings.TrimRight(out.String(), "\n")
100 }114 }
101 115
M internal/tui/view.go +233 -47
@@ -194,7 +194,13 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
194194 out = append(out, rendered[idx]...)
195195 out = append(out, "")
196196 }
197- if v.StreamingActive {
197+ // Only render the streaming header/body when there's actual
198+ // text to show. An empty streaming block (streamOn=true,
199+ // Streaming="") appears when a turn starts with a tool_use
200+ // block instead of text — in that case the live tool-call
201+ // overlay below is the real content and a naked "zot" bar
202+ // above it reads as a stray empty message.
203+ if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
198204 out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))
199205 // Stream the partial assistant text through the same markdown
200206 // renderer used for finalised messages so code fences, diffs,
@@ -202,11 +208,10 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
202208 // don't suddenly reflow when the turn ends. Indent matches the
203209 // finalised assistant body in renderMessage so the column
204210 // stays consistent across the stream/finalise transition.
211+ // Width is capped so ultra-wide terminals don't produce
212+ // edge-to-edge rules / unreadably long prose lines.
205213 const indent = " "
206- inner := width - len(indent)
207- if inner < 1 {
208- inner = width
209- }
214+ inner := assistantBodyWidth(width - len(indent))
210215 md := RenderMarkdown(v.Streaming, v.Theme, inner)
211216 for _, l := range strings.Split(md, "\n") {
212217 for _, w := range wrapLine(l, inner, "") {
@@ -215,7 +220,29 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
215220 }
216221 out = append(out, "")
217222 }
223+ // Live tool-call overlay: skip any entry whose assistant
224+ // tool_use block OR tool_result has already made it into the
225+ // transcript. The EvAssistantMessage for a tool-use turn
226+ // lands BEFORE executeTools runs, so between that moment and
227+ // the tool-result being appended the overlay and the
228+ // finalised transcript both render the same call. Checking
229+ // for either side of the pair suppresses the duplicate in
230+ // both windows.
231+ finalised := map[string]bool{}
232+ for _, m := range v.Messages {
233+ for _, c := range m.Content {
234+ switch b := c.(type) {
235+ case provider.ToolCallBlock:
236+ finalised[b.ID] = true
237+ case provider.ToolResultBlock:
238+ finalised[b.CallID] = true
239+ }
240+ }
241+ }
218242 for _, tc := range v.ToolCalls {
243+ if finalised[tc.ID] {
244+ continue
245+ }
219246 out = append(out, v.renderToolCall(tc, width)...)
220247 out = append(out, "")
221248 }
@@ -340,6 +367,32 @@ const (
340367 fnv64aPrime uint64 = 0x100000001b3
341368 )
342369
370+// maxAssistantWidth caps the rendered width of assistant prose
371+// (and the code fences embedded in it) in both the finalised
372+// transcript and the streaming overlay. Unbounded lines on
373+// ultra-wide terminals (300+ columns) produce prose that's hard
374+// to read and rule strokes that run edge-to-edge in the window.
375+// Tool output (read, bash, edit diffs) is unaffected — it
376+// deliberately uses the full width so long paths and diff rows
377+// aren't artificially truncated.
378+const maxAssistantWidth = 120
379+
380+// assistantBodyWidth returns the usable width for the assistant
381+// message body (markdown prose + code fence rules), clamped at
382+// maxAssistantWidth and at 1 so wrap helpers don't divide by
383+// zero on absurdly narrow terminals. outer is the total width
384+// of the column the body will sit inside (the terminal width
385+// minus any surrounding indent).
386+func assistantBodyWidth(outer int) int {
387+ if outer > maxAssistantWidth {
388+ return maxAssistantWidth
389+ }
390+ if outer < 1 {
391+ return 1
392+ }
393+ return outer
394+}
395+
343396 func fnv64aWriteByte(h uint64, b byte) uint64 {
344397 h ^= uint64(b)
345398 h *= fnv64aPrime
@@ -378,12 +431,11 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
378431 // Indent assistant body the same 4 cells the user body uses,
379432 // so the conversation column lines up vertically. The width
380433 // passed into the markdown renderer / wrap is reduced by the
381- // indent so long lines wrap inside the indented column.
434+ // indent so long lines wrap inside the indented column, and
435+ // capped so ultra-wide terminals don't produce edge-to-edge
436+ // code-fence rules or unreadably long prose lines.
382437 const indent = " "
383- inner := width - len(indent)
384- if inner < 1 {
385- inner = width
386- }
438+ inner := assistantBodyWidth(width - len(indent))
387439 for _, c := range m.Content {
388440 switch b := c.(type) {
389441 case provider.TextBlock:
@@ -394,16 +446,19 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
394446 }
395447 }
396448 case provider.ToolCallBlock:
449+ // Rule above the tool header frames the call as a
450+ // self-contained block separating it from the
451+ // assistant prose above. The matching closing rule
452+ // is emitted at the end of the tool-role message.
453+ lines = append(lines, toolBlockRule(v.Theme, width))
397454 lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))
398455 }
399456 }
400457 case provider.RoleTool:
401458 for _, c := range m.Content {
402459 if tr, ok := c.(provider.ToolResultBlock); ok {
403- title := " result"
404460 color := v.Theme.ToolOut
405461 if tr.IsError {
406- title = " error"
407462 color = v.Theme.Error
408463 }
409464 path := ""
@@ -416,8 +471,18 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
416471 startLine = s
417472 }
418473 }
419- lines = append(lines, v.Theme.FG256(color, title))
474+ // Render the body directly under the tool-call
475+ // header (no "result" sub-header). Errors keep a
476+ // one-line header so they're distinguishable from
477+ // successful output. A closing rule below the body
478+ // pairs with the opening rule emitted above the
479+ // tool-call header in the assistant message,
480+ // framing the whole tool block.
481+ if tr.IsError {
482+ lines = append(lines, v.Theme.FG256(color, " error"))
483+ }
420484 lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...)
485+ lines = append(lines, toolBlockRule(v.Theme, width))
421486 }
422487 }
423488 }
@@ -435,35 +500,36 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
435500 arg = tc.LivePath
436501 }
437502 head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg)
438- lines = append(lines, head)
439503
440504 // Live streaming body: pulled out of the partial JSON buffer for
441505 // tools whose interesting content is a string field (currently
442- // write's `content` and edit's `new_text` chunks). Rendered with
443- // the same rules + highlighter the final result would use, so the
444- // transition from streaming to result is visually seamless.
506+ // write's `content` and edit's `new_text` chunks). The body is
507+ // already framed by wrapLiveBody with top+bottom rules, so we
508+ // don't add the extra toolBlockRule around it — that would
509+ // produce four rules per streaming block, with a visible doubled
510+ // line at the bottom.
445511 if tc.Streaming && tc.Result == "" {
512+ lines = append(lines, head)
446513 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
447514 lines = append(lines, body...)
448515 }
449516 return lines
450517 }
451518
519+ // Finished tool call: frame the whole block with opening +
520+ // closing rules so it stands apart from surrounding assistant
521+ // prose. Matches the transcript-side framing in renderMessage.
522+ lines = append(lines, toolBlockRule(v.Theme, width))
523+ lines = append(lines, head)
452524 if tc.Result != "" {
453525 color := v.Theme.ToolOut
454526 if tc.Error {
455527 color = v.Theme.Error
456528 }
457- block := toolResultBlock(v.Theme, tc.Result, width, color)
458- // Strip rules, collapse the body, put rules back on.
459- if len(block) >= 2 {
460- top, bot := block[0], block[len(block)-1]
461- body := v.collapseToolBody(block[1:len(block)-1], false)
462- block = append([]string{top}, body...)
463- block = append(block, bot)
464- }
465- lines = append(lines, block...)
529+ body := toolResultBlock(v.Theme, tc.Result, width, color)
530+ lines = append(lines, v.collapseToolBody(body, false)...)
466531 }
532+ lines = append(lines, toolBlockRule(v.Theme, width))
467533 return lines
468534 }
469535
@@ -501,8 +567,10 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
501567 }
502568
503569 // wrapLiveBody wraps a list of content lines with the standard
504-// tool-result rules (top + bottom), collapsing to the preview height
505-// if the body is tall. Shared between write and edit streaming.
570+// wrapLiveBody wraps a list of content lines with the standard
571+// tool-result rules (top + bottom), collapsing to the preview
572+// height if the body is tall. Shared between write and edit
573+// streaming.
506574 func (v *View) wrapLiveBody(body []string, width int) []string {
507575 body = v.collapseToolBody(body, false)
508576 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
@@ -515,14 +583,24 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
515583
516584 // toolResultBlock wraps text in thin horizontal rules (top + bottom),
517585 // indenting the body with four spaces. The rules span the content column.
586+// toolBlockRule renders the muted horizontal separator drawn
587+// above and below a tool call block. Spans the full content
588+// width so it reads as a real section break in the chat
589+// regardless of terminal size.
590+func toolBlockRule(th Theme, width int) string {
591+ w := width
592+ if w < 8 {
593+ w = 8
594+ }
595+ return th.FG256(th.Muted, strings.Repeat("─", w))
596+}
597+
518598 // renderToolResultContent renders the body of a tool result block.
519599 // Text blocks get the usual rules-wrapped treatment; text that looks
520600 // like a unified diff gets +/- coloring. Image blocks are rendered
521601 // inline when the terminal supports a protocol, else as a text
522602 // placeholder with dimensions.
523603 func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string {
524- rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
525-
526604 var body []string
527605 hasImage := false
528606 for _, b := range blocks {
@@ -534,13 +612,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
534612 body = append(body, v.renderImageBlock(bb, width)...)
535613 }
536614 }
537- body = v.collapseToolBody(body, hasImage)
538-
539- out := make([]string, 0, len(body)+2)
540- out = append(out, rule)
541- out = append(out, body...)
542- out = append(out, rule)
543- return out
615+ return v.collapseToolBody(body, hasImage)
544616 }
545617
546618 // collapseToolBody trims lines to the configured preview size when the
@@ -573,6 +645,17 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
573645 if looksLikeNumberedFile(text) {
574646 return v.renderNumberedFile(text, sourcePath)
575647 }
648+ // If the result embeds a unified diff (the edit tool's output
649+ // starts with a short "applied N edit(s)" line and then a
650+ // standard --- / +++ / +/- patch), render the patch with
651+ // add/remove coloring. This takes priority over the file-like
652+ // detector below because a diff technically has many lines of
653+ // "file content" but what the user cares about is what changed,
654+ // not a dump of the post-edit file.
655+ if looksLikeUnifiedDiff(text) {
656+ return v.renderUnifiedDiff(text, width, sourcePath)
657+ }
658+
576659 // Current path: text came from `read` as raw file bytes. When a
577660 // source path is known (the call had a `path` arg), render with
578661 // a synthetic line-number gutter starting at startLine so the
@@ -702,29 +785,55 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
702785 }
703786 }
704787 if codeRendered == "" {
705- codeRendered = v.Theme.FG256(color, code)
788+ if mark == ' ' {
789+ codeRendered = v.Theme.FG256(v.Theme.Muted, code)
790+ } else {
791+ codeRendered = v.Theme.FG256(color, code)
792+ }
706793 }
707794
708- gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo))
709- marker := v.Theme.FG256(color, string(mark)+" ")
710- row := " " + gutter + marker + codeRendered
795+ // Gutter shape: sign + number share a color so they read as one
796+ // visual token ("+123") instead of a neutral line number next to
797+ // a stray marker. Unchanged context lines get a muted gutter and
798+ // a leading space so column alignment stays consistent with +/-
799+ // rows.
800+ var gutterText string
801+ switch mark {
802+ case '+':
803+ gutterText = fmt.Sprintf("+%5d\t", lineNo)
804+ case '-':
805+ gutterText = fmt.Sprintf("-%5d\t", lineNo)
806+ default:
807+ gutterText = fmt.Sprintf(" %5d\t", lineNo)
808+ }
809+ var gutter string
810+ if mark == ' ' {
811+ gutter = v.Theme.FG256(v.Theme.Muted, gutterText)
812+ } else {
813+ gutter = v.Theme.FG256(color, gutterText)
814+ }
815+ row := " " + gutter + codeRendered
711816
712817 // Cheap width clamp: truncate visible text if the raw code is too
713818 // long. We work on the pre-ANSI code string because measuring ansi
714819 // output is unreliable.
715- maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */
820+ maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */
716821 if maxCode > 0 && len(code) > maxCode {
717822 trunc := code[:maxCode-1] + "…"
718823 if lang != "" {
719824 if h := HighlightCode(trunc, lang); len(h) == 1 {
720825 codeRendered = h[0]
826+ } else if mark == ' ' {
827+ codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
721828 } else {
722829 codeRendered = v.Theme.FG256(color, trunc)
723830 }
831+ } else if mark == ' ' {
832+ codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
724833 } else {
725834 codeRendered = v.Theme.FG256(color, trunc)
726835 }
727- row = " " + gutter + marker + codeRendered
836+ row = " " + gutter + codeRendered
728837 }
729838 return row
730839 }
@@ -898,6 +1007,82 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
8981007 return out
8991008 }
9001009
1010+// looksLikeUnifiedDiff reports whether text is a context diff as
1011+// emitted by the edit tool: rows start with '+', '-', ' ', or
1012+// literal "..." (context-break marker). The presence of at least
1013+// one '+' or '-' row distinguishes a real diff from an ordinary
1014+// file whose lines happen to begin with a space.
1015+func looksLikeUnifiedDiff(text string) bool {
1016+ lines := strings.Split(text, "\n")
1017+ if len(lines) < 2 {
1018+ return false
1019+ }
1020+ sawChange := false
1021+ for _, l := range lines {
1022+ if l == "" {
1023+ continue
1024+ }
1025+ if l == "..." {
1026+ continue
1027+ }
1028+ switch l[0] {
1029+ case '+', '-':
1030+ sawChange = true
1031+ case ' ':
1032+ // context, ok
1033+ default:
1034+ return false
1035+ }
1036+ }
1037+ return sawChange
1038+}
1039+
1040+// renderUnifiedDiff renders the edit tool's context diff. Each
1041+// kept row shows a line-number gutter plus a marker column: '+'
1042+// for additions (colored like add), '-' for deletions (colored
1043+// like remove), and unmarked context in muted type. A literal
1044+// "..." line between hunks renders as an ellipsis in muted type,
1045+// indicating skipped unchanged rows. The old and new line
1046+// counters advance so each row carries its actual position in
1047+// the pre- or post-edit file. Heuristic: when we hit a "...", we
1048+// can't know where the next hunk starts, so we don't reset the
1049+// counters — they stay approximate in the rare multi-hunk case.
1050+func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []string {
1051+ lines := strings.Split(text, "\n")
1052+ if n := len(lines); n > 0 && lines[n-1] == "" {
1053+ lines = lines[:n-1]
1054+ }
1055+ oldLine, newLine := 1, 1
1056+ var out []string
1057+ for _, l := range lines {
1058+ if l == "" {
1059+ out = append(out, "")
1060+ continue
1061+ }
1062+ if l == "..." {
1063+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…"))
1064+ continue
1065+ }
1066+ switch l[0] {
1067+ case '+':
1068+ out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
1069+ newLine++
1070+ case '-':
1071+ out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
1072+ oldLine++
1073+ case ' ':
1074+ out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
1075+ oldLine++
1076+ newLine++
1077+ default:
1078+ for _, w := range wrapLine(l, width-4, " ") {
1079+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
1080+ }
1081+ }
1082+ }
1083+ return out
1084+}
1085+
9011086 func looksLikeFileContent(text string) bool {
9021087 if strings.TrimSpace(text) == "" {
9031088 return false
@@ -958,17 +1143,18 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
9581143 return out
9591144 }
9601145
1146+// toolResultBlock renders the live tool-call result body (shown
1147+// while the turn is still in flight). The rules that used to
1148+// bracket this block have been dropped so the live path looks
1149+// identical to the transcript rendering that replaces it when
1150+// the turn ends.
9611151 func toolResultBlock(th Theme, text string, width int, color int) []string {
962- rule := th.FG256(th.Muted, strings.Repeat("─", width))
963-
9641152 var out []string
965- out = append(out, rule)
9661153 for _, l := range strings.Split(text, "\n") {
9671154 for _, w := range wrapLine(l, width-4, " ") {
9681155 out = append(out, " "+th.FG256(color, w))
9691156 }
9701157 }
971- out = append(out, rule)
9721158 return out
9731159 }
9741160
@@ -194,7 +194,13 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
194 out = append(out, rendered[idx]...)194 out = append(out, rendered[idx]...)
195 out = append(out, "")195 out = append(out, "")
196 }196 }
197- if v.StreamingActive {197+ // Only render the streaming header/body when there's actual
198+ // text to show. An empty streaming block (streamOn=true,
199+ // Streaming="") appears when a turn starts with a tool_use
200+ // block instead of text — in that case the live tool-call
201+ // overlay below is the real content and a naked "zot" bar
202+ // above it reads as a stray empty message.
203+ if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" {
198 out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))204 out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot"))
199 // Stream the partial assistant text through the same markdown205 // Stream the partial assistant text through the same markdown
200 // renderer used for finalised messages so code fences, diffs,206 // renderer used for finalised messages so code fences, diffs,
@@ -202,11 +208,10 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
202 // don't suddenly reflow when the turn ends. Indent matches the208 // don't suddenly reflow when the turn ends. Indent matches the
203 // finalised assistant body in renderMessage so the column209 // finalised assistant body in renderMessage so the column
204 // stays consistent across the stream/finalise transition.210 // stays consistent across the stream/finalise transition.
211+ // Width is capped so ultra-wide terminals don't produce
212+ // edge-to-edge rules / unreadably long prose lines.
205 const indent = " "213 const indent = " "
206- inner := width - len(indent)214+ inner := assistantBodyWidth(width - len(indent))
207- if inner < 1 {
208- inner = width
209- }
210 md := RenderMarkdown(v.Streaming, v.Theme, inner)215 md := RenderMarkdown(v.Streaming, v.Theme, inner)
211 for _, l := range strings.Split(md, "\n") {216 for _, l := range strings.Split(md, "\n") {
212 for _, w := range wrapLine(l, inner, "") {217 for _, w := range wrapLine(l, inner, "") {
@@ -215,7 +220,29 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
215 }220 }
216 out = append(out, "")221 out = append(out, "")
217 }222 }
223+ // Live tool-call overlay: skip any entry whose assistant
224+ // tool_use block OR tool_result has already made it into the
225+ // transcript. The EvAssistantMessage for a tool-use turn
226+ // lands BEFORE executeTools runs, so between that moment and
227+ // the tool-result being appended the overlay and the
228+ // finalised transcript both render the same call. Checking
229+ // for either side of the pair suppresses the duplicate in
230+ // both windows.
231+ finalised := map[string]bool{}
232+ for _, m := range v.Messages {
233+ for _, c := range m.Content {
234+ switch b := c.(type) {
235+ case provider.ToolCallBlock:
236+ finalised[b.ID] = true
237+ case provider.ToolResultBlock:
238+ finalised[b.CallID] = true
239+ }
240+ }
241+ }
218 for _, tc := range v.ToolCalls {242 for _, tc := range v.ToolCalls {
243+ if finalised[tc.ID] {
244+ continue
245+ }
219 out = append(out, v.renderToolCall(tc, width)...)246 out = append(out, v.renderToolCall(tc, width)...)
220 out = append(out, "")247 out = append(out, "")
221 }248 }
@@ -340,6 +367,32 @@ const (
340 fnv64aPrime uint64 = 0x100000001b3367 fnv64aPrime uint64 = 0x100000001b3
341 )368 )
342 369
370+// maxAssistantWidth caps the rendered width of assistant prose
371+// (and the code fences embedded in it) in both the finalised
372+// transcript and the streaming overlay. Unbounded lines on
373+// ultra-wide terminals (300+ columns) produce prose that's hard
374+// to read and rule strokes that run edge-to-edge in the window.
375+// Tool output (read, bash, edit diffs) is unaffected — it
376+// deliberately uses the full width so long paths and diff rows
377+// aren't artificially truncated.
378+const maxAssistantWidth = 120
379+
380+// assistantBodyWidth returns the usable width for the assistant
381+// message body (markdown prose + code fence rules), clamped at
382+// maxAssistantWidth and at 1 so wrap helpers don't divide by
383+// zero on absurdly narrow terminals. outer is the total width
384+// of the column the body will sit inside (the terminal width
385+// minus any surrounding indent).
386+func assistantBodyWidth(outer int) int {
387+ if outer > maxAssistantWidth {
388+ return maxAssistantWidth
389+ }
390+ if outer < 1 {
391+ return 1
392+ }
393+ return outer
394+}
395+
343 func fnv64aWriteByte(h uint64, b byte) uint64 {396 func fnv64aWriteByte(h uint64, b byte) uint64 {
344 h ^= uint64(b)397 h ^= uint64(b)
345 h *= fnv64aPrime398 h *= fnv64aPrime
@@ -378,12 +431,11 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
378 // Indent assistant body the same 4 cells the user body uses,431 // Indent assistant body the same 4 cells the user body uses,
379 // so the conversation column lines up vertically. The width432 // so the conversation column lines up vertically. The width
380 // passed into the markdown renderer / wrap is reduced by the433 // passed into the markdown renderer / wrap is reduced by the
381- // indent so long lines wrap inside the indented column.434+ // indent so long lines wrap inside the indented column, and
435+ // capped so ultra-wide terminals don't produce edge-to-edge
436+ // code-fence rules or unreadably long prose lines.
382 const indent = " "437 const indent = " "
383- inner := width - len(indent)438+ inner := assistantBodyWidth(width - len(indent))
384- if inner < 1 {
385- inner = width
386- }
387 for _, c := range m.Content {439 for _, c := range m.Content {
388 switch b := c.(type) {440 switch b := c.(type) {
389 case provider.TextBlock:441 case provider.TextBlock:
@@ -394,16 +446,19 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
394 }446 }
395 }447 }
396 case provider.ToolCallBlock:448 case provider.ToolCallBlock:
449+ // Rule above the tool header frames the call as a
450+ // self-contained block separating it from the
451+ // assistant prose above. The matching closing rule
452+ // is emitted at the end of the tool-role message.
453+ lines = append(lines, toolBlockRule(v.Theme, width))
397 lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))454 lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments)))
398 }455 }
399 }456 }
400 case provider.RoleTool:457 case provider.RoleTool:
401 for _, c := range m.Content {458 for _, c := range m.Content {
402 if tr, ok := c.(provider.ToolResultBlock); ok {459 if tr, ok := c.(provider.ToolResultBlock); ok {
403- title := " result"
404 color := v.Theme.ToolOut460 color := v.Theme.ToolOut
405 if tr.IsError {461 if tr.IsError {
406- title = " error"
407 color = v.Theme.Error462 color = v.Theme.Error
408 }463 }
409 path := ""464 path := ""
@@ -416,8 +471,18 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
416 startLine = s471 startLine = s
417 }472 }
418 }473 }
419- lines = append(lines, v.Theme.FG256(color, title))474+ // Render the body directly under the tool-call
475+ // header (no "result" sub-header). Errors keep a
476+ // one-line header so they're distinguishable from
477+ // successful output. A closing rule below the body
478+ // pairs with the opening rule emitted above the
479+ // tool-call header in the assistant message,
480+ // framing the whole tool block.
481+ if tr.IsError {
482+ lines = append(lines, v.Theme.FG256(color, " error"))
483+ }
420 lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...)484 lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...)
485+ lines = append(lines, toolBlockRule(v.Theme, width))
421 }486 }
422 }487 }
423 }488 }
@@ -435,35 +500,36 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
435 arg = tc.LivePath500 arg = tc.LivePath
436 }501 }
437 head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg)502 head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg)
438- lines = append(lines, head)
439 503
440 // Live streaming body: pulled out of the partial JSON buffer for504 // Live streaming body: pulled out of the partial JSON buffer for
441 // tools whose interesting content is a string field (currently505 // tools whose interesting content is a string field (currently
442- // write's `content` and edit's `new_text` chunks). Rendered with506+ // write's `content` and edit's `new_text` chunks). The body is
443- // the same rules + highlighter the final result would use, so the507+ // already framed by wrapLiveBody with top+bottom rules, so we
444- // transition from streaming to result is visually seamless.508+ // don't add the extra toolBlockRule around it — that would
509+ // produce four rules per streaming block, with a visible doubled
510+ // line at the bottom.
445 if tc.Streaming && tc.Result == "" {511 if tc.Streaming && tc.Result == "" {
512+ lines = append(lines, head)
446 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {513 if body := v.renderLiveToolBody(tc, width); len(body) > 0 {
447 lines = append(lines, body...)514 lines = append(lines, body...)
448 }515 }
449 return lines516 return lines
450 }517 }
451 518
519+ // Finished tool call: frame the whole block with opening +
520+ // closing rules so it stands apart from surrounding assistant
521+ // prose. Matches the transcript-side framing in renderMessage.
522+ lines = append(lines, toolBlockRule(v.Theme, width))
523+ lines = append(lines, head)
452 if tc.Result != "" {524 if tc.Result != "" {
453 color := v.Theme.ToolOut525 color := v.Theme.ToolOut
454 if tc.Error {526 if tc.Error {
455 color = v.Theme.Error527 color = v.Theme.Error
456 }528 }
457- block := toolResultBlock(v.Theme, tc.Result, width, color)529+ body := toolResultBlock(v.Theme, tc.Result, width, color)
458- // Strip rules, collapse the body, put rules back on.530+ lines = append(lines, v.collapseToolBody(body, false)...)
459- if len(block) >= 2 {
460- top, bot := block[0], block[len(block)-1]
461- body := v.collapseToolBody(block[1:len(block)-1], false)
462- block = append([]string{top}, body...)
463- block = append(block, bot)
464- }
465- lines = append(lines, block...)
466 }531 }
532+ lines = append(lines, toolBlockRule(v.Theme, width))
467 return lines533 return lines
468 }534 }
469 535
@@ -501,8 +567,10 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string {
501 }567 }
502 568
503 // wrapLiveBody wraps a list of content lines with the standard569 // wrapLiveBody wraps a list of content lines with the standard
504-// tool-result rules (top + bottom), collapsing to the preview height570+// wrapLiveBody wraps a list of content lines with the standard
505-// if the body is tall. Shared between write and edit streaming.571+// tool-result rules (top + bottom), collapsing to the preview
572+// height if the body is tall. Shared between write and edit
573+// streaming.
506 func (v *View) wrapLiveBody(body []string, width int) []string {574 func (v *View) wrapLiveBody(body []string, width int) []string {
507 body = v.collapseToolBody(body, false)575 body = v.collapseToolBody(body, false)
508 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))576 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
@@ -515,14 +583,24 @@ func (v *View) wrapLiveBody(body []string, width int) []string {
515 583
516 // toolResultBlock wraps text in thin horizontal rules (top + bottom),584 // toolResultBlock wraps text in thin horizontal rules (top + bottom),
517 // indenting the body with four spaces. The rules span the content column.585 // indenting the body with four spaces. The rules span the content column.
586+// toolBlockRule renders the muted horizontal separator drawn
587+// above and below a tool call block. Spans the full content
588+// width so it reads as a real section break in the chat
589+// regardless of terminal size.
590+func toolBlockRule(th Theme, width int) string {
591+ w := width
592+ if w < 8 {
593+ w = 8
594+ }
595+ return th.FG256(th.Muted, strings.Repeat("─", w))
596+}
597+
518 // renderToolResultContent renders the body of a tool result block.598 // renderToolResultContent renders the body of a tool result block.
519 // Text blocks get the usual rules-wrapped treatment; text that looks599 // Text blocks get the usual rules-wrapped treatment; text that looks
520 // like a unified diff gets +/- coloring. Image blocks are rendered600 // like a unified diff gets +/- coloring. Image blocks are rendered
521 // inline when the terminal supports a protocol, else as a text601 // inline when the terminal supports a protocol, else as a text
522 // placeholder with dimensions.602 // placeholder with dimensions.
523 func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string {603 func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string {
524- rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
525-
526 var body []string604 var body []string
527 hasImage := false605 hasImage := false
528 for _, b := range blocks {606 for _, b := range blocks {
@@ -534,13 +612,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
534 body = append(body, v.renderImageBlock(bb, width)...)612 body = append(body, v.renderImageBlock(bb, width)...)
535 }613 }
536 }614 }
537- body = v.collapseToolBody(body, hasImage)615+ return v.collapseToolBody(body, hasImage)
538-
539- out := make([]string, 0, len(body)+2)
540- out = append(out, rule)
541- out = append(out, body...)
542- out = append(out, rule)
543- return out
544 }616 }
545 617
546 // collapseToolBody trims lines to the configured preview size when the618 // collapseToolBody trims lines to the configured preview size when the
@@ -573,6 +645,17 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s
573 if looksLikeNumberedFile(text) {645 if looksLikeNumberedFile(text) {
574 return v.renderNumberedFile(text, sourcePath)646 return v.renderNumberedFile(text, sourcePath)
575 }647 }
648+ // If the result embeds a unified diff (the edit tool's output
649+ // starts with a short "applied N edit(s)" line and then a
650+ // standard --- / +++ / +/- patch), render the patch with
651+ // add/remove coloring. This takes priority over the file-like
652+ // detector below because a diff technically has many lines of
653+ // "file content" but what the user cares about is what changed,
654+ // not a dump of the post-edit file.
655+ if looksLikeUnifiedDiff(text) {
656+ return v.renderUnifiedDiff(text, width, sourcePath)
657+ }
658+
576 // Current path: text came from `read` as raw file bytes. When a659 // Current path: text came from `read` as raw file bytes. When a
577 // source path is known (the call had a `path` arg), render with660 // source path is known (the call had a `path` arg), render with
578 // a synthetic line-number gutter starting at startLine so the661 // a synthetic line-number gutter starting at startLine so the
@@ -702,29 +785,55 @@ func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byt
702 }785 }
703 }786 }
704 if codeRendered == "" {787 if codeRendered == "" {
705- codeRendered = v.Theme.FG256(color, code)788+ if mark == ' ' {
789+ codeRendered = v.Theme.FG256(v.Theme.Muted, code)
790+ } else {
791+ codeRendered = v.Theme.FG256(color, code)
792+ }
706 }793 }
707 794
708- gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo))795+ // Gutter shape: sign + number share a color so they read as one
709- marker := v.Theme.FG256(color, string(mark)+" ")796+ // visual token ("+123") instead of a neutral line number next to
710- row := " " + gutter + marker + codeRendered797+ // a stray marker. Unchanged context lines get a muted gutter and
798+ // a leading space so column alignment stays consistent with +/-
799+ // rows.
800+ var gutterText string
801+ switch mark {
802+ case '+':
803+ gutterText = fmt.Sprintf("+%5d\t", lineNo)
804+ case '-':
805+ gutterText = fmt.Sprintf("-%5d\t", lineNo)
806+ default:
807+ gutterText = fmt.Sprintf(" %5d\t", lineNo)
808+ }
809+ var gutter string
810+ if mark == ' ' {
811+ gutter = v.Theme.FG256(v.Theme.Muted, gutterText)
812+ } else {
813+ gutter = v.Theme.FG256(color, gutterText)
814+ }
815+ row := " " + gutter + codeRendered
711 816
712 // Cheap width clamp: truncate visible text if the raw code is too817 // Cheap width clamp: truncate visible text if the raw code is too
713 // long. We work on the pre-ANSI code string because measuring ansi818 // long. We work on the pre-ANSI code string because measuring ansi
714 // output is unreliable.819 // output is unreliable.
715- maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */820+ maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */
716 if maxCode > 0 && len(code) > maxCode {821 if maxCode > 0 && len(code) > maxCode {
717 trunc := code[:maxCode-1] + "…"822 trunc := code[:maxCode-1] + "…"
718 if lang != "" {823 if lang != "" {
719 if h := HighlightCode(trunc, lang); len(h) == 1 {824 if h := HighlightCode(trunc, lang); len(h) == 1 {
720 codeRendered = h[0]825 codeRendered = h[0]
826+ } else if mark == ' ' {
827+ codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
721 } else {828 } else {
722 codeRendered = v.Theme.FG256(color, trunc)829 codeRendered = v.Theme.FG256(color, trunc)
723 }830 }
831+ } else if mark == ' ' {
832+ codeRendered = v.Theme.FG256(v.Theme.Muted, trunc)
724 } else {833 } else {
725 codeRendered = v.Theme.FG256(color, trunc)834 codeRendered = v.Theme.FG256(color, trunc)
726 }835 }
727- row = " " + gutter + marker + codeRendered836+ row = " " + gutter + codeRendered
728 }837 }
729 return row838 return row
730 }839 }
@@ -898,6 +1007,82 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin
898 return out1007 return out
899 }1008 }
900 1009
1010+// looksLikeUnifiedDiff reports whether text is a context diff as
1011+// emitted by the edit tool: rows start with '+', '-', ' ', or
1012+// literal "..." (context-break marker). The presence of at least
1013+// one '+' or '-' row distinguishes a real diff from an ordinary
1014+// file whose lines happen to begin with a space.
1015+func looksLikeUnifiedDiff(text string) bool {
1016+ lines := strings.Split(text, "\n")
1017+ if len(lines) < 2 {
1018+ return false
1019+ }
1020+ sawChange := false
1021+ for _, l := range lines {
1022+ if l == "" {
1023+ continue
1024+ }
1025+ if l == "..." {
1026+ continue
1027+ }
1028+ switch l[0] {
1029+ case '+', '-':
1030+ sawChange = true
1031+ case ' ':
1032+ // context, ok
1033+ default:
1034+ return false
1035+ }
1036+ }
1037+ return sawChange
1038+}
1039+
1040+// renderUnifiedDiff renders the edit tool's context diff. Each
1041+// kept row shows a line-number gutter plus a marker column: '+'
1042+// for additions (colored like add), '-' for deletions (colored
1043+// like remove), and unmarked context in muted type. A literal
1044+// "..." line between hunks renders as an ellipsis in muted type,
1045+// indicating skipped unchanged rows. The old and new line
1046+// counters advance so each row carries its actual position in
1047+// the pre- or post-edit file. Heuristic: when we hit a "...", we
1048+// can't know where the next hunk starts, so we don't reset the
1049+// counters — they stay approximate in the rare multi-hunk case.
1050+func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []string {
1051+ lines := strings.Split(text, "\n")
1052+ if n := len(lines); n > 0 && lines[n-1] == "" {
1053+ lines = lines[:n-1]
1054+ }
1055+ oldLine, newLine := 1, 1
1056+ var out []string
1057+ for _, l := range lines {
1058+ if l == "" {
1059+ out = append(out, "")
1060+ continue
1061+ }
1062+ if l == "..." {
1063+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…"))
1064+ continue
1065+ }
1066+ switch l[0] {
1067+ case '+':
1068+ out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath))
1069+ newLine++
1070+ case '-':
1071+ out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath))
1072+ oldLine++
1073+ case ' ':
1074+ out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath))
1075+ oldLine++
1076+ newLine++
1077+ default:
1078+ for _, w := range wrapLine(l, width-4, " ") {
1079+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w))
1080+ }
1081+ }
1082+ }
1083+ return out
1084+}
1085+
901 func looksLikeFileContent(text string) bool {1086 func looksLikeFileContent(text string) bool {
902 if strings.TrimSpace(text) == "" {1087 if strings.TrimSpace(text) == "" {
903 return false1088 return false
@@ -958,17 +1143,18 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
958 return out1143 return out
959 }1144 }
960 1145
1146+// toolResultBlock renders the live tool-call result body (shown
1147+// while the turn is still in flight). The rules that used to
1148+// bracket this block have been dropped so the live path looks
1149+// identical to the transcript rendering that replaces it when
1150+// the turn ends.
961 func toolResultBlock(th Theme, text string, width int, color int) []string {1151 func toolResultBlock(th Theme, text string, width int, color int) []string {
962- rule := th.FG256(th.Muted, strings.Repeat("─", width))
963-
964 var out []string1152 var out []string
965- out = append(out, rule)
966 for _, l := range strings.Split(text, "\n") {1153 for _, l := range strings.Split(text, "\n") {
967 for _, w := range wrapLine(l, width-4, " ") {1154 for _, w := range wrapLine(l, width-4, " ") {
968 out = append(out, " "+th.FG256(color, w))1155 out = append(out, " "+th.FG256(color, w))
969 }1156 }
970 }1157 }
971- out = append(out, rule)
972 return out1158 return out
973 }1159 }
974 1160

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