diff --git a/go.mod b/go.mod index f326f39..375fb41 100755 --- a/go.mod +++ b/go.mod @@ -12,11 +12,13 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index 235ce18..18c4a74 100755 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -14,6 +16,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/internal/ui/highlight.go b/internal/ui/highlight.go new file mode 100644 index 0000000..68187f8 --- /dev/null +++ b/internal/ui/highlight.go @@ -0,0 +1,97 @@ +package ui + +import ( + "bytes" + "encoding/json" + "regexp" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" +) + +// ansiEscPattern matches ANSI escape sequences (CSI and simple ESC sequences). +var ansiEscPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]|\x1b[^\[]`) + +// stripANSI removes all ANSI escape sequences from a string. +func stripANSI(s string) string { + return ansiEscPattern.ReplaceAllString(s, "") +} + +// extractJSON finds the first JSON object or array in a string, +// skipping any leading non-JSON content (e.g. ANSI codes, log prefixes). +// Returns the JSON substring and any prefix before it. +func extractJSON(s string) (prefix, jsonStr string, ok bool) { + // Find first { or [ + idx := strings.IndexAny(s, "{[") + if idx < 0 { + return "", "", false + } + return s[:idx], s[idx:], true +} + +// highlightJSON attempts to detect JSON content, pretty-print it, and apply +// syntax highlighting for terminal output. Returns the original string +// unchanged if the content is not valid JSON. +func highlightJSON(s string) string { + trimmed := strings.TrimSpace(s) + if len(trimmed) == 0 { + return s + } + + // Strip ANSI codes for JSON detection and parsing + clean := stripANSI(trimmed) + + // Extract JSON from the string (may have a non-JSON prefix) + prefix, jsonStr, ok := extractJSON(clean) + if !ok { + return s + } + + // Try to pretty-print the JSON portion + var buf bytes.Buffer + if err := json.Indent(&buf, []byte(jsonStr), "", " "); err != nil { + return s + } + pretty := buf.String() + + // Highlight with chroma (only the JSON portion) + lexer := lexers.Get("json") + if lexer == nil { + return pretty + } + lexer = chroma.Coalesce(lexer) + + style := styles.Get("monokai") + if style == nil { + style = styles.Fallback + } + + formatter := formatters.Get("terminal256") + if formatter == nil { + formatter = formatters.Fallback + } + + iterator, err := lexer.Tokenise(nil, pretty) + if err != nil { + return pretty + } + + var out bytes.Buffer + if err := formatter.Format(&out, style, iterator); err != nil { + return pretty + } + + // Re-attach any non-JSON prefix (stripped of ANSI) + result := out.String() + if prefix != "" { + prefix = strings.TrimSpace(prefix) + if prefix != "" { + result = prefix + "\n" + result + } + } + + return result +} diff --git a/internal/ui/highlight_test.go b/internal/ui/highlight_test.go new file mode 100644 index 0000000..df8d802 --- /dev/null +++ b/internal/ui/highlight_test.go @@ -0,0 +1,164 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestWrapTextANSI(t *testing.T) { + t.Run("ANSI sequences are not split", func(t *testing.T) { + // Red "ab" then reset: \033[31mab\033[0m + input := "\033[31mabcdef\033[0m" + lines := wrapText(input, 3) + // Should wrap into "abc" and "def", each with proper ANSI + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), lines) + } + // First line should have the color and a reset + if !strings.Contains(lines[0], "\033[31m") { + t.Errorf("first line missing color code: %q", lines[0]) + } + if !strings.Contains(lines[0], "abc") { + t.Errorf("first line missing 'abc': %q", lines[0]) + } + // Second line should re-apply the color + if !strings.Contains(lines[1], "\033[31m") { + t.Errorf("second line missing re-applied color code: %q", lines[1]) + } + if !strings.Contains(lines[1], "def") { + t.Errorf("second line missing 'def': %q", lines[1]) + } + }) + + t.Run("no ANSI still works", func(t *testing.T) { + lines := wrapText("abcdef", 3) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + if lines[0] != "abc" { + t.Errorf("expected 'abc', got %q", lines[0]) + } + if lines[1] != "def" { + t.Errorf("expected 'def', got %q", lines[1]) + } + }) + + t.Run("ANSI reset clears active state", func(t *testing.T) { + // Color "ab", reset, then "cd" + input := "\033[31mab\033[0mcd" + lines := wrapText(input, 2) + // "ab" on first line (with color), "cd" on second (no color re-applied) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %q", len(lines), lines) + } + // Second line should NOT have the red color re-applied + if strings.Contains(lines[1], "\033[31m") { + t.Errorf("second line should not have color after reset: %q", lines[1]) + } + }) +} + +func TestWrapPreviewContentANSI(t *testing.T) { + // Multi-line input with ANSI codes should survive split + wrap + input := "\033[31mhello\033[0m\n\033[32mworld\033[0m" + lines := wrapPreviewContent(input, 80) + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } + if !strings.Contains(lines[0], "\033[31m") || !strings.Contains(lines[0], "hello") { + t.Errorf("first line missing color or content: %q", lines[0]) + } + if !strings.Contains(lines[1], "\033[32m") || !strings.Contains(lines[1], "world") { + t.Errorf("second line missing color or content: %q", lines[1]) + } +} + +func TestHighlightJSON(t *testing.T) { + t.Run("valid JSON object is pretty-printed and highlighted", func(t *testing.T) { + input := `{"name":"test","count":42}` + result := highlightJSON(input) + + // Should be pretty-printed (multi-line) + if !strings.Contains(result, "\n") { + t.Error("expected multi-line pretty-printed output") + } + // Should contain the key and value + if !strings.Contains(result, "name") { + t.Error("expected output to contain 'name'") + } + if !strings.Contains(result, "test") { + t.Error("expected output to contain 'test'") + } + if !strings.Contains(result, "42") { + t.Error("expected output to contain '42'") + } + // Should contain ANSI escape codes (syntax highlighting) + if !strings.Contains(result, "\033[") { + t.Error("expected ANSI color codes in output") + } + }) + + t.Run("valid JSON array", func(t *testing.T) { + input := `[1, 2, 3]` + result := highlightJSON(input) + if !strings.Contains(result, "\033[") { + t.Error("expected ANSI color codes for JSON array") + } + }) + + t.Run("non-JSON returns unchanged", func(t *testing.T) { + input := "hello world" + result := highlightJSON(input) + if result != input { + t.Errorf("expected unchanged output for non-JSON, got %q", result) + } + }) + + t.Run("invalid JSON returns unchanged", func(t *testing.T) { + input := `{"broken": }` + result := highlightJSON(input) + if result != input { + t.Errorf("expected unchanged output for invalid JSON, got %q", result) + } + }) + + t.Run("empty string returns unchanged", func(t *testing.T) { + result := highlightJSON("") + if result != "" { + t.Errorf("expected empty string, got %q", result) + } + }) + + t.Run("whitespace-only returns unchanged", func(t *testing.T) { + input := " " + result := highlightJSON(input) + if result != input { + t.Errorf("expected unchanged output, got %q", result) + } + }) + + t.Run("JSON with leading ANSI escape sequences", func(t *testing.T) { + input := "\x1b[34h\x1b[?25h{\"key\":\"value\"}" + result := highlightJSON(input) + if !strings.Contains(result, "key") { + t.Error("expected output to contain 'key'") + } + if !strings.Contains(result, "value") { + t.Error("expected output to contain 'value'") + } + if !strings.Contains(result, "\033[") { + t.Error("expected ANSI color codes in output") + } + }) + + t.Run("JSON with non-JSON prefix text", func(t *testing.T) { + input := `some prefix {"key":"value"}` + result := highlightJSON(input) + if !strings.Contains(result, "key") { + t.Error("expected output to contain 'key'") + } + if !strings.Contains(result, "some prefix") { + t.Error("expected output to preserve prefix") + } + }) +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c30beae..09426bb 100755 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -598,6 +598,9 @@ func truncateToWidth(s string, maxWidth int) string { } // wrapText wraps text to fit within the given width, returning multiple lines. +// It is ANSI-aware: escape sequences are preserved intact and don't count +// toward the visible width. When a line wraps, any active ANSI state is +// carried over so colours continue on the next line. func wrapText(s string, width int) []string { if width <= 0 { return nil @@ -607,29 +610,84 @@ func wrapText(s string, width int) []string { } var lines []string - currentLine := "" + var currentLine strings.Builder currentWidth := 0 + // Track the last seen ANSI escape so we can re-apply it after a wrap + var activeANSI string - for _, r := range s { + i := 0 + runes := []rune(s) + for i < len(runes) { + // Check for ANSI escape sequence: ESC [ ... final_byte + if runes[i] == '\033' && i+1 < len(runes) && runes[i+1] == '[' { + // Consume entire escape sequence + var seq strings.Builder + seq.WriteRune(runes[i]) // ESC + i++ + seq.WriteRune(runes[i]) // [ + i++ + for i < len(runes) { + seq.WriteRune(runes[i]) + // Final byte of CSI sequence is in range 0x40-0x7E + if runes[i] >= 0x40 && runes[i] <= 0x7E { + i++ + break + } + i++ + } + seqStr := seq.String() + currentLine.WriteString(seqStr) + // Track reset vs color sequences + if seqStr == "\033[0m" || seqStr == "\033[m" { + activeANSI = "" + } else { + activeANSI = seqStr + } + continue + } + + r := runes[i] runeWidth := lipgloss.Width(string(r)) if currentWidth+runeWidth > width { - // Start new line - lines = append(lines, currentLine) - currentLine = string(r) - currentWidth = runeWidth - } else { - currentLine += string(r) - currentWidth += runeWidth + // Close any active ANSI on this line before wrapping + if activeANSI != "" { + currentLine.WriteString("\033[0m") + } + lines = append(lines, currentLine.String()) + currentLine.Reset() + currentWidth = 0 + // Re-apply active ANSI on the new line + if activeANSI != "" { + currentLine.WriteString(activeANSI) + } } + currentLine.WriteRune(r) + currentWidth += runeWidth + i++ } // Don't forget the last line - if currentLine != "" { - lines = append(lines, currentLine) + if currentLine.Len() > 0 { + lines = append(lines, currentLine.String()) } return lines } +// wrapPreviewContent splits multi-line content (e.g. pretty-printed JSON) by +// newlines first, then wraps each line to fit within the given width. +func wrapPreviewContent(s string, width int) []string { + var result []string + for line := range strings.SplitSeq(s, "\n") { + if line == "" { + result = append(result, "") + continue + } + wrapped := wrapText(line, width) + result = append(result, wrapped...) + } + return result +} + func (m *model) updateFiltered() { m.filtered = []int{} m.filterRegexErr = nil @@ -1178,7 +1236,7 @@ func (m model) renderMainView() string { if m.showPreview && len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) { idx := m.filtered[m.cursor] if idx < len(m.lines) { - previewContent = m.lines[idx].Content + previewContent = highlightJSON(m.lines[idx].Content) } } @@ -1231,7 +1289,7 @@ func (m model) renderMainView() string { var previewLines []string // Wrap preview content to fit width if previewContent != "" { - previewLines = wrapText(previewContent, innerWidth) + previewLines = wrapPreviewContent(previewContent, innerWidth) } // Pad preview to height for len(previewLines) < previewH { @@ -1285,12 +1343,11 @@ func (m model) renderMainView() string { // Prepare preview lines (wrap text instead of truncating) var previewLines []string if previewContent != "" { - // Determine preview width for wrapping previewW := leftW if m.config.PreviewPosition == PreviewRight { previewW = rightW } - previewLines = wrapText(previewContent, previewW) + previewLines = wrapPreviewContent(previewContent, previewW) } for len(previewLines) < listHeight { previewLines = append(previewLines, "")