mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
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:
parent
5cc54822cd
commit
f5719c6be1
3 changed files with 141 additions and 17 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue