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