| 1 | package logreader |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "github.com/charmbracelet/lipgloss" |
| 10 | ) |
| 11 | |
| 12 | var ( |
| 13 | jsonKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("117")) // light blue |
| 14 | jsonStrStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("107")) // green |
| 15 | jsonNumStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // orange |
| 16 | jsonBoolStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")).Bold(true) // purple |
| 17 | jsonNullStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Italic(true) |
| 18 | jsonBracStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) // white |
| 19 | ) |
| 20 | |
| 21 | // prettifyBody detects SSE "data: {json}" lines in the body and pretty-prints |
| 22 | // each JSON payload. Non-SSE bodies (already pretty JSON) are returned as-is. |
| 23 | func prettifyBody(body string) string { |
| 24 | // Check if this looks like SSE data. |
| 25 | hasSSE := false |
| 26 | for line := range strings.SplitSeq(body, "\n") { |
| 27 | if strings.HasPrefix(line, "data: {") { |
| 28 | hasSSE = true |
| 29 | break |
| 30 | } |
| 31 | } |
| 32 | if !hasSSE { |
| 33 | return body |
| 34 | } |
| 35 | |
| 36 | var b strings.Builder |
| 37 | chunkNum := 0 |
| 38 | for line := range strings.SplitSeq(body, "\n") { |
| 39 | line = strings.TrimSpace(line) |
| 40 | if line == "" { |
| 41 | continue |
| 42 | } |
| 43 | if !strings.HasPrefix(line, "data: ") { |
| 44 | // Non-data lines like [END OF STREAM], [Total chunks...] |
| 45 | b.WriteString(line) |
| 46 | b.WriteByte('\n') |
| 47 | continue |
| 48 | } |
| 49 | |
| 50 | payload := strings.TrimPrefix(line, "data: ") |
| 51 | if payload == "[DONE]" { |
| 52 | b.WriteString("data: [DONE]\n") |
| 53 | continue |
| 54 | } |
| 55 | |
| 56 | // Try to pretty-print JSON. |
| 57 | var pretty bytes.Buffer |
| 58 | if json.Indent(&pretty, []byte(payload), " ", " ") == nil { |
| 59 | chunkNum++ |
| 60 | fmt.Fprintf(&b, "chunk #%d:\n", chunkNum) |
| 61 | b.WriteString(" ") |
| 62 | b.WriteString(pretty.String()) |
| 63 | b.WriteByte('\n') |
| 64 | } else { |
| 65 | // Not valid JSON — show as-is. |
| 66 | b.WriteString(line) |
| 67 | b.WriteByte('\n') |
| 68 | } |
| 69 | } |
| 70 | return b.String() |
| 71 | } |
| 72 | |
| 73 | // colorizeJSON applies syntax highlighting to pretty-printed JSON. |
| 74 | // Input must already be wrapped/formatted to fit the display width. |
| 75 | func colorizeJSON(s string) string { |
| 76 | var b strings.Builder |
| 77 | b.Grow(len(s) * 2) |
| 78 | |
| 79 | for line := range strings.SplitSeq(s, "\n") { |
| 80 | b.WriteString(colorizeLine(line)) |
| 81 | b.WriteByte('\n') |
| 82 | } |
| 83 | return b.String() |
| 84 | } |
| 85 | |
| 86 | func colorizeLine(line string) string { |
| 87 | trimmed := strings.TrimLeft(line, " ") |
| 88 | indent := line[:len(line)-len(trimmed)] |
| 89 | |
| 90 | if trimmed == "" { |
| 91 | return "" |
| 92 | } |
| 93 | |
| 94 | // Key-value line: "key": value |
| 95 | if strings.HasPrefix(trimmed, `"`) { |
| 96 | colonIdx := strings.Index(trimmed, `": `) |
| 97 | if colonIdx > 0 { |
| 98 | key := trimmed[:colonIdx+1] // includes closing quote |
| 99 | rest := trimmed[colonIdx+1:] // ": value..." |
| 100 | return indent + jsonKeyStyle.Render(key) + colorizeValue(rest) |
| 101 | } |
| 102 | // Standalone string (e.g. array element) |
| 103 | return indent + colorizeValue(trimmed) |
| 104 | } |
| 105 | |
| 106 | // Pure value lines (in arrays, or closing brackets). |
| 107 | return indent + colorizeValue(trimmed) |
| 108 | } |
| 109 | |
| 110 | func colorizeValue(s string) string { |
| 111 | // Handle ": value" prefix. |
| 112 | prefix := "" |
| 113 | val := s |
| 114 | if strings.HasPrefix(s, ": ") { |
| 115 | prefix = ": " |
| 116 | val = s[2:] |
| 117 | } |
| 118 | |
| 119 | val = strings.TrimRight(val, ",") |
| 120 | comma := "" |
| 121 | if len(val) < len(strings.TrimRight(s[len(prefix):], ",")) || strings.HasSuffix(s, ",") { |
| 122 | comma = "," |
| 123 | } |
| 124 | |
| 125 | var styled string |
| 126 | switch { |
| 127 | case val == "{" || val == "}" || val == "[" || val == "]" || |
| 128 | val == "{}" || val == "[]" || |
| 129 | strings.HasPrefix(val, "{") || strings.HasPrefix(val, "["): |
| 130 | styled = jsonBracStyle.Render(val) |
| 131 | case val == "null": |
| 132 | styled = jsonNullStyle.Render(val) |
| 133 | case val == "true" || val == "false": |
| 134 | styled = jsonBoolStyle.Render(val) |
| 135 | case strings.HasPrefix(val, `"`): |
| 136 | styled = jsonStrStyle.Render(val) |
| 137 | case len(val) > 0 && (val[0] >= '0' && val[0] <= '9' || val[0] == '-'): |
| 138 | styled = jsonNumStyle.Render(val) |
| 139 | default: |
| 140 | styled = val |
| 141 | } |
| 142 | |
| 143 | return prefix + styled + comma |
| 144 | } |
| 145 | |