1package view
2
3import (
4 "bytes"
5 "encoding/base64"
6 "fmt"
7 "image"
8 "image/png"
9 "io"
10 "mime/quotedprintable"
11 "net/http"
12 "os"
13 "regexp"
14 "strings"
15 "sync"
16 "time"
17
18 _ "image/gif"
19 _ "image/jpeg"
20
21 "charm.land/lipgloss/v2"
22 "github.com/PuerkitoBio/goquery"
23 "github.com/floatpane/matcha/theme"
24 "github.com/yuin/goldmark"
25 "github.com/yuin/goldmark/renderer/html"
26 "golang.org/x/sys/unix"
27)
28
29func linkStyle() lipgloss.Style {
30 return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link)
31}
32
33// getTerminalCellSize returns the height of a terminal cell in pixels.
34// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
35// Falls back to a default of 18 pixels if the query fails.
36func getTerminalCellSize() int {
37 const defaultCellHeight = 18
38
39 // Try stdout, stdin, stderr, then /dev/tty as last resort
40 fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
41
42 for _, fd := range fds {
43 if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
44 return cellHeight
45 }
46 }
47
48 // Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
49 if tty, err := os.Open("/dev/tty"); err == nil {
50 defer tty.Close()
51 if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
52 return cellHeight
53 }
54 }
55
56 debugImageProtocol("using default cell height: %d pixels", defaultCellHeight)
57 return defaultCellHeight
58}
59
60// getCellHeightFromFd attempts to get the terminal cell height from a file descriptor.
61// Returns 0 if it fails or if pixel dimensions are not available.
62func getCellHeightFromFd(fd int) int {
63 ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
64 if err != nil {
65 return 0
66 }
67
68 // ws.Row = number of character rows
69 // ws.Ypixel = height in pixels
70 // Some terminals don't report pixel dimensions (return 0)
71 if ws.Row > 0 && ws.Ypixel > 0 {
72 cellHeight := int(ws.Ypixel) / int(ws.Row)
73 if cellHeight > 0 {
74 debugImageProtocol("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
75 return cellHeight
76 }
77 }
78
79 // Terminal reported dimensions but no pixel info - this is common
80 if ws.Row > 0 && ws.Ypixel == 0 {
81 debugImageProtocol("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
82 }
83
84 return 0
85}
86
87// hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
88func hyperlinkSupported() bool {
89 term := strings.ToLower(os.Getenv("TERM"))
90
91 // Terminals known to support OSC 8 hyperlinks
92 supportedTerms := []string{
93 "kitty",
94 "ghostty",
95 "wezterm",
96 "alacritty",
97 "foot",
98 "tmux",
99 "screen",
100 }
101
102 for _, supported := range supportedTerms {
103 if strings.Contains(term, supported) {
104 return true
105 }
106 }
107
108 // Check for specific terminal programs
109 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
110 supportedPrograms := []string{
111 "iterm.app",
112 "hyper",
113 "vscode",
114 "ghostty",
115 "wezterm",
116 }
117
118 for _, supported := range supportedPrograms {
119 if strings.Contains(termProgram, supported) {
120 return true
121 }
122 }
123
124 // Check for VTE-based terminals (GNOME Terminal, etc.)
125 if os.Getenv("VTE_VERSION") != "" {
126 return true
127 }
128
129 // Check for specific environment variables that indicate hyperlink support
130 if os.Getenv("KITTY_WINDOW_ID") != "" ||
131 os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
132 os.Getenv("WEZTERM_EXECUTABLE") != "" {
133 return true
134 }
135
136 return false
137}
138
139// hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL.
140func hyperlink(url, text string) string {
141 if text == "" {
142 text = url
143 }
144
145 supported := hyperlinkSupported()
146
147 if supported {
148 // Use OSC 8 hyperlink sequence for supported terminals
149 return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text))
150 } else {
151 // Fallback to plain text format for unsupported terminals
152 // Use HTML-encoded angle brackets to prevent HTML parser from treating them as tags
153 if text == url {
154 return fmt.Sprintf("<%s>", linkStyle().Render(url))
155 }
156 return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url))
157 }
158}
159
160func decodeQuotedPrintable(s string) (string, error) {
161 reader := quotedprintable.NewReader(strings.NewReader(s))
162 body, err := io.ReadAll(reader)
163 if err != nil {
164 return "", err
165 }
166 return string(body), nil
167}
168
169// markdownToHTML converts a Markdown string to an HTML string.
170func markdownToHTML(md []byte) []byte {
171 var buf bytes.Buffer
172 p := goldmark.New(
173 goldmark.WithRendererOptions(
174 html.WithUnsafe(), // Allow raw HTML in email.
175 ),
176 )
177 if err := p.Convert(md, &buf); err != nil {
178 return md // Fallback to original markdown.
179 }
180 return buf.Bytes()
181}
182
183func kittySupported() bool {
184 term := strings.ToLower(os.Getenv("TERM"))
185 if strings.Contains(term, "kitty") {
186 return true
187 }
188 return os.Getenv("KITTY_WINDOW_ID") != ""
189}
190
191func ghosttySupported() bool {
192 // Check for TERM containing ghostty
193 term := strings.ToLower(os.Getenv("TERM"))
194 if strings.Contains(term, "ghostty") {
195 return true
196 }
197
198 // Check for Ghostty-specific environment variables
199 if os.Getenv("TERM_PROGRAM") == "ghostty" {
200 return true
201 }
202
203 // Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
204 return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
205}
206
207func iterm2Supported() bool {
208 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
209 if termProgram == "iterm.app" {
210 return true
211 }
212
213 // Check for iTerm2-specific environment variables
214 if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
215 return true
216 }
217
218 return false
219}
220
221func weztermSupported() bool {
222 // Check for WezTerm-specific environment variables
223 if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
224 return true
225 }
226
227 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
228 if termProgram == "wezterm" {
229 return true
230 }
231
232 term := strings.ToLower(os.Getenv("TERM"))
233 if strings.Contains(term, "wezterm") {
234 return true
235 }
236
237 return false
238}
239
240func waystSupported() bool {
241 term := strings.ToLower(os.Getenv("TERM"))
242 if strings.Contains(term, "wayst") {
243 return true
244 }
245
246 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
247 if termProgram == "wayst" {
248 return true
249 }
250
251 return false
252}
253
254func warpSupported() bool {
255 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
256 if termProgram == "warp" {
257 return true
258 }
259
260 // Check for Warp-specific environment variables
261 if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
262 return true
263 }
264
265 return false
266}
267
268func konsoleSupported() bool {
269 // Check for Konsole-specific environment variables
270 if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
271 return true
272 }
273
274 termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
275 if termProgram == "konsole" {
276 return true
277 }
278
279 return false
280}
281
282// ImageProtocolSupported checks if any supported image protocol terminal is detected.
283func ImageProtocolSupported() bool {
284 return imageProtocolSupported()
285}
286
287// imageProtocolSupported checks if any supported image protocol terminal is detected.
288func imageProtocolSupported() bool {
289 return kittySupported() || ghosttySupported() || iterm2Supported() ||
290 weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
291}
292
293func debugImageProtocol(format string, args ...interface{}) {
294 if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
295 return
296 }
297 msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
298 fmt.Print(msg)
299 if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_LOG"); path != "" {
300 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
301 _, _ = f.WriteString(msg)
302 _ = f.Close()
303 }
304 } else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
305 if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
306 _, _ = f.WriteString(msg)
307 _ = f.Close()
308 }
309 }
310}
311
312// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
313var remoteImageCache sync.Map
314
315// nextImageID is an auto-incrementing counter for Kitty image IDs.
316var nextImageID uint32 = 1000
317
318// allocImageID returns a unique Kitty image ID.
319func allocImageID() uint32 {
320 id := nextImageID
321 nextImageID++
322 return id
323}
324
325func fetchRemoteBase64(url string) string {
326 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
327 return ""
328 }
329
330 // Check cache first
331 if cached, ok := remoteImageCache.Load(url); ok {
332 debugImageProtocol("remote cache hit url=%s", url)
333 return cached.(string)
334 }
335
336 client := &http.Client{Timeout: 5 * time.Second}
337 resp, err := client.Get(url)
338 if err != nil {
339 debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
340 return ""
341 }
342 defer resp.Body.Close()
343 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
344 debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
345 return ""
346 }
347 data, err := io.ReadAll(resp.Body)
348 if err != nil {
349 debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
350 return ""
351 }
352
353 img, _, err := image.Decode(bytes.NewReader(data))
354 if err != nil {
355 debugImageProtocol("remote decode failed url=%s err=%v", url, err)
356 return ""
357 }
358
359 var buf bytes.Buffer
360 if err := png.Encode(&buf, img); err != nil {
361 debugImageProtocol("remote png encode failed url=%s err=%v", url, err)
362 return ""
363 }
364
365 encoded := base64.StdEncoding.EncodeToString(buf.Bytes())
366 debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
367 remoteImageCache.Store(url, encoded)
368 return encoded
369}
370
371func dataURIBase64(uri string) string {
372 if !strings.HasPrefix(uri, "data:") {
373 return ""
374 }
375 comma := strings.Index(uri, ",")
376 if comma == -1 || comma+1 >= len(uri) {
377 return ""
378 }
379 return uri[comma+1:]
380}
381
382// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
383// This prevents the newline-collapsing regex from removing intentional spacing.
384// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
385const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
386const imageRowPlaceholderSuffix = "]]"
387
388func kittyInlineImage(payload string) string {
389 if payload == "" {
390 return ""
391 }
392
393 const chunkSize = 4096
394 var b strings.Builder
395
396 // Calculate how many terminal rows the image occupies to advance text after it.
397 rows := 1
398 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
399 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
400 cellHeight := getTerminalCellSize()
401 h := img.Bounds().Dy()
402 rows = (h + cellHeight - 1) / cellHeight
403 if rows < 1 {
404 rows = 1
405 }
406 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
407 }
408 }
409
410 for offset := 0; offset < len(payload); offset += chunkSize {
411 end := offset + chunkSize
412 if end > len(payload) {
413 end = len(payload)
414 }
415 more := "0"
416 if end < len(payload) {
417 more = "1"
418 }
419
420 chunk := payload[offset:end]
421 if offset == 0 {
422 // C=1 means cursor does NOT move after image render (stays at top-left of image position)
423 // This is needed for proper TUI rendering, but we must add newlines to push text below
424 b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
425 } else {
426 b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
427 }
428 }
429
430 // Add newlines to push cursor below the image.
431 // Use a placeholder that won't be collapsed by the newline regex.
432 b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
433
434 return b.String()
435}
436
437// iterm2InlineImage renders an image using iTerm2's image protocol
438func iterm2InlineImage(payload string) string {
439 if payload == "" {
440 return ""
441 }
442
443 // Calculate rows for cursor positioning
444 rows := 1
445 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
446 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
447 cellHeight := getTerminalCellSize()
448 h := img.Bounds().Dy()
449 rows = (h + cellHeight - 1) / cellHeight
450 if rows < 1 {
451 rows = 1
452 }
453 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
454 }
455 }
456
457 // iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
458 result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
459
460 // Add placeholder for row spacing
461 result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
462
463 return result
464}
465
466// renderInlineImage renders an image using the appropriate protocol for the detected terminal
467func renderInlineImage(payload string) string {
468 if payload == "" {
469 return ""
470 }
471
472 if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
473 // These terminals use the Kitty graphics protocol
474 return kittyInlineImage(payload)
475 } else if iterm2Supported() || warpSupported() {
476 // iTerm2 and Warp use the iTerm2 image protocol
477 return iterm2InlineImage(payload)
478 }
479
480 return ""
481}
482
483// imageRows calculates the number of terminal rows an image occupies.
484func imageRows(payload string) int {
485 rows := 1
486 if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
487 if img, _, err := image.Decode(bytes.NewReader(data)); err == nil {
488 cellHeight := getTerminalCellSize()
489 h := img.Bounds().Dy()
490 rows = (h + cellHeight - 1) / cellHeight
491 if rows < 1 {
492 rows = 1
493 }
494 debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
495 }
496 }
497 return rows
498}
499
500// kittyUploadImage uploads image data to the terminal with a unique ID using
501// the Kitty graphics protocol transmit action (a=t). The image is stored in
502// the terminal's memory and can be displayed later by ID without re-sending data.
503func kittyUploadImage(payload string, id uint32) {
504 if payload == "" {
505 return
506 }
507
508 const chunkSize = 4096
509 for offset := 0; offset < len(payload); offset += chunkSize {
510 end := offset + chunkSize
511 if end > len(payload) {
512 end = len(payload)
513 }
514 more := "0"
515 if end < len(payload) {
516 more = "1"
517 }
518
519 chunk := payload[offset:end]
520 if offset == 0 {
521 // a=t: transmit (upload) only, don't display yet
522 // i=ID: assign this image ID
523 fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk)
524 } else {
525 fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk)
526 }
527 }
528 os.Stdout.Sync()
529}
530
531// kittyDisplayImage displays a previously uploaded image by its ID at the
532// current cursor position. This is very fast since no image data is transmitted.
533func kittyDisplayImage(id uint32) string {
534 // a=p: put (display) an already-uploaded image by ID
535 // C=1: cursor does not move
536 return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1\x1b\\", id)
537}
538
539// iterm2ImageEscapeOnly returns only the iTerm2 image protocol escape sequence
540// without any row placeholders. Used for out-of-band rendering to stdout.
541func iterm2ImageEscapeOnly(payload string) string {
542 if payload == "" {
543 return ""
544 }
545 return fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07", payload)
546}
547
548// RenderImageToStdout writes an image directly to stdout at the given screen
549// row using cursor positioning. This bypasses bubbletea's cell-based renderer
550// which cannot handle graphics protocol escape sequences.
551//
552// For Kitty-protocol terminals, images are uploaded once and then displayed by
553// ID on subsequent calls, making scroll rendering nearly instant.
554func RenderImageToStdout(placement *ImagePlacement, screenRow int) {
555 if placement.Base64 == "" {
556 return
557 }
558
559 useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
560 useIterm2 := iterm2Supported() || warpSupported()
561
562 if useKitty {
563 // Upload once, display by ID on subsequent renders
564 if !placement.Uploaded {
565 placement.ID = allocImageID()
566 kittyUploadImage(placement.Base64, placement.ID)
567 placement.Uploaded = true
568 }
569 seq := kittyDisplayImage(placement.ID)
570 fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
571 os.Stdout.Sync()
572 } else if useIterm2 {
573 seq := iterm2ImageEscapeOnly(placement.Base64)
574 fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;1H%s\x1b[u", screenRow+1, seq)
575 os.Stdout.Sync()
576 }
577}
578
579// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
580func expandImageRowPlaceholders(text string) string {
581 re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
582 return re.ReplaceAllStringFunc(text, func(match string) string {
583 // Extract the number of rows from the placeholder
584 numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
585 numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
586 rows := 1
587 if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
588 rows = 1
589 }
590 // Return the newlines needed to push content below the image
591 return strings.Repeat("\n", rows)
592 })
593}
594
595type InlineImage struct {
596 CID string
597 Base64 string
598}
599
600// ImagePlacement holds the data needed to render an image at a specific
601// line in the email body. Images are rendered directly to stdout (bypassing
602// bubbletea's cell-based renderer which cannot handle graphics protocols).
603type ImagePlacement struct {
604 Line int // Line number in the processed body text where the image starts
605 Base64 string // Base64-encoded image data (PNG)
606 Rows int // Number of terminal rows the image occupies
607 Uploaded bool // Whether the image has been uploaded to the terminal via Kitty ID
608 ID uint32 // Kitty image ID for display-by-reference
609}
610
611// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
612// Returns the rendered body text, image placements for out-of-band rendering, and any error.
613func ProcessBodyWithInline(rawBody string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
614 inlineMap := make(map[string]string, len(inline))
615 for _, img := range inline {
616 cid := strings.TrimSpace(img.CID)
617 cid = strings.TrimPrefix(cid, "<")
618 cid = strings.TrimSuffix(cid, ">")
619 cid = strings.TrimPrefix(cid, "cid:")
620 if cid == "" || img.Base64 == "" {
621 continue
622 }
623 inlineMap[cid] = img.Base64
624 }
625 return processBody(rawBody, inlineMap, h1Style, h2Style, bodyStyle, disableImages)
626}
627
628// ProcessBody takes a raw email body, decodes it, and formats it as plain
629// text with terminal hyperlinks.
630func ProcessBody(rawBody string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
631 return processBody(rawBody, nil, h1Style, h2Style, bodyStyle, disableImages)
632}
633
634func processBody(rawBody string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
635 decodedBody, err := decodeQuotedPrintable(rawBody)
636 if err != nil {
637 decodedBody = rawBody
638 }
639
640 htmlBody := markdownToHTML([]byte(decodedBody))
641
642 doc, err := goquery.NewDocumentFromReader(bytes.NewReader(htmlBody))
643 if err != nil {
644 return "", nil, fmt.Errorf("could not parse email body: %w", err)
645 }
646
647 doc.Find("style, script").Remove()
648
649 // Style headers by setting their text content.
650 // We use SetText so the h1/h2 tags remain in the document for spacing logic.
651 doc.Find("h1").Each(func(i int, s *goquery.Selection) {
652 s.SetText(h1Style.Render(s.Text()))
653 })
654
655 doc.Find("h2").Each(func(i int, s *goquery.Selection) {
656 s.SetText(h2Style.Render(s.Text()))
657 })
658
659 // Add newlines after block elements for better spacing.
660 doc.Find("p, div, h1, h2").Each(func(i int, s *goquery.Selection) {
661 s.After("\n\n")
662 })
663
664 // Replace <br> tags with newlines
665 doc.Find("br").Each(func(i int, s *goquery.Selection) {
666 s.ReplaceWithHtml("\n")
667 })
668
669 // Handle blockquote elements (quoted replies)
670 // We collect quote data and use placeholders, then render after doc.Text()
671 var quoteData []struct {
672 from, date string
673 content string
674 }
675 doc.Find("blockquote").Each(func(i int, s *goquery.Selection) {
676 // Try to extract sender info from cite attribute or preceding text
677 cite, _ := s.Attr("cite")
678 quoteText := strings.TrimSpace(s.Text())
679
680 // Look for "On DATE, EMAIL wrote:" pattern in previous sibling or cite
681 var from, date string
682 prevText := ""
683 if prev := s.Prev(); prev.Length() > 0 {
684 prevText = strings.TrimSpace(prev.Text())
685 }
686
687 onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`)
688 if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil {
689 date = parseDateForDisplay(matches[1])
690 from = matches[2]
691 // Remove the "On ... wrote:" from the previous element
692 s.Prev().Remove()
693 } else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil {
694 date = parseDateForDisplay(matches[1])
695 from = matches[2]
696 }
697
698 // Store quote data and use placeholder
699 quoteData = append(quoteData, struct {
700 from, date string
701 content string
702 }{from, date, quoteText})
703 placeholder := fmt.Sprintf("\n[[MATCHA_QUOTE:%d]]\n", len(quoteData)-1)
704 s.ReplaceWithHtml(placeholder)
705 })
706
707 // Format links and images.
708 // Collect image placements for out-of-band rendering (bubbletea v2's
709 // ultraviolet renderer cannot pass through graphics protocol sequences).
710 var imgIndex int
711 var pendingImages []struct {
712 index int
713 payload string
714 rows int
715 }
716
717 doc.Find("a").Each(func(i int, s *goquery.Selection) {
718 href, exists := s.Attr("href")
719 if !exists {
720 return
721 }
722 s.ReplaceWithHtml(hyperlink(href, s.Text()))
723 })
724
725 doc.Find("img").Each(func(i int, s *goquery.Selection) {
726 src, exists := s.Attr("src")
727 if !exists {
728 return
729 }
730 alt, _ := s.Attr("alt")
731 if alt == "" {
732 alt = "Does not contain alt text"
733 }
734
735 if !disableImages && imageProtocolSupported() {
736 var payload string
737 if strings.HasPrefix(src, "data:image/") {
738 payload = dataURIBase64(src)
739 } else if strings.HasPrefix(src, "cid:") {
740 cid := strings.TrimPrefix(src, "cid:")
741 cid = strings.Trim(cid, "<>")
742 if inline != nil {
743 payload = inline[cid]
744 debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
745 } else {
746 debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
747 }
748 } else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
749 payload = fetchRemoteBase64(src)
750 }
751
752 if payload != "" {
753 rows := imageRows(payload)
754 debugImageProtocol("collected image placement src=%s rows=%d (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, rows, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
755
756 idx := imgIndex
757 imgIndex++
758 pendingImages = append(pendingImages, struct {
759 index int
760 payload string
761 rows int
762 }{idx, payload, rows})
763
764 // Insert a placeholder with blank lines for spacing.
765 // The image placement marker lets us find the line number later.
766 placeholder := fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
767 s.ReplaceWithHtml(fmt.Sprintf("\n[[MATCHA_IMG:%d]]%s", idx, placeholder))
768 return
769 }
770 debugImageProtocol("no payload for src=%s dataURI=%t cid=%t", src, strings.HasPrefix(src, "data:"), strings.HasPrefix(src, "cid:"))
771 } else {
772 debugImageProtocol("image protocol not supported for src=%s (kitty=%t ghostty=%t iterm2=%t wezterm=%t wayst=%t warp=%t konsole=%t)", src, kittySupported(), ghosttySupported(), iterm2Supported(), weztermSupported(), waystSupported(), warpSupported(), konsoleSupported())
773 }
774 if hyperlinkSupported() {
775 s.ReplaceWithHtml(hyperlink(src, fmt.Sprintf("\n [Click here to view image: %s] \n", alt)))
776 } else {
777 s.ReplaceWithHtml(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
778 }
779 })
780
781 text := doc.Text()
782
783 // Collapse excessive newlines, but not the image row placeholders
784 re := regexp.MustCompile(`\n{3,}`)
785 text = re.ReplaceAllString(text, "\n\n")
786
787 // Now expand the image row placeholders to actual newlines
788 text = expandImageRowPlaceholders(text)
789
790 // Build image placements by finding the line numbers of image markers.
791 var placements []ImagePlacement
792 if len(pendingImages) > 0 {
793 lines := strings.Split(text, "\n")
794 imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
795 for lineNum, line := range lines {
796 if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
797 var idx int
798 fmt.Sscanf(matches[1], "%d", &idx)
799 for _, pi := range pendingImages {
800 if pi.index == idx {
801 placements = append(placements, ImagePlacement{
802 Line: lineNum,
803 Base64: pi.payload,
804 Rows: pi.rows,
805 })
806 break
807 }
808 }
809 }
810 }
811
812 // Remove the image markers from the text (leave the spacing)
813 text = imgMarkerRegex.ReplaceAllString(text, "")
814 }
815
816 // Replace quote placeholders with styled quote boxes
817 quoteRegex := regexp.MustCompile(`\[\[MATCHA_QUOTE:(\d+)\]\]`)
818 text = quoteRegex.ReplaceAllStringFunc(text, func(match string) string {
819 idxStr := quoteRegex.FindStringSubmatch(match)[1]
820 var idx int
821 fmt.Sscanf(idxStr, "%d", &idx)
822 if idx >= 0 && idx < len(quoteData) {
823 q := quoteData[idx]
824 return renderQuoteBox(q.from, q.date, strings.Split(q.content, "\n"))
825 }
826 return match
827 })
828
829 // Style quoted reply sections (for plain text > quotes)
830 text = styleQuotedReplies(text)
831
832 return bodyStyle.Render(text), placements, nil
833}
834
835func quoteBoxStyle() lipgloss.Style {
836 return lipgloss.NewStyle().
837 Border(lipgloss.RoundedBorder()).
838 BorderForeground(theme.ActiveTheme.Secondary).
839 Padding(0, 1).
840 Foreground(theme.ActiveTheme.Secondary)
841}
842
843func quoteHeaderStyle() lipgloss.Style {
844 return lipgloss.NewStyle().
845 Foreground(theme.ActiveTheme.Secondary)
846}
847
848// styleQuotedReplies detects quoted reply sections and styles them in a box
849func styleQuotedReplies(text string) string {
850 lines := strings.Split(text, "\n")
851 var result []string
852 var quoteBlock []string
853 var quoteFrom, quoteDate string
854 inQuote := false
855
856 // Regex to match "On DATE, EMAIL wrote:" pattern
857 // Matches various date formats
858 onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`)
859
860 for i := 0; i < len(lines); i++ {
861 line := lines[i]
862 trimmedLine := strings.TrimSpace(line)
863
864 // Check for "On DATE, EMAIL wrote:" header
865 if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil {
866 // If we were already in a quote block, render it first
867 if inQuote && len(quoteBlock) > 0 {
868 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
869 quoteBlock = nil
870 }
871
872 // Parse the date and email from the match
873 dateStr := matches[1]
874 quoteFrom = matches[2]
875 quoteDate = parseDateForDisplay(dateStr)
876 inQuote = true
877 continue
878 }
879
880 // Check if line starts with ">" (quoted text)
881 if strings.HasPrefix(trimmedLine, ">") {
882 if !inQuote {
883 // Start a new quote block without header info
884 inQuote = true
885 quoteFrom = ""
886 quoteDate = ""
887 }
888 // Remove the leading "> " and add to quote block
889 quotedContent := strings.TrimPrefix(trimmedLine, ">")
890 quotedContent = strings.TrimPrefix(quotedContent, " ")
891 quoteBlock = append(quoteBlock, quotedContent)
892 } else if inQuote {
893 // End of quote block - check if it's just whitespace
894 if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") {
895 // Empty line within quote block, keep it
896 quoteBlock = append(quoteBlock, "")
897 } else if trimmedLine == "" && len(quoteBlock) == 0 {
898 // Empty line before any quoted content, skip
899 continue
900 } else {
901 // End of quote block
902 if len(quoteBlock) > 0 {
903 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
904 quoteBlock = nil
905 }
906 inQuote = false
907 quoteFrom = ""
908 quoteDate = ""
909 result = append(result, line)
910 }
911 } else {
912 result = append(result, line)
913 }
914 }
915
916 // Handle any remaining quote block
917 if inQuote && len(quoteBlock) > 0 {
918 result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
919 }
920
921 return strings.Join(result, "\n")
922}
923
924// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM
925func parseDateForDisplay(dateStr string) string {
926 // Common date formats to try
927 formats := []string{
928 "Jan 2, 2006 at 3:04 PM",
929 "02:01:06 15:04",
930 "2006-01-02 15:04:05",
931 "Mon, 02 Jan 2006 15:04:05 -0700",
932 "Mon, 2 Jan 2006 15:04:05 -0700",
933 "2 Jan 2006 15:04:05",
934 "January 2, 2006 at 3:04 PM",
935 "Jan 2, 2006 3:04 PM",
936 time.RFC1123Z,
937 time.RFC1123,
938 time.RFC822Z,
939 time.RFC822,
940 }
941
942 for _, format := range formats {
943 if t, err := time.Parse(format, dateStr); err == nil {
944 return t.Format("02:01:06 15:04")
945 }
946 }
947
948 // Return original if parsing fails
949 return dateStr
950}
951
952// renderQuoteBox renders a quoted section in a styled box
953func renderQuoteBox(from, date string, lines []string) string {
954 // Build header with email on left and date on right
955 var header string
956 if from != "" || date != "" {
957 if from != "" && date != "" {
958 header = quoteHeaderStyle().Render(from + " " + date)
959 } else if from != "" {
960 header = quoteHeaderStyle().Render(from)
961 } else {
962 header = quoteHeaderStyle().Render(date)
963 }
964 }
965
966 // Join the quoted content
967 content := strings.Join(lines, "\n")
968
969 // Build the box content
970 var boxContent string
971 if header != "" {
972 boxContent = header + "\n\n" + content
973 } else {
974 boxContent = content
975 }
976
977 return quoteBoxStyle().Render(boxContent)
978}