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 ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | require ( | |
| 12 | + | github.com/alecthomas/chroma/v2 v2.23.1 // indirect | |
| 13 | + | github.com/dlclark/regexp2 v1.11.5 // indirect | |
| 12 | 14 | github.com/rivo/uniseg v0.2.0 // indirect | |
| 13 | 15 | golang.org/x/sys v0.26.0 // indirect | |
| 14 | 16 | ) | |
| @@ -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 // indirect | 14 | github.com/rivo/uniseg v0.2.0 // indirect | ||
| 13 | golang.org/x/sys v0.26.0 // indirect | 15 | 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 { | |||
| 204 | 204 | Theme: tui.Dark, | |
| 205 | 205 | Model: r.Model, | |
| 206 | 206 | Provider: r.Provider, | |
| 207 | + | AuthMethod: r.AuthMethod, | |
| 207 | 208 | BaseURL: r.BaseURL, | |
| 208 | 209 | Reasoning: r.Reasoning, | |
| 209 | 210 | 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 { | |||
| 21 | 21 | Theme tui.Theme | |
| 22 | 22 | Model string | |
| 23 | 23 | Provider string | |
| 24 | + | AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar | |
| 24 | 25 | BaseURL string | |
| 25 | 26 | Reasoning string | |
| 26 | 27 | SystemPrompt string | |
| @@ -173,9 +174,55 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 173 | 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 | 180 | tick := time.NewTicker(120 * time.Millisecond) | |
| 177 | 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 | 226 | i.invalidate() | |
| 180 | 227 | ||
| 181 | 228 | for { | |
| @@ -191,10 +238,11 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 191 | 238 | i.handleAuthEvent(ev) | |
| 192 | 239 | i.invalidate() | |
| 193 | 240 | case <-i.dirty: | |
| 194 | - | i.redraw() | |
| 241 | + | requestRedraw() | |
| 195 | 242 | case <-tick.C: | |
| 196 | 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 | 309 | if n := len(i.view.Messages); n > 0 && i.view.Messages[n-1].Role == provider.RoleAssistant { | |
| 262 | 310 | i.view.StreamingActive = false | |
| 263 | 311 | } | |
| 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 | + | } | |
| 268 | 324 | } | |
| 269 | 325 | } | |
| 270 | 326 | i.view.Err = i.statusErr | |
| @@ -319,7 +375,20 @@ func (i *Interactive) redraw() { | |||
| 319 | 375 | if m, err := provider.FindModel(i.cfg.Provider, i.cfg.Model); err == nil { | |
| 320 | 376 | 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 | 392 | edLines, curR, curC := i.ed.Render(cols) | |
| 324 | 393 | ||
| 325 | 394 | // Bottom-sticky sections (always visible, never scroll). | |
| @@ -21,6 +21,7 @@ type InteractiveConfig struct { | |||||
| 21 | Theme tui.Theme | 21 | Theme tui.Theme | ||
| 22 | Model string | 22 | Model string | ||
| 23 | Provider string | 23 | Provider string | ||
| 24 | + | AuthMethod string // "apikey" | "oauth" — used to tag cost as (sub) in status bar | |||
| 24 | BaseURL string | 25 | BaseURL string | ||
| 25 | Reasoning string | 26 | Reasoning string | ||
| 26 | SystemPrompt string | 27 | 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 = false | 310 | 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.statusErr | 326 | 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.ContextWindow | 376 | 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 { | |||
| 20 | 20 | ||
| 21 | 21 | lines := strings.Split(src, "\n") | |
| 22 | 22 | var out strings.Builder | |
| 23 | + | var fenceBuf strings.Builder | |
| 23 | 24 | inFence := false | |
| 25 | + | fenceLang := "" | |
| 24 | 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 | 47 | for _, line := range lines { | |
| 26 | 48 | trim := strings.TrimLeft(line, " ") | |
| 27 | 49 | if strings.HasPrefix(trim, "```") { | |
| 28 | 50 | if inFence { | |
| 29 | - | // closing fence: emit bottom rule | |
| 51 | + | flushFence() | |
| 30 | 52 | inFence = false | |
| 53 | + | fenceLang = "" | |
| 31 | 54 | out.WriteString(rule + "\n") | |
| 32 | 55 | } else { | |
| 33 | - | // opening fence: emit top rule | |
| 34 | 56 | inFence = true | |
| 35 | 57 | fenceIndent = line[:len(line)-len(trim)] | |
| 58 | + | fenceLang = strings.TrimSpace(strings.TrimPrefix(trim, "```")) | |
| 36 | 59 | out.WriteString(rule + "\n") | |
| 37 | 60 | } | |
| 38 | 61 | continue | |
| @@ -41,7 +64,8 @@ func RenderMarkdown(src string, th Theme, width int) string { | |||
| 41 | 64 | if strings.HasPrefix(line, fenceIndent) { | |
| 42 | 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 | 69 | continue | |
| 46 | 70 | } | |
| 47 | 71 | // 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.Builder | 22 | var out strings.Builder | ||
| 23 | + | var fenceBuf strings.Builder | |||
| 23 | inFence := false | 24 | 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 rule | 51 | + | flushFence() |
| 30 | inFence = false | 52 | 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 = true | 56 | 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 | continue | 61 | 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 | continue | 69 | continue | ||
| 46 | } | 70 | } | ||
| 47 | // Headings. | 71 | // Headings. | ||
M internal/tui/render.go +50 -2
| @@ -3,8 +3,14 @@ package tui | |||
| 3 | 3 | import ( | |
| 4 | 4 | "io" | |
| 5 | 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 | 14 | // Renderer maintains a previous frame and writes only the lines that | |
| 9 | 15 | // changed on each Draw(). Callers pass a full target frame (slice of | |
| 10 | 16 | // styled lines, already wrapped to width). | |
| @@ -58,6 +64,46 @@ func containsImageEscape(s string) bool { | |||
| 58 | 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 | 107 | func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |
| 62 | 108 | if r.cols == 0 || r.rows == 0 { | |
| 63 | 109 | return | |
| @@ -68,14 +114,16 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) { | |||
| 68 | 114 | visible = visible[len(visible)-r.rows:] | |
| 69 | 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 | 120 | frame := make([]string, r.rows) | |
| 73 | 121 | top := r.rows - len(visible) | |
| 74 | 122 | for i := 0; i < top; i++ { | |
| 75 | 123 | frame[i] = "" | |
| 76 | 124 | } | |
| 77 | 125 | for i, line := range visible { | |
| 78 | - | frame[top+i] = line | |
| 126 | + | frame[top+i] = truncateToWidth(line, r.cols) | |
| 79 | 127 | } | |
| 80 | 128 | ||
| 81 | 129 | 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 that | 14 | // Renderer maintains a previous frame and writes only the lines that | ||
| 9 | // changed on each Draw(). Callers pass a full target frame (slice of | 15 | // 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 | return | 109 | 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] = line | 126 | + | frame[top+i] = truncateToWidth(line, r.cols) |
| 79 | } | 127 | } | ||
| 80 | 128 | ||||
| 81 | var w strings.Builder | 129 | var w strings.Builder | ||
M internal/tui/view.go +240 -31
| @@ -4,20 +4,45 @@ import ( | |||
| 4 | 4 | "encoding/json" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | + | "regexp" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| 9 | 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 | 33 | // osUserHomeDir is aliased so the test file can swap it. | |
| 13 | 34 | var osUserHomeDir = os.UserHomeDir | |
| 14 | 35 | ||
| 15 | 36 | // View turns a transcript + live state into a slice of styled lines, | |
| 16 | 37 | // already wrapped to width. | |
| 17 | 38 | 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 | |
| 21 | 46 | Streaming string // current assistant text delta | |
| 22 | 47 | StreamingActive bool | |
| 23 | 48 | ToolCalls []ToolCallView // tool calls in flight or completed | |
| @@ -37,6 +62,19 @@ type ToolCallView struct { | |||
| 37 | 62 | ||
| 38 | 63 | // Build returns the chat log lines for the given width. | |
| 39 | 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 | 78 | var out []string | |
| 41 | 79 | for _, m := range v.Messages { | |
| 42 | 80 | out = append(out, v.renderMessage(m, width)...) | |
| @@ -103,8 +141,12 @@ func (v *View) renderMessage(m provider.Message, width int) []string { | |||
| 103 | 141 | title = " error" | |
| 104 | 142 | color = v.Theme.Error | |
| 105 | 143 | } | |
| 144 | + | path := "" | |
| 145 | + | if v.toolPaths != nil { | |
| 146 | + | path = v.toolPaths[tr.CallID] | |
| 147 | + | } | |
| 106 | 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 | 174 | // like a unified diff gets +/- coloring. Image blocks are rendered | |
| 133 | 175 | // inline when the terminal supports a protocol, else as a text | |
| 134 | 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 | 178 | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | |
| 137 | 179 | ||
| 138 | 180 | var out []string | |
| @@ -140,7 +182,7 @@ func (v *View) renderToolResultContent(blocks []provider.Content, width, color i | |||
| 140 | 182 | for _, b := range blocks { | |
| 141 | 183 | switch bb := b.(type) { | |
| 142 | 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 | 186 | case provider.ImageBlock: | |
| 145 | 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 | 195 | // text contains a unified-diff section (lines starting with "--- " / | |
| 154 | 196 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | |
| 155 | 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 | 208 | // No truncation — the full tool output is rendered into chat and | |
| 158 | 209 | // becomes part of the scrollback you can page back through. | |
| 159 | 210 | lines := strings.Split(text, "\n") | |
| @@ -242,6 +293,86 @@ func (v *View) renderImageBlock(b provider.ImageBlock, width int) []string { | |||
| 242 | 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 | 376 | func toolResultBlock(th Theme, text string, width int, color int) []string { | |
| 246 | 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 | 441 | // cols is the terminal width; when > 0 the cwd is placed flush-right | |
| 311 | 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 | 473 | 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 | + | ||
| 319 | 510 | var hint string | |
| 320 | - | if busy { | |
| 511 | + | if p.Busy { | |
| 321 | 512 | hint = " esc cancel " | |
| 322 | 513 | } else { | |
| 323 | 514 | hint = " ctrl+c exit " + sep + " /help " | |
| 324 | 515 | } | |
| 325 | 516 | ||
| 326 | 517 | 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+" ")) | |
| 329 | 520 | leftBuilder.WriteString(" ") | |
| 330 | 521 | } | |
| 331 | 522 | leftBuilder.WriteString(th.FG256(th.Muted, left)) | |
| 332 | 523 | leftBuilder.WriteString(" ") | |
| 524 | + | // `middle` already has colorized context segments; wrap the rest in muted. | |
| 333 | 525 | leftBuilder.WriteString(th.FG256(th.Muted, middle)) | |
| 334 | 526 | leftBuilder.WriteString(" ") | |
| 335 | 527 | leftBuilder.WriteString(th.FG256(th.Muted, hint)) | |
| 336 | 528 | ||
| 337 | 529 | // 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 != "" { | |
| 340 | 532 | right = "· locked · " + right | |
| 341 | 533 | } | |
| 342 | - | if right == "" || cols <= 0 { | |
| 534 | + | if right == "" || p.Cols <= 0 { | |
| 343 | 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 | 540 | rightStr := " " + right + " " | |
| 349 | 541 | rightWidth := visibleWidth(rightStr) | |
| 350 | 542 | ||
| 351 | - | gap := cols - leftWidth - rightWidth | |
| 543 | + | gap := p.Cols - leftWidth - rightWidth | |
| 352 | 544 | if gap < 1 { | |
| 353 | - | // Not enough room for the cwd; drop it silently to avoid wrapping. | |
| 354 | 545 | return leftRendered | |
| 355 | 546 | } | |
| 356 | 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 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) { | |
| 362 | 553 | if max <= 0 { | |
| 363 | 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 | 559 | 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 | 578 | switch { | |
| 374 | - | case n <= 0: | |
| 579 | + | case n < 0: | |
| 375 | 580 | return "0" | |
| 376 | 581 | case n < 1000: | |
| 377 | 582 | return fmt.Sprintf("%d", n) | |
| 378 | - | case n < 1_000_000: | |
| 583 | + | case n < 10000: | |
| 379 | 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 | 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 | ||
| @@ -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.UserHomeDir | 34 | 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 Theme | 39 | + | Theme Theme |
| 19 | - | ImageProto ImageProtocol // how to render inline images in this terminal | 40 | + | ImageProto ImageProtocol // how to render inline images in this terminal |
| 20 | - | Messages []provider.Message | 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 | |||
| 21 | Streaming string // current assistant text delta | 46 | Streaming string // current assistant text delta | ||
| 22 | StreamingActive bool | 47 | StreamingActive bool | ||
| 23 | ToolCalls []ToolCallView // tool calls in flight or completed | 48 | 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 []string | 78 | 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.Error | 142 | 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 rendered | 174 | // like a unified diff gets +/- coloring. Image blocks are rendered | ||
| 133 | // inline when the terminal supports a protocol, else as a text | 175 | // 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 []string | 180 | 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/remove | 196 | // "+++ " / "+" / "-"/" "), 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 and | 208 | // 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-right | 441 | // 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 string | 510 | 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.Builder | 517 | 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 · " + right | 532 | 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 - rightWidth | 543 | + | 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 leftRendered | 545 | 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 the | 550 | + | // 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) * 100 | 559 | 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 | ||||