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.
This commit is contained in:
patriceckhart 2026-04-19 17:33:05 +02:00
parent 5cc54822cd
commit f5719c6be1
3 changed files with 141 additions and 17 deletions

View file

@ -126,10 +126,19 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
truncLines = true
}
// Render with 1-indexed line numbers, cat -n style.
// Raw file contents go to the model. We deliberately DON'T
// prepend line numbers here: they'd inflate the token count by
// ~15-20% on typical source files (7 bytes per line, every
// line, every time the file gets re-sent as context on later
// turns) and the model doesn't need them — edit goes through
// exact-match text replacement, not line ranges.
//
// The TUI renders its own gutter using the start offset stored
// in Details, so the on-screen view still looks like cat -n.
var sb strings.Builder
for i, line := range selected {
fmt.Fprintf(&sb, "%6d\t%s\n", start+i+1, line)
for _, line := range selected {
sb.WriteString(line)
sb.WriteByte('\n')
}
if truncLines {
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
Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
Details: map[string]any{
"path": path,
"start_line": start + 1, // 1-indexed; TUI draws the gutter
"lines_truncated": truncLines,
"bytes_truncated": truncBytes,
"total_lines": len(lines),

View file

@ -45,11 +45,13 @@ func TestReadOffsetLimit(t *testing.T) {
tool := &ReadTool{CWD: dir}
res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil)
got := res.Content[0].(provider.TextBlock).Text
if !strings.Contains(got, "2\t2") || !strings.Contains(got, "3\t3") {
t.Fatalf("got %q", got)
// Current output format is raw bytes (no embedded line numbers):
// the tui draws its own gutter from the `start_line` detail.
if got != "2\n3\n" {
t.Fatalf("want \"2\\n3\\n\", got %q", got)
}
if strings.Contains(got, "1\t1") || strings.Contains(got, "4\t4") {
t.Fatalf("leaked lines: %q", got)
if start, ok := res.Details.(map[string]any)["start_line"]; !ok || start != 2 {
t.Errorf("start_line detail want 2, got %v", start)
}
}

View file

@ -31,6 +31,29 @@ func pathFromToolArgs(raw json.RawMessage) string {
return ""
}
// offsetFromToolArgs returns the read tool's 1-indexed `offset`
// arg (the first line of the slice the tool was asked to return),
// or 0 when the call didn't specify one. Used by the tui to draw
// the line-number gutter aligned to the right starting row, even
// though the tool's text content itself no longer carries line
// numbers.
func offsetFromToolArgs(raw json.RawMessage) int {
if len(raw) == 0 {
return 0
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return 0
}
switch v := m["offset"].(type) {
case float64:
return int(v)
case int:
return v
}
return 0
}
// osUserHomeDir is aliased so the test file can swap it.
var osUserHomeDir = os.UserHomeDir
@ -43,7 +66,13 @@ type View struct {
// toolPaths maps tool_use_id to the "path" argument of the call, if
// any, so tool_result rendering can pick the right syntax language.
// Rebuilt on each Build().
toolPaths map[string]string
toolPaths map[string]string
// toolStartLines maps tool_use_id to the 1-indexed first line
// number of a `read` result, pulled from the call's offset arg.
// Used by renderNumberedFile to draw a line-number gutter over
// raw (unnumbered) file content the model receives. Rebuilt on
// each Build().
toolStartLines map[string]int
Streaming string // current assistant text delta
StreamingActive bool
ToolCalls []ToolCallView // tool calls in flight or completed
@ -186,12 +215,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
// cheap compared to markdown/chroma work it enables.
func (v *View) refreshToolPaths() {
v.toolPaths = map[string]string{}
v.toolStartLines = map[string]int{}
for _, m := range v.Messages {
for _, c := range m.Content {
if tc, ok := c.(provider.ToolCallBlock); ok {
if p := pathFromToolArgs(tc.Arguments); p != "" {
v.toolPaths[tc.ID] = p
}
if off := offsetFromToolArgs(tc.Arguments); off >= 1 {
v.toolStartLines[tc.ID] = off
}
}
}
}
@ -359,8 +392,14 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
if v.toolPaths != nil {
path = v.toolPaths[tr.CallID]
}
startLine := 1
if v.toolStartLines != nil {
if s := v.toolStartLines[tr.CallID]; s > 0 {
startLine = s
}
}
lines = append(lines, v.Theme.FG256(color, title))
lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path)...)
lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path, startLine)...)
}
}
}
@ -396,7 +435,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
// 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) []string {
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
@ -404,7 +443,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
for _, b := range blocks {
switch bb := b.(type) {
case provider.TextBlock:
body = append(body, v.renderToolText(bb.Text, width, color, sourcePath)...)
body = append(body, v.renderToolText(bb.Text, width, color, sourcePath, startLine)...)
case provider.ImageBlock:
hasImage = true
body = append(body, v.renderImageBlock(bb, width)...)
@ -442,15 +481,21 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
// text contains a unified-diff section (lines starting with "--- " /
// "+++ " / "+" / "-"/" "), those rows are styled with add/remove
// colors matching git diff conventions.
func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string) []string {
// Detect whether the text is `read`-style numbered output
// (" 1\t…") so we can strip the gutter, highlight the code, and
// re-apply the line numbers in muted color. Runs even without a
// source path — language is guessed from the first line, falling
// back to "text" (no highlighting) if nothing obvious matches.
func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string, startLine int) []string {
// Legacy path: transcripts saved before we dropped line numbers
// from the read tool still carry " 1\t…" prefixes. Detect and
// strip them, then fall through to the highlighter.
if looksLikeNumberedFile(text) {
return v.renderNumberedFile(text, 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
// on-screen view still looks like cat -n. Doesn't apply to non-
// file tool outputs (bash stdout, display notes, etc.).
if sourcePath != "" && looksLikeFileContent(text) {
return v.renderRawFile(text, sourcePath, startLine)
}
// No truncation — the full tool output is rendered into chat and
// becomes part of the scrollback you can page back through.
@ -712,6 +757,73 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
return out
}
// looksLikeFileContent is a cheap guard to distinguish a read-tool
// result from bash stdout or a status message. File content usually
// contains characters that status messages don't (code punctuation,
// longer lines, multiple lines) and rarely starts with the " >"-
// or "error:"-style prefixes tools emit. False positives are OK,
// the worst case is a line-number gutter on something that isn't
// really code.
func looksLikeFileContent(text string) bool {
if strings.TrimSpace(text) == "" {
return false
}
lines := strings.Split(text, "\n")
return len(lines) >= 2
}
// renderRawFile renders file content received without embedded line
// numbers (the current read-tool output). Draws a muted gutter like
// "%6d \t" starting at startLine, highlights the code using the
// source path's language, and returns the formatted lines.
func (v *View) renderRawFile(text, sourcePath string, startLine int) []string {
lines := strings.Split(text, "\n")
// Drop the trailing empty line that Split produces when text ends
// in "\n" so the gutter doesn't show a phantom last number.
if n := len(lines); n > 0 && lines[n-1] == "" {
lines = lines[:n-1]
}
// Split code from trailing footer lines ("... [truncated ...]")
// so we don't number the footer.
codeEnd := len(lines)
for i := len(lines) - 1; i >= 0; i-- {
if strings.HasPrefix(lines[i], "...") {
codeEnd = i
continue
}
break
}
code := lines[:codeEnd]
footer := lines[codeEnd:]
lang := LanguageFromPath(sourcePath)
var highlighted []string
if lang != "" {
highlighted = HighlightCode(strings.Join(code, "\n"), lang)
for len(highlighted) < len(code) {
highlighted = append(highlighted, "")
}
if len(highlighted) > len(code) {
highlighted = highlighted[:len(code)]
}
} else {
highlighted = make([]string, len(code))
for i, c := range code {
highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
}
}
out := make([]string, 0, len(lines))
for i, c := range highlighted {
gutter := fmt.Sprintf("%6d\t", startLine+i)
out = append(out, " "+v.Theme.FG256(v.Theme.Muted, gutter)+c)
}
for _, f := range footer {
out = append(out, " "+v.Theme.FG256(v.Theme.Muted, f))
}
return out
}
func toolResultBlock(th Theme, text string, width int, color int) []string {
rule := th.FG256(th.Muted, strings.Repeat("─", width))