detached a4e6cde

fix code highlighting

commit
a4e6cde56f3de0b1a1b48e07483b18c8c606004a
author
patriceckhart · · 2026-04-18 08:16 UTC
parent
091d5df
signature
unsigned
8 files · +650 -43
M go.mod +2 -0
@@ -9,6 +9,8 @@ require (
99 )
1010
1111 require (
12+ github.com/alecthomas/chroma/v2 v2.23.1 // indirect
13+ github.com/dlclark/regexp2 v1.11.5 // indirect
1214 github.com/rivo/uniseg v0.2.0 // indirect
1315 golang.org/x/sys v0.26.0 // indirect
1416 )
@@ -9,6 +9,8 @@ require (
9 )9 )
10 10
11 require (11 require (
12+ github.com/alecthomas/chroma/v2 v2.23.1 // indirect
13+ github.com/dlclark/regexp2 v1.11.5 // indirect
12 github.com/rivo/uniseg v0.2.0 // indirect14 github.com/rivo/uniseg v0.2.0 // indirect
13 golang.org/x/sys v0.26.0 // indirect15 golang.org/x/sys v0.26.0 // indirect
14 )16 )
M go.sum +4 -0 generated file: collapsed
M internal/agent/cli.go +1 -0
@@ -204,6 +204,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
204204 Theme: tui.Dark,
205205 Model: r.Model,
206206 Provider: r.Provider,
207+ AuthMethod: r.AuthMethod,
207208 BaseURL: r.BaseURL,
208209 Reasoning: r.Reasoning,
209210 SystemPrompt: r.SystemPrompt,
@@ -204,6 +204,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
204 Theme: tui.Dark,204 Theme: tui.Dark,
205 Model: r.Model,205 Model: r.Model,
206 Provider: r.Provider,206 Provider: r.Provider,
207+ AuthMethod: r.AuthMethod,
207 BaseURL: r.BaseURL,208 BaseURL: r.BaseURL,
208 Reasoning: r.Reasoning,209 Reasoning: r.Reasoning,
209 SystemPrompt: r.SystemPrompt,210 SystemPrompt: r.SystemPrompt,
M internal/agent/modes/interactive.go +76 -7
@@ -21,6 +21,7 @@ type InteractiveConfig struct {
2121 Theme tui.Theme
2222 Model string
2323 Provider string
24+ AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
2425 BaseURL string
2526 Reasoning string
2627 SystemPrompt string
@@ -173,9 +174,55 @@ func (i *Interactive) Run(ctx context.Context) error {
173174 authEvents = i.cfg.AuthManager.Events()
174175 }
175176
177+ // Animation ticker: drives spinner and dialog-related redraws when
178+ // nothing else changed. 120ms is slow enough that highlighting a huge
179+ // transcript doesn't spin the cpu.
176180 tick := time.NewTicker(120 * time.Millisecond)
177181 defer tick.Stop()
178182
183+ // Redraw throttle: coalesce bursts of invalidate() calls so we paint
184+ // at most once every redrawMinInterval. Huge tool-result dumps can
185+ // fire hundreds of invalidations while the user is typing; without
186+ // this, the input goroutine never gets CPU and keystrokes lag.
187+ const redrawMinInterval = 16 * time.Millisecond
188+ var lastRedraw time.Time
189+ var pendingRedraw bool
190+ var pendingTimer *time.Timer
191+
192+ drainPending := func() {
193+ if pendingTimer != nil {
194+ pendingTimer.Stop()
195+ pendingTimer = nil
196+ }
197+ if pendingRedraw {
198+ pendingRedraw = false
199+ lastRedraw = time.Now()
200+ i.redraw()
201+ }
202+ }
203+
204+ requestRedraw := func() {
205+ since := time.Since(lastRedraw)
206+ if since >= redrawMinInterval {
207+ lastRedraw = time.Now()
208+ i.redraw()
209+ return
210+ }
211+ if pendingRedraw {
212+ return // already scheduled
213+ }
214+ pendingRedraw = true
215+ wait := redrawMinInterval - since
216+ if pendingTimer == nil {
217+ pendingTimer = time.AfterFunc(wait, func() {
218+ // Poke the dirty channel; the main loop will call drainPending().
219+ i.invalidate()
220+ })
221+ } else {
222+ pendingTimer.Reset(wait)
223+ }
224+ }
225+
179226 i.invalidate()
180227
181228 for {
@@ -191,10 +238,11 @@ func (i *Interactive) Run(ctx context.Context) error {
191238 i.handleAuthEvent(ev)
192239 i.invalidate()
193240 case <-i.dirty:
194- i.redraw()
241+ requestRedraw()
195242 case <-tick.C:
196243 if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() {
197- i.redraw()
244+ drainPending()
245+ requestRedraw()
198246 }
199247 }
200248 }
@@ -261,10 +309,18 @@ func (i *Interactive) redraw() {
261309 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
262310 i.view.StreamingActive = false
263311 }
264- i.view.ToolCalls = make([]tui.ToolCallView, 0, len(i.toolOrder))
265- for _, id := range i.toolOrder {
266- if tc, ok := i.toolCalls[id]; ok {
267- i.view.ToolCalls = append(i.view.ToolCalls, *tc)
312+ // Live tool-call view: only shown while a turn is in flight. Once
313+ // the agent is idle, every tool call has already been folded into
314+ // the transcript (as assistant.ToolCallBlock + a tool-role message),
315+ // so showing v.ToolCalls a second time would duplicate them below
316+ // the final assistant text — which looks like the summary came
317+ // "before" the tools.
318+ i.view.ToolCalls = i.view.ToolCalls[:0]
319+ if i.busy {
320+ for _, id := range i.toolOrder {
321+ if tc, ok := i.toolCalls[id]; ok {
322+ i.view.ToolCalls = append(i.view.ToolCalls, *tc)
323+ }
268324 }
269325 }
270326 i.view.Err = i.statusErr
@@ -319,7 +375,20 @@ func (i *Interactive) redraw() {
319375 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
320376 ctxMax = m.ContextWindow
321377 }
322- status := tui.StatusBar(i.cfg.Theme, i.cfg.Model, i.cfg.Provider, i.cumUsage, i.busy, busyPrefix, i.cfg.CWD, i.cfg.Sandbox.Locked(), i.lastCtxInput, ctxMax, cols)
378+ status := tui.StatusBar(tui.StatusBarParams{
379+ Theme: i.cfg.Theme,
380+ Provider: i.cfg.Provider,
381+ Model: i.cfg.Model,
382+ Busy: i.busy,
383+ BusyPrefix: busyPrefix,
384+ CWD: i.cfg.CWD,
385+ Locked: i.cfg.Sandbox.Locked(),
386+ Usage: i.cumUsage,
387+ Subscription: i.cfg.AuthMethod == "oauth",
388+ ContextUsed: i.lastCtxInput,
389+ ContextMax: ctxMax,
390+ Cols: cols,
391+ })
323392 edLines, curR, curC := i.ed.Render(cols)
324393
325394 // Bottom-sticky sections (always visible, never scroll).
@@ -21,6 +21,7 @@ type InteractiveConfig struct {
21 Theme tui.Theme21 Theme tui.Theme
22 Model string22 Model string
23 Provider string23 Provider string
24+ AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar
24 BaseURL string25 BaseURL string
25 Reasoning string26 Reasoning string
26 SystemPrompt string27 SystemPrompt string
@@ -173,9 +174,55 @@ func (i *Interactive) Run(ctx context.Context) error {
173 authEvents = i.cfg.AuthManager.Events()174 authEvents = i.cfg.AuthManager.Events()
174 }175 }
175 176
177+ // Animation ticker: drives spinner and dialog-related redraws when
178+ // nothing else changed. 120ms is slow enough that highlighting a huge
179+ // transcript doesn't spin the cpu.
176 tick := time.NewTicker(120 * time.Millisecond)180 tick := time.NewTicker(120 * time.Millisecond)
177 defer tick.Stop()181 defer tick.Stop()
178 182
183+ // Redraw throttle: coalesce bursts of invalidate() calls so we paint
184+ // at most once every redrawMinInterval. Huge tool-result dumps can
185+ // fire hundreds of invalidations while the user is typing; without
186+ // this, the input goroutine never gets CPU and keystrokes lag.
187+ const redrawMinInterval = 16 * time.Millisecond
188+ var lastRedraw time.Time
189+ var pendingRedraw bool
190+ var pendingTimer *time.Timer
191+
192+ drainPending := func() {
193+ if pendingTimer != nil {
194+ pendingTimer.Stop()
195+ pendingTimer = nil
196+ }
197+ if pendingRedraw {
198+ pendingRedraw = false
199+ lastRedraw = time.Now()
200+ i.redraw()
201+ }
202+ }
203+
204+ requestRedraw := func() {
205+ since := time.Since(lastRedraw)
206+ if since >= redrawMinInterval {
207+ lastRedraw = time.Now()
208+ i.redraw()
209+ return
210+ }
211+ if pendingRedraw {
212+ return // already scheduled
213+ }
214+ pendingRedraw = true
215+ wait := redrawMinInterval - since
216+ if pendingTimer == nil {
217+ pendingTimer = time.AfterFunc(wait, func() {
218+ // Poke the dirty channel; the main loop will call drainPending().
219+ i.invalidate()
220+ })
221+ } else {
222+ pendingTimer.Reset(wait)
223+ }
224+ }
225+
179 i.invalidate()226 i.invalidate()
180 227
181 for {228 for {
@@ -191,10 +238,11 @@ func (i *Interactive) Run(ctx context.Context) error {
191 i.handleAuthEvent(ev)238 i.handleAuthEvent(ev)
192 i.invalidate()239 i.invalidate()
193 case <-i.dirty:240 case <-i.dirty:
194- i.redraw()241+ requestRedraw()
195 case <-tick.C:242 case <-tick.C:
196 if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() {243 if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() {
197- i.redraw()244+ drainPending()
245+ requestRedraw()
198 }246 }
199 }247 }
200 }248 }
@@ -261,10 +309,18 @@ func (i *Interactive) redraw() {
261 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {309 if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant {
262 i.view.StreamingActive = false310 i.view.StreamingActive = false
263 }311 }
264- i.view.ToolCalls = make([]tui.ToolCallView, 0, len(i.toolOrder))312+ // Live tool-call view: only shown while a turn is in flight. Once
265- for _, id := range i.toolOrder {313+ // the agent is idle, every tool call has already been folded into
266- if tc, ok := i.toolCalls[id]; ok {314+ // the transcript (as assistant.ToolCallBlock + a tool-role message),
267- i.view.ToolCalls = append(i.view.ToolCalls, *tc)315+ // so showing v.ToolCalls a second time would duplicate them below
316+ // the final assistant text — which looks like the summary came
317+ // "before" the tools.
318+ i.view.ToolCalls = i.view.ToolCalls[:0]
319+ if i.busy {
320+ for _, id := range i.toolOrder {
321+ if tc, ok := i.toolCalls[id]; ok {
322+ i.view.ToolCalls = append(i.view.ToolCalls, *tc)
323+ }
268 }324 }
269 }325 }
270 i.view.Err = i.statusErr326 i.view.Err = i.statusErr
@@ -319,7 +375,20 @@ func (i *Interactive) redraw() {
319 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {375 if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil {
320 ctxMax = m.ContextWindow376 ctxMax = m.ContextWindow
321 }377 }
322- status := tui.StatusBar(i.cfg.Theme, i.cfg.Model, i.cfg.Provider, i.cumUsage, i.busy, busyPrefix, i.cfg.CWD, i.cfg.Sandbox.Locked(), i.lastCtxInput, ctxMax, cols)378+ status := tui.StatusBar(tui.StatusBarParams{
379+ Theme: i.cfg.Theme,
380+ Provider: i.cfg.Provider,
381+ Model: i.cfg.Model,
382+ Busy: i.busy,
383+ BusyPrefix: busyPrefix,
384+ CWD: i.cfg.CWD,
385+ Locked: i.cfg.Sandbox.Locked(),
386+ Usage: i.cumUsage,
387+ Subscription: i.cfg.AuthMethod == "oauth",
388+ ContextUsed: i.lastCtxInput,
389+ ContextMax: ctxMax,
390+ Cols: cols,
391+ })
323 edLines, curR, curC := i.ed.Render(cols)392 edLines, curR, curC := i.ed.Render(cols)
324 393
325 // Bottom-sticky sections (always visible, never scroll).394 // Bottom-sticky sections (always visible, never scroll).
A internal/tui/highlight.go +250 -0
@@ -0,0 +1,250 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "path/filepath"
6+ "strings"
7+ "sync"
8+
9+ "github.com/alecthomas/chroma/v2"
10+ "github.com/alecthomas/chroma/v2/formatters"
11+ "github.com/alecthomas/chroma/v2/lexers"
12+ "github.com/alecthomas/chroma/v2/styles"
13+)
14+
15+// HighlightCode syntax-colors src and returns the result split into lines,
16+// ready for a line-based diff renderer. If no language is given or
17+// chroma has no lexer for it, src is returned as-is (one entry per line).
18+// Safe to call from multiple goroutines.
19+//
20+// Results are memoised by (lang, src) so repeated calls from the view
21+// builder (which runs on every redraw) don't re-tokenise. Cache is
22+// bounded and evicts oldest entries past its cap.
23+func HighlightCode(src, lang string) []string {
24+ if out, ok := highlightCache.lookup(lang, src); ok {
25+ return out
26+ }
27+ lexer := chooseLexer(lang)
28+ if lexer == nil {
29+ out := strings.Split(src, "\n")
30+ highlightCache.store(lang, src, out)
31+ return out
32+ }
33+ style := zotChromaStyle
34+ formatter := formatters.Get("terminal256")
35+ if formatter == nil {
36+ return strings.Split(src, "\n")
37+ }
38+
39+ iterator, err := lexer.Tokenise(nil, src)
40+ if err != nil {
41+ out := strings.Split(src, "\n")
42+ highlightCache.store(lang, src, out)
43+ return out
44+ }
45+ var buf bytes.Buffer
46+ if err := formatter.Format(&buf, style, iterator); err != nil {
47+ out := strings.Split(src, "\n")
48+ highlightCache.store(lang, src, out)
49+ return out
50+ }
51+ out := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
52+ highlightCache.store(lang, src, out)
53+ return out
54+}
55+
56+// highlightResultCache is a simple LRU-ish cache: when it exceeds its
57+// capacity we drop half of the entries (the oldest, based on insertion
58+// order). That's good enough since tool_result text doesn't change once
59+// emitted, so cache hits are frequent and evictions rare.
60+type highlightResultCache struct {
61+ mu sync.Mutex
62+ max int
63+ data map[string][]string
64+ order []string
65+}
66+
67+func (c *highlightResultCache) key(lang, src string) string {
68+ return lang + "\x00" + src
69+}
70+
71+func (c *highlightResultCache) lookup(lang, src string) ([]string, bool) {
72+ c.mu.Lock()
73+ defer c.mu.Unlock()
74+ v, ok := c.data[c.key(lang, src)]
75+ return v, ok
76+}
77+
78+func (c *highlightResultCache) store(lang, src string, out []string) {
79+ c.mu.Lock()
80+ defer c.mu.Unlock()
81+ if c.data == nil {
82+ c.data = make(map[string][]string)
83+ }
84+ k := c.key(lang, src)
85+ if _, ok := c.data[k]; !ok {
86+ c.order = append(c.order, k)
87+ }
88+ c.data[k] = out
89+ if c.max > 0 && len(c.order) > c.max {
90+ // Evict the oldest half so we amortise the cost.
91+ cut := len(c.order) / 2
92+ for _, old := range c.order[:cut] {
93+ delete(c.data, old)
94+ }
95+ c.order = append([]string(nil), c.order[cut:]...)
96+ }
97+}
98+
99+var highlightCache = &highlightResultCache{max: 512}
100+
101+// LanguageFromPath maps file extensions to chroma lexer names.
102+func LanguageFromPath(p string) string {
103+ ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
104+ base := strings.ToLower(filepath.Base(p))
105+ if lang := filenameLang[base]; lang != "" {
106+ return lang
107+ }
108+ return extLang[ext]
109+}
110+
111+// chooseLexer picks the best lexer for a language hint; falls back to
112+// nil (no highlighting) if the hint is empty or unknown.
113+func chooseLexer(lang string) chroma.Lexer {
114+ if lang == "" {
115+ return nil
116+ }
117+ if l := lexers.Get(lang); l != nil {
118+ return chroma.Coalesce(l)
119+ }
120+ // Chroma accepts aliases; try common spellings.
121+ if l := lexers.Get(strings.ToLower(lang)); l != nil {
122+ return chroma.Coalesce(l)
123+ }
124+ return nil
125+}
126+
127+// zotChromaStyle is a terminal style tuned for zot's dark palette.
128+// Built lazily so the cost is paid once per process.
129+var zotChromaStyle = func() *chroma.Style {
130+ chromaStyleOnce.Do(func() {
131+ chromaStyleCached = buildZotStyle()
132+ })
133+ return chromaStyleCached
134+}()
135+
136+var (
137+ chromaStyleOnce sync.Once
138+ chromaStyleCached *chroma.Style
139+)
140+
141+// buildZotStyle returns a chroma style with colors that match zot's
142+// theme: accent blue for keywords, green for strings, muted grey for
143+// comments, user-tan for numbers. Falls back to chroma's bundled
144+// "fruity" style if the builder fails for any reason.
145+func buildZotStyle() *chroma.Style {
146+ builder := styles.Get("monokai").Builder()
147+ // Override the most visible tokens with explicit colors that match
148+ // pi's cli-highlight look on a dark terminal.
149+ builder.Add(chroma.Keyword, "#81a1c1 bold") // imports, funcs, control flow
150+ builder.Add(chroma.KeywordConstant, "#81a1c1") // true, false, null
151+ builder.Add(chroma.KeywordDeclaration, "#81a1c1") // const, let, var, type
152+ builder.Add(chroma.KeywordNamespace, "#81a1c1") // import, from, package
153+ builder.Add(chroma.KeywordReserved, "#81a1c1 bold")
154+ builder.Add(chroma.KeywordType, "#88c0d0") // string, int, bool
155+ builder.Add(chroma.NameBuiltin, "#88c0d0")
156+ builder.Add(chroma.NameFunction, "#8fbcbb")
157+ builder.Add(chroma.NameClass, "#a3be8c bold")
158+ builder.Add(chroma.NameDecorator, "#b48ead")
159+ builder.Add(chroma.LiteralString, "#a3be8c") // strings
160+ builder.Add(chroma.LiteralStringEscape, "#bf616a")
161+ builder.Add(chroma.LiteralNumber, "#d08770")
162+ builder.Add(chroma.Comment, "#616e88 italic")
163+ builder.Add(chroma.CommentPreproc, "#b48ead")
164+ builder.Add(chroma.Operator, "#eceff4")
165+ builder.Add(chroma.Punctuation, "#d8dee9")
166+ builder.Add(chroma.Text, "#e5e9f0")
167+ builder.Add(chroma.Background, " bg:")
168+
169+ s, err := builder.Build()
170+ if err != nil {
171+ return styles.Fallback
172+ }
173+ return s
174+}
175+
176+// extLang mirrors pi's extToLang. Only extensions that chroma supports
177+// are included.
178+var extLang = map[string]string{
179+ "ts": "typescript",
180+ "tsx": "tsx",
181+ "js": "javascript",
182+ "jsx": "jsx",
183+ "mjs": "javascript",
184+ "cjs": "javascript",
185+ "py": "python",
186+ "rb": "ruby",
187+ "rs": "rust",
188+ "go": "go",
189+ "java": "java",
190+ "kt": "kotlin",
191+ "swift": "swift",
192+ "c": "c",
193+ "h": "c",
194+ "cpp": "cpp",
195+ "cc": "cpp",
196+ "cxx": "cpp",
197+ "hpp": "cpp",
198+ "cs": "csharp",
199+ "php": "php",
200+ "sh": "bash",
201+ "bash": "bash",
202+ "zsh": "bash",
203+ "fish": "fish",
204+ "ps1": "powershell",
205+ "sql": "sql",
206+ "html": "html",
207+ "htm": "html",
208+ "css": "css",
209+ "scss": "scss",
210+ "sass": "sass",
211+ "less": "less",
212+ "json": "json",
213+ "yaml": "yaml",
214+ "yml": "yaml",
215+ "toml": "toml",
216+ "xml": "xml",
217+ "md": "markdown",
218+ "markdown": "markdown",
219+ "dockerfile": "docker",
220+ "makefile": "makefile",
221+ "lua": "lua",
222+ "perl": "perl",
223+ "pl": "perl",
224+ "proto": "protobuf",
225+ "tf": "terraform",
226+ "hcl": "terraform",
227+ "graphql": "graphql",
228+ "gql": "graphql",
229+ "vue": "vue",
230+ "svelte": "svelte",
231+ "r": "r",
232+ "jl": "julia",
233+ "ex": "elixir",
234+ "exs": "elixir",
235+ "scala": "scala",
236+ "nix": "nix",
237+}
238+
239+var filenameLang = map[string]string{
240+ "dockerfile": "docker",
241+ "makefile": "makefile",
242+ "gnumakefile": "makefile",
243+ ".gitconfig": "ini",
244+ ".gitattributes": "ini",
245+ "cargo.toml": "toml",
246+ "go.mod": "go-module",
247+ "go.sum": "text",
248+ "package.json": "json",
249+ "tsconfig.json": "json",
250+}
@@ -0,0 +1,250 @@
1+package tui
2+
3+import (
4+ "bytes"
5+ "path/filepath"
6+ "strings"
7+ "sync"
8+
9+ "github.com/alecthomas/chroma/v2"
10+ "github.com/alecthomas/chroma/v2/formatters"
11+ "github.com/alecthomas/chroma/v2/lexers"
12+ "github.com/alecthomas/chroma/v2/styles"
13+)
14+
15+// HighlightCode syntax-colors src and returns the result split into lines,
16+// ready for a line-based diff renderer. If no language is given or
17+// chroma has no lexer for it, src is returned as-is (one entry per line).
18+// Safe to call from multiple goroutines.
19+//
20+// Results are memoised by (lang, src) so repeated calls from the view
21+// builder (which runs on every redraw) don't re-tokenise. Cache is
22+// bounded and evicts oldest entries past its cap.
23+func HighlightCode(src, lang string) []string {
24+ if out, ok := highlightCache.lookup(lang, src); ok {
25+ return out
26+ }
27+ lexer := chooseLexer(lang)
28+ if lexer == nil {
29+ out := strings.Split(src, "\n")
30+ highlightCache.store(lang, src, out)
31+ return out
32+ }
33+ style := zotChromaStyle
34+ formatter := formatters.Get("terminal256")
35+ if formatter == nil {
36+ return strings.Split(src, "\n")
37+ }
38+
39+ iterator, err := lexer.Tokenise(nil, src)
40+ if err != nil {
41+ out := strings.Split(src, "\n")
42+ highlightCache.store(lang, src, out)
43+ return out
44+ }
45+ var buf bytes.Buffer
46+ if err := formatter.Format(&buf, style, iterator); err != nil {
47+ out := strings.Split(src, "\n")
48+ highlightCache.store(lang, src, out)
49+ return out
50+ }
51+ out := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
52+ highlightCache.store(lang, src, out)
53+ return out
54+}
55+
56+// highlightResultCache is a simple LRU-ish cache: when it exceeds its
57+// capacity we drop half of the entries (the oldest, based on insertion
58+// order). That's good enough since tool_result text doesn't change once
59+// emitted, so cache hits are frequent and evictions rare.
60+type highlightResultCache struct {
61+ mu sync.Mutex
62+ max int
63+ data map[string][]string
64+ order []string
65+}
66+
67+func (c *highlightResultCache) key(lang, src string) string {
68+ return lang + "\x00" + src
69+}
70+
71+func (c *highlightResultCache) lookup(lang, src string) ([]string, bool) {
72+ c.mu.Lock()
73+ defer c.mu.Unlock()
74+ v, ok := c.data[c.key(lang, src)]
75+ return v, ok
76+}
77+
78+func (c *highlightResultCache) store(lang, src string, out []string) {
79+ c.mu.Lock()
80+ defer c.mu.Unlock()
81+ if c.data == nil {
82+ c.data = make(map[string][]string)
83+ }
84+ k := c.key(lang, src)
85+ if _, ok := c.data[k]; !ok {
86+ c.order = append(c.order, k)
87+ }
88+ c.data[k] = out
89+ if c.max > 0 && len(c.order) > c.max {
90+ // Evict the oldest half so we amortise the cost.
91+ cut := len(c.order) / 2
92+ for _, old := range c.order[:cut] {
93+ delete(c.data, old)
94+ }
95+ c.order = append([]string(nil), c.order[cut:]...)
96+ }
97+}
98+
99+var highlightCache = &highlightResultCache{max: 512}
100+
101+// LanguageFromPath maps file extensions to chroma lexer names.
102+func LanguageFromPath(p string) string {
103+ ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(p)), ".")
104+ base := strings.ToLower(filepath.Base(p))
105+ if lang := filenameLang[base]; lang != "" {
106+ return lang
107+ }
108+ return extLang[ext]
109+}
110+
111+// chooseLexer picks the best lexer for a language hint; falls back to
112+// nil (no highlighting) if the hint is empty or unknown.
113+func chooseLexer(lang string) chroma.Lexer {
114+ if lang == "" {
115+ return nil
116+ }
117+ if l := lexers.Get(lang); l != nil {
118+ return chroma.Coalesce(l)
119+ }
120+ // Chroma accepts aliases; try common spellings.
121+ if l := lexers.Get(strings.ToLower(lang)); l != nil {
122+ return chroma.Coalesce(l)
123+ }
124+ return nil
125+}
126+
127+// zotChromaStyle is a terminal style tuned for zot's dark palette.
128+// Built lazily so the cost is paid once per process.
129+var zotChromaStyle = func() *chroma.Style {
130+ chromaStyleOnce.Do(func() {
131+ chromaStyleCached = buildZotStyle()
132+ })
133+ return chromaStyleCached
134+}()
135+
136+var (
137+ chromaStyleOnce sync.Once
138+ chromaStyleCached *chroma.Style
139+)
140+
141+// buildZotStyle returns a chroma style with colors that match zot's
142+// theme: accent blue for keywords, green for strings, muted grey for
143+// comments, user-tan for numbers. Falls back to chroma's bundled
144+// "fruity" style if the builder fails for any reason.
145+func buildZotStyle() *chroma.Style {
146+ builder := styles.Get("monokai").Builder()
147+ // Override the most visible tokens with explicit colors that match
148+ // pi's cli-highlight look on a dark terminal.
149+ builder.Add(chroma.Keyword, "#81a1c1 bold") // imports, funcs, control flow
150+ builder.Add(chroma.KeywordConstant, "#81a1c1") // true, false, null
151+ builder.Add(chroma.KeywordDeclaration, "#81a1c1") // const, let, var, type
152+ builder.Add(chroma.KeywordNamespace, "#81a1c1") // import, from, package
153+ builder.Add(chroma.KeywordReserved, "#81a1c1 bold")
154+ builder.Add(chroma.KeywordType, "#88c0d0") // string, int, bool
155+ builder.Add(chroma.NameBuiltin, "#88c0d0")
156+ builder.Add(chroma.NameFunction, "#8fbcbb")
157+ builder.Add(chroma.NameClass, "#a3be8c bold")
158+ builder.Add(chroma.NameDecorator, "#b48ead")
159+ builder.Add(chroma.LiteralString, "#a3be8c") // strings
160+ builder.Add(chroma.LiteralStringEscape, "#bf616a")
161+ builder.Add(chroma.LiteralNumber, "#d08770")
162+ builder.Add(chroma.Comment, "#616e88 italic")
163+ builder.Add(chroma.CommentPreproc, "#b48ead")
164+ builder.Add(chroma.Operator, "#eceff4")
165+ builder.Add(chroma.Punctuation, "#d8dee9")
166+ builder.Add(chroma.Text, "#e5e9f0")
167+ builder.Add(chroma.Background, " bg:")
168+
169+ s, err := builder.Build()
170+ if err != nil {
171+ return styles.Fallback
172+ }
173+ return s
174+}
175+
176+// extLang mirrors pi's extToLang. Only extensions that chroma supports
177+// are included.
178+var extLang = map[string]string{
179+ "ts": "typescript",
180+ "tsx": "tsx",
181+ "js": "javascript",
182+ "jsx": "jsx",
183+ "mjs": "javascript",
184+ "cjs": "javascript",
185+ "py": "python",
186+ "rb": "ruby",
187+ "rs": "rust",
188+ "go": "go",
189+ "java": "java",
190+ "kt": "kotlin",
191+ "swift": "swift",
192+ "c": "c",
193+ "h": "c",
194+ "cpp": "cpp",
195+ "cc": "cpp",
196+ "cxx": "cpp",
197+ "hpp": "cpp",
198+ "cs": "csharp",
199+ "php": "php",
200+ "sh": "bash",
201+ "bash": "bash",
202+ "zsh": "bash",
203+ "fish": "fish",
204+ "ps1": "powershell",
205+ "sql": "sql",
206+ "html": "html",
207+ "htm": "html",
208+ "css": "css",
209+ "scss": "scss",
210+ "sass": "sass",
211+ "less": "less",
212+ "json": "json",
213+ "yaml": "yaml",
214+ "yml": "yaml",
215+ "toml": "toml",
216+ "xml": "xml",
217+ "md": "markdown",
218+ "markdown": "markdown",
219+ "dockerfile": "docker",
220+ "makefile": "makefile",
221+ "lua": "lua",
222+ "perl": "perl",
223+ "pl": "perl",
224+ "proto": "protobuf",
225+ "tf": "terraform",
226+ "hcl": "terraform",
227+ "graphql": "graphql",
228+ "gql": "graphql",
229+ "vue": "vue",
230+ "svelte": "svelte",
231+ "r": "r",
232+ "jl": "julia",
233+ "ex": "elixir",
234+ "exs": "elixir",
235+ "scala": "scala",
236+ "nix": "nix",
237+}
238+
239+var filenameLang = map[string]string{
240+ "dockerfile": "docker",
241+ "makefile": "makefile",
242+ "gnumakefile": "makefile",
243+ ".gitconfig": "ini",
244+ ".gitattributes": "ini",
245+ "cargo.toml": "toml",
246+ "go.mod": "go-module",
247+ "go.sum": "text",
248+ "package.json": "json",
249+ "tsconfig.json": "json",
250+}
M internal/tui/markdown.go +27 -3
@@ -20,19 +20,42 @@ func RenderMarkdown(src string, th Theme, width int) string {
2020
2121 lines := strings.Split(src, "\n")
2222 var out strings.Builder
23+ var fenceBuf strings.Builder
2324 inFence := false
25+ fenceLang := ""
2426 fenceIndent := ""
27+
28+ flushFence := func() {
29+ if fenceBuf.Len() == 0 {
30+ return
31+ }
32+ code := strings.TrimRight(fenceBuf.String(), "\n")
33+ if fenceLang != "" {
34+ for _, l := range HighlightCode(code, fenceLang) {
35+ out.WriteString(l)
36+ out.WriteString("\n")
37+ }
38+ } else {
39+ for _, l := range strings.Split(code, "\n") {
40+ out.WriteString(th.FG256(th.Accent, l))
41+ out.WriteString("\n")
42+ }
43+ }
44+ fenceBuf.Reset()
45+ }
46+
2547 for _, line := range lines {
2648 trim := strings.TrimLeft(line, " ")
2749 if strings.HasPrefix(trim, "```") {
2850 if inFence {
29- // closing fence: emit bottom rule
51+ flushFence()
3052 inFence = false
53+ fenceLang = ""
3154 out.WriteString(rule + "\n")
3255 } else {
33- // opening fence: emit top rule
3456 inFence = true
3557 fenceIndent = line[:len(line)-len(trim)]
58+ fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))
3659 out.WriteString(rule + "\n")
3760 }
3861 continue
@@ -41,7 +64,8 @@ func RenderMarkdown(src string, th Theme, width int) string {
4164 if strings.HasPrefix(line, fenceIndent) {
4265 line = line[len(fenceIndent):]
4366 }
44- out.WriteString(th.FG256(th.Accent, line) + "\n")
67+ fenceBuf.WriteString(line)
68+ fenceBuf.WriteString("\n")
4569 continue
4670 }
4771 // Headings.
@@ -20,19 +20,42 @@ func RenderMarkdown(src string, th Theme, width int) string {
20 20
21 lines := strings.Split(src, "\n")21 lines := strings.Split(src, "\n")
22 var out strings.Builder22 var out strings.Builder
23+ var fenceBuf strings.Builder
23 inFence := false24 inFence := false
25+ fenceLang := ""
24 fenceIndent := ""26 fenceIndent := ""
27+
28+ flushFence := func() {
29+ if fenceBuf.Len() == 0 {
30+ return
31+ }
32+ code := strings.TrimRight(fenceBuf.String(), "\n")
33+ if fenceLang != "" {
34+ for _, l := range HighlightCode(code, fenceLang) {
35+ out.WriteString(l)
36+ out.WriteString("\n")
37+ }
38+ } else {
39+ for _, l := range strings.Split(code, "\n") {
40+ out.WriteString(th.FG256(th.Accent, l))
41+ out.WriteString("\n")
42+ }
43+ }
44+ fenceBuf.Reset()
45+ }
46+
25 for _, line := range lines {47 for _, line := range lines {
26 trim := strings.TrimLeft(line, " ")48 trim := strings.TrimLeft(line, " ")
27 if strings.HasPrefix(trim, "```") {49 if strings.HasPrefix(trim, "```") {
28 if inFence {50 if inFence {
29- // closing fence: emit bottom rule51+ flushFence()
30 inFence = false52 inFence = false
53+ fenceLang = ""
31 out.WriteString(rule + "\n")54 out.WriteString(rule + "\n")
32 } else {55 } else {
33- // opening fence: emit top rule
34 inFence = true56 inFence = true
35 fenceIndent = line[:len(line)-len(trim)]57 fenceIndent = line[:len(line)-len(trim)]
58+ fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```"))
36 out.WriteString(rule + "\n")59 out.WriteString(rule + "\n")
37 }60 }
38 continue61 continue
@@ -41,7 +64,8 @@ func RenderMarkdown(src string, th Theme, width int) string {
41 if strings.HasPrefix(line, fenceIndent) {64 if strings.HasPrefix(line, fenceIndent) {
42 line = line[len(fenceIndent):]65 line = line[len(fenceIndent):]
43 }66 }
44- out.WriteString(th.FG256(th.Accent, line) + "\n")67+ fenceBuf.WriteString(line)
68+ fenceBuf.WriteString("\n")
45 continue69 continue
46 }70 }
47 // Headings.71 // Headings.
M internal/tui/render.go +50 -2
@@ -3,8 +3,14 @@ package tui
33 import (
44 "io"
55 "strings"
6+
7+ "github.com/mattn/go-runewidth"
68 )
79
10+// runewidthRune reports the number of cells a rune occupies, pinned
11+// here so the renderer does not depend on the editor's helper.
12+func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
13+
814 // Renderer maintains a previous frame and writes only the lines that
915 // changed on each Draw(). Callers pass a full target frame (slice of
1016 // styled lines, already wrapped to width).
@@ -58,6 +64,46 @@ func containsImageEscape(s string) bool {
5864 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
5965 }
6066
67+// truncateToWidth clips s so its on-screen width doesn't exceed cols
68+// cells, preserving ANSI CSI escape sequences (which don't consume
69+// cells). Lines carrying an inline-image escape are returned as-is
70+// since we can't measure their painted size.
71+func truncateToWidth(s string, cols int) string {
72+ if cols <= 0 || containsImageEscape(s) {
73+ return s
74+ }
75+ var out strings.Builder
76+ out.Grow(len(s))
77+ seen := 0
78+ runes := []rune(s)
79+ for i := 0; i < len(runes); {
80+ r := runes[i]
81+ // CSI escape sequence (ESC [ ... final): zero-width.
82+ if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
83+ out.WriteRune(r)
84+ out.WriteRune(runes[i+1])
85+ i += 2
86+ for i < len(runes) {
87+ c := runes[i]
88+ out.WriteRune(c)
89+ i++
90+ if c >= 0x40 && c <= 0x7e {
91+ break
92+ }
93+ }
94+ continue
95+ }
96+ rw := runewidthRune(r)
97+ if seen+rw > cols {
98+ break
99+ }
100+ out.WriteRune(r)
101+ seen += rw
102+ i++
103+ }
104+ return out.String()
105+}
106+
61107 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
62108 if r.cols == 0 || r.rows == 0 {
63109 return
@@ -68,14 +114,16 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
68114 visible = visible[len(visible)-r.rows:]
69115 cursorRow -= len(lines) - len(visible)
70116 }
71- // Pad to r.rows with empty lines at the top.
117+ // Pad to r.rows with empty lines at the top. Every line is also
118+ // hard-truncated to cols so the terminal never soft-wraps our output
119+ // (which would push the status bar out of its row).
72120 frame := make([]string, r.rows)
73121 top := r.rows - len(visible)
74122 for i := 0; i < top; i++ {
75123 frame[i] = ""
76124 }
77125 for i, line := range visible {
78- frame[top+i] = line
126+ frame[top+i] = truncateToWidth(line, r.cols)
79127 }
80128
81129 var w strings.Builder
@@ -3,8 +3,14 @@ package tui
3 import (3 import (
4 "io"4 "io"
5 "strings"5 "strings"
6+
7+ "github.com/mattn/go-runewidth"
6 )8 )
7 9
10+// runewidthRune reports the number of cells a rune occupies, pinned
11+// here so the renderer does not depend on the editor's helper.
12+func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
13+
8 // Renderer maintains a previous frame and writes only the lines that14 // Renderer maintains a previous frame and writes only the lines that
9 // changed on each Draw(). Callers pass a full target frame (slice of15 // changed on each Draw(). Callers pass a full target frame (slice of
10 // styled lines, already wrapped to width).16 // styled lines, already wrapped to width).
@@ -58,6 +64,46 @@ func containsImageEscape(s string) bool {
58 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")64 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
59 }65 }
60 66
67+// truncateToWidth clips s so its on-screen width doesn't exceed cols
68+// cells, preserving ANSI CSI escape sequences (which don't consume
69+// cells). Lines carrying an inline-image escape are returned as-is
70+// since we can't measure their painted size.
71+func truncateToWidth(s string, cols int) string {
72+ if cols <= 0 || containsImageEscape(s) {
73+ return s
74+ }
75+ var out strings.Builder
76+ out.Grow(len(s))
77+ seen := 0
78+ runes := []rune(s)
79+ for i := 0; i < len(runes); {
80+ r := runes[i]
81+ // CSI escape sequence (ESC [ ... final): zero-width.
82+ if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
83+ out.WriteRune(r)
84+ out.WriteRune(runes[i+1])
85+ i += 2
86+ for i < len(runes) {
87+ c := runes[i]
88+ out.WriteRune(c)
89+ i++
90+ if c >= 0x40 && c <= 0x7e {
91+ break
92+ }
93+ }
94+ continue
95+ }
96+ rw := runewidthRune(r)
97+ if seen+rw > cols {
98+ break
99+ }
100+ out.WriteRune(r)
101+ seen += rw
102+ i++
103+ }
104+ return out.String()
105+}
106+
61 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {107 func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
62 if r.cols == 0 || r.rows == 0 {108 if r.cols == 0 || r.rows == 0 {
63 return109 return
@@ -68,14 +114,16 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
68 visible = visible[len(visible)-r.rows:]114 visible = visible[len(visible)-r.rows:]
69 cursorRow -= len(lines) - len(visible)115 cursorRow -= len(lines) - len(visible)
70 }116 }
71- // Pad to r.rows with empty lines at the top.117+ // Pad to r.rows with empty lines at the top. Every line is also
118+ // hard-truncated to cols so the terminal never soft-wraps our output
119+ // (which would push the status bar out of its row).
72 frame := make([]string, r.rows)120 frame := make([]string, r.rows)
73 top := r.rows - len(visible)121 top := r.rows - len(visible)
74 for i := 0; i < top; i++ {122 for i := 0; i < top; i++ {
75 frame[i] = ""123 frame[i] = ""
76 }124 }
77 for i, line := range visible {125 for i, line := range visible {
78- frame[top+i] = line126+ frame[top+i] = truncateToWidth(line, r.cols)
79 }127 }
80 128
81 var w strings.Builder129 var w strings.Builder
M internal/tui/view.go +240 -31
@@ -4,20 +4,45 @@ import (
44 "encoding/json"
55 "fmt"
66 "os"
7+ "regexp"
78 "strings"
89
910 "github.com/patriceckhart/zot/internal/provider"
1011 )
1112
13+// pathFromToolArgs returns the "path" argument from a tool_call's
14+// JSON arguments, or "" if the args aren't a JSON object or don't
15+// include one. Used to pick a syntax language for rendering the
16+// corresponding tool_result.
17+func pathFromToolArgs(raw json.RawMessage) string {
18+ if len(raw) == 0 {
19+ return ""
20+ }
21+ var m map[string]any
22+ if err := json.Unmarshal(raw, &m); err != nil {
23+ return ""
24+ }
25+ for _, k := range []string{"path", "file_path"} {
26+ if s, ok := m[k].(string); ok && s != "" {
27+ return s
28+ }
29+ }
30+ return ""
31+}
32+
1233 // osUserHomeDir is aliased so the test file can swap it.
1334 var osUserHomeDir = os.UserHomeDir
1435
1536 // View turns a transcript + live state into a slice of styled lines,
1637 // already wrapped to width.
1738 type View struct {
18- Theme Theme
19- ImageProto ImageProtocol // how to render inline images in this terminal
20- Messages []provider.Message
39+ Theme Theme
40+ ImageProto ImageProtocol // how to render inline images in this terminal
41+ Messages []provider.Message
42+ // toolPaths maps tool_use_id to the "path" argument of the call, if
43+ // any, so tool_result rendering can pick the right syntax language.
44+ // Rebuilt on each Build().
45+ toolPaths map[string]string
2146 Streaming string // current assistant text delta
2247 StreamingActive bool
2348 ToolCalls []ToolCallView // tool calls in flight or completed
@@ -37,6 +62,19 @@ type ToolCallView struct {
3762
3863 // Build returns the chat log lines for the given width.
3964 func (v *View) Build(width int) []string {
65+ // Map tool_use_id -> path argument, if any, so tool results can be
66+ // rendered with the file's language for syntax highlighting.
67+ v.toolPaths = map[string]string{}
68+ for _, m := range v.Messages {
69+ for _, c := range m.Content {
70+ if tc, ok := c.(provider.ToolCallBlock); ok {
71+ if p := pathFromToolArgs(tc.Arguments); p != "" {
72+ v.toolPaths[tc.ID] = p
73+ }
74+ }
75+ }
76+ }
77+
4078 var out []string
4179 for _, m := range v.Messages {
4280 out = append(out, v.renderMessage(m, width)...)
@@ -103,8 +141,12 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
103141 title = " error"
104142 color = v.Theme.Error
105143 }
144+ path := ""
145+ if v.toolPaths != nil {
146+ path = v.toolPaths[tr.CallID]
147+ }
106148 lines = append(lines, v.Theme.FG256(color, title))
107- lines = append(lines, v.renderToolResultContent(tr.Content, width, color)...)
149+ lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path)...)
108150 }
109151 }
110152 }
@@ -132,7 +174,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
132174 // like a unified diff gets +/- coloring. Image blocks are rendered
133175 // inline when the terminal supports a protocol, else as a text
134176 // placeholder with dimensions.
135-func (v *View) renderToolResultContent(blocks []provider.Content, width, color int) []string {
177+func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string {
136178 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
137179
138180 var out []string
@@ -140,7 +182,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
140182 for _, b := range blocks {
141183 switch bb := b.(type) {
142184 case provider.TextBlock:
143- out = append(out, v.renderToolText(bb.Text, width, color)...)
185+ out = append(out, v.renderToolText(bb.Text, width, color, sourcePath)...)
144186 case provider.ImageBlock:
145187 out = append(out, v.renderImageBlock(bb, width)...)
146188 }
@@ -153,7 +195,16 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
153195 // text contains a unified-diff section (lines starting with "--- " /
154196 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
155197 // colors matching git diff conventions.
156-func (v *View) renderToolText(text string, width, defaultColor int) []string {
198+func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string) []string {
199+ // Detect whether the text is `read`-style numbered output
200+ // (" 1\t…") so we can strip the gutter, highlight the code, and
201+ // re-apply the line numbers in muted color. Runs even without a
202+ // source path — language is guessed from the first line, falling
203+ // back to "text" (no highlighting) if nothing obvious matches.
204+ if looksLikeNumberedFile(text) {
205+ return v.renderNumberedFile(text, sourcePath)
206+ }
207+
157208 // No truncation — the full tool output is rendered into chat and
158209 // becomes part of the scrollback you can page back through.
159210 lines := strings.Split(text, "\n")
@@ -242,6 +293,86 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
242293 return []string{v.Theme.FG256(v.Theme.Muted, info)}
243294 }
244295
296+// looksLikeNumberedFile returns true when text matches the `read`
297+// tool's " N\tcontent" format for most of its lines.
298+func looksLikeNumberedFile(text string) bool {
299+ lines := strings.Split(text, "\n")
300+ if len(lines) == 0 {
301+ return false
302+ }
303+ hits := 0
304+ scanned := 0
305+ for _, l := range lines {
306+ if l == "" {
307+ continue
308+ }
309+ scanned++
310+ if scanned > 20 {
311+ break
312+ }
313+ if numberedLineRE.MatchString(l) {
314+ hits++
315+ }
316+ }
317+ if scanned == 0 {
318+ return false
319+ }
320+ return hits*2 >= scanned // majority of non-empty lines match
321+}
322+
323+var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
324+
325+// renderNumberedFile strips line numbers, highlights the code, and
326+// re-attaches the line numbers in muted color.
327+func (v *View) renderNumberedFile(text, sourcePath string) []string {
328+ lines := strings.Split(text, "\n")
329+ gutters := make([]string, 0, len(lines))
330+ codes := make([]string, 0, len(lines))
331+ for _, l := range lines {
332+ idx := strings.IndexByte(l, '\t')
333+ if idx < 0 || !numberedLineRE.MatchString(l) {
334+ // Non-code footer (e.g. "[truncated at 2000 lines]").
335+ gutters = append(gutters, "")
336+ codes = append(codes, l)
337+ continue
338+ }
339+ gutter := l[:idx+1] // keep the tab so alignment is preserved
340+ code := l[idx+1:]
341+ gutters = append(gutters, gutter)
342+ codes = append(codes, code)
343+ }
344+ lang := LanguageFromPath(sourcePath)
345+ var highlighted []string
346+ if lang != "" {
347+ highlighted = HighlightCode(strings.Join(codes, "\n"), lang)
348+ // Chroma sometimes collapses the trailing empty line. Pad to
349+ // align with the gutter slice so per-line zipping works.
350+ for len(highlighted) < len(codes) {
351+ highlighted = append(highlighted, "")
352+ }
353+ if len(highlighted) > len(codes) {
354+ highlighted = highlighted[:len(codes)]
355+ }
356+ } else {
357+ // No lexer for this file type — render in the ToolOut color so
358+ // code is visually distinct from the muted gutter.
359+ highlighted = make([]string, len(codes))
360+ for i, c := range codes {
361+ highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
362+ }
363+ }
364+ out := make([]string, 0, len(codes))
365+ for i, code := range highlighted {
366+ g := gutters[i]
367+ if g == "" {
368+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, code))
369+ continue
370+ }
371+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, g)+code)
372+ }
373+ return out
374+}
375+
245376 func toolResultBlock(th Theme, text string, width int, color int) []string {
246377 rule := th.FG256(th.Muted, strings.Repeat("─", width))
247378
@@ -309,37 +440,98 @@ func truncateLines(s string, n int) string {
309440 //
310441 // cols is the terminal width; when > 0 the cwd is placed flush-right
311442 // with spaces. busyPrefix, if non-empty, is injected at the far left.
312-func StatusBar(th Theme, model, prov string, cum provider.Usage, busy bool, busyPrefix, cwd string, locked bool, ctxUsed, ctxMax int, cols int) string {
443+// StatusBarParams groups the many bits of state the status bar needs.
444+// Grew from a flat argument list once we started matching pi's format.
445+type StatusBarParams struct {
446+ Theme Theme
447+ Provider string
448+ Model string
449+ Busy bool
450+ BusyPrefix string // spinner + funny line when busy
451+ CWD string
452+ Locked bool // sandbox on?
453+
454+ // Cumulative session usage and cost.
455+ Usage provider.Usage
456+ // Subscription is true when the credential is an OAuth token (claude
457+ // pro/max, chatgpt plus/pro) rather than a paid api key. We still
458+ // compute a cost for visibility, but pi-style we append "(sub)" so
459+ // the user knows no money actually moved.
460+ Subscription bool
461+
462+ // Last turn's input+cache tokens (approximates current live context).
463+ ContextUsed int
464+ ContextMax int // model's context window; 0 disables the percentage
465+
466+ Cols int // terminal width; drives right-alignment of cwd
467+}
468+
469+// StatusBar builds the single-line status shown above the editor.
470+// Format: ↑N ↓N RN WN $cost[(sub)] pct%/ctxMax
471+func StatusBar(p StatusBarParams) string {
472+ th := p.Theme
313473 sep := " - "
314- cost := fmt.Sprintf("$%.4f", cum.CostUSD)
315- tokens := fmt.Sprintf("%d↑ %d↓", cum.InputTokens, cum.OutputTokens)
316- left := fmt.Sprintf(" %s%s%s ", prov, sep, model)
317- ctx := formatContextUsage(ctxUsed, ctxMax)
318- middle := fmt.Sprintf(" %s%s%s%s%s ", tokens, sep, cost, sep, ctx)
474+
475+ // Token stats: only include each segment when non-zero (pi's
476+ // behavior). Keeps the bar compact on brand-new sessions.
477+ var stats []string
478+ if p.Usage.InputTokens > 0 {
479+ stats = append(stats, fmt.Sprintf("↑%s", piFormatTokens(p.Usage.InputTokens)))
480+ }
481+ if p.Usage.OutputTokens > 0 {
482+ stats = append(stats, fmt.Sprintf("↓%s", piFormatTokens(p.Usage.OutputTokens)))
483+ }
484+ if p.Usage.CacheReadTokens > 0 {
485+ stats = append(stats, fmt.Sprintf("R%s", piFormatTokens(p.Usage.CacheReadTokens)))
486+ }
487+ if p.Usage.CacheWriteTokens > 0 {
488+ stats = append(stats, fmt.Sprintf("W%s", piFormatTokens(p.Usage.CacheWriteTokens)))
489+ }
490+
491+ var costStr string
492+ if p.Subscription {
493+ costStr = "$0.000 (sub)"
494+ } else if p.Usage.CostUSD > 0 {
495+ costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
496+ }
497+ if costStr != "" {
498+ stats = append(stats, costStr)
499+ }
500+
501+ // Context %. Color-coded: yellow >70, red >90.
502+ ctx, ctxColor := piContextUsage(th, p.ContextUsed, p.ContextMax)
503+ if ctx != "" {
504+ stats = append(stats, th.FG256(ctxColor, ctx))
505+ }
506+
507+ left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model)
508+ middle := " " + strings.Join(stats, " ") + " "
509+
319510 var hint string
320- if busy {
511+ if p.Busy {
321512 hint = " esc cancel "
322513 } else {
323514 hint = " ctrl+c exit " + sep + " /help "
324515 }
325516
326517 var leftBuilder strings.Builder
327- if busyPrefix != "" {
328- leftBuilder.WriteString(th.FG256(th.Accent, " "+busyPrefix+" "))
518+ if p.BusyPrefix != "" {
519+ leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" "))
329520 leftBuilder.WriteString(" ")
330521 }
331522 leftBuilder.WriteString(th.FG256(th.Muted, left))
332523 leftBuilder.WriteString(" ")
524+ // `middle` already has colorized context segments; wrap the rest in muted.
333525 leftBuilder.WriteString(th.FG256(th.Muted, middle))
334526 leftBuilder.WriteString(" ")
335527 leftBuilder.WriteString(th.FG256(th.Muted, hint))
336528
337529 // Compose cwd on the right side. shortenHome abbreviates $HOME to "~".
338- right := shortenHome(cwd)
339- if locked && right != "" {
530+ right := shortenHome(p.CWD)
531+ if p.Locked && right != "" {
340532 right = "· locked · " + right
341533 }
342- if right == "" || cols <= 0 {
534+ if right == "" || p.Cols <= 0 {
343535 return leftBuilder.String()
344536 }
345537
@@ -348,37 +540,54 @@ func StatusBar(th Theme, model, prov string, cum provider.Usage, busy bool, busy
348540 rightStr := " " + right + " "
349541 rightWidth := visibleWidth(rightStr)
350542
351- gap := cols - leftWidth - rightWidth
543+ gap := p.Cols - leftWidth - rightWidth
352544 if gap < 1 {
353- // Not enough room for the cwd; drop it silently to avoid wrapping.
354545 return leftRendered
355546 }
356547 return leftRendered + strings.Repeat(" ", gap) + th.FG256(th.Muted, rightStr)
357548 }
358549
359-// formatContextUsage returns a short string describing the share of the
360-// model's context window currently occupied by the latest turn's input.
361-func formatContextUsage(used, max int) string {
550+// piContextUsage renders the "N%/ctxMax" fragment, returning the
551+// rendered string plus the colour to wrap it in.
552+func piContextUsage(th Theme, used, max int) (string, int) {
362553 if max <= 0 {
363554 if used <= 0 {
364- return "ctx –"
555+ return "", th.Muted
365556 }
366- return fmt.Sprintf("ctx %s", shortTokens(used))
557+ return piFormatTokens(used), th.Muted
367558 }
368559 pct := float64(used) / float64(max) * 100
369- return fmt.Sprintf("ctx %s/%s (%.0f%%)", shortTokens(used), shortTokens(max), pct)
560+ text := fmt.Sprintf("%.1f%%/%s", pct, piFormatTokens(max))
561+ switch {
562+ case pct > 90:
563+ return text, th.Error
564+ case pct > 70:
565+ return text, th.Warning
566+ }
567+ return text, th.Muted
370568 }
371569
372-func shortTokens(n int) string {
570+// piFormatTokens footer formatter:
571+//
572+// < 1000 -> "42"
573+// < 10_000 -> "2.7k"
574+// < 1_000_000 -> "35k"
575+// < 10M -> "1.1M"
576+// else -> "12M"
577+func piFormatTokens(n int) string {
373578 switch {
374- case n <= 0:
579+ case n < 0:
375580 return "0"
376581 case n < 1000:
377582 return fmt.Sprintf("%d", n)
378- case n < 1_000_000:
583+ case n < 10000:
379584 return fmt.Sprintf("%.1fk", float64(n)/1000)
380- default:
585+ case n < 1_000_000:
586+ return fmt.Sprintf("%dk", (n+500)/1000)
587+ case n < 10_000_000:
381588 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
589+ default:
590+ return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
382591 }
383592 }
384593
@@ -4,20 +4,45 @@ import (
4 "encoding/json"4 "encoding/json"
5 "fmt"5 "fmt"
6 "os"6 "os"
7+ "regexp"
7 "strings"8 "strings"
8 9
9 "github.com/patriceckhart/zot/internal/provider"10 "github.com/patriceckhart/zot/internal/provider"
10 )11 )
11 12
13+// pathFromToolArgs returns the "path" argument from a tool_call's
14+// JSON arguments, or "" if the args aren't a JSON object or don't
15+// include one. Used to pick a syntax language for rendering the
16+// corresponding tool_result.
17+func pathFromToolArgs(raw json.RawMessage) string {
18+ if len(raw) == 0 {
19+ return ""
20+ }
21+ var m map[string]any
22+ if err := json.Unmarshal(raw, &m); err != nil {
23+ return ""
24+ }
25+ for _, k := range []string{"path", "file_path"} {
26+ if s, ok := m[k].(string); ok && s != "" {
27+ return s
28+ }
29+ }
30+ return ""
31+}
32+
12 // osUserHomeDir is aliased so the test file can swap it.33 // osUserHomeDir is aliased so the test file can swap it.
13 var osUserHomeDir = os.UserHomeDir34 var osUserHomeDir = os.UserHomeDir
14 35
15 // View turns a transcript + live state into a slice of styled lines,36 // View turns a transcript + live state into a slice of styled lines,
16 // already wrapped to width.37 // already wrapped to width.
17 type View struct {38 type View struct {
18- Theme Theme39+ Theme Theme
19- ImageProto ImageProtocol // how to render inline images in this terminal40+ ImageProto ImageProtocol // how to render inline images in this terminal
20- Messages []provider.Message41+ Messages []provider.Message
42+ // toolPaths maps tool_use_id to the "path" argument of the call, if
43+ // any, so tool_result rendering can pick the right syntax language.
44+ // Rebuilt on each Build().
45+ toolPaths map[string]string
21 Streaming string // current assistant text delta46 Streaming string // current assistant text delta
22 StreamingActive bool47 StreamingActive bool
23 ToolCalls []ToolCallView // tool calls in flight or completed48 ToolCalls []ToolCallView // tool calls in flight or completed
@@ -37,6 +62,19 @@ type ToolCallView struct {
37 62
38 // Build returns the chat log lines for the given width.63 // Build returns the chat log lines for the given width.
39 func (v *View) Build(width int) []string {64 func (v *View) Build(width int) []string {
65+ // Map tool_use_id -> path argument, if any, so tool results can be
66+ // rendered with the file's language for syntax highlighting.
67+ v.toolPaths = map[string]string{}
68+ for _, m := range v.Messages {
69+ for _, c := range m.Content {
70+ if tc, ok := c.(provider.ToolCallBlock); ok {
71+ if p := pathFromToolArgs(tc.Arguments); p != "" {
72+ v.toolPaths[tc.ID] = p
73+ }
74+ }
75+ }
76+ }
77+
40 var out []string78 var out []string
41 for _, m := range v.Messages {79 for _, m := range v.Messages {
42 out = append(out, v.renderMessage(m, width)...)80 out = append(out, v.renderMessage(m, width)...)
@@ -103,8 +141,12 @@ func (v *View) renderMessage(m provider.Message, width int) []string {
103 title = " error"141 title = " error"
104 color = v.Theme.Error142 color = v.Theme.Error
105 }143 }
144+ path := ""
145+ if v.toolPaths != nil {
146+ path = v.toolPaths[tr.CallID]
147+ }
106 lines = append(lines, v.Theme.FG256(color, title))148 lines = append(lines, v.Theme.FG256(color, title))
107- lines = append(lines, v.renderToolResultContent(tr.Content, width, color)...)149+ lines = append(lines, v.renderToolResultContent(tr.Content, width, color, path)...)
108 }150 }
109 }151 }
110 }152 }
@@ -132,7 +174,7 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string {
132 // like a unified diff gets +/- coloring. Image blocks are rendered174 // like a unified diff gets +/- coloring. Image blocks are rendered
133 // inline when the terminal supports a protocol, else as a text175 // inline when the terminal supports a protocol, else as a text
134 // placeholder with dimensions.176 // placeholder with dimensions.
135-func (v *View) renderToolResultContent(blocks []provider.Content, width, color int) []string {177+func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string {
136 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))178 rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width))
137 179
138 var out []string180 var out []string
@@ -140,7 +182,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
140 for _, b := range blocks {182 for _, b := range blocks {
141 switch bb := b.(type) {183 switch bb := b.(type) {
142 case provider.TextBlock:184 case provider.TextBlock:
143- out = append(out, v.renderToolText(bb.Text, width, color)...)185+ out = append(out, v.renderToolText(bb.Text, width, color, sourcePath)...)
144 case provider.ImageBlock:186 case provider.ImageBlock:
145 out = append(out, v.renderImageBlock(bb, width)...)187 out = append(out, v.renderImageBlock(bb, width)...)
146 }188 }
@@ -153,7 +195,16 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i
153 // text contains a unified-diff section (lines starting with "--- " /195 // text contains a unified-diff section (lines starting with "--- " /
154 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove196 // "+++ " / "+" / "-"/" "), those rows are styled with add/remove
155 // colors matching git diff conventions.197 // colors matching git diff conventions.
156-func (v *View) renderToolText(text string, width, defaultColor int) []string {198+func (v *View) renderToolText(text string, width, defaultColor int, sourcePath string) []string {
199+ // Detect whether the text is `read`-style numbered output
200+ // (" 1\t…") so we can strip the gutter, highlight the code, and
201+ // re-apply the line numbers in muted color. Runs even without a
202+ // source path — language is guessed from the first line, falling
203+ // back to "text" (no highlighting) if nothing obvious matches.
204+ if looksLikeNumberedFile(text) {
205+ return v.renderNumberedFile(text, sourcePath)
206+ }
207+
157 // No truncation — the full tool output is rendered into chat and208 // No truncation — the full tool output is rendered into chat and
158 // becomes part of the scrollback you can page back through.209 // becomes part of the scrollback you can page back through.
159 lines := strings.Split(text, "\n")210 lines := strings.Split(text, "\n")
@@ -242,6 +293,86 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string {
242 return []string{v.Theme.FG256(v.Theme.Muted, info)}293 return []string{v.Theme.FG256(v.Theme.Muted, info)}
243 }294 }
244 295
296+// looksLikeNumberedFile returns true when text matches the `read`
297+// tool's " N\tcontent" format for most of its lines.
298+func looksLikeNumberedFile(text string) bool {
299+ lines := strings.Split(text, "\n")
300+ if len(lines) == 0 {
301+ return false
302+ }
303+ hits := 0
304+ scanned := 0
305+ for _, l := range lines {
306+ if l == "" {
307+ continue
308+ }
309+ scanned++
310+ if scanned > 20 {
311+ break
312+ }
313+ if numberedLineRE.MatchString(l) {
314+ hits++
315+ }
316+ }
317+ if scanned == 0 {
318+ return false
319+ }
320+ return hits*2 >= scanned // majority of non-empty lines match
321+}
322+
323+var numberedLineRE = regexp.MustCompile(`^\s*\d+\t`)
324+
325+// renderNumberedFile strips line numbers, highlights the code, and
326+// re-attaches the line numbers in muted color.
327+func (v *View) renderNumberedFile(text, sourcePath string) []string {
328+ lines := strings.Split(text, "\n")
329+ gutters := make([]string, 0, len(lines))
330+ codes := make([]string, 0, len(lines))
331+ for _, l := range lines {
332+ idx := strings.IndexByte(l, '\t')
333+ if idx < 0 || !numberedLineRE.MatchString(l) {
334+ // Non-code footer (e.g. "[truncated at 2000 lines]").
335+ gutters = append(gutters, "")
336+ codes = append(codes, l)
337+ continue
338+ }
339+ gutter := l[:idx+1] // keep the tab so alignment is preserved
340+ code := l[idx+1:]
341+ gutters = append(gutters, gutter)
342+ codes = append(codes, code)
343+ }
344+ lang := LanguageFromPath(sourcePath)
345+ var highlighted []string
346+ if lang != "" {
347+ highlighted = HighlightCode(strings.Join(codes, "\n"), lang)
348+ // Chroma sometimes collapses the trailing empty line. Pad to
349+ // align with the gutter slice so per-line zipping works.
350+ for len(highlighted) < len(codes) {
351+ highlighted = append(highlighted, "")
352+ }
353+ if len(highlighted) > len(codes) {
354+ highlighted = highlighted[:len(codes)]
355+ }
356+ } else {
357+ // No lexer for this file type — render in the ToolOut color so
358+ // code is visually distinct from the muted gutter.
359+ highlighted = make([]string, len(codes))
360+ for i, c := range codes {
361+ highlighted[i] = v.Theme.FG256(v.Theme.ToolOut, c)
362+ }
363+ }
364+ out := make([]string, 0, len(codes))
365+ for i, code := range highlighted {
366+ g := gutters[i]
367+ if g == "" {
368+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, code))
369+ continue
370+ }
371+ out = append(out, " "+v.Theme.FG256(v.Theme.Muted, g)+code)
372+ }
373+ return out
374+}
375+
245 func toolResultBlock(th Theme, text string, width int, color int) []string {376 func toolResultBlock(th Theme, text string, width int, color int) []string {
246 rule := th.FG256(th.Muted, strings.Repeat("─", width))377 rule := th.FG256(th.Muted, strings.Repeat("─", width))
247 378
@@ -309,37 +440,98 @@ func truncateLines(s string, n int) string {
309 //440 //
310 // cols is the terminal width; when > 0 the cwd is placed flush-right441 // cols is the terminal width; when > 0 the cwd is placed flush-right
311 // with spaces. busyPrefix, if non-empty, is injected at the far left.442 // with spaces. busyPrefix, if non-empty, is injected at the far left.
312-func StatusBar(th Theme, model, prov string, cum provider.Usage, busy bool, busyPrefix, cwd string, locked bool, ctxUsed, ctxMax int, cols int) string {443+// StatusBarParams groups the many bits of state the status bar needs.
444+// Grew from a flat argument list once we started matching pi's format.
445+type StatusBarParams struct {
446+ Theme Theme
447+ Provider string
448+ Model string
449+ Busy bool
450+ BusyPrefix string // spinner + funny line when busy
451+ CWD string
452+ Locked bool // sandbox on?
453+
454+ // Cumulative session usage and cost.
455+ Usage provider.Usage
456+ // Subscription is true when the credential is an OAuth token (claude
457+ // pro/max, chatgpt plus/pro) rather than a paid api key. We still
458+ // compute a cost for visibility, but pi-style we append "(sub)" so
459+ // the user knows no money actually moved.
460+ Subscription bool
461+
462+ // Last turn's input+cache tokens (approximates current live context).
463+ ContextUsed int
464+ ContextMax int // model's context window; 0 disables the percentage
465+
466+ Cols int // terminal width; drives right-alignment of cwd
467+}
468+
469+// StatusBar builds the single-line status shown above the editor.
470+// Format: ↑N ↓N RN WN $cost[(sub)] pct%/ctxMax
471+func StatusBar(p StatusBarParams) string {
472+ th := p.Theme
313 sep := " - "473 sep := " - "
314- cost := fmt.Sprintf("$%.4f", cum.CostUSD)474+
315- tokens := fmt.Sprintf("%d↑ %d↓", cum.InputTokens, cum.OutputTokens)475+ // Token stats: only include each segment when non-zero (pi's
316- left := fmt.Sprintf(" %s%s%s ", prov, sep, model)476+ // behavior). Keeps the bar compact on brand-new sessions.
317- ctx := formatContextUsage(ctxUsed, ctxMax)477+ var stats []string
318- middle := fmt.Sprintf(" %s%s%s%s%s ", tokens, sep, cost, sep, ctx)478+ if p.Usage.InputTokens > 0 {
479+ stats = append(stats, fmt.Sprintf("↑%s", piFormatTokens(p.Usage.InputTokens)))
480+ }
481+ if p.Usage.OutputTokens > 0 {
482+ stats = append(stats, fmt.Sprintf("↓%s", piFormatTokens(p.Usage.OutputTokens)))
483+ }
484+ if p.Usage.CacheReadTokens > 0 {
485+ stats = append(stats, fmt.Sprintf("R%s", piFormatTokens(p.Usage.CacheReadTokens)))
486+ }
487+ if p.Usage.CacheWriteTokens > 0 {
488+ stats = append(stats, fmt.Sprintf("W%s", piFormatTokens(p.Usage.CacheWriteTokens)))
489+ }
490+
491+ var costStr string
492+ if p.Subscription {
493+ costStr = "$0.000 (sub)"
494+ } else if p.Usage.CostUSD > 0 {
495+ costStr = fmt.Sprintf("$%.3f", p.Usage.CostUSD)
496+ }
497+ if costStr != "" {
498+ stats = append(stats, costStr)
499+ }
500+
501+ // Context %. Color-coded: yellow >70, red >90.
502+ ctx, ctxColor := piContextUsage(th, p.ContextUsed, p.ContextMax)
503+ if ctx != "" {
504+ stats = append(stats, th.FG256(ctxColor, ctx))
505+ }
506+
507+ left := fmt.Sprintf(" (%s) %s ", p.Provider, p.Model)
508+ middle := " " + strings.Join(stats, " ") + " "
509+
319 var hint string510 var hint string
320- if busy {511+ if p.Busy {
321 hint = " esc cancel "512 hint = " esc cancel "
322 } else {513 } else {
323 hint = " ctrl+c exit " + sep + " /help "514 hint = " ctrl+c exit " + sep + " /help "
324 }515 }
325 516
326 var leftBuilder strings.Builder517 var leftBuilder strings.Builder
327- if busyPrefix != "" {518+ if p.BusyPrefix != "" {
328- leftBuilder.WriteString(th.FG256(th.Accent, " "+busyPrefix+" "))519+ leftBuilder.WriteString(th.FG256(th.Accent, " "+p.BusyPrefix+" "))
329 leftBuilder.WriteString(" ")520 leftBuilder.WriteString(" ")
330 }521 }
331 leftBuilder.WriteString(th.FG256(th.Muted, left))522 leftBuilder.WriteString(th.FG256(th.Muted, left))
332 leftBuilder.WriteString(" ")523 leftBuilder.WriteString(" ")
524+ // `middle` already has colorized context segments; wrap the rest in muted.
333 leftBuilder.WriteString(th.FG256(th.Muted, middle))525 leftBuilder.WriteString(th.FG256(th.Muted, middle))
334 leftBuilder.WriteString(" ")526 leftBuilder.WriteString(" ")
335 leftBuilder.WriteString(th.FG256(th.Muted, hint))527 leftBuilder.WriteString(th.FG256(th.Muted, hint))
336 528
337 // Compose cwd on the right side. shortenHome abbreviates $HOME to "~".529 // Compose cwd on the right side. shortenHome abbreviates $HOME to "~".
338- right := shortenHome(cwd)530+ right := shortenHome(p.CWD)
339- if locked && right != "" {531+ if p.Locked && right != "" {
340 right = "· locked · " + right532 right = "· locked · " + right
341 }533 }
342- if right == "" || cols <= 0 {534+ if right == "" || p.Cols <= 0 {
343 return leftBuilder.String()535 return leftBuilder.String()
344 }536 }
345 537
@@ -348,37 +540,54 @@ func StatusBar(th Theme, model, prov string, cum provider.Usage, busy bool, busy
348 rightStr := " " + right + " "540 rightStr := " " + right + " "
349 rightWidth := visibleWidth(rightStr)541 rightWidth := visibleWidth(rightStr)
350 542
351- gap := cols - leftWidth - rightWidth543+ gap := p.Cols - leftWidth - rightWidth
352 if gap < 1 {544 if gap < 1 {
353- // Not enough room for the cwd; drop it silently to avoid wrapping.
354 return leftRendered545 return leftRendered
355 }546 }
356 return leftRendered + strings.Repeat(" ", gap) + th.FG256(th.Muted, rightStr)547 return leftRendered + strings.Repeat(" ", gap) + th.FG256(th.Muted, rightStr)
357 }548 }
358 549
359-// formatContextUsage returns a short string describing the share of the550+// piContextUsage renders the "N%/ctxMax" fragment, returning the
360-// model's context window currently occupied by the latest turn's input.551+// rendered string plus the colour to wrap it in.
361-func formatContextUsage(used, max int) string {552+func piContextUsage(th Theme, used, max int) (string, int) {
362 if max <= 0 {553 if max <= 0 {
363 if used <= 0 {554 if used <= 0 {
364- return "ctx –"555+ return "", th.Muted
365 }556 }
366- return fmt.Sprintf("ctx %s", shortTokens(used))557+ return piFormatTokens(used), th.Muted
367 }558 }
368 pct := float64(used) / float64(max) * 100559 pct := float64(used) / float64(max) * 100
369- return fmt.Sprintf("ctx %s/%s (%.0f%%)", shortTokens(used), shortTokens(max), pct)560+ text := fmt.Sprintf("%.1f%%/%s", pct, piFormatTokens(max))
561+ switch {
562+ case pct > 90:
563+ return text, th.Error
564+ case pct > 70:
565+ return text, th.Warning
566+ }
567+ return text, th.Muted
370 }568 }
371 569
372-func shortTokens(n int) string {570+// piFormatTokens footer formatter:
571+//
572+// < 1000 -> "42"
573+// < 10_000 -> "2.7k"
574+// < 1_000_000 -> "35k"
575+// < 10M -> "1.1M"
576+// else -> "12M"
577+func piFormatTokens(n int) string {
373 switch {578 switch {
374- case n <= 0:579+ case n < 0:
375 return "0"580 return "0"
376 case n < 1000:581 case n < 1000:
377 return fmt.Sprintf("%d", n)582 return fmt.Sprintf("%d", n)
378- case n < 1_000_000:583+ case n < 10000:
379 return fmt.Sprintf("%.1fk", float64(n)/1000)584 return fmt.Sprintf("%.1fk", float64(n)/1000)
380- default:585+ case n < 1_000_000:
586+ return fmt.Sprintf("%dk", (n+500)/1000)
587+ case n < 10_000_000:
381 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)588 return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
589+ default:
590+ return fmt.Sprintf("%dM", (n+500_000)/1_000_000)
382 }591 }
383 }592 }
384 593

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 477

↑↓ moveenter openesc close