html.go

   1package view
   2
   3import (
   4	"encoding/base64"
   5	"fmt"
   6	"io"
   7	"mime/quotedprintable"
   8	"os"
   9	"regexp"
  10	"strings"
  11	"sync/atomic"
  12	"time"
  13
  14	"charm.land/lipgloss/v2"
  15	"github.com/floatpane/matcha/clib"
  16	"github.com/floatpane/matcha/internal/httpclient"
  17	"github.com/floatpane/matcha/internal/loglevel"
  18	"github.com/floatpane/matcha/theme"
  19	lru "github.com/hashicorp/golang-lru/v2"
  20)
  21
  22func linkStyle() lipgloss.Style {
  23	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Link)
  24}
  25
  26// getTerminalCellSize returns the height of a terminal cell in pixels.
  27// It queries the terminal using TIOCGWINSZ to get both character and pixel dimensions.
  28// Falls back to a default of 18 pixels if the query fails.
  29func getTerminalCellSize() int {
  30	const defaultCellHeight = 18
  31
  32	// Try stdout, stdin, stderr, then /dev/tty as last resort
  33	fds := []int{int(os.Stdout.Fd()), int(os.Stdin.Fd()), int(os.Stderr.Fd())}
  34
  35	for _, fd := range fds {
  36		if cellHeight := getCellHeightFromFd(fd); cellHeight > 0 {
  37			return cellHeight
  38		}
  39	}
  40
  41	// Try /dev/tty directly - this works even when stdio is redirected (e.g., in Bubble Tea)
  42	if tty, err := os.Open("/dev/tty"); err == nil {
  43		defer tty.Close()
  44		if cellHeight := getCellHeightFromFd(int(tty.Fd())); cellHeight > 0 {
  45			return cellHeight
  46		}
  47	}
  48
  49	debugImageProtocol("using default cell height: %d pixels", defaultCellHeight)
  50	return defaultCellHeight
  51}
  52
  53// hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
  54func hyperlinkSupported() bool {
  55	term := strings.ToLower(os.Getenv("TERM"))
  56
  57	// Terminals known to support OSC 8 hyperlinks
  58	supportedTerms := []string{
  59		"kitty",
  60		"ghostty",
  61		"wezterm",
  62		"alacritty",
  63		"foot",
  64		"tmux",
  65		"screen",
  66	}
  67
  68	for _, supported := range supportedTerms {
  69		if strings.Contains(term, supported) {
  70			return true
  71		}
  72	}
  73
  74	// Check for specific terminal programs
  75	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
  76	supportedPrograms := []string{
  77		"iterm.app",
  78		"hyper",
  79		"vscode",
  80		"ghostty",
  81		"wezterm",
  82	}
  83
  84	for _, supported := range supportedPrograms {
  85		if strings.Contains(termProgram, supported) {
  86			return true
  87		}
  88	}
  89
  90	// Check for VTE-based terminals (GNOME Terminal, etc.)
  91	if os.Getenv("VTE_VERSION") != "" {
  92		return true
  93	}
  94
  95	// Check for specific environment variables that indicate hyperlink support
  96	if os.Getenv("KITTY_WINDOW_ID") != "" ||
  97		os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
  98		os.Getenv("WEZTERM_EXECUTABLE") != "" ||
  99		os.Getenv("WT_SESSION") != "" {
 100		return true
 101	}
 102
 103	return false
 104}
 105
 106// hyperlink formats a string as either a terminal-clickable hyperlink or plain text with URL.
 107func hyperlink(url, text string) string {
 108	if text == "" {
 109		text = url
 110	}
 111
 112	supported := hyperlinkSupported()
 113
 114	if supported {
 115		// Use OSC 8 hyperlink sequence for supported terminals
 116		return fmt.Sprintf("\x1b]8;;%s\x07%s\x1b]8;;\x07", url, linkStyle().Render(text))
 117	} else {
 118		// Fallback to plain text format for unsupported terminals
 119		if text == url {
 120			return fmt.Sprintf("<%s>", linkStyle().Render(url))
 121		}
 122		return fmt.Sprintf("%s <%s>", linkStyle().Render(text), linkStyle().Render(url))
 123	}
 124}
 125
 126func decodeQuotedPrintable(s string) (string, error) {
 127	reader := quotedprintable.NewReader(strings.NewReader(s))
 128	body, err := io.ReadAll(reader)
 129	if err != nil {
 130		return "", err
 131	}
 132	return string(body), nil
 133}
 134
 135// markdownToHTML converts a Markdown string to an HTML string using md4c (C).
 136func markdownToHTML(md []byte) []byte {
 137	return clib.MarkdownToHTML(md)
 138}
 139
 140func kittySupported() bool {
 141	term := strings.ToLower(os.Getenv("TERM"))
 142	if strings.Contains(term, "kitty") {
 143		return true
 144	}
 145	return os.Getenv("KITTY_WINDOW_ID") != ""
 146}
 147
 148func ghosttySupported() bool {
 149	// Check for TERM containing ghostty
 150	term := strings.ToLower(os.Getenv("TERM"))
 151	if strings.Contains(term, "ghostty") {
 152		return true
 153	}
 154
 155	// Check for Ghostty-specific environment variables
 156	if os.Getenv("TERM_PROGRAM") == "ghostty" {
 157		return true
 158	}
 159
 160	// Check for GHOSTTY_RESOURCES_DIR which Ghostty sets
 161	return os.Getenv("GHOSTTY_RESOURCES_DIR") != ""
 162}
 163
 164func iterm2Supported() bool {
 165	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 166	if termProgram == "iterm.app" {
 167		return true
 168	}
 169
 170	// Check for iTerm2-specific environment variables
 171	if os.Getenv("ITERM_SESSION_ID") != "" || os.Getenv("ITERM_PROFILE") != "" {
 172		return true
 173	}
 174
 175	return false
 176}
 177
 178func weztermSupported() bool {
 179	// Check for WezTerm-specific environment variables
 180	if os.Getenv("WEZTERM_EXECUTABLE") != "" || os.Getenv("WEZTERM_CONFIG_FILE") != "" {
 181		return true
 182	}
 183
 184	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 185	if termProgram == "wezterm" {
 186		return true
 187	}
 188
 189	term := strings.ToLower(os.Getenv("TERM"))
 190	if strings.Contains(term, "wezterm") {
 191		return true
 192	}
 193
 194	return false
 195}
 196
 197func waystSupported() bool {
 198	term := strings.ToLower(os.Getenv("TERM"))
 199	if strings.Contains(term, "wayst") {
 200		return true
 201	}
 202
 203	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 204	if termProgram == "wayst" {
 205		return true
 206	}
 207
 208	return false
 209}
 210
 211func warpSupported() bool {
 212	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 213	if termProgram == "warp" {
 214		return true
 215	}
 216
 217	// Check for Warp-specific environment variables
 218	if os.Getenv("WARP_IS_LOCAL_SHELL_SESSION") != "" || os.Getenv("WARP_COMBINED_PROMPT_COMMAND_FINISHED") != "" {
 219		return true
 220	}
 221
 222	return false
 223}
 224
 225func konsoleSupported() bool {
 226	// Check for Konsole-specific environment variables
 227	if os.Getenv("KONSOLE_DBUS_SESSION") != "" || os.Getenv("KONSOLE_VERSION") != "" {
 228		return true
 229	}
 230
 231	termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
 232	if termProgram == "konsole" {
 233		return true
 234	}
 235
 236	return false
 237}
 238
 239func zellijSupported() bool {
 240	return os.Getenv("ZELLIJ") != "" || os.Getenv("ZELLIJ_SESSION_NAME") != ""
 241}
 242
 243func sixelSupported() bool {
 244	// Zellij always supports Sixel
 245	if zellijSupported() {
 246		return true
 247	}
 248
 249	// Native Sixel terminals
 250	term := strings.ToLower(os.Getenv("TERM"))
 251	return strings.Contains(term, "mlterm") ||
 252		strings.Contains(term, "foot") ||
 253		(strings.Contains(term, "xterm") && os.Getenv("SIXEL") == "1")
 254}
 255
 256// ImageProtocolSupported checks if any supported image protocol terminal is detected.
 257func ImageProtocolSupported() bool {
 258	return imageProtocolSupported()
 259}
 260
 261// SixelSupported returns true if the terminal uses the Sixel graphics protocol.
 262func SixelSupported() bool {
 263	return sixelSupported()
 264}
 265
 266// imageProtocolSupported checks if any supported image protocol terminal is detected.
 267func imageProtocolSupported() bool {
 268	return sixelSupported() || kittySupported() || ghosttySupported() || iterm2Supported() ||
 269		weztermSupported() || waystSupported() || warpSupported() || konsoleSupported()
 270}
 271
 272func debugImageProtocol(format string, args ...interface{}) {
 273	if os.Getenv("DEBUG_IMAGE_PROTOCOL") == "" && os.Getenv("DEBUG_KITTY_IMAGES") == "" {
 274		return
 275	}
 276	msg := fmt.Sprintf("[img-protocol] "+format+"\n", args...)
 277	loglevel.Infof("%s", strings.TrimSuffix(msg, "\n"))
 278	if path := os.Getenv("DEBUG_IMAGE_PROTOCOL_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	} else if path := os.Getenv("DEBUG_KITTY_LOG"); path != "" {
 284		if f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644); err == nil {
 285			_, _ = f.WriteString(msg)
 286			_ = f.Close()
 287		}
 288	}
 289}
 290
 291const remoteImageCacheSize = 20
 292
 293// remoteImageCache caches fetched remote images (URL -> base64 PNG string).
 294var remoteImageCache *lru.Cache[string, string]
 295
 296func init() {
 297	c, err := lru.New[string, string](remoteImageCacheSize)
 298	if err != nil {
 299		panic(err) // only fails on size <= 0
 300	}
 301	remoteImageCache = c
 302}
 303
 304// nextImageID is an auto-incrementing counter for Kitty image IDs.
 305var nextImageID uint32 = 1000
 306
 307// allocImageID returns a unique Kitty image ID.
 308func allocImageID() uint32 {
 309	return atomic.AddUint32(&nextImageID, 1)
 310}
 311
 312func fetchRemoteBase64(url string) string {
 313	if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
 314		return ""
 315	}
 316
 317	// Check cache first
 318	if cached, ok := remoteImageCache.Get(url); ok {
 319		debugImageProtocol("remote cache hit url=%s", url)
 320		return cached
 321	}
 322
 323	client := httpclient.New(httpclient.RemoteImageTimeout)
 324	resp, err := client.Get(url)
 325	if err != nil {
 326		debugImageProtocol("remote fetch failed url=%s err=%v", url, err)
 327		return ""
 328	}
 329	defer resp.Body.Close()
 330	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
 331		debugImageProtocol("remote fetch non-200 url=%s status=%d", url, resp.StatusCode)
 332		return ""
 333	}
 334	// Limit response body to 10 MB to prevent memory exhaustion from
 335	// malicious or very large images.
 336	const maxImageSize = 10 << 20 // 10 MB
 337	data, err := io.ReadAll(io.LimitReader(resp.Body, maxImageSize))
 338	if err != nil {
 339		debugImageProtocol("remote fetch read error url=%s err=%v", url, err)
 340		return ""
 341	}
 342
 343	result, ok := clib.DecodeToPNG(data)
 344	if !ok {
 345		debugImageProtocol("remote decode failed url=%s", url)
 346		return ""
 347	}
 348
 349	encoded := base64.StdEncoding.EncodeToString(result.PNGData)
 350	debugImageProtocol("remote fetch ok url=%s len=%d", url, len(encoded))
 351	remoteImageCache.Add(url, encoded)
 352	return encoded
 353}
 354
 355func dataURIBase64(uri string) string {
 356	if !strings.HasPrefix(uri, "data:") {
 357		return ""
 358	}
 359	comma := strings.Index(uri, ",")
 360	if comma == -1 || comma+1 >= len(uri) {
 361		return ""
 362	}
 363	return uri[comma+1:]
 364}
 365
 366// imageRowPlaceholderPrefix is used to mark where image row spacing should be inserted.
 367// This prevents the newline-collapsing regex from removing intentional spacing.
 368// Uses brackets instead of angle brackets to avoid being interpreted as HTML tags.
 369const imageRowPlaceholderPrefix = "[[MATCHA_IMG_ROWS:"
 370const imageRowPlaceholderSuffix = "]]"
 371
 372func kittyInlineImage(payload string) string {
 373	if payload == "" {
 374		return ""
 375	}
 376
 377	const chunkSize = 4096
 378	var b strings.Builder
 379
 380	// Calculate how many terminal rows the image occupies to advance text after it.
 381	rows := 1
 382	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
 383		if _, h, ok := clib.ImageDimensions(data); ok {
 384			cellHeight := getTerminalCellSize()
 385			rows = (h + cellHeight - 1) / cellHeight
 386			if rows < 1 {
 387				rows = 1
 388			}
 389			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
 390		}
 391	}
 392
 393	for offset := 0; offset < len(payload); offset += chunkSize {
 394		end := offset + chunkSize
 395		if end > len(payload) {
 396			end = len(payload)
 397		}
 398		more := "0"
 399		if end < len(payload) {
 400			more = "1"
 401		}
 402
 403		chunk := payload[offset:end]
 404		if offset == 0 {
 405			// C=1 means cursor does NOT move after image render (stays at top-left of image position)
 406			// This is needed for proper TUI rendering, but we must add newlines to push text below
 407			b.WriteString(fmt.Sprintf("\x1b_Gf=100,a=T,q=2,C=1,m=%s;%s\x1b\\", more, chunk))
 408		} else {
 409			b.WriteString(fmt.Sprintf("\x1b_Gm=%s;%s\x1b\\", more, chunk))
 410		}
 411	}
 412
 413	// Add newlines to push cursor below the image.
 414	// Use a placeholder that won't be collapsed by the newline regex.
 415	b.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
 416
 417	return b.String()
 418}
 419
 420// iterm2InlineImage renders an image using iTerm2's image protocol
 421func iterm2InlineImage(payload string) string {
 422	if payload == "" {
 423		return ""
 424	}
 425
 426	// Calculate rows for cursor positioning
 427	rows := 1
 428	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
 429		if _, h, ok := clib.ImageDimensions(data); ok {
 430			cellHeight := getTerminalCellSize()
 431			rows = (h + cellHeight - 1) / cellHeight
 432			if rows < 1 {
 433				rows = 1
 434			}
 435			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
 436		}
 437	}
 438
 439	// iTerm2 image protocol: ESC]1337;File=inline=1:<base64_data>BEL
 440	result := fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07\n", payload)
 441
 442	// Add placeholder for row spacing
 443	result += fmt.Sprintf("%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix)
 444
 445	return result
 446}
 447
 448// sixelInlineImage returns Sixel escape sequence + newline placeholders
 449func sixelInlineImage(base64PNG string) string {
 450	data, err := base64.StdEncoding.DecodeString(base64PNG)
 451	if err != nil {
 452		return ""
 453	}
 454
 455	cellHeight := getTerminalCellSize()
 456	sixel, rows, err := clib.EncodePNGToSixel(data, cellHeight)
 457	if err != nil {
 458		debugImageProtocol("Sixel encoding failed: %v", err)
 459		return ""
 460	}
 461
 462	debugImageProtocol("Sixel: encoded %d bytes, %d rows", len(sixel), rows)
 463
 464	// Sixel sequences don't auto-advance cursor
 465	// Add newlines to preserve layout
 466	return sixel + strings.Repeat("\n", rows)
 467}
 468
 469// sixelImageEscapeOnly returns raw Sixel for out-of-band rendering
 470func sixelImageEscapeOnly(base64PNG string) string {
 471	data, err := base64.StdEncoding.DecodeString(base64PNG)
 472	if err != nil {
 473		return ""
 474	}
 475
 476	cellHeight := getTerminalCellSize()
 477	sixel, _, err := clib.EncodePNGToSixel(data, cellHeight)
 478	if err != nil {
 479		return ""
 480	}
 481
 482	return sixel
 483}
 484
 485// renderInlineImage renders an image using the appropriate protocol for the detected terminal
 486func renderInlineImage(payload string) string {
 487	if payload == "" {
 488		return ""
 489	}
 490
 491	// Priority: Sixel in multiplexers overrides native protocols
 492	if sixelSupported() {
 493		return sixelInlineImage(payload)
 494	}
 495
 496	if kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported() {
 497		// These terminals use the Kitty graphics protocol
 498		return kittyInlineImage(payload)
 499	} else if iterm2Supported() || warpSupported() {
 500		// iTerm2 and Warp use the iTerm2 image protocol
 501		return iterm2InlineImage(payload)
 502	}
 503
 504	return ""
 505}
 506
 507// imageRows calculates the number of terminal rows an image occupies.
 508func imageRows(payload string) int {
 509	rows := 1
 510	if data, err := base64.StdEncoding.DecodeString(payload); err == nil {
 511		if _, h, ok := clib.ImageDimensions(data); ok {
 512			cellHeight := getTerminalCellSize()
 513			if cellHeight == 0 {
 514				cellHeight = 16
 515			}
 516			rows = (h + cellHeight - 1) / cellHeight
 517			if rows < 1 {
 518				rows = 1
 519			}
 520			debugImageProtocol("image height: %d pixels, cell height: %d pixels, rows needed: %d", h, cellHeight, rows)
 521		}
 522	}
 523	return rows
 524}
 525
 526// kittyUploadImage uploads image data to the terminal with a unique ID using
 527// the Kitty graphics protocol transmit action (a=t). The image is stored in
 528// the terminal's memory and can be displayed later by ID without re-sending data.
 529func kittyUploadImage(payload string, id uint32) {
 530	if payload == "" {
 531		return
 532	}
 533
 534	const chunkSize = 4096
 535	for offset := 0; offset < len(payload); offset += chunkSize {
 536		end := offset + chunkSize
 537		if end > len(payload) {
 538			end = len(payload)
 539		}
 540		more := "0"
 541		if end < len(payload) {
 542			more = "1"
 543		}
 544
 545		chunk := payload[offset:end]
 546		if offset == 0 {
 547			// a=t: transmit (upload) only, don't display yet
 548			// i=ID: assign this image ID
 549			fmt.Fprintf(os.Stdout, "\x1b_Gf=100,a=t,i=%d,q=2,m=%s;%s\x1b\\", id, more, chunk)
 550		} else {
 551			fmt.Fprintf(os.Stdout, "\x1b_Gm=%s;%s\x1b\\", more, chunk)
 552		}
 553	}
 554	os.Stdout.Sync()
 555}
 556
 557// kittyDisplayImage displays a previously uploaded image by its ID at the
 558// current cursor position. This is very fast since no image data is transmitted.
 559func kittyDisplayImage(id uint32) string {
 560	// a=p: put (display) an already-uploaded image by ID
 561	// C=1: cursor does not move
 562	return fmt.Sprintf("\x1b_Ga=p,i=%d,q=2,C=1\x1b\\", id)
 563}
 564
 565// iterm2ImageEscapeOnly returns only the iTerm2 image protocol escape sequence
 566// without any row placeholders. Used for out-of-band rendering to stdout.
 567func iterm2ImageEscapeOnly(payload string) string {
 568	if payload == "" {
 569		return ""
 570	}
 571	return fmt.Sprintf("\x1b]1337;File=inline=1:%s\x07", payload)
 572}
 573
 574// RenderImageToStdout writes an image directly to stdout at the given screen
 575// row using cursor positioning. This bypasses bubbletea's cell-based renderer
 576// which cannot handle graphics protocol escape sequences.
 577//
 578// For Kitty-protocol terminals, images are uploaded once and then displayed by
 579// ID on subsequent calls, making scroll rendering nearly instant.
 580func RenderImageToStdout(placement *ImagePlacement, screenRow int, screenCol ...int) {
 581	if placement.Base64 == "" {
 582		return
 583	}
 584
 585	col := 1
 586	if len(screenCol) > 0 && screenCol[0] > 0 {
 587		col = screenCol[0]
 588	}
 589
 590	// Priority: Sixel in multiplexers
 591	if sixelSupported() {
 592		debugImageProtocol("Sixel: RenderImageToStdout row=%d col=%d base64len=%d", screenRow, col, len(placement.Base64))
 593
 594		// Encode once, reuse cached Sixel on subsequent renders (like Kitty's upload-once pattern)
 595		if placement.SixelEncoded == "" {
 596			placement.SixelEncoded = sixelImageEscapeOnly(placement.Base64)
 597			if placement.SixelEncoded == "" {
 598				debugImageProtocol("Sixel: sixelImageEscapeOnly returned empty")
 599				return
 600			}
 601		}
 602
 603		debugImageProtocol("Sixel: rendering %d bytes at row=%d col=%d", len(placement.SixelEncoded), screenRow+1, col)
 604		// Position cursor + render Sixel
 605		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u",
 606			screenRow+1, col, placement.SixelEncoded)
 607		os.Stdout.Sync()
 608		return
 609	}
 610
 611	useKitty := kittySupported() || ghosttySupported() || weztermSupported() || waystSupported() || konsoleSupported()
 612	useIterm2 := iterm2Supported() || warpSupported()
 613
 614	if useKitty {
 615		// Upload once, display by ID on subsequent renders
 616		if !placement.Uploaded {
 617			placement.ID = allocImageID()
 618			kittyUploadImage(placement.Base64, placement.ID)
 619			placement.Uploaded = true
 620		}
 621		seq := kittyDisplayImage(placement.ID)
 622		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq)
 623		os.Stdout.Sync()
 624	} else if useIterm2 {
 625		seq := iterm2ImageEscapeOnly(placement.Base64)
 626		fmt.Fprintf(os.Stdout, "\x1b[s\x1b[%d;%dH%s\x1b[u", screenRow+1, col, seq)
 627		os.Stdout.Sync()
 628	}
 629}
 630
 631// expandImageRowPlaceholders replaces image row placeholders with actual newlines.
 632func expandImageRowPlaceholders(text string) string {
 633	re := regexp.MustCompile(regexp.QuoteMeta(imageRowPlaceholderPrefix) + `(\d+)` + regexp.QuoteMeta(imageRowPlaceholderSuffix))
 634	return re.ReplaceAllStringFunc(text, func(match string) string {
 635		// Extract the number of rows from the placeholder
 636		numStr := strings.TrimPrefix(match, imageRowPlaceholderPrefix)
 637		numStr = strings.TrimSuffix(numStr, imageRowPlaceholderSuffix)
 638		rows := 1
 639		if _, err := fmt.Sscanf(numStr, "%d", &rows); err != nil || rows < 1 {
 640			rows = 1
 641		}
 642		// Return the newlines needed to push content below the image
 643		return strings.Repeat("\n", rows)
 644	})
 645}
 646
 647type InlineImage struct {
 648	CID    string
 649	Base64 string
 650}
 651
 652// ImagePlacement holds the data needed to render an image at a specific
 653// line in the email body. Images are rendered directly to stdout (bypassing
 654// bubbletea's cell-based renderer which cannot handle graphics protocols).
 655type ImagePlacement struct {
 656	Line         int    // Line number in the processed body text where the image starts
 657	Base64       string // Base64-encoded image data (PNG)
 658	Rows         int    // Number of terminal rows the image occupies
 659	Uploaded     bool   // Whether the image has been uploaded to the terminal via Kitty ID
 660	ID           uint32 // Kitty image ID for display-by-reference
 661	SixelEncoded string // Cached Sixel escape sequence (encode once, reuse on scroll)
 662}
 663
 664// BodyMIMEType values understood by ProcessBody/ProcessBodyWithInline. Empty
 665// string means "unknown" — the renderer falls back to running markdownToHTML
 666// before HTML parsing, which is correct for plaintext-with-markdown bodies but
 667// can mangle complex HTML (e.g. tables with attribute-heavy <td style="...">).
 668const (
 669	BodyMIMETypeHTML  = "text/html"
 670	BodyMIMETypePlain = "text/plain"
 671)
 672
 673// ProcessBodyWithInline renders the body and resolves CID inline images when provided.
 674// Returns the rendered body text, image placements for out-of-band rendering, and any error.
 675// mimeType is "text/html", "text/plain", or "" (unknown — falls back to legacy markdown→HTML pre-pass).
 676func ProcessBodyWithInline(rawBody, mimeType string, inline []InlineImage, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 677	inlineMap := make(map[string]string, len(inline))
 678	for _, img := range inline {
 679		cid := strings.TrimSpace(img.CID)
 680		cid = strings.TrimPrefix(cid, "<")
 681		cid = strings.TrimSuffix(cid, ">")
 682		cid = strings.TrimPrefix(cid, "cid:")
 683		if cid == "" || img.Base64 == "" {
 684			continue
 685		}
 686		inlineMap[cid] = img.Base64
 687	}
 688	return processBody(rawBody, mimeType, inlineMap, h1Style, h2Style, bodyStyle, disableImages)
 689}
 690
 691// ProcessBody takes a raw email body, decodes it, and formats it as plain
 692// text with terminal hyperlinks.
 693// mimeType is "text/html", "text/plain", or "" (unknown — falls back to legacy markdown→HTML pre-pass).
 694func ProcessBody(rawBody, mimeType string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 695	return processBody(rawBody, mimeType, nil, h1Style, h2Style, bodyStyle, disableImages)
 696}
 697
 698func processBody(rawBody, mimeType string, inline map[string]string, h1Style, h2Style, bodyStyle lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 699	decodedBody, err := decodeQuotedPrintable(rawBody)
 700	if err != nil {
 701		decodedBody = rawBody
 702	}
 703
 704	// HTML bodies skip the markdown pre-pass — md4c can mangle attribute-heavy
 705	// or indented HTML (#602-style raw-tag bleed-through). Empty mimeType keeps
 706	// legacy behavior for cached/legacy callers that don't supply one.
 707	directHTML := mimeType == BodyMIMETypeHTML
 708	var htmlBody []byte
 709	if directHTML {
 710		htmlBody = []byte(decodedBody)
 711	} else {
 712		htmlBody = markdownToHTML([]byte(decodedBody))
 713	}
 714
 715	result, placements, err := renderHTMLToText(htmlBody, inline, h1Style, h2Style, disableImages)
 716	if err != nil {
 717		return "", nil, err
 718	}
 719
 720	// Some real-world HTML emails (newsletters with table-only layouts and no
 721	// <th>, AWeber-shape bodies) emit no visible content from htmlconv. Pre-
 722	// c11de45, every body went through markdownToHTML first, which happened to
 723	// keep these alive. Retry through the markdown pre-pass when the direct
 724	// HTML path produces nothing.
 725	if directHTML && strings.TrimSpace(result) == "" {
 726		result, placements, err = renderHTMLToText(markdownToHTML([]byte(decodedBody)), inline, h1Style, h2Style, disableImages)
 727		if err != nil {
 728			return "", nil, err
 729		}
 730	}
 731
 732	result = styleQuotedReplies(result)
 733	return bodyStyle.Render(result), placements, nil
 734}
 735
 736func renderHTMLToText(htmlBody []byte, inline map[string]string, h1Style, h2Style lipgloss.Style, disableImages bool) (string, []ImagePlacement, error) {
 737	// Parse HTML into structured elements using C parser.
 738	elements, ok := clib.HTMLToElements(string(htmlBody))
 739	if !ok {
 740		return "", nil, fmt.Errorf("could not parse email body")
 741	}
 742
 743	// Process elements: apply styles and collect image placements.
 744	var text strings.Builder
 745	var imgIndex int
 746	var pendingImages []struct {
 747		index   int
 748		payload string
 749		rows    int
 750	}
 751
 752	onWroteRegex := regexp.MustCompile(`On\s+(.+?),\s+(.+?)\s+wrote:`)
 753
 754	for _, elem := range elements {
 755		switch elem.Type {
 756		case clib.HElemText:
 757			text.WriteString(elem.Text)
 758
 759		case clib.HElemH1:
 760			text.WriteString(h1Style.Render(elem.Text))
 761			text.WriteString("\n\n")
 762
 763		case clib.HElemH2:
 764			text.WriteString(h2Style.Render(elem.Text))
 765			text.WriteString("\n\n")
 766
 767		case clib.HElemLink:
 768			text.WriteString(hyperlink(elem.Attr1, elem.Text))
 769
 770		case clib.HElemImage:
 771			src := elem.Attr1
 772			alt := elem.Attr2
 773
 774			if !disableImages && imageProtocolSupported() {
 775				var payload string
 776				if strings.HasPrefix(src, "data:image/") {
 777					payload = dataURIBase64(src)
 778				} else if strings.HasPrefix(src, "cid:") {
 779					cid := strings.TrimPrefix(src, "cid:")
 780					cid = strings.Trim(cid, "<>")
 781					if inline != nil {
 782						payload = inline[cid]
 783						debugImageProtocol("cid lookup for %s found=%t len=%d", cid, payload != "", len(payload))
 784					} else {
 785						debugImageProtocol("cid lookup skipped inline map nil for %s", cid)
 786					}
 787				} else if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
 788					payload = fetchRemoteBase64(src)
 789				}
 790
 791				if payload != "" {
 792					rows := imageRows(payload)
 793					debugImageProtocol("collected image placement src=%s rows=%d", src, rows)
 794
 795					idx := imgIndex
 796					imgIndex++
 797					pendingImages = append(pendingImages, struct {
 798						index   int
 799						payload string
 800						rows    int
 801					}{idx, payload, rows})
 802
 803					text.WriteString(fmt.Sprintf("\n[[MATCHA_IMG:%d]]", idx))
 804					text.WriteString(fmt.Sprintf("\n%s%d%s\n", imageRowPlaceholderPrefix, rows, imageRowPlaceholderSuffix))
 805					continue
 806				}
 807				debugImageProtocol("no payload for src=%s", src)
 808			}
 809			if hyperlinkSupported() {
 810				text.WriteString(fmt.Sprintf("\n %s \n", hyperlink(src, fmt.Sprintf("[Click here to view image: %s]", alt))))
 811			} else {
 812				text.WriteString(fmt.Sprintf("\n %s \n", linkStyle().Render(fmt.Sprintf("[Image: %s, %s]", alt, src))))
 813			}
 814
 815		case clib.HElemTable:
 816			headerRows := 0
 817			if elem.Attr1 != "" {
 818				fmt.Sscanf(elem.Attr1, "%d", &headerRows)
 819			}
 820			text.WriteString("\n")
 821			text.WriteString(renderTable(elem.Text, headerRows))
 822			text.WriteString("\n")
 823
 824		case clib.HElemBlockquote:
 825			var from, date string
 826			prevText := elem.Attr2
 827			cite := elem.Attr1
 828
 829			if matches := onWroteRegex.FindStringSubmatch(prevText); matches != nil {
 830				date = parseDateForDisplay(matches[1])
 831				from = matches[2]
 832			} else if matches := onWroteRegex.FindStringSubmatch(cite); matches != nil {
 833				date = parseDateForDisplay(matches[1])
 834				from = matches[2]
 835			}
 836
 837			text.WriteString(renderQuoteBox(from, date, strings.Split(elem.Text, "\n")))
 838		}
 839	}
 840
 841	result := text.String()
 842
 843	// Collapse excessive newlines, but not the image row placeholders
 844	re := regexp.MustCompile(`\n{3,}`)
 845	result = re.ReplaceAllString(result, "\n\n")
 846
 847	// Now expand the image row placeholders to actual newlines
 848	result = expandImageRowPlaceholders(result)
 849
 850	// Build image placements by finding the line numbers of image markers.
 851	var placements []ImagePlacement
 852	if len(pendingImages) > 0 {
 853		lines := strings.Split(result, "\n")
 854		imgMarkerRegex := regexp.MustCompile(`\[\[MATCHA_IMG:(\d+)\]\]`)
 855		for lineNum, line := range lines {
 856			if matches := imgMarkerRegex.FindStringSubmatch(line); matches != nil {
 857				var idx int
 858				fmt.Sscanf(matches[1], "%d", &idx)
 859				for _, pi := range pendingImages {
 860					if pi.index == idx {
 861						placements = append(placements, ImagePlacement{
 862							Line:   lineNum,
 863							Base64: pi.payload,
 864							Rows:   pi.rows,
 865						})
 866						break
 867					}
 868				}
 869			}
 870		}
 871
 872		// Remove the image markers from the text (leave the spacing)
 873		result = imgMarkerRegex.ReplaceAllString(result, "")
 874	}
 875
 876	return result, placements, nil
 877}
 878
 879func tableHeaderStyle() lipgloss.Style {
 880	return lipgloss.NewStyle().Bold(true).Foreground(theme.ActiveTheme.Accent)
 881}
 882
 883func tableBorderStyle() lipgloss.Style {
 884	return lipgloss.NewStyle().Foreground(theme.ActiveTheme.Secondary)
 885}
 886
 887// renderTable renders table data as a Unicode box-drawing table.
 888// data is tab-separated cells, newline-separated rows.
 889// headerRows is the number of header rows.
 890func renderTable(data string, headerRows int) string {
 891	rows := strings.Split(data, "\n")
 892	if len(rows) == 0 {
 893		return ""
 894	}
 895
 896	// Parse into 2D grid and trim cell whitespace
 897	var grid [][]string
 898	maxCols := 0
 899	for _, row := range rows {
 900		cells := strings.Split(row, "\t")
 901		trimmed := make([]string, len(cells))
 902		for i, c := range cells {
 903			trimmed[i] = strings.TrimSpace(c)
 904		}
 905		grid = append(grid, trimmed)
 906		if len(trimmed) > maxCols {
 907			maxCols = len(trimmed)
 908		}
 909	}
 910
 911	// Normalize: ensure all rows have the same number of columns
 912	for i := range grid {
 913		for len(grid[i]) < maxCols {
 914			grid[i] = append(grid[i], "")
 915		}
 916	}
 917
 918	// Calculate column widths
 919	colWidths := make([]int, maxCols)
 920	for _, row := range grid {
 921		for j, cell := range row {
 922			if len(cell) > colWidths[j] {
 923				colWidths[j] = len(cell)
 924			}
 925		}
 926	}
 927
 928	// Minimum width per column
 929	for i := range colWidths {
 930		if colWidths[i] < 3 {
 931			colWidths[i] = 3
 932		}
 933	}
 934
 935	bs := tableBorderStyle()
 936	hs := tableHeaderStyle()
 937
 938	// Build horizontal borders
 939	buildBorder := func(left, mid, right, fill string) string {
 940		var b strings.Builder
 941		b.WriteString(bs.Render(left))
 942		for j, w := range colWidths {
 943			b.WriteString(bs.Render(strings.Repeat(fill, w+2)))
 944			if j < len(colWidths)-1 {
 945				b.WriteString(bs.Render(mid))
 946			}
 947		}
 948		b.WriteString(bs.Render(right))
 949		return b.String()
 950	}
 951
 952	topBorder := buildBorder("┌", "┬", "┐", "─")
 953	midBorder := buildBorder("├", "┼", "┤", "─")
 954	botBorder := buildBorder("└", "┴", "┘", "─")
 955
 956	var out strings.Builder
 957	out.WriteString(topBorder)
 958	out.WriteString("\n")
 959
 960	for i, row := range grid {
 961		out.WriteString(bs.Render("│"))
 962		for j, cell := range row {
 963			padded := cell + strings.Repeat(" ", colWidths[j]-len(cell))
 964			if i < headerRows {
 965				out.WriteString(" " + hs.Render(padded) + " ")
 966			} else {
 967				out.WriteString(" " + padded + " ")
 968			}
 969			out.WriteString(bs.Render("│"))
 970		}
 971		out.WriteString("\n")
 972
 973		if i < headerRows && (i+1 == headerRows || i+1 == len(grid)) {
 974			out.WriteString(midBorder)
 975			out.WriteString("\n")
 976		}
 977	}
 978
 979	out.WriteString(botBorder)
 980	return out.String()
 981}
 982
 983func quoteBoxStyle() lipgloss.Style {
 984	return lipgloss.NewStyle().
 985		Border(lipgloss.RoundedBorder()).
 986		BorderForeground(theme.ActiveTheme.Secondary).
 987		Padding(0, 1).
 988		Foreground(theme.ActiveTheme.Secondary)
 989}
 990
 991func quoteHeaderStyle() lipgloss.Style {
 992	return lipgloss.NewStyle().
 993		Foreground(theme.ActiveTheme.Secondary)
 994}
 995
 996// styleQuotedReplies detects quoted reply sections and styles them in a box
 997func styleQuotedReplies(text string) string {
 998	lines := strings.Split(text, "\n")
 999	var result []string
1000	var quoteBlock []string
1001	var quoteFrom, quoteDate string
1002	inQuote := false
1003
1004	// Regex to match "On DATE, EMAIL wrote:" pattern
1005	// Matches various date formats
1006	onWroteRegex := regexp.MustCompile(`^On\s+(.+?),\s+(.+?)\s+wrote:$`)
1007
1008	for i := 0; i < len(lines); i++ {
1009		line := lines[i]
1010		trimmedLine := strings.TrimSpace(line)
1011
1012		// Check for "On DATE, EMAIL wrote:" header
1013		if matches := onWroteRegex.FindStringSubmatch(trimmedLine); matches != nil {
1014			// If we were already in a quote block, render it first
1015			if inQuote && len(quoteBlock) > 0 {
1016				result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
1017				quoteBlock = nil
1018			}
1019
1020			// Parse the date and email from the match
1021			dateStr := matches[1]
1022			quoteFrom = matches[2]
1023			quoteDate = parseDateForDisplay(dateStr)
1024			inQuote = true
1025			continue
1026		}
1027
1028		// Check if line starts with ">" (quoted text)
1029		if strings.HasPrefix(trimmedLine, ">") {
1030			if !inQuote {
1031				// Start a new quote block without header info
1032				inQuote = true
1033				quoteFrom = ""
1034				quoteDate = ""
1035			}
1036			// Remove the leading "> " and add to quote block
1037			quotedContent := strings.TrimPrefix(trimmedLine, ">")
1038			quotedContent = strings.TrimPrefix(quotedContent, " ")
1039			quoteBlock = append(quoteBlock, quotedContent)
1040		} else if inQuote {
1041			// End of quote block - check if it's just whitespace
1042			if trimmedLine == "" && i+1 < len(lines) && strings.HasPrefix(strings.TrimSpace(lines[i+1]), ">") {
1043				// Empty line within quote block, keep it
1044				quoteBlock = append(quoteBlock, "")
1045			} else if trimmedLine == "" && len(quoteBlock) == 0 {
1046				// Empty line before any quoted content, skip
1047				continue
1048			} else {
1049				// End of quote block
1050				if len(quoteBlock) > 0 {
1051					result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
1052					quoteBlock = nil
1053				}
1054				inQuote = false
1055				quoteFrom = ""
1056				quoteDate = ""
1057				result = append(result, line)
1058			}
1059		} else {
1060			result = append(result, line)
1061		}
1062	}
1063
1064	// Handle any remaining quote block
1065	if inQuote && len(quoteBlock) > 0 {
1066		result = append(result, renderQuoteBox(quoteFrom, quoteDate, quoteBlock))
1067	}
1068
1069	return strings.Join(result, "\n")
1070}
1071
1072// parseDateForDisplay converts various date formats to DD:MM:YY HH:MM
1073func parseDateForDisplay(dateStr string) string {
1074	// Common date formats to try
1075	formats := []string{
1076		"Jan 2, 2006 at 3:04 PM",
1077		"02:01:06 15:04",
1078		"2006-01-02 15:04:05",
1079		"Mon, 02 Jan 2006 15:04:05 -0700",
1080		"Mon, 2 Jan 2006 15:04:05 -0700",
1081		"2 Jan 2006 15:04:05",
1082		"January 2, 2006 at 3:04 PM",
1083		"Jan 2, 2006 3:04 PM",
1084		time.RFC1123Z,
1085		time.RFC1123,
1086		time.RFC822Z,
1087		time.RFC822,
1088	}
1089
1090	for _, format := range formats {
1091		if t, err := time.Parse(format, dateStr); err == nil {
1092			return t.Format("02:01:06 15:04")
1093		}
1094	}
1095
1096	// Return original if parsing fails
1097	return dateStr
1098}
1099
1100// renderQuoteBox renders a quoted section in a styled box
1101func renderQuoteBox(from, date string, lines []string) string {
1102	// Build header with email on left and date on right
1103	var header string
1104	if from != "" || date != "" {
1105		if from != "" && date != "" {
1106			header = quoteHeaderStyle().Render(from + "  " + date)
1107		} else if from != "" {
1108			header = quoteHeaderStyle().Render(from)
1109		} else {
1110			header = quoteHeaderStyle().Render(date)
1111		}
1112	}
1113
1114	// Join the quoted content
1115	content := strings.Join(lines, "\n")
1116
1117	// Build the box content
1118	var boxContent string
1119	if header != "" {
1120		boxContent = header + "\n\n" + content
1121	} else {
1122		boxContent = content
1123	}
1124
1125	return quoteBoxStyle().Render(boxContent)
1126}