11package tui
22
33import (
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.
2128type 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.
0 commit comments