perf(read): drop line numbers from model-facing output
The read tool used to prefix every line with '%6d\t' (6-digit line number + tab), which added ~7 bytes / ~2 tokens per line to every read. On typical source files that's 15-20% of the read's token budget, repeated on every subsequent turn as the tool result stays in context. The line numbers weren't earning their keep: the model edits via exact-match text replacement, never line ranges, and the tui has always been capable of drawing its own gutter. Now it does: - Tool output is raw file bytes, no prefix. - A 'start_line' detail is attached to the ToolResult so the tui knows where to start counting. - The tui renders a synthetic gutter over the raw content using the existing renderNumberedFile path (new: renderRawFile), so on-screen it still looks exactly like cat -n. The old numbered format is still recognised for legacy transcripts saved before this commit (looksLikeNumberedFile guard stays). Measured: sample.ts (388 lines) used to cost 14957 bytes to send, now costs 12291 bytes (raw file). Saves ~670 tokens per read of a medium file; the same fraction applies to larger files too. Tests: TestReadOffsetLimit rewritten to assert raw output + start_line detail. TUI renderToolText signature grew one int (startLine) plumbed through renderToolResultContent.
- commit
- f5719c6be142621cce04a2aafb800595686190ba
- author
- patriceckhart · · 2026-04-19 15:33 UTC
- parent
- 5cc5482
- signature
- unsigned
- refs
- tag: v0.0.39
3 files · +141 -17
M internal/agent/tools/read.go +13 -3
| @@ -126,10 +126,19 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 126 | 126 | truncLines = true | |
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | - | // Render with 1-indexed line numbers, cat -n style. | |
| 129 | + | // Raw file contents go to the model. We deliberately DON'T | |
| 130 | + | // prepend line numbers here: they'd inflate the token count by | |
| 131 | + | // ~15-20% on typical source files (7 bytes per line, every | |
| 132 | + | // line, every time the file gets re-sent as context on later | |
| 133 | + | // turns) and the model doesn't need them — edit goes through | |
| 134 | + | // exact-match text replacement, not line ranges. | |
| 135 | + | // | |
| 136 | + | // The TUI renders its own gutter using the start offset stored | |
| 137 | + | // in Details, so the on-screen view still looks like cat -n. | |
| 130 | 138 | var sb strings.Builder | |
| 131 | - | for i, line := range selected { | |
| 132 | - | fmt.Fprintf(&sb, "%6d\t%s\n", start+i+1, line) | |
| 139 | + | for _, line := range selected { | |
| 140 | + | sb.WriteString(line) | |
| 141 | + | sb.WriteByte('\n') | |
| 133 | 142 | } | |
| 134 | 143 | if truncLines { | |
| 135 | 144 | sb.WriteString(fmt.Sprintf("... [truncated at %d lines]\n", maxReadLines)) | |
| @@ -142,6 +151,7 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||
| 142 | 151 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 143 | 152 | Details: map[string]any{ | |
| 144 | 153 | "path": path, | |
| 154 | + | "start_line": start + 1, // 1-indexed; TUI draws the gutter | |
| 145 | 155 | "lines_truncated": truncLines, | |
| 146 | 156 | "bytes_truncated": truncBytes, | |
| 147 | 157 | "total_lines": len(lines), | |
| @@ -126,10 +126,19 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 126 | truncLines = true | 126 | truncLines = true | ||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | - | // Render with 1-indexed line numbers, cat -n style. | 129 | + | // Raw file contents go to the model. We deliberately DON'T |
| 130 | + | // prepend line numbers here: they'd inflate the token count by | |||
| 131 | + | // ~15-20% on typical source files (7 bytes per line, every | |||
| 132 | + | // line, every time the file gets re-sent as context on later | |||
| 133 | + | // turns) and the model doesn't need them — edit goes through | |||
| 134 | + | // exact-match text replacement, not line ranges. | |||
| 135 | + | // | |||
| 136 | + | // The TUI renders its own gutter using the start offset stored | |||
| 137 | + | // in Details, so the on-screen view still looks like cat -n. | |||
| 130 | var sb strings.Builder | 138 | var sb strings.Builder | ||
| 131 | - | for i, line := range selected { | 139 | + | for _, line := range selected { |
| 132 | - | fmt.Fprintf(&sb, "%6d\t%s\n", start+i+1, line) | 140 | + | sb.WriteString(line) |
| 141 | + | sb.WriteByte('\n') | |||
| 133 | } | 142 | } | ||
| 134 | if truncLines { | 143 | if truncLines { | ||
| 135 | sb.WriteString(fmt.Sprintf("... [truncated at %d lines]\n", maxReadLines)) | 144 | sb.WriteString(fmt.Sprintf("... [truncated at %d lines]\n", maxReadLines)) | ||
| @@ -142,6 +151,7 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu | |||||
| 142 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | 151 | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | ||
| 143 | Details: map[string]any{ | 152 | Details: map[string]any{ | ||
| 144 | "path": path, | 153 | "path": path, | ||
| 154 | + | "start_line": start + 1, // 1-indexed; TUI draws the gutter | |||
| 145 | "lines_truncated": truncLines, | 155 | "lines_truncated": truncLines, | ||
| 146 | "bytes_truncated": truncBytes, | 156 | "bytes_truncated": truncBytes, | ||
| 147 | "total_lines": len(lines), | 157 | "total_lines": len(lines), | ||
M internal/agent/tools/tools_test.go +6 -4
| @@ -45,11 +45,13 @@ func TestReadOffsetLimit(t *testing.T) { | |||
| 45 | 45 | tool := &ReadTool{CWD: dir} | |
| 46 | 46 | res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil) | |
| 47 | 47 | got := res.Content[0].(provider.TextBlock).Text | |
| 48 | - | if !strings.Contains(got, "2\t2") || !strings.Contains(got, "3\t3") { | |
| 49 | - | t.Fatalf("got %q", got) | |
| 48 | + | // Current output format is raw bytes (no embedded line numbers): | |
| 49 | + | // the tui draws its own gutter from the `start_line` detail. | |
| 50 | + | if got != "2\n3\n" { | |
| 51 | + | t.Fatalf("want \"2\\n3\\n\", got %q", got) | |
| 50 | 52 | } | |
| 51 | - | if strings.Contains(got, "1\t1") || strings.Contains(got, "4\t4") { | |
| 52 | - | t.Fatalf("leaked lines: %q", got) | |
| 53 | + | if start, ok := res.Details.(map[string]any)["start_line"]; !ok || start != 2 { | |
| 54 | + | t.Errorf("start_line detail want 2, got %v", start) | |
| 53 | 55 | } | |
| 54 | 56 | } | |
| 55 | 57 | ||
| @@ -45,11 +45,13 @@ func TestReadOffsetLimit(t *testing.T) { | |||||
| 45 | tool := &ReadTool{CWD: dir} | 45 | tool := &ReadTool{CWD: dir} | ||
| 46 | res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil) | 46 | res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil) | ||
| 47 | got := res.Content[0].(provider.TextBlock).Text | 47 | got := res.Content[0].(provider.TextBlock).Text | ||
| 48 | - | if !strings.Contains(got, "2\t2") || !strings.Contains(got, "3\t3") { | 48 | + | // Current output format is raw bytes (no embedded line numbers): |
| 49 | - | t.Fatalf("got %q", got) | 49 | + | // the tui draws its own gutter from the `start_line` detail. |
| 50 | + | if got != "2\n3\n" { | |||
| 51 | + | t.Fatalf("want \"2\\n3\\n\", got %q", got) | |||
| 50 | } | 52 | } | ||
| 51 | - | if strings.Contains(got, "1\t1") || strings.Contains(got, "4\t4") { | 53 | + | if start, ok := res.Details.(map[string]any)["start_line"]; !ok || start != 2 { |
| 52 | - | t.Fatalf("leaked lines: %q", got) | 54 | + | t.Errorf("start_line detail want 2, got %v", start) |
| 53 | } | 55 | } | ||
| 54 | } | 56 | } | ||
| 55 | 57 | ||||
M internal/tui/view.go +122 -10
| @@ -31,6 +31,29 @@ func pathFromToolArgs(raw json.RawMessage) string { | |||
| 31 | 31 | return "" | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | // offsetFromToolArgs returns the read tool's 1-indexed `offset` | |
| 35 | + | // arg (the first line of the slice the tool was asked to return), | |
| 36 | + | // or 0 when the call didn't specify one. Used by the tui to draw | |
| 37 | + | // the line-number gutter aligned to the right starting row, even | |
| 38 | + | // though the tool's text content itself no longer carries line | |
| 39 | + | // numbers. | |
| 40 | + | func offsetFromToolArgs(raw json.RawMessage) int { | |
| 41 | + | if len(raw) == 0 { | |
| 42 | + | return 0 | |
| 43 | + | } | |
| 44 | + | var m map[string]any | |
| 45 | + | if err := json.Unmarshal(raw, &m); err != nil { | |
| 46 | + | return 0 | |
| 47 | + | } | |
| 48 | + | switch v := m["offset"].(type) { | |
| 49 | + | case float64: | |
| 50 | + | return int(v) | |
| 51 | + | case int: | |
| 52 | + | return v | |
| 53 | + | } | |
| 54 | + | return 0 | |
| 55 | + | } | |
| 56 | + | ||
| 34 | 57 | // osUserHomeDir is aliased so the test file can swap it. | |
| 35 | 58 | var osUserHomeDir = os.UserHomeDir | |
| 36 | 59 | ||
| @@ -43,7 +66,13 @@ type View struct { | |||
| 43 | 66 | // toolPaths maps tool_use_id to the "path" argument of the call, if | |
| 44 | 67 | // any, so tool_result rendering can pick the right syntax language. | |
| 45 | 68 | // Rebuilt on each Build(). | |
| 46 | - | toolPaths map[string]string | |
| 69 | + | toolPaths map[string]string | |
| 70 | + | // toolStartLines maps tool_use_id to the 1-indexed first line | |
| 71 | + | // number of a `read` result, pulled from the call's offset arg. | |
| 72 | + | // Used by renderNumberedFile to draw a line-number gutter over | |
| 73 | + | // raw (unnumbered) file content the model receives. Rebuilt on | |
| 74 | + | // each Build(). | |
| 75 | + | toolStartLines map[string]int | |
| 47 | 76 | Streaming string // current assistant text delta | |
| 48 | 77 | StreamingActive bool | |
| 49 | 78 | ToolCalls []ToolCallView // tool calls in flight or completed | |
| @@ -186,12 +215,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||
| 186 | 215 | // cheap compared to markdown/chroma work it enables. | |
| 187 | 216 | func (v *View) refreshToolPaths() { | |
| 188 | 217 | v.toolPaths = map[string]string{} | |
| 218 | + | v.toolStartLines = map[string]int{} | |
| 189 | 219 | for _, m := range v.Messages { | |
| 190 | 220 | for _, c := range m.Content { | |
| 191 | 221 | if tc, ok := c.(provider.ToolCallBlock); ok { | |
| 192 | 222 | if p := pathFromToolArgs(tc.Arguments); p != "" { | |
| 193 | 223 | v.toolPaths[tc.ID] = p | |
| 194 | 224 | } | |
| 225 | + | if off := offsetFromToolArgs(tc.Arguments); off >= 1 { | |
| 226 | + | v.toolStartLines[tc.ID] = off | |
| 227 | + | } | |
| 195 | 228 | } | |
| 196 | 229 | } | |
| 197 | 230 | } | |
| @@ -359,8 +392,14 @@ func (v *View) renderMessage(m provider.Message, width int) []string { | |||
| 359 | 392 | if v.toolPaths != nil { | |
| 360 | 393 | path = v.toolPaths[tr.CallID] | |
| 361 | 394 | } | |
| 395 | + | startLine := 1 | |
| 396 | + | if v.toolStartLines != nil { | |
| 397 | + | if s := v.toolStartLines[tr.CallID]; s > 0 { | |
| 398 | + | startLine = s | |
| 399 | + | } | |
| 400 | + | } | |
| 362 | 401 | lines = append(lines, v.Theme.FG256(color, title)) | |
| 363 | - | lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path)...) | |
| 402 | + | lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...) | |
| 364 | 403 | } | |
| 365 | 404 | } | |
| 366 | 405 | } | |
| @@ -396,7 +435,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 396 | 435 | // like a unified diff gets +/- coloring. Image blocks are rendered | |
| 397 | 436 | // inline when the terminal supports a protocol, else as a text | |
| 398 | 437 | // placeholder with dimensions. | |
| 399 | - | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string { | |
| 438 | + | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string { | |
| 400 | 439 | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | |
| 401 | 440 | ||
| 402 | 441 | var body []string | |
| @@ -404,7 +443,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i | |||
| 404 | 443 | for _, b := range blocks { | |
| 405 | 444 | switch bb := b.(type) { | |
| 406 | 445 | case provider.TextBlock: | |
| 407 | - | body = append(body, v.renderToolText(bb.Text, width, color, sourcePath)...) | |
| 446 | + | body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...) | |
| 408 | 447 | case provider.ImageBlock: | |
| 409 | 448 | hasImage = true | |
| 410 | 449 | body = append(body, v.renderImageBlock(bb, width)...) | |
| @@ -442,15 +481,21 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||
| 442 | 481 | // text contains a unified-diff section (lines starting with "--- " / | |
| 443 | 482 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | |
| 444 | 483 | // colors matching git diff conventions. | |
| 445 | - | func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string) []string { | |
| 446 | - | // Detect whether the text is `read`-style numbered output | |
| 447 | - | // (" 1\t…") so we can strip the gutter, highlight the code, and | |
| 448 | - | // re-apply the line numbers in muted color. Runs even without a | |
| 449 | - | // source path — language is guessed from the first line, falling | |
| 450 | - | // back to "text" (no highlighting) if nothing obvious matches. | |
| 484 | + | func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string { | |
| 485 | + | // Legacy path: transcripts saved before we dropped line numbers | |
| 486 | + | // from the read tool still carry " 1\t…" prefixes. Detect and | |
| 487 | + | // strip them, then fall through to the highlighter. | |
| 451 | 488 | if looksLikeNumberedFile(text) { | |
| 452 | 489 | return v.renderNumberedFile(text, sourcePath) | |
| 453 | 490 | } | |
| 491 | + | // Current path: text came from `read` as raw file bytes. When a | |
| 492 | + | // source path is known (the call had a `path` arg), render with | |
| 493 | + | // a synthetic line-number gutter starting at startLine so the | |
| 494 | + | // on-screen view still looks like cat -n. Doesn't apply to non- | |
| 495 | + | // file tool outputs (bash stdout, display notes, etc.). | |
| 496 | + | if sourcePath != "" && looksLikeFileContent(text) { | |
| 497 | + | return v.renderRawFile(text, sourcePath, startLine) | |
| 498 | + | } | |
| 454 | 499 | ||
| 455 | 500 | // No truncation — the full tool output is rendered into chat and | |
| 456 | 501 | // becomes part of the scrollback you can page back through. | |
| @@ -712,6 +757,73 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||
| 712 | 757 | return out | |
| 713 | 758 | } | |
| 714 | 759 | ||
| 760 | + | // looksLikeFileContent is a cheap guard to distinguish a read-tool | |
| 761 | + | // result from bash stdout or a status message. File content usually | |
| 762 | + | // contains characters that status messages don't (code punctuation, | |
| 763 | + | // longer lines, multiple lines) and rarely starts with the " >"- | |
| 764 | + | // or "error:"-style prefixes tools emit. False positives are OK, | |
| 765 | + | // the worst case is a line-number gutter on something that isn't | |
| 766 | + | // really code. | |
| 767 | + | func looksLikeFileContent(text string) bool { | |
| 768 | + | if strings.TrimSpace(text) == "" { | |
| 769 | + | return false | |
| 770 | + | } | |
| 771 | + | lines := strings.Split(text, "\n") | |
| 772 | + | return len(lines) >= 2 | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // renderRawFile renders file content received without embedded line | |
| 776 | + | // numbers (the current read-tool output). Draws a muted gutter like | |
| 777 | + | // "%6d \t" starting at startLine, highlights the code using the | |
| 778 | + | // source path's language, and returns the formatted lines. | |
| 779 | + | func (v *View) renderRawFile(text, sourcePath string, startLine int) []string { | |
| 780 | + | lines := strings.Split(text, "\n") | |
| 781 | + | // Drop the trailing empty line that Split produces when text ends | |
| 782 | + | // in "\n" so the gutter doesn't show a phantom last number. | |
| 783 | + | if n := len(lines); n > 0 && lines[n-1] == "" { | |
| 784 | + | lines = lines[:n-1] | |
| 785 | + | } | |
| 786 | + | // Split code from trailing footer lines ("... [truncated ...]") | |
| 787 | + | // so we don't number the footer. | |
| 788 | + | codeEnd := len(lines) | |
| 789 | + | for i := len(lines) - 1; i >= 0; i-- { | |
| 790 | + | if strings.HasPrefix(lines[i], "...") { | |
| 791 | + | codeEnd = i | |
| 792 | + | continue | |
| 793 | + | } | |
| 794 | + | break | |
| 795 | + | } | |
| 796 | + | code := lines[:codeEnd] | |
| 797 | + | footer := lines[codeEnd:] | |
| 798 | + | ||
| 799 | + | lang := LanguageFromPath(sourcePath) | |
| 800 | + | var highlighted []string | |
| 801 | + | if lang != "" { | |
| 802 | + | highlighted = HighlightCode(strings.Join(code, "\n"), lang) | |
| 803 | + | for len(highlighted) < len(code) { | |
| 804 | + | highlighted = append(highlighted, "") | |
| 805 | + | } | |
| 806 | + | if len(highlighted) > len(code) { | |
| 807 | + | highlighted = highlighted[:len(code)] | |
| 808 | + | } | |
| 809 | + | } else { | |
| 810 | + | highlighted = make([]string, len(code)) | |
| 811 | + | for i, c := range code { | |
| 812 | + | highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c) | |
| 813 | + | } | |
| 814 | + | } | |
| 815 | + | ||
| 816 | + | out := make([]string, 0, len(lines)) | |
| 817 | + | for i, c := range highlighted { | |
| 818 | + | gutter := fmt.Sprintf("%6d\t", startLine+i) | |
| 819 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c) | |
| 820 | + | } | |
| 821 | + | for _, f := range footer { | |
| 822 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f)) | |
| 823 | + | } | |
| 824 | + | return out | |
| 825 | + | } | |
| 826 | + | ||
| 715 | 827 | func toolResultBlock(th Theme, text string, width int, color int) []string { | |
| 716 | 828 | rule := th.FG256(th.Muted, strings.Repeat("─", width)) | |
| 717 | 829 | ||
| @@ -31,6 +31,29 @@ func pathFromToolArgs(raw json.RawMessage) string { | |||||
| 31 | return "" | 31 | return "" | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | + | // offsetFromToolArgs returns the read tool's 1-indexed `offset` | |||
| 35 | + | // arg (the first line of the slice the tool was asked to return), | |||
| 36 | + | // or 0 when the call didn't specify one. Used by the tui to draw | |||
| 37 | + | // the line-number gutter aligned to the right starting row, even | |||
| 38 | + | // though the tool's text content itself no longer carries line | |||
| 39 | + | // numbers. | |||
| 40 | + | func offsetFromToolArgs(raw json.RawMessage) int { | |||
| 41 | + | if len(raw) == 0 { | |||
| 42 | + | return 0 | |||
| 43 | + | } | |||
| 44 | + | var m map[string]any | |||
| 45 | + | if err := json.Unmarshal(raw, &m); err != nil { | |||
| 46 | + | return 0 | |||
| 47 | + | } | |||
| 48 | + | switch v := m["offset"].(type) { | |||
| 49 | + | case float64: | |||
| 50 | + | return int(v) | |||
| 51 | + | case int: | |||
| 52 | + | return v | |||
| 53 | + | } | |||
| 54 | + | return 0 | |||
| 55 | + | } | |||
| 56 | + | ||||
| 34 | // osUserHomeDir is aliased so the test file can swap it. | 57 | // osUserHomeDir is aliased so the test file can swap it. | ||
| 35 | var osUserHomeDir = os.UserHomeDir | 58 | var osUserHomeDir = os.UserHomeDir | ||
| 36 | 59 | ||||
| @@ -43,7 +66,13 @@ type View struct { | |||||
| 43 | // toolPaths maps tool_use_id to the "path" argument of the call, if | 66 | // toolPaths maps tool_use_id to the "path" argument of the call, if | ||
| 44 | // any, so tool_result rendering can pick the right syntax language. | 67 | // any, so tool_result rendering can pick the right syntax language. | ||
| 45 | // Rebuilt on each Build(). | 68 | // Rebuilt on each Build(). | ||
| 46 | - | toolPaths map[string]string | 69 | + | toolPaths map[string]string |
| 70 | + | // toolStartLines maps tool_use_id to the 1-indexed first line | |||
| 71 | + | // number of a `read` result, pulled from the call's offset arg. | |||
| 72 | + | // Used by renderNumberedFile to draw a line-number gutter over | |||
| 73 | + | // raw (unnumbered) file content the model receives. Rebuilt on | |||
| 74 | + | // each Build(). | |||
| 75 | + | toolStartLines map[string]int | |||
| 47 | Streaming string // current assistant text delta | 76 | Streaming string // current assistant text delta | ||
| 48 | StreamingActive bool | 77 | StreamingActive bool | ||
| 49 | ToolCalls []ToolCallView // tool calls in flight or completed | 78 | ToolCalls []ToolCallView // tool calls in flight or completed | ||
| @@ -186,12 +215,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) { | |||||
| 186 | // cheap compared to markdown/chroma work it enables. | 215 | // cheap compared to markdown/chroma work it enables. | ||
| 187 | func (v *View) refreshToolPaths() { | 216 | func (v *View) refreshToolPaths() { | ||
| 188 | v.toolPaths = map[string]string{} | 217 | v.toolPaths = map[string]string{} | ||
| 218 | + | v.toolStartLines = map[string]int{} | |||
| 189 | for _, m := range v.Messages { | 219 | for _, m := range v.Messages { | ||
| 190 | for _, c := range m.Content { | 220 | for _, c := range m.Content { | ||
| 191 | if tc, ok := c.(provider.ToolCallBlock); ok { | 221 | if tc, ok := c.(provider.ToolCallBlock); ok { | ||
| 192 | if p := pathFromToolArgs(tc.Arguments); p != "" { | 222 | if p := pathFromToolArgs(tc.Arguments); p != "" { | ||
| 193 | v.toolPaths[tc.ID] = p | 223 | v.toolPaths[tc.ID] = p | ||
| 194 | } | 224 | } | ||
| 225 | + | if off := offsetFromToolArgs(tc.Arguments); off >= 1 { | |||
| 226 | + | v.toolStartLines[tc.ID] = off | |||
| 227 | + | } | |||
| 195 | } | 228 | } | ||
| 196 | } | 229 | } | ||
| 197 | } | 230 | } | ||
| @@ -359,8 +392,14 @@ func (v *View) renderMessage(m provider.Message, width int) []string { | |||||
| 359 | if v.toolPaths != nil { | 392 | if v.toolPaths != nil { | ||
| 360 | path = v.toolPaths[tr.CallID] | 393 | path = v.toolPaths[tr.CallID] | ||
| 361 | } | 394 | } | ||
| 395 | + | startLine := 1 | |||
| 396 | + | if v.toolStartLines != nil { | |||
| 397 | + | if s := v.toolStartLines[tr.CallID]; s > 0 { | |||
| 398 | + | startLine = s | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 362 | lines = append(lines, v.Theme.FG256(color, title)) | 401 | lines = append(lines, v.Theme.FG256(color, title)) | ||
| 363 | - | lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path)...) | 402 | + | lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...) |
| 364 | } | 403 | } | ||
| 365 | } | 404 | } | ||
| 366 | } | 405 | } | ||
| @@ -396,7 +435,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 396 | // like a unified diff gets +/- coloring. Image blocks are rendered | 435 | // like a unified diff gets +/- coloring. Image blocks are rendered | ||
| 397 | // inline when the terminal supports a protocol, else as a text | 436 | // inline when the terminal supports a protocol, else as a text | ||
| 398 | // placeholder with dimensions. | 437 | // placeholder with dimensions. | ||
| 399 | - | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string { | 438 | + | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string, startLine int) []string { |
| 400 | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | 439 | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | ||
| 401 | 440 | ||||
| 402 | var body []string | 441 | var body []string | ||
| @@ -404,7 +443,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i | |||||
| 404 | for _, b := range blocks { | 443 | for _, b := range blocks { | ||
| 405 | switch bb := b.(type) { | 444 | switch bb := b.(type) { | ||
| 406 | case provider.TextBlock: | 445 | case provider.TextBlock: | ||
| 407 | - | body = append(body, v.renderToolText(bb.Text, width, color, sourcePath)...) | 446 | + | body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...) |
| 408 | case provider.ImageBlock: | 447 | case provider.ImageBlock: | ||
| 409 | hasImage = true | 448 | hasImage = true | ||
| 410 | body = append(body, v.renderImageBlock(bb, width)...) | 449 | body = append(body, v.renderImageBlock(bb, width)...) | ||
| @@ -442,15 +481,21 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||||
| 442 | // text contains a unified-diff section (lines starting with "--- " / | 481 | // text contains a unified-diff section (lines starting with "--- " / | ||
| 443 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | 482 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | ||
| 444 | // colors matching git diff conventions. | 483 | // colors matching git diff conventions. | ||
| 445 | - | func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string) []string { | 484 | + | func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string { |
| 446 | - | // Detect whether the text is `read`-style numbered output | 485 | + | // Legacy path: transcripts saved before we dropped line numbers |
| 447 | - | // (" 1\t…") so we can strip the gutter, highlight the code, and | 486 | + | // from the read tool still carry " 1\t…" prefixes. Detect and |
| 448 | - | // re-apply the line numbers in muted color. Runs even without a | 487 | + | // strip them, then fall through to the highlighter. |
| 449 | - | // source path — language is guessed from the first line, falling | |||
| 450 | - | // back to "text" (no highlighting) if nothing obvious matches. | |||
| 451 | if looksLikeNumberedFile(text) { | 488 | if looksLikeNumberedFile(text) { | ||
| 452 | return v.renderNumberedFile(text, sourcePath) | 489 | return v.renderNumberedFile(text, sourcePath) | ||
| 453 | } | 490 | } | ||
| 491 | + | // Current path: text came from `read` as raw file bytes. When a | |||
| 492 | + | // source path is known (the call had a `path` arg), render with | |||
| 493 | + | // a synthetic line-number gutter starting at startLine so the | |||
| 494 | + | // on-screen view still looks like cat -n. Doesn't apply to non- | |||
| 495 | + | // file tool outputs (bash stdout, display notes, etc.). | |||
| 496 | + | if sourcePath != "" && looksLikeFileContent(text) { | |||
| 497 | + | return v.renderRawFile(text, sourcePath, startLine) | |||
| 498 | + | } | |||
| 454 | 499 | ||||
| 455 | // No truncation — the full tool output is rendered into chat and | 500 | // No truncation — the full tool output is rendered into chat and | ||
| 456 | // becomes part of the scrollback you can page back through. | 501 | // becomes part of the scrollback you can page back through. | ||
| @@ -712,6 +757,73 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string { | |||||
| 712 | return out | 757 | return out | ||
| 713 | } | 758 | } | ||
| 714 | 759 | ||||
| 760 | + | // looksLikeFileContent is a cheap guard to distinguish a read-tool | |||
| 761 | + | // result from bash stdout or a status message. File content usually | |||
| 762 | + | // contains characters that status messages don't (code punctuation, | |||
| 763 | + | // longer lines, multiple lines) and rarely starts with the " >"- | |||
| 764 | + | // or "error:"-style prefixes tools emit. False positives are OK, | |||
| 765 | + | // the worst case is a line-number gutter on something that isn't | |||
| 766 | + | // really code. | |||
| 767 | + | func looksLikeFileContent(text string) bool { | |||
| 768 | + | if strings.TrimSpace(text) == "" { | |||
| 769 | + | return false | |||
| 770 | + | } | |||
| 771 | + | lines := strings.Split(text, "\n") | |||
| 772 | + | return len(lines) >= 2 | |||
| 773 | + | } | |||
| 774 | + | ||||
| 775 | + | // renderRawFile renders file content received without embedded line | |||
| 776 | + | // numbers (the current read-tool output). Draws a muted gutter like | |||
| 777 | + | // "%6d \t" starting at startLine, highlights the code using the | |||
| 778 | + | // source path's language, and returns the formatted lines. | |||
| 779 | + | func (v *View) renderRawFile(text, sourcePath string, startLine int) []string { | |||
| 780 | + | lines := strings.Split(text, "\n") | |||
| 781 | + | // Drop the trailing empty line that Split produces when text ends | |||
| 782 | + | // in "\n" so the gutter doesn't show a phantom last number. | |||
| 783 | + | if n := len(lines); n > 0 && lines[n-1] == "" { | |||
| 784 | + | lines = lines[:n-1] | |||
| 785 | + | } | |||
| 786 | + | // Split code from trailing footer lines ("... [truncated ...]") | |||
| 787 | + | // so we don't number the footer. | |||
| 788 | + | codeEnd := len(lines) | |||
| 789 | + | for i := len(lines) - 1; i >= 0; i-- { | |||
| 790 | + | if strings.HasPrefix(lines[i], "...") { | |||
| 791 | + | codeEnd = i | |||
| 792 | + | continue | |||
| 793 | + | } | |||
| 794 | + | break | |||
| 795 | + | } | |||
| 796 | + | code := lines[:codeEnd] | |||
| 797 | + | footer := lines[codeEnd:] | |||
| 798 | + | ||||
| 799 | + | lang := LanguageFromPath(sourcePath) | |||
| 800 | + | var highlighted []string | |||
| 801 | + | if lang != "" { | |||
| 802 | + | highlighted = HighlightCode(strings.Join(code, "\n"), lang) | |||
| 803 | + | for len(highlighted) < len(code) { | |||
| 804 | + | highlighted = append(highlighted, "") | |||
| 805 | + | } | |||
| 806 | + | if len(highlighted) > len(code) { | |||
| 807 | + | highlighted = highlighted[:len(code)] | |||
| 808 | + | } | |||
| 809 | + | } else { | |||
| 810 | + | highlighted = make([]string, len(code)) | |||
| 811 | + | for i, c := range code { | |||
| 812 | + | highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c) | |||
| 813 | + | } | |||
| 814 | + | } | |||
| 815 | + | ||||
| 816 | + | out := make([]string, 0, len(lines)) | |||
| 817 | + | for i, c := range highlighted { | |||
| 818 | + | gutter := fmt.Sprintf("%6d\t", startLine+i) | |||
| 819 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c) | |||
| 820 | + | } | |||
| 821 | + | for _, f := range footer { | |||
| 822 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f)) | |||
| 823 | + | } | |||
| 824 | + | return out | |||
| 825 | + | } | |||
| 826 | + | ||||
| 715 | func toolResultBlock(th Theme, text string, width int, color int) []string { | 827 | func toolResultBlock(th Theme, text string, width int, color int) []string { | ||
| 716 | rule := th.FG256(th.Muted, strings.Repeat("─", width)) | 828 | rule := th.FG256(th.Muted, strings.Repeat("─", width)) | ||
| 717 | 829 | ||||