detached f5719c6

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
126126 truncLines = true
127127 }
128128
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.
130138 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')
133142 }
134143 if truncLines {
135144 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
142151 Content: []provider.Content{provider.TextBlock{Text: sb.String()}},
143152 Details: map[string]any{
144153 "path": path,
154+ "start_line": start + 1, // 1-indexed; TUI draws the gutter
145155 "lines_truncated": truncLines,
146156 "bytes_truncated": truncBytes,
147157 "total_lines": len(lines),
@@ -126,10 +126,19 @@ func (t *ReadTool) Execute(ctx context.Context, raw json.RawMessage, progress fu
126 truncLines = true126 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.Builder138 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) {
4545 tool := &ReadTool{CWD: dir}
4646 res, _ := tool.Execute(context.Background(), mustJSON(t, map[string]any{"path": "a.txt", "offset": 2, "limit": 2}), nil)
4747 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)
5052 }
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)
5355 }
5456 }
5557
@@ -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).Text47 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 {
3131 return ""
3232 }
3333
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+
3457 // osUserHomeDir is aliased so the test file can swap it.
3558 var osUserHomeDir = os.UserHomeDir
3659
@@ -43,7 +66,13 @@ type View struct {
4366 // toolPaths maps tool_use_id to the "path" argument of the call, if
4467 // any, so tool_result rendering can pick the right syntax language.
4568 // 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
4776 Streaming string // current assistant text delta
4877 StreamingActive bool
4978 ToolCalls []ToolCallView // tool calls in flight or completed
@@ -186,12 +215,16 @@ func (v *View) BuildWithAnchors(width int) ([]string, []MessageAnchor) {
186215 // cheap compared to markdown/chroma work it enables.
187216 func (v *View) refreshToolPaths() {
188217 v.toolPaths = map[string]string{}
218+ v.toolStartLines = map[string]int{}
189219 for _, m := range v.Messages {
190220 for _, c := range m.Content {
191221 if tc, ok := c.(provider.ToolCallBlock); ok {
192222 if p := pathFromToolArgs(tc.Arguments); p != "" {
193223 v.toolPaths[tc.ID] = p
194224 }
225+ if off := offsetFromToolArgs(tc.Arguments); off >= 1 {
226+ v.toolStartLines[tc.ID] = off
227+ }
195228 }
196229 }
197230 }
@@ -359,8 +392,14 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
359392 if v.toolPaths != nil {
360393 path = v.toolPaths[tr.CallID]
361394 }
395+ startLine := 1
396+ if v.toolStartLines != nil {
397+ if s := v.toolStartLines[tr.CallID]; s > 0 {
398+ startLine = s
399+ }
400+ }
362401 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)...)
364403 }
365404 }
366405 }
@@ -396,7 +435,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
396435 // like a unified diff gets +/- coloring. Image blocks are rendered
397436 // inline when the terminal supports a protocol, else as a text
398437 // 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 {
400439 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
401440
402441 var body []string
@@ -404,7 +443,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
404443 for _, b := range blocks {
405444 switch bb := b.(type) {
406445 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)...)
408447 case provider.ImageBlock:
409448 hasImage = true
410449 body = append(body, v.renderImageBlock(bb, width)...)
@@ -442,15 +481,21 @@ func (v *View) collapseToolBody(lines []string, hasImage bool) []string {
442481 // text contains a unified-diff section (lines starting with "--- " /
443482 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
444483 // 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.
451488 if looksLikeNumberedFile(text) {
452489 return v.renderNumberedFile(text, sourcePath)
453490 }
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+ }
454499
455500 // No truncation — the full tool output is rendered into chat and
456501 // becomes part of the scrollback you can page back through.
@@ -712,6 +757,73 @@ func (v *View) renderNumberedFile(text, sourcePath string) []string {
712757 return out
713758 }
714759
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+
715827 func toolResultBlock(th Theme, text string, width int, color int) []string {
716828 rule := th.FG256(th.Muted, strings.Repeat("─", width))
717829
@@ -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.UserHomeDir58 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, if66 // 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]string69+ 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 delta76 Streaming string // current assistant text delta
48 StreamingActive bool77 StreamingActive bool
49 ToolCalls []ToolCallView // tool calls in flight or completed78 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] = p223 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 rendered435 // like a unified diff gets +/- coloring. Image blocks are rendered
397 // inline when the terminal supports a protocol, else as a text436 // 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 []string441 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 = true448 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/remove482 // "+++ " / "+" / "-"/" "), 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 output485+ // Legacy path: transcripts saved before we dropped line numbers
447- // (" 1\t…") so we can strip the gutter, highlight the code, and486+ // from the read tool still carry " 1\t…" prefixes. Detect and
448- // re-apply the line numbers in muted color. Runs even without a487+ // 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 and500 // 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 out757 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

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