Skip to content

Commit 330b566

Browse files
feat(tui): add syntax highlighting for code snippets in log view
Use Chroma library with Tokyo Night theme to highlight code from Read tool results. Detects language from file extension and strips line number prefixes from output before highlighting.
1 parent d2c128e commit 330b566

File tree

4 files changed

+199
-10
lines changed

4 files changed

+199
-10
lines changed

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ require (
1010
)
1111

1212
require (
13+
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
1314
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
1415
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
1516
github.com/charmbracelet/x/ansi v0.10.1 // indirect
1617
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
1718
github.com/charmbracelet/x/term v0.2.1 // indirect
19+
github.com/dlclark/regexp2 v1.11.5 // indirect
1820
github.com/ebitengine/purego v0.4.1 // indirect
1921
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2022
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
2+
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
13
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
24
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
35
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -12,6 +14,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
1214
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
1315
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
1416
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
17+
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
18+
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
1519
github.com/ebitengine/purego v0.4.1 h1:atcZEBdukuoClmy7TI89amtqAsJUzDQyY/JU7HaK+io=
1620
github.com/ebitengine/purego v0.4.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
1721
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=

internal/tui/log.go

Lines changed: 150 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
package tui
22

33
import (
4+
"bytes"
45
"fmt"
6+
"path/filepath"
57
"strings"
68

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"
713
"github.com/charmbracelet/lipgloss"
814
"github.com/minicodemonkey/chief/internal/loop"
915
)
@@ -15,15 +21,17 @@ type LogEntry struct {
1521
Tool string
1622
ToolInput map[string]interface{}
1723
StoryID string
24+
FilePath string // For Read tool results, stores the file path for syntax highlighting
1825
}
1926

2027
// LogViewer manages the log viewport state.
2128
type LogViewer struct {
22-
entries []LogEntry
23-
scrollPos int // Current scroll position (top line index)
24-
height int // Viewport height (lines)
25-
width int // Viewport width
26-
autoScroll bool // Auto-scroll to bottom when new content arrives
29+
entries []LogEntry
30+
scrollPos int // Current scroll position (top line index)
31+
height int // Viewport height (lines)
32+
width int // Viewport width
33+
autoScroll bool // Auto-scroll to bottom when new content arrives
34+
lastReadFilePath string // Track the last Read tool's file path for syntax highlighting
2735
}
2836

2937
// NewLogViewer creates a new log viewer.
@@ -45,6 +53,19 @@ func (l *LogViewer) AddEvent(event loop.Event) {
4553
StoryID: event.StoryID,
4654
}
4755

56+
// Track Read tool file paths for syntax highlighting
57+
if event.Type == loop.EventToolStart && event.Tool == "Read" {
58+
if filePath, ok := event.ToolInput["file_path"].(string); ok {
59+
l.lastReadFilePath = filePath
60+
}
61+
}
62+
63+
// For tool results, attach the file path from the preceding Read tool
64+
if event.Type == loop.EventToolResult && l.lastReadFilePath != "" {
65+
entry.FilePath = l.lastReadFilePath
66+
l.lastReadFilePath = "" // Clear after consuming
67+
}
68+
4869
// Filter out events we don't want to display
4970
switch event.Type {
5071
case loop.EventAssistantText, loop.EventToolStart, loop.EventToolResult,
@@ -378,20 +399,139 @@ func (l *LogViewer) renderToolResult(entry LogEntry) []string {
378399
resultStyle := lipgloss.NewStyle().Foreground(MutedColor)
379400
checkStyle := lipgloss.NewStyle().Foreground(SuccessColor)
380401

381-
// Show a compact result indicator, truncate to available width
382402
text := entry.Text
383-
maxLen := l.width - 8 // Account for " ↳ " prefix and padding
403+
if text == "" {
404+
return []string{resultStyle.Render(checkStyle.Render(" ↳ ") + "(no output)")}
405+
}
406+
407+
// If this is a Read result with a file path, apply syntax highlighting
408+
if entry.FilePath != "" {
409+
highlighted := l.highlightCode(text, entry.FilePath)
410+
if highlighted != "" {
411+
lines := strings.Split(highlighted, "\n")
412+
var result []string
413+
result = append(result, checkStyle.Render(" ↳ ")) // Result indicator
414+
// Limit to 20 lines to keep the log view manageable
415+
maxLines := 20
416+
for i, line := range lines {
417+
if i >= maxLines {
418+
result = append(result, resultStyle.Render(fmt.Sprintf(" ... (%d more lines)", len(lines)-maxLines)))
419+
break
420+
}
421+
result = append(result, " "+line)
422+
}
423+
return result
424+
}
425+
}
426+
427+
// Fallback: show a compact single-line result
428+
maxLen := l.width - 8
384429
if maxLen < 20 {
385430
maxLen = 20
386431
}
387432
if len(text) > maxLen {
388433
text = text[:maxLen-3] + "..."
389434
}
390-
if text == "" {
391-
text = "(no output)"
435+
return []string{resultStyle.Render(checkStyle.Render(" ↳ ") + text)}
436+
}
437+
438+
// highlightCode applies syntax highlighting to code based on file extension.
439+
func (l *LogViewer) highlightCode(code, filePath string) string {
440+
// Strip line number prefixes from Read tool output (format: " 1→" or " 1\t")
441+
code = stripLineNumbers(code)
442+
443+
// Get lexer based on file extension
444+
ext := filepath.Ext(filePath)
445+
lexer := lexers.Match(filePath)
446+
if lexer == nil {
447+
lexer = lexers.Get(ext)
448+
}
449+
if lexer == nil {
450+
lexer = lexers.Fallback
392451
}
452+
lexer = chroma.Coalesce(lexer)
393453

394-
return []string{resultStyle.Render(checkStyle.Render(" ↳ ") + text)}
454+
// Use Tokyo Night theme for syntax highlighting
455+
style := styles.Get("tokyonight-night")
456+
if style == nil {
457+
style = styles.Fallback
458+
}
459+
460+
// Use terminal256 formatter for ANSI color output
461+
formatter := formatters.Get("terminal256")
462+
if formatter == nil {
463+
formatter = formatters.Fallback
464+
}
465+
466+
// Tokenize and format
467+
iterator, err := lexer.Tokenise(nil, code)
468+
if err != nil {
469+
return ""
470+
}
471+
472+
var buf bytes.Buffer
473+
if err := formatter.Format(&buf, style, iterator); err != nil {
474+
return ""
475+
}
476+
477+
return buf.String()
478+
}
479+
480+
// stripLineNumbers removes line number prefixes from Read tool output.
481+
// The format is: optional spaces + line number + → or tab + content
482+
func stripLineNumbers(code string) string {
483+
lines := strings.Split(code, "\n")
484+
var result []string
485+
486+
for _, line := range lines {
487+
// Look for patterns like " 1→", " 10→", " 1\t", etc.
488+
stripped := line
489+
490+
// Find the arrow or tab after the line number
491+
arrowIdx := strings.Index(line, "→")
492+
tabIdx := strings.Index(line, "\t")
493+
494+
idx := -1
495+
if arrowIdx != -1 && tabIdx != -1 {
496+
if arrowIdx < tabIdx {
497+
idx = arrowIdx
498+
} else {
499+
idx = tabIdx
500+
}
501+
} else if arrowIdx != -1 {
502+
idx = arrowIdx
503+
} else if tabIdx != -1 {
504+
idx = tabIdx
505+
}
506+
507+
if idx > 0 && idx < 10 { // Line number prefix is typically short
508+
// Check if everything before is spaces and digits
509+
prefix := line[:idx]
510+
isLineNum := true
511+
hasDigit := false
512+
for _, ch := range prefix {
513+
if ch >= '0' && ch <= '9' {
514+
hasDigit = true
515+
} else if ch != ' ' {
516+
isLineNum = false
517+
break
518+
}
519+
}
520+
if isLineNum && hasDigit {
521+
// Skip the arrow/tab character (→ is multi-byte)
522+
if line[idx] == '\t' {
523+
stripped = line[idx+1:]
524+
} else {
525+
// → is 3 bytes in UTF-8
526+
stripped = line[idx+3:]
527+
}
528+
}
529+
}
530+
531+
result = append(result, stripped)
532+
}
533+
534+
return strings.Join(result, "\n")
395535
}
396536

397537
// renderStoryStarted renders a story started marker.

internal/tui/log_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,46 @@ func TestLogViewer_IsAutoScrolling(t *testing.T) {
171171
t.Error("Expected IsAutoScrolling to remain true when at top")
172172
}
173173
}
174+
175+
func TestStripLineNumbers(t *testing.T) {
176+
tests := []struct {
177+
name string
178+
input string
179+
expected string
180+
}{
181+
{
182+
name: "arrow format",
183+
input: " 1→<?php\n 2→\n 3→use App\\Models;",
184+
expected: "<?php\n\nuse App\\Models;",
185+
},
186+
{
187+
name: "tab format",
188+
input: " 1\t<?php\n 2\t\n 3\tuse App\\Models;",
189+
expected: "<?php\n\nuse App\\Models;",
190+
},
191+
{
192+
name: "double digit line numbers",
193+
input: " 10→function test() {\n 11→ return true;\n 12→}",
194+
expected: "function test() {\n return true;\n}",
195+
},
196+
{
197+
name: "no line numbers",
198+
input: "<?php\nuse App\\Models;",
199+
expected: "<?php\nuse App\\Models;",
200+
},
201+
{
202+
name: "empty string",
203+
input: "",
204+
expected: "",
205+
},
206+
}
207+
208+
for _, tt := range tests {
209+
t.Run(tt.name, func(t *testing.T) {
210+
result := stripLineNumbers(tt.input)
211+
if result != tt.expected {
212+
t.Errorf("stripLineNumbers() =\n%q\nwant:\n%q", result, tt.expected)
213+
}
214+
})
215+
}
216+
}

0 commit comments

Comments
 (0)