From adcb9c8ab832b7357e984753bf5cb47d3b42d1d0 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 12:48:52 -0400 Subject: [PATCH 1/2] Show attachments by name and kind instead of by URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image in a thread read "Image: name → url", and HEY's own attachment URLs are long enough to fill a couple of lines each with nothing a reader can use. An image now reads "šŸ“· name". Its name is still the link, so an image on the web can be selected with Tab and the status row shows where it goes; HEY's own attachments open from the Attachments box as before. Other files are marked by kind: šŸŽ¬ video, šŸŽµ audio, šŸ“„ a PDF or document, šŸ“Ž anything else. These emoji are all drawn as emoji without a variation selector, so every terminal gives them the same two cells. šŸ–¼ļø and šŸŽžļø need U+FE0F, and terminals disagree on how wide that makes them. The Markdown in --json keeps each image's URL. --- docs/tui.md | 2 +- internal/htmlutil/markdown.go | 26 ++++++++++++++++-- internal/htmlutil/markdown_safety_test.go | 2 +- internal/htmlutil/markdown_test.go | 32 +++++++++++++++++++++-- internal/markdown/render_test.go | 17 ++++++++++++ internal/markdown/style.go | 11 +++++--- internal/tui/mail_test.go | 4 +-- 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 41c32b70..aac66904 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -172,7 +172,7 @@ the iOS and Android apps each keep their own local choice too, and this is the s ## Attachments -Thread attachments always appear with their filename, media type, and size. Use `[` and `]` to select an attachment, `s` to save it without replacing an existing file, and `o` to download and open it in an external application. Attachments never open automatically. Kitty and Ghostty can show inline images. Foot and other terminals use visible text markers. +Thread attachments always appear with their filename, media type, and size. Use `[` and `]` to select an attachment, `s` to save it without replacing an existing file, and `o` to download and open it in an external application. Attachments never open automatically. Kitty and Ghostty can show inline images. In the message itself, a file is marked by its kind: šŸ“· an image, šŸŽ¬ a video, šŸŽµ audio, šŸ“„ a PDF or document, šŸ“Ž anything else. An image shows by name rather than by its URL; an image on the web is a link you can select with Tab, and the row above the shortcut bar shows its whole destination. ## Contacts diff --git a/internal/htmlutil/markdown.go b/internal/htmlutil/markdown.go index d1d7098f..afea71b1 100644 --- a/internal/htmlutil/markdown.go +++ b/internal/htmlutil/markdown.go @@ -724,7 +724,8 @@ func (m *markdownizer) actionTextAttachment(n *html.Node) { } func (m *markdownizer) attachment(filename, url, contentType string) { - filename = escapeText(strings.Join(strings.Fields(filename), " "), "šŸ“Ž ") + icon := attachmentIcon(contentType) + filename = escapeText(strings.Join(strings.Fields(filename), " "), icon) if filename == "" { filename = "attachment" } @@ -733,11 +734,32 @@ func (m *markdownizer) attachment(filename, url, contentType string) { if isImageContentType(contentType) && linkable { m.write("![" + filename + "](" + dest + ")") } else { - m.write("šŸ“Ž " + filename) + m.write(icon + filename) } }) } +// attachmentIcon says what kind of file an attachment is. Each of these emoji is +// drawn as an emoji without a variation selector, so every terminal gives it the +// same two cells; šŸ–¼ļø and šŸŽžļø need U+FE0F for that, and terminals disagree on how +// wide it makes them. +func attachmentIcon(contentType string) string { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + switch { + case isImageContentType(contentType): + return "šŸ“· " + case strings.HasPrefix(contentType, "video/"): + return "šŸŽ¬ " + case strings.HasPrefix(contentType, "audio/"): + return "šŸŽµ " + case contentType == "application/pdf", strings.HasPrefix(contentType, "text/"), + strings.Contains(contentType, "msword"), strings.Contains(contentType, "document"): + return "šŸ“„ " + default: + return "šŸ“Ž " + } +} + func (m *markdownizer) children(n *html.Node) { for child := n.FirstChild; child != nil; child = child.NextSibling { m.walk(child) diff --git a/internal/htmlutil/markdown_safety_test.go b/internal/htmlutil/markdown_safety_test.go index c445c880..4cffd714 100644 --- a/internal/htmlutil/markdown_safety_test.go +++ b/internal/htmlutil/markdown_safety_test.go @@ -448,7 +448,7 @@ func TestToMarkdownUnlinkableImageAltIsProse(t *testing.T) { func TestToMarkdownAttachmentNamesAreSerialized(t *testing.T) { got := toMarkdown(`
` + `
`) - want := "šŸ“Ž \\*report\\*.png\n\nšŸ“Ž \\[q3\\].pdf" + want := "šŸ“· \\*report\\*.png\n\nšŸ“„ \\[q3\\].pdf" if got != want { t.Errorf("ToMarkdown = %q, want %q", got, want) } diff --git a/internal/htmlutil/markdown_test.go b/internal/htmlutil/markdown_test.go index fafab9f3..2e1bc2e6 100644 --- a/internal/htmlutil/markdown_test.go +++ b/internal/htmlutil/markdown_test.go @@ -278,7 +278,35 @@ func TestMarkdownRoundTripPreservesAuthoredContent(t *testing.T) { func TestToMarkdownTrixFileAttachment(t *testing.T) { got := toMarkdown(`
`) - want := "šŸ“Ž q3-report.pdf" + want := "šŸ“„ q3-report.pdf" + if got != want { + t.Errorf("ToMarkdown = %q, want %q", got, want) + } +} + +func TestAttachmentIconSaysWhatKindOfFileItIs(t *testing.T) { + for contentType, want := range map[string]string{ + "image/jpeg": "šŸ“· ", + "video/mp4": "šŸŽ¬ ", + "audio/mpeg": "šŸŽµ ", + "application/pdf": "šŸ“„ ", + "text/plain": "šŸ“„ ", + "application/msword": "šŸ“„ ", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "šŸ“„ ", + "application/zip": "šŸ“Ž ", + "": "šŸ“Ž ", + } { + if got := attachmentIcon(contentType); got != want { + t.Errorf("attachmentIcon(%q) = %q, want %q", contentType, got, want) + } + } +} + +func TestToMarkdownAttachmentsCarryTheirKind(t *testing.T) { + got := toMarkdown(`
` + + `
` + + `
`) + want := "šŸŽ¬ walkthrough.mp4\n\nšŸŽµ voicemail.m4a\n\nšŸ“Ž receipts.zip" if got != want { t.Errorf("ToMarkdown = %q, want %q", got, want) } @@ -286,7 +314,7 @@ func TestToMarkdownTrixFileAttachment(t *testing.T) { func TestToMarkdownActionTextAttachment(t *testing.T) { got := toMarkdown(`

Attached:

`) - want := "Attached:\n\nšŸ“Ž q3-report.pdf" + want := "Attached:\n\nšŸ“„ q3-report.pdf" if got != want { t.Errorf("ToMarkdown = %q, want %q", got, want) } diff --git a/internal/markdown/render_test.go b/internal/markdown/render_test.go index e6c18e10..43745c6f 100644 --- a/internal/markdown/render_test.go +++ b/internal/markdown/render_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" + "github.com/charmbracelet/x/ansi" + "github.com/basecamp/hey-cli/internal/htmlutil" ) @@ -230,3 +232,18 @@ func TestRenderLinkedRejectsMalformedDestinationAndHandlesNoLinks(t *testing.T) } } } + +func TestRenderShowsAnImageByNameAndLinksTheName(t *testing.T) { + attached := Render(htmlutil.ToMarkdown(`
`), 80) + if text := ansi.Strip(attached); text != "šŸ“· 1_an_Gustav.jpg" { + t.Errorf("attached image = %q, want its name behind a camera and no URL", text) + } + + linked := RenderLinked(htmlutil.ToMarkdown(`

Lantern walk

`), 80, -1) + if text := ansi.Strip(linked.Text); text != "šŸ“· Lantern walk" { + t.Errorf("web image = %q, want its name behind a camera and no URL", text) + } + if len(linked.Links) != 1 || linked.Links[0].Destination != "https://images.example.com/lanterns.jpg" { + t.Errorf("web image links = %#v, want its name selectable with the whole destination", linked.Links) + } +} diff --git a/internal/markdown/style.go b/internal/markdown/style.go index 7d7b0fba..700e9994 100644 --- a/internal/markdown/style.go +++ b/internal/markdown/style.go @@ -67,13 +67,16 @@ var terminalStyle = ansi.StyleConfig{ LinkText: ansi.StylePrimitive{ Color: stringPointer("12"), }, + // An image is its name behind a camera, not its URL: the name carries the link, and + // the TUI's status row shows the whole destination when the name is selected. + // Glamour writes the URL as a separate element, so its Format drops it. Image: ansi.StylePrimitive{ - Color: stringPointer("14"), - Underline: boolPointer(true), + Format: "{{/* shown in the status row */}}", }, ImageText: ansi.StylePrimitive{ - Faint: boolPointer(true), - Format: "Image: {{.text}} →", + BlockPrefix: "šŸ“· ", + Color: stringPointer("14"), + Underline: boolPointer(true), }, Code: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{ diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 0cf72ef1..5ad8a55a 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -3111,8 +3111,8 @@ func TestMailViewAlwaysRendersAttachmentPanelAndTextMarker(t *testing.T) { }) view := v.View() - for _, want := range []string{"Image: ", "Attachments", "chart.png", "image/png", "1.5 KB"} { - if !strings.Contains(view, want) { + for _, want := range []string{"šŸ“· chart.png", "Attachments", "chart.png", "image/png", "1.5 KB"} { + if !strings.Contains(stripANSI(view), want) { t.Errorf("thread view does not contain %q: %q", want, view) } } From bc21fca9574ab6d6f2340adb8acd65444bb188dd Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Wed, 30 Sep 2026 12:54:18 -0400 Subject: [PATCH 2/2] Show where an image goes when its label looks like a URL With the URL hidden, an image labelled https://bank.example/login could link to somewhere else entirely and nothing on screen would say so. A web image whose label could pass for a URL, and is not just the name of the file it points at, now has its destination written out beside it, the way a link's label does. --- docs/tui.md | 2 +- internal/htmlutil/markdown.go | 21 ++++++++++++++++++--- internal/htmlutil/markdown_safety_test.go | 15 +++++++++++++++ internal/markdown/render_test.go | 7 +++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index aac66904..cec763c0 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -172,7 +172,7 @@ the iOS and Android apps each keep their own local choice too, and this is the s ## Attachments -Thread attachments always appear with their filename, media type, and size. Use `[` and `]` to select an attachment, `s` to save it without replacing an existing file, and `o` to download and open it in an external application. Attachments never open automatically. Kitty and Ghostty can show inline images. In the message itself, a file is marked by its kind: šŸ“· an image, šŸŽ¬ a video, šŸŽµ audio, šŸ“„ a PDF or document, šŸ“Ž anything else. An image shows by name rather than by its URL; an image on the web is a link you can select with Tab, and the row above the shortcut bar shows its whole destination. +Thread attachments always appear with their filename, media type, and size. Use `[` and `]` to select an attachment, `s` to save it without replacing an existing file, and `o` to download and open it in an external application. Attachments never open automatically. Kitty and Ghostty can show inline images. In the message itself, a file is marked by its kind: šŸ“· an image, šŸŽ¬ a video, šŸŽµ audio, šŸ“„ a PDF or document, šŸ“Ž anything else. An image shows by name rather than by its URL, unless its name looks like a URL, when the real destination is shown beside it; an image on the web is a link you can select with Tab, and the row above the shortcut bar shows its whole destination. ## Contacts diff --git a/internal/htmlutil/markdown.go b/internal/htmlutil/markdown.go index afea71b1..73e0db3b 100644 --- a/internal/htmlutil/markdown.go +++ b/internal/htmlutil/markdown.go @@ -673,7 +673,7 @@ func (m *markdownizer) image(n *html.Node) { src, linkable := destination(getAttr(n, "src")) switch { case linkable: - m.write("![" + escapeText(alt, "alt") + "](" + src + ")") + m.write(imageMarkdown(alt, escapeText(alt, "alt"), src)) case alt != "": m.write(escapeText(alt, m.line.String())) } @@ -725,20 +725,35 @@ func (m *markdownizer) actionTextAttachment(n *html.Node) { func (m *markdownizer) attachment(filename, url, contentType string) { icon := attachmentIcon(contentType) - filename = escapeText(strings.Join(strings.Fields(filename), " "), icon) + name := strings.Join(strings.Fields(filename), " ") + filename = escapeText(name, icon) if filename == "" { filename = "attachment" } dest, linkable := destination(url) m.block(func() { if isImageContentType(contentType) && linkable { - m.write("![" + filename + "](" + dest + ")") + m.write(imageMarkdown(name, filename, dest)) } else { m.write(icon + filename) } }) } +// imageMarkdown writes an image. A terminal shows an image by its label alone, so a +// label a reader could take for a URL gets the destination it links to written out +// beside it, as a link's label does: "https://bank.example/login" pointed at +// https://evil.example shows both. A label that is just the file the destination +// names is not mistaken for anywhere else, and a relative destination is never a link. +func imageMarkdown(label, escaped, dest string) string { + md := "![" + escaped + "](" + dest + ")" + if absolute(dest) && !strings.ContainsFunc(label, unicode.IsSpace) && + strings.ContainsAny(label, ".:/@") && label != destinationFilename(dest) { + md += " <" + dest + ">" + } + return md +} + // attachmentIcon says what kind of file an attachment is. Each of these emoji is // drawn as an emoji without a variation selector, so every terminal gives it the // same two cells; šŸ–¼ļø and šŸŽžļø need U+FE0F for that, and terminals disagree on how diff --git a/internal/htmlutil/markdown_safety_test.go b/internal/htmlutil/markdown_safety_test.go index 4cffd714..5803df84 100644 --- a/internal/htmlutil/markdown_safety_test.go +++ b/internal/htmlutil/markdown_safety_test.go @@ -630,3 +630,18 @@ func FuzzToMarkdownTerminalSafety(f *testing.F) { assertSafeTree(t, md) }) } + +func TestToMarkdownImageLabelThatLooksLikeAURLShowsItsDestination(t *testing.T) { + for input, want := range map[string]string{ + `https://bank.example/login`: "![https://bank.example/login](https://evil.example/login) ", + `bank.example`: "![bank.example](https://evil.example/pixel.png) ", + `lanterns.jpg`: "![lanterns.jpg](https://images.example.org/lanterns.jpg)", + `Lantern walk poster`: "![Lantern walk poster](https://images.example.org/p.jpg)", + `
`: "![bank.example](/rails/blobs/abc/bank.example)", + `
`: "![https://bank.example](https://evil.example/q3.png) ", + } { + if got := toMarkdown(input); got != want { + t.Errorf("ToMarkdown(%s) = %q, want %q", input, got, want) + } + } +} diff --git a/internal/markdown/render_test.go b/internal/markdown/render_test.go index 43745c6f..5d56748e 100644 --- a/internal/markdown/render_test.go +++ b/internal/markdown/render_test.go @@ -247,3 +247,10 @@ func TestRenderShowsAnImageByNameAndLinksTheName(t *testing.T) { t.Errorf("web image links = %#v, want its name selectable with the whole destination", linked.Links) } } + +func TestRenderNeverHidesWhereAURLShapedImageLabelGoes(t *testing.T) { + out := ansi.Strip(Render(htmlutil.ToMarkdown(`

https://bank.example/login

`), 80)) + if !strings.Contains(out, "šŸ“· https://bank.example/login") || !strings.Contains(out, "https://evil.example/login") { + t.Errorf("rendered = %q, want the label and the destination it really links to", out) + } +}