From bb50aa3044cf6f232cb0093d41013731277a3d9c Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Mon, 20 Apr 2026 15:50:39 +0200 Subject: [PATCH] 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. --- internal/agent/systemprompt.go | 4 +- internal/agent/tools/edit.go | 64 +++++++- internal/tui/markdown.go | 20 ++- internal/tui/view.go | 280 +++++++++++++++++++++++++++------ 4 files changed, 311 insertions(+), 57 deletions(-) diff --git a/internal/agent/systemprompt.go b/internal/agent/systemprompt.go index 3a55d89..4c466a4 100644 --- a/internal/agent/systemprompt.go +++ b/internal/agent/systemprompt.go @@ -77,4 +77,6 @@ func BuildSystemPrompt(o SystemPromptOpts) string { 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. -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.` +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. + +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.` diff --git a/internal/agent/tools/edit.go b/internal/agent/tools/edit.go index f8a1d4c..d96f51b 100644 --- a/internal/agent/tools/edit.go +++ b/internal/agent/tools/edit.go @@ -130,9 +130,13 @@ func (t *EditTool) Execute(ctx context.Context, raw json.RawMessage, progress fu } diff := unifiedDiff(a.Path, string(orig), strings.ReplaceAll(newBody, "\r\n", "\n")) - msg := fmt.Sprintf("applied %d edit(s) to %s", len(a.Edits), a.Path) + // The tool-call header renders the path above the result, so the + // result body is just the context diff — no "applied N edit(s)" + // prose prefix. The Details map carries the edit count for + // programmatic consumers (json mode, rpc clients) that might + // want it. return core.ToolResult{ - Content: []provider.Content{provider.TextBlock{Text: msg + "\n" + diff}}, + Content: []provider.Content{provider.TextBlock{Text: diff}}, Details: map[string]any{"path": path, "edits": len(a.Edits), "diff": diff}, }, nil } @@ -144,18 +148,63 @@ func detectLineEnding(b []byte) string { return "\n" } -// unifiedDiff is a minimal unified diff good enough for tool output. +// diffContextLines is the number of unchanged lines kept on each +// side of an edit when rendering the diff. 3 is the git-diff +// default and balances readability with transcript size. +const diffContextLines = 3 + +// unifiedDiff emits a context diff for the edit tool's result. +// +// Shape: each output row is either +// - " " unchanged context +// - "-" deletion (from a) +// - "+" addition (to b) +// - "..." context break between hunks +// +// The legacy "--- name / +++ name" header is omitted because the +// tool-call header above the result already shows the path. Only +// lines within diffContextLines of a +/- row are kept; longer +// runs of unchanged content collapse into a single "..." row so +// a one-line edit in a thousand-line file produces a short +// transcript. func unifiedDiff(name, a, b string) string { if a == b { return "" } aLines := strings.Split(a, "\n") bLines := strings.Split(b, "\n") - // Use simple LCS-based diff. ops := diffLines(aLines, bLines) + + // Mark ops that sit within diffContextLines of any +/- op. + keep := make([]bool, len(ops)) + for i, op := range ops { + if op.kind == '+' || op.kind == '-' { + keep[i] = true + for d := 1; d <= diffContextLines; d++ { + if i-d >= 0 { + keep[i-d] = true + } + if i+d < len(ops) { + keep[i+d] = true + } + } + } + } + var sb strings.Builder - fmt.Fprintf(&sb, "--- %s\n+++ %s\n", name, name) - for _, op := range ops { + prevKept := false + anyOutput := false + for i, op := range ops { + if !keep[i] { + if prevKept { + sb.WriteString("...\n") + prevKept = false + } + continue + } + if !prevKept && anyOutput { + sb.WriteString("...\n") + } switch op.kind { case ' ': fmt.Fprintf(&sb, " %s\n", op.line) @@ -164,7 +213,10 @@ func unifiedDiff(name, a, b string) string { case '+': fmt.Fprintf(&sb, "+%s\n", op.line) } + prevKept = true + anyOutput = true } + _ = name // header dropped; kept in signature for call-site stability return sb.String() } diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go index 72b3fb1..75f7fb3 100644 --- a/internal/tui/markdown.go +++ b/internal/tui/markdown.go @@ -16,7 +16,6 @@ func RenderMarkdown(src string, th Theme, width int) string { if width <= 0 { width = 80 } - rule := th.FG256(th.Muted, strings.Repeat("─", width)) lines := strings.Split(src, "\n") var out strings.Builder @@ -25,6 +24,14 @@ func RenderMarkdown(src string, th Theme, width int) string { fenceLang := "" fenceIndent := "" + // flushFence emits the buffered fence content without decorative + // horizontal rules. The tui draws rules around tool-result + // boxes, where they delimit real content; inside assistant + // prose they clutter the chat without adding information and + // look particularly bad around one-line snippets like `rm -rf + // foo`. Syntax highlighting alone is enough to signal "this is + // code"; unambiguous because prose doesn't use the accent + // palette. flushFence := func() { if fenceBuf.Len() == 0 { return @@ -51,12 +58,13 @@ func RenderMarkdown(src string, th Theme, width int) string { flushFence() inFence = false fenceLang = "" - out.WriteString(rule + "\n") } else { inFence = true fenceIndent = line[:len(line)-len(trim)] fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```")) - out.WriteString(rule + "\n") + // Rule will be emitted by flushFence once the + // content is known so we can size it to the + // widest line inside the fence. } continue } @@ -96,6 +104,12 @@ func RenderMarkdown(src string, th Theme, width int) string { } out.WriteString(renderInline(line, th) + "\n") } + // Handle streaming / truncated input: the opening ``` arrived + // but the closing one hasn't yet. Emit the buffered content + // with both rules so the partial fence still reads cleanly. + if inFence { + flushFence() + } return strings.TrimRight(out.String(), "\n") } diff --git a/internal/tui/view.go b/internal/tui/view.go index 2ba9088..d3a84f3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -194,7 +194,13 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { out = append(out, rendered[idx]...) out = append(out, "") } - if v.StreamingActive { + // Only render the streaming header/body when there's actual + // text to show. An empty streaming block (streamOn=true, + // Streaming="") appears when a turn starts with a tool_use + // block instead of text — in that case the live tool-call + // overlay below is the real content and a naked "zot" bar + // above it reads as a stray empty message. + if v.StreamingActive && strings.TrimSpace(v.Streaming) != "" { out = append(out, v.Theme.FG256(v.Theme.Assistant, "▍ zot")) // Stream the partial assistant text through the same markdown // renderer used for finalised messages so code fences, diffs, @@ -202,11 +208,10 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { // don't suddenly reflow when the turn ends. Indent matches the // finalised assistant body in renderMessage so the column // stays consistent across the stream/finalise transition. + // Width is capped so ultra-wide terminals don't produce + // edge-to-edge rules / unreadably long prose lines. const indent = " " - inner := width - len(indent) - if inner < 1 { - inner = width - } + inner := assistantBodyWidth(width - len(indent)) md := RenderMarkdown(v.Streaming, v.Theme, inner) for _, l := range strings.Split(md, "\n") { for _, w := range wrapLine(l, inner, "") { @@ -215,7 +220,29 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { } out = append(out, "") } + // Live tool-call overlay: skip any entry whose assistant + // tool_use block OR tool_result has already made it into the + // transcript. The EvAssistantMessage for a tool-use turn + // lands BEFORE executeTools runs, so between that moment and + // the tool-result being appended the overlay and the + // finalised transcript both render the same call. Checking + // for either side of the pair suppresses the duplicate in + // both windows. + finalised := map[string]bool{} + for _, m := range v.Messages { + for _, c := range m.Content { + switch b := c.(type) { + case provider.ToolCallBlock: + finalised[b.ID] = true + case provider.ToolResultBlock: + finalised[b.CallID] = true + } + } + } for _, tc := range v.ToolCalls { + if finalised[tc.ID] { + continue + } out = append(out, v.renderToolCall(tc, width)...) out = append(out, "") } @@ -340,6 +367,32 @@ const ( fnv64aPrime uint64 = 0x100000001b3 ) +// maxAssistantWidth caps the rendered width of assistant prose +// (and the code fences embedded in it) in both the finalised +// transcript and the streaming overlay. Unbounded lines on +// ultra-wide terminals (300+ columns) produce prose that's hard +// to read and rule strokes that run edge-to-edge in the window. +// Tool output (read, bash, edit diffs) is unaffected — it +// deliberately uses the full width so long paths and diff rows +// aren't artificially truncated. +const maxAssistantWidth = 120 + +// assistantBodyWidth returns the usable width for the assistant +// message body (markdown prose + code fence rules), clamped at +// maxAssistantWidth and at 1 so wrap helpers don't divide by +// zero on absurdly narrow terminals. outer is the total width +// of the column the body will sit inside (the terminal width +// minus any surrounding indent). +func assistantBodyWidth(outer int) int { + if outer > maxAssistantWidth { + return maxAssistantWidth + } + if outer < 1 { + return 1 + } + return outer +} + func fnv64aWriteByte(h uint64, b byte) uint64 { h ^= uint64(b) h *= fnv64aPrime @@ -378,12 +431,11 @@ func (v *View) renderMessage(m provider.Message, width int) []string { // Indent assistant body the same 4 cells the user body uses, // so the conversation column lines up vertically. The width // passed into the markdown renderer / wrap is reduced by the - // indent so long lines wrap inside the indented column. + // indent so long lines wrap inside the indented column, and + // capped so ultra-wide terminals don't produce edge-to-edge + // code-fence rules or unreadably long prose lines. const indent = " " - inner := width - len(indent) - if inner < 1 { - inner = width - } + inner := assistantBodyWidth(width - len(indent)) for _, c := range m.Content { switch b := c.(type) { case provider.TextBlock: @@ -394,16 +446,19 @@ func (v *View) renderMessage(m provider.Message, width int) []string { } } case provider.ToolCallBlock: + // Rule above the tool header frames the call as a + // self-contained block separating it from the + // assistant prose above. The matching closing rule + // is emitted at the end of the tool-role message. + lines = append(lines, toolBlockRule(v.Theme, width)) lines = append(lines, indent+v.Theme.FG256(v.Theme.Tool, "▸ "+b.Name+" "+shortArgs(b.Arguments))) } } case provider.RoleTool: for _, c := range m.Content { if tr, ok := c.(provider.ToolResultBlock); ok { - title := " result" color := v.Theme.ToolOut if tr.IsError { - title = " error" color = v.Theme.Error } path := "" @@ -416,8 +471,18 @@ func (v *View) renderMessage(m provider.Message, width int) []string { startLine = s } } - lines = append(lines, v.Theme.FG256(color, title)) + // Render the body directly under the tool-call + // header (no "result" sub-header). Errors keep a + // one-line header so they're distinguishable from + // successful output. A closing rule below the body + // pairs with the opening rule emitted above the + // tool-call header in the assistant message, + // framing the whole tool block. + if tr.IsError { + lines = append(lines, v.Theme.FG256(color, " error")) + } lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...) + lines = append(lines, toolBlockRule(v.Theme, width)) } } } @@ -435,35 +500,36 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { arg = tc.LivePath } head := v.Theme.FG256(v.Theme.Tool, "▸ "+tc.Name+" "+arg) - lines = append(lines, head) // Live streaming body: pulled out of the partial JSON buffer for // tools whose interesting content is a string field (currently - // write's `content` and edit's `new_text` chunks). Rendered with - // the same rules + highlighter the final result would use, so the - // transition from streaming to result is visually seamless. + // write's `content` and edit's `new_text` chunks). The body is + // already framed by wrapLiveBody with top+bottom rules, so we + // don't add the extra toolBlockRule around it — that would + // produce four rules per streaming block, with a visible doubled + // line at the bottom. if tc.Streaming && tc.Result == "" { + lines = append(lines, head) if body := v.renderLiveToolBody(tc, width); len(body) > 0 { lines = append(lines, body...) } return lines } + // Finished tool call: frame the whole block with opening + + // closing rules so it stands apart from surrounding assistant + // prose. Matches the transcript-side framing in renderMessage. + lines = append(lines, toolBlockRule(v.Theme, width)) + lines = append(lines, head) if tc.Result != "" { color := v.Theme.ToolOut if tc.Error { color = v.Theme.Error } - block := toolResultBlock(v.Theme, tc.Result, width, color) - // Strip rules, collapse the body, put rules back on. - if len(block) >= 2 { - top, bot := block[0], block[len(block)-1] - body := v.collapseToolBody(block[1:len(block)-1], false) - block = append([]string{top}, body...) - block = append(block, bot) - } - lines = append(lines, block...) + body := toolResultBlock(v.Theme, tc.Result, width, color) + lines = append(lines, v.collapseToolBody(body, false)...) } + lines = append(lines, toolBlockRule(v.Theme, width)) return lines } @@ -501,8 +567,10 @@ func (v *View) renderLiveToolBody(tc ToolCallView, width int) []string { } // wrapLiveBody wraps a list of content lines with the standard -// tool-result rules (top + bottom), collapsing to the preview height -// if the body is tall. Shared between write and edit streaming. +// wrapLiveBody wraps a list of content lines with the standard +// tool-result rules (top + bottom), collapsing to the preview +// height if the body is tall. Shared between write and edit +// streaming. func (v *View) wrapLiveBody(body []string, width int) []string { body = v.collapseToolBody(body, false) rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) @@ -515,14 +583,24 @@ func (v *View) wrapLiveBody(body []string, width int) []string { // toolResultBlock wraps text in thin horizontal rules (top + bottom), // indenting the body with four spaces. The rules span the content column. +// toolBlockRule renders the muted horizontal separator drawn +// above and below a tool call block. Spans the full content +// width so it reads as a real section break in the chat +// regardless of terminal size. +func toolBlockRule(th Theme, width int) string { + w := width + if w < 8 { + w = 8 + } + return th.FG256(th.Muted, strings.Repeat("─", w)) +} + // renderToolResultContent renders the body of a tool result block. // Text blocks get the usual rules-wrapped treatment; text that looks // like a unified diff gets +/- coloring. Image blocks are rendered // inline when the terminal supports a protocol, else as a text // placeholder with dimensions. func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string { - rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) - var body []string hasImage := false for _, b := range blocks { @@ -534,13 +612,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i body = append(body, v.renderImageBlock(bb, width)...) } } - body = v.collapseToolBody(body, hasImage) - - out := make([]string, 0, len(body)+2) - out = append(out, rule) - out = append(out, body...) - out = append(out, rule) - return out + return v.collapseToolBody(body, hasImage) } // 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 if looksLikeNumberedFile(text) { return v.renderNumberedFile(text, sourcePath) } + // If the result embeds a unified diff (the edit tool's output + // starts with a short "applied N edit(s)" line and then a + // standard --- / +++ / +/- patch), render the patch with + // add/remove coloring. This takes priority over the file-like + // detector below because a diff technically has many lines of + // "file content" but what the user cares about is what changed, + // not a dump of the post-edit file. + if looksLikeUnifiedDiff(text) { + return v.renderUnifiedDiff(text, width, sourcePath) + } + // Current path: text came from `read` as raw file bytes. When a // source path is known (the call had a `path` arg), render with // 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 } } if codeRendered == "" { - codeRendered = v.Theme.FG256(color, code) + if mark == ' ' { + codeRendered = v.Theme.FG256(v.Theme.Muted, code) + } else { + codeRendered = v.Theme.FG256(color, code) + } } - gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo)) - marker := v.Theme.FG256(color, string(mark)+" ") - row := " " + gutter + marker + codeRendered + // Gutter shape: sign + number share a color so they read as one + // visual token ("+123") instead of a neutral line number next to + // a stray marker. Unchanged context lines get a muted gutter and + // a leading space so column alignment stays consistent with +/- + // rows. + var gutterText string + switch mark { + case '+': + gutterText = fmt.Sprintf("+%5d\t", lineNo) + case '-': + gutterText = fmt.Sprintf("-%5d\t", lineNo) + default: + gutterText = fmt.Sprintf(" %5d\t", lineNo) + } + var gutter string + if mark == ' ' { + gutter = v.Theme.FG256(v.Theme.Muted, gutterText) + } else { + gutter = v.Theme.FG256(color, gutterText) + } + row := " " + gutter + codeRendered // Cheap width clamp: truncate visible text if the raw code is too // long. We work on the pre-ANSI code string because measuring ansi // output is unreliable. - maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */ + maxCode := width - 4 /* indent */ - 7 /* gutter (sign+5 digits+tab) */ if maxCode > 0 && len(code) > maxCode { trunc := code[:maxCode-1] + "…" if lang != "" { if h := HighlightCode(trunc, lang); len(h) == 1 { codeRendered = h[0] + } else if mark == ' ' { + codeRendered = v.Theme.FG256(v.Theme.Muted, trunc) } else { codeRendered = v.Theme.FG256(color, trunc) } + } else if mark == ' ' { + codeRendered = v.Theme.FG256(v.Theme.Muted, trunc) } else { codeRendered = v.Theme.FG256(color, trunc) } - row = " " + gutter + marker + codeRendered + row = " " + gutter + codeRendered } return row } @@ -898,6 +1007,82 @@ func (v *View) renderBashResult(lines []string, width, defaultColor int) []strin return out } +// looksLikeUnifiedDiff reports whether text is a context diff as +// emitted by the edit tool: rows start with '+', '-', ' ', or +// literal "..." (context-break marker). The presence of at least +// one '+' or '-' row distinguishes a real diff from an ordinary +// file whose lines happen to begin with a space. +func looksLikeUnifiedDiff(text string) bool { + lines := strings.Split(text, "\n") + if len(lines) < 2 { + return false + } + sawChange := false + for _, l := range lines { + if l == "" { + continue + } + if l == "..." { + continue + } + switch l[0] { + case '+', '-': + sawChange = true + case ' ': + // context, ok + default: + return false + } + } + return sawChange +} + +// renderUnifiedDiff renders the edit tool's context diff. Each +// kept row shows a line-number gutter plus a marker column: '+' +// for additions (colored like add), '-' for deletions (colored +// like remove), and unmarked context in muted type. A literal +// "..." line between hunks renders as an ellipsis in muted type, +// indicating skipped unchanged rows. The old and new line +// counters advance so each row carries its actual position in +// the pre- or post-edit file. Heuristic: when we hit a "...", we +// can't know where the next hunk starts, so we don't reset the +// counters — they stay approximate in the rare multi-hunk case. +func (v *View) renderUnifiedDiff(text string, width int, sourcePath string) []string { + lines := strings.Split(text, "\n") + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + oldLine, newLine := 1, 1 + var out []string + for _, l := range lines { + if l == "" { + out = append(out, "") + continue + } + if l == "..." { + out = append(out, " "+v.Theme.FG256(v.Theme.Muted, "…")) + continue + } + switch l[0] { + case '+': + out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)) + newLine++ + case '-': + out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)) + oldLine++ + case ' ': + out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)) + oldLine++ + newLine++ + default: + for _, w := range wrapLine(l, width-4, " ") { + out = append(out, " "+v.Theme.FG256(v.Theme.Muted, w)) + } + } + } + return out +} + func looksLikeFileContent(text string) bool { if strings.TrimSpace(text) == "" { return false @@ -958,17 +1143,18 @@ func (v *View) renderRawFile(text, sourcePath string, startLine int) []string { return out } +// toolResultBlock renders the live tool-call result body (shown +// while the turn is still in flight). The rules that used to +// bracket this block have been dropped so the live path looks +// identical to the transcript rendering that replaces it when +// the turn ends. func toolResultBlock(th Theme, text string, width int, color int) []string { - rule := th.FG256(th.Muted, strings.Repeat("─", width)) - var out []string - out = append(out, rule) for _, l := range strings.Split(text, "\n") { for _, w := range wrapLine(l, width-4, " ") { out = append(out, " "+th.FG256(color, w)) } } - out = append(out, rule) return out }