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