From 2dd594978b533042f194c28cb5109f978141a459 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:34:10 +0200 Subject: [PATCH 1/3] Preview a sender's email in The Screener with space Co-Authored-By: Claude Opus 5.5 --- docs/tui.md | 5 + internal/tui/screener.go | 42 +++++- internal/tui/screener_preview.go | 189 ++++++++++++++++++++++++++ internal/tui/screener_preview_test.go | 140 +++++++++++++++++++ internal/tui/screener_test.go | 2 + 5 files changed, 375 insertions(+), 3 deletions(-) create mode 100644 internal/tui/screener_preview.go create mode 100644 internal/tui/screener_preview_test.go diff --git a/docs/tui.md b/docs/tui.md index a333d475a..e9b1f6822 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -146,6 +146,11 @@ screens them out, Tab moves to Screener History and back, `X` clears the whole S after a confirmation, and Escape or `q` returns to mail. Both lists keep going as you scroll, the same way the mail list does. +Space opens a bigger preview of the selected sender's most recent email: the whole message, +not just the first line the list has room for. Arrow keys and PgUp/PgDn scroll it, `y` and +`n` answer for that sender straight from it, and Space or Escape closes it again. Opening a +preview only reads the email; it doesn't screen the sender or tell them anything. + ## Imbox cover art The Imbox can wear cover art, the way the HEY web app does: everything you have already diff --git a/internal/tui/screener.go b/internal/tui/screener.go index 4b29b8d0d..118ed6ecf 100644 --- a/internal/tui/screener.go +++ b/internal/tui/screener.go @@ -82,6 +82,7 @@ var screenerTabItems = []navItem{ // someone already screened in or out. type screenerRow struct { id int64 + entryID int64 // the most recent email they sent, which space previews; pending rows only name string email string subject string // subject of what they sent, or the address they write from; pending rows only @@ -244,6 +245,9 @@ type screenerView struct { liveRequestID uint64 // identifies the only live re-read allowed to update the queue moreRequestID uint64 // identifies the only page-below read allowed to grow a pane mutations int + + preview *screenerPreview // space's full look at a sender's email, nil when closed + previewRequestID uint64 // identifies the only preview read allowed to fill it } func newScreenerView(vc *viewContext) *screenerView { @@ -253,15 +257,24 @@ func newScreenerView(vc *viewContext) *screenerView { func (v *screenerView) Init() tea.Cmd { v.notice = "" v.confirmingClear = false + v.closePreview() v.tab = screenerPendingTab return v.requestPending() } -// Restyle is a no-op: the screener keeps plain rows and styles them on every View. -func (v *screenerView) Restyle() {} +// Restyle renders an open preview again; the rows are plain and styled on every View. +func (v *screenerView) Restyle() { + if v.preview != nil { + v.preview.width = 0 + } +} func (v *screenerView) Update(msg tea.Msg) (tea.Cmd, bool) { switch msg := msg.(type) { + case screenerPreviewLoadedMsg: + v.previewLoaded(msg) + return nil, true + case screenerPendingLoadedMsg: if msg.requestID != v.requestID { return nil, true @@ -351,6 +364,9 @@ func (v *screenerView) View() string { if v.confirmingClear { return v.clearConfirmationView() } + if v.preview != nil { + return v.previewView() + } var b strings.Builder if v.notice != "" { @@ -382,10 +398,18 @@ func (v *screenerView) HelpBindings() []helpBinding { {"n/esc", "cancel"}, } } + if v.preview != nil { + return []helpBinding{ + {"↑↓", "scroll"}, + {key: v.screenQuestion()}, + {"space/esc", "close preview"}, + } + } bindings := []helpBinding{{"↑↓", "navigate"}} if v.tab == screenerPendingTab { bindings = append(bindings, helpBinding{key: v.screenQuestion()}, + helpBinding{"space", "preview"}, helpBinding{"tab", "screener history"}, ) } else { @@ -425,6 +449,9 @@ func (v *screenerView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if v.confirmingClear { return v.handleClearConfirmationKey(msg) } + if v.preview != nil { + return v.handlePreviewKey(msg) + } key := msg.String() if key == "F" { @@ -462,6 +489,8 @@ func (v *screenerView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { return v.screen(hey.ClearanceApproved) case "n": return v.screen(hey.ClearanceDenied) + case "space", " ": + return v.openPreview() case "X": v.confirmingClear = true v.notice = "" @@ -567,7 +596,13 @@ func (v *screenerView) screen(status string) tea.Cmd { if row == nil { return nil } - clearanceID, name := row.id, screenerRowName(*row) + return v.screenRow(*row, status) +} + +// screenRow answers Yes or No for one sender: the one under the cursor, or the one open +// in the preview. +func (v *screenerView) screenRow(row screenerRow, status string) tea.Cmd { + clearanceID, name := row.id, screenerRowName(row) v.mutations++ v.notice = "" return func() tea.Msg { @@ -717,6 +752,7 @@ func pendingScreenerRow(clearance generated.Clearance) screenerRow { subject, summary := clearanceEntryParts(clearance) return screenerRow{ id: clearance.Id, + entryID: clearance.MostRecentEntry.Id, name: terminal.SanitizeLine(clearance.Petitioner.Name), email: terminal.SanitizeLine(clearance.Petitioner.EmailAddress), subject: subject, diff --git a/internal/tui/screener_preview.go b/internal/tui/screener_preview.go new file mode 100644 index 000000000..37bf829c5 --- /dev/null +++ b/internal/tui/screener_preview.go @@ -0,0 +1,189 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + hey "github.com/basecamp/hey-sdk/go/pkg/hey" + + "github.com/basecamp/hey-cli/internal/htmlutil" + "github.com/basecamp/hey-cli/internal/markdown" +) + +// screenerPreviewLoadedMsg is the email a sender in The Screener wrote, read so the +// reader can see all of it before deciding. requestID is the only preview read allowed +// to fill the preview, so a slow answer for a sender the reader already closed is dropped. +type screenerPreviewLoadedMsg struct { + requestID uint64 + clearanceID int64 + body htmlutil.Markdown + err error +} + +// screenerPreview is space's bigger look at the sender under the cursor: their most +// recent email in full, over the queue, where a row only has room for its first line. +// It is read-only — the answer is still Yes or No, and either one closes it. +type screenerPreview struct { + row screenerRow + loading bool + failure string + body htmlutil.Markdown + width int // the width body was last rendered at, 0 when it needs rendering again + viewport viewport.Model +} + +// openPreview reads the most recent email of the sender under the cursor. HEY serves its +// entry with each clearance, so this is one message read; nothing about it marks the +// email seen or answers the sender. +func (v *screenerView) openPreview() tea.Cmd { + if v.tab != screenerPendingTab { + return nil + } + row := v.pane().selected() + if row == nil { + return nil + } + if row.entryID == 0 { + v.notice = "HEY sent nothing to preview for " + screenerRowName(*row) + return nil + } + v.previewRequestID++ + requestID := v.previewRequestID + v.notice = "" + v.preview = &screenerPreview{ + row: *row, + loading: true, + viewport: viewport.New(viewport.WithWidth(0), viewport.WithHeight(0)), + } + sdk, ctx, entryID, clearanceID := v.vc.sdk, v.vc.ctx, row.entryID, row.id + return func() tea.Msg { + message, err := sdk.Messages().Get(ctx, entryID) + if err == nil && message == nil { + err = fmt.Errorf("message %d returned no data", entryID) + } + if err != nil { + return screenerPreviewLoadedMsg{requestID: requestID, clearanceID: clearanceID, err: err} + } + return screenerPreviewLoadedMsg{requestID: requestID, clearanceID: clearanceID, body: htmlToMarkdown(message.Content)} + } +} + +func (v *screenerView) previewLoaded(msg screenerPreviewLoadedMsg) { + if v.preview == nil || msg.requestID != v.previewRequestID { + return + } + v.preview.loading = false + if msg.err != nil { + v.preview.failure = errorNotice("Could not load the email", msg.err) + return + } + v.preview.body = msg.body + v.preview.width = 0 + v.layoutPreview() + v.preview.viewport.GotoTop() +} + +func (v *screenerView) closePreview() { + v.preview = nil + v.previewRequestID++ +} + +// handlePreviewKey is every key while the preview is open. Yes and No answer for the +// sender being previewed — not whoever a live re-read has since put under the cursor — +// and put the preview away with the decision. +func (v *screenerView) handlePreviewKey(msg tea.KeyPressMsg) tea.Cmd { + key := msg.String() + switch { + case key == "space" || key == " " || key == "q" || msg.Key().Code == tea.KeyEscape: + v.closePreview() + return nil + case key == "F": + v.emphaticNo = !v.emphaticNo + return nil + case key == "y" || key == "i" || key == "n": + row := v.preview.row + v.closePreview() + status := hey.ClearanceApproved + if key == "n" { + status = hey.ClearanceDenied + } + return v.screenRow(row, status) + } + if v.preview.loading || v.preview.failure != "" { + return nil + } + v.layoutPreview() + var cmd tea.Cmd + v.preview.viewport, cmd = v.preview.viewport.Update(msg) + return cmd +} + +// previewHeader is who wrote, what about and when, above the body. +func (v *screenerView) previewHeader() string { + row := v.preview.row + width := max(v.vc.width, 20) + name := lipgloss.NewStyle().Foreground(colorBright).Bold(true).Render(row.name) + if row.name == "" { + name = lipgloss.NewStyle().Foreground(colorBright).Bold(true).Render(row.email) + } else if row.email != "" { + name += lipgloss.NewStyle().Foreground(colorBright).Render(" <" + row.email + ">") + } + subject := row.subject + if subject == row.email { + subject = "" + } + var b strings.Builder + b.WriteString(truncateStr(" "+name, width)) + if row.trailing != "" { + b.WriteString(styleMuted.Render(" " + row.trailing)) + } + b.WriteString("\n") + b.WriteString(truncateStr(" "+lipgloss.NewStyle().Foreground(colorLink).Bold(true).Render(subject), width) + "\n\n") + return b.String() +} + +// layoutPreview fits the viewport under the header and renders the body at the width +// there is, again only when the width changed since the last time. +func (v *screenerView) layoutPreview() { + preview := v.preview + headerLines := strings.Count(v.previewHeader(), "\n") + if v.notice != "" { + headerLines++ + } + preview.viewport.SetWidth(max(v.vc.width, 1)) + preview.viewport.SetHeight(max(v.vc.height-headerLines, 1)) + width := max(v.vc.width-4, 40) + if preview.loading || preview.failure != "" || preview.width == width { + return + } + preview.width = width + content := markdown.Render(preview.body, width) + if preview.body.IsEmpty() { + content = styleMuted.Render("(this email has no text)") + } + offset := preview.viewport.YOffset() + preview.viewport.SetContent(content) + preview.viewport.SetYOffset(offset) +} + +func (v *screenerView) previewView() string { + var b strings.Builder + if v.notice != "" { + b.WriteString(v.vc.styles.title.Render(truncateStr(v.notice, max(v.vc.width, 1))) + "\n") + } + b.WriteString(v.previewHeader()) + switch { + case v.preview.loading: + b.WriteString(styleMuted.Render(" Loading…")) + case v.preview.failure != "": + b.WriteString(styleMuted.Render(" " + v.preview.failure)) + default: + v.layoutPreview() + b.WriteString(v.preview.viewport.View()) + } + return b.String() +} diff --git a/internal/tui/screener_preview_test.go b/internal/tui/screener_preview_test.go new file mode 100644 index 000000000..d6b37bbcb --- /dev/null +++ b/internal/tui/screener_preview_test.go @@ -0,0 +1,140 @@ +package tui + +import ( + "net/http" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +// openedPreview presses space on the first sender and lands the email it reads. +func openedPreview(t *testing.T) (*screenerView, *screenerServerState) { + t.Helper() + view, state := loadedScreener(t) + view.vc.width = 80 + view.vc.height = 20 + loaded, ok := runCmd(view.HandleContentKey(keyPress("space"))).(screenerPreviewLoadedMsg) + if !ok { + t.Fatalf("space returned %T, want screenerPreviewLoadedMsg", loaded) + } + if loaded.err != nil { + t.Fatalf("preview read failed: %v", loaded.err) + } + view.Update(loaded) + return view, state +} + +// Space shows the whole of what the sender wrote, where the row only has room for the +// first line of it. +func TestScreenerSpacePreviewsTheSendersEmail(t *testing.T) { + view, state := openedPreview(t) + + requests := state.snapshot() + if last := requests[len(requests)-1]; last.method != http.MethodGet || last.path != "/messages/501.json" { + t.Errorf("preview read = %+v, want the sender's most recent email", last) + } + screen := plainText(view.View()) + for _, want := range []string{"Jane Doe ", "Quarterly planning", "walk through the budget before the board meeting", "Jane"} { + if !strings.Contains(screen, want) { + t.Errorf("preview is missing %q:\n%s", want, screen) + } + } + if strings.Contains(screen, "Bob Smith") { + t.Errorf("the queue should be covered by the preview:\n%s", screen) + } + if !hasBinding(view.HelpBindings(), "space/esc") { + t.Errorf("help bar should say how to close the preview: %v", view.HelpBindings()) + } +} + +// Space, escape and q all put the preview away and leave the reader in The Screener. +func TestScreenerPreviewClosesBackToTheQueue(t *testing.T) { + for _, key := range []string{"space", "esc", "q"} { + t.Run(key, func(t *testing.T) { + view, _ := openedPreview(t) + + if cmd := view.HandleContentKey(keyPress(key)); cmd != nil { + t.Errorf("%s returned %T, want the Screener to stay open", key, runCmd(cmd)) + } + if view.preview != nil { + t.Fatalf("%s left the preview open", key) + } + if screen := plainText(view.View()); !strings.Contains(screen, "Bob Smith") { + t.Errorf("the queue should be back:\n%s", screen) + } + }) + } +} + +// Yes and No answer from the preview, for the sender being previewed, and close it. +func TestScreenerAnswersFromThePreview(t *testing.T) { + view, state := openedPreview(t) + + done, ok := runCmd(view.HandleContentKey(keyPress("n"))).(screenerDecisionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("n returned %#v", done) + } + if view.preview != nil { + t.Error("answering should close the preview") + } + requests := state.snapshot() + last := requests[len(requests)-1] + if last.method != http.MethodPatch || last.path != "/clearances/91.json" || !strings.Contains(last.body, `"status":"denied"`) { + t.Errorf("screen out request = %+v", last) + } +} + +// An answer for a preview the reader already closed is dropped, rather than opening a +// preview nobody asked for. +func TestScreenerDropsAPreviewThatArrivesAfterClosing(t *testing.T) { + view, _ := loadedScreener(t) + cmd := view.HandleContentKey(keyPress("space")) + view.HandleContentKey(keyPress("esc")) + + view.Update(runCmd(cmd)) + + if view.preview != nil { + t.Error("a late answer reopened the preview") + } +} + +// A sender HEY served no email for has nothing to preview, and the reader is told so. +func TestScreenerSaysWhenThereIsNothingToPreview(t *testing.T) { + view, _ := loadedScreener(t) + view.pending.rows[0].entryID = 0 + + if cmd := view.HandleContentKey(keyPress("space")); cmd != nil { + t.Errorf("space made a request for a sender with no email") + } + if view.preview != nil || view.notice != "HEY sent nothing to preview for Jane Doe" { + t.Errorf("preview=%v notice=%q", view.preview, view.notice) + } +} + +// Screener History has decisions, not emails, so space does nothing there. +func TestScreenerHistoryHasNoPreview(t *testing.T) { + view, _ := loadedScreener(t) + view.tab = screenerHistoryTab + + if cmd := view.HandleContentKey(keyPress("space")); cmd != nil || view.preview != nil { + t.Error("space opened a preview on Screener History") + } +} + +// A long email scrolls inside the preview; the header stays put. +func TestScreenerPreviewScrolls(t *testing.T) { + view, _ := openedPreview(t) + view.vc.height = 6 + view.preview.body = htmlToMarkdown(strings.Repeat("

Another paragraph about the quarterly budget.

", 30)) + view.preview.width = 0 + + view.HandleContentKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyPgDown})) + + if view.preview.viewport.YOffset() == 0 { + t.Error("PgDn did not scroll the preview") + } + if screen := plainText(view.View()); !strings.Contains(screen, "Jane Doe ") { + t.Errorf("the header scrolled away:\n%s", screen) + } +} diff --git a/internal/tui/screener_test.go b/internal/tui/screener_test.go index 8394e5f0d..2cf95dcfb 100644 --- a/internal/tui/screener_test.go +++ b/internal/tui/screener_test.go @@ -79,6 +79,8 @@ func screenerTestView(t *testing.T) (*screenerView, *screenerServerState) { _, _ = w.Write([]byte(`{"id":91,"status":"approved","petitioner":{"id":11,"name":"Jane Doe","email_address":"jane@example.com"}}`)) case r.Method == http.MethodPost && r.URL.Path == "/clearances/punt.json": w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet && r.URL.Path == "/messages/501.json": + _, _ = w.Write([]byte(`{"id":501,"subject":"Quarterly planning","content":"

Can we meet Thursday?

I would like to walk through the budget before the board meeting.

Jane

"}`)) default: http.NotFound(w, r) } From 218f107d4f486a8c34a852f89e880ff878686663 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:49:56 +0200 Subject: [PATCH 2/3] Keep the Screener preview's header to one line each Co-Authored-By: Claude Opus 5.5 --- internal/tui/screener_preview.go | 32 ++++++++++++++++++--------- internal/tui/screener_preview_test.go | 20 +++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/internal/tui/screener_preview.go b/internal/tui/screener_preview.go index 37bf829c5..1488a303f 100644 --- a/internal/tui/screener_preview.go +++ b/internal/tui/screener_preview.go @@ -122,27 +122,37 @@ func (v *screenerView) handlePreviewKey(msg tea.KeyPressMsg) tea.Cmd { return cmd } -// previewHeader is who wrote, what about and when, above the body. +// previewHeader is who wrote, what about and when, above the body. Each line is cut to +// the width before it is styled, the date's room held back first: a line that wrapped +// would make the header taller than layoutPreview counts, and push the preview off the +// bottom of the screen. func (v *screenerView) previewHeader() string { row := v.preview.row width := max(v.vc.width, 20) - name := lipgloss.NewStyle().Foreground(colorBright).Bold(true).Render(row.name) - if row.name == "" { - name = lipgloss.NewStyle().Foreground(colorBright).Bold(true).Render(row.email) + date := "" + if row.trailing != "" { + date = " " + row.trailing + } + room := max(width-2-displayWidth(date), 1) + name, address := row.name, "" + if name == "" { + name = row.email } else if row.email != "" { - name += lipgloss.NewStyle().Foreground(colorBright).Render(" <" + row.email + ">") + address = " <" + row.email + ">" } + name = truncateStr(name, room) + address = truncateStr(address, room-displayWidth(name)) subject := row.subject if subject == row.email { subject = "" } + subject = truncateStr(subject, width-2) + var b strings.Builder - b.WriteString(truncateStr(" "+name, width)) - if row.trailing != "" { - b.WriteString(styleMuted.Render(" " + row.trailing)) - } - b.WriteString("\n") - b.WriteString(truncateStr(" "+lipgloss.NewStyle().Foreground(colorLink).Bold(true).Render(subject), width) + "\n\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(colorBright).Bold(true).Render(name)) + b.WriteString(lipgloss.NewStyle().Foreground(colorBright).Render(address)) + b.WriteString(styleMuted.Render(date) + "\n") + b.WriteString(" " + lipgloss.NewStyle().Foreground(colorLink).Bold(true).Render(subject) + "\n\n") return b.String() } diff --git a/internal/tui/screener_preview_test.go b/internal/tui/screener_preview_test.go index d6b37bbcb..b22677186 100644 --- a/internal/tui/screener_preview_test.go +++ b/internal/tui/screener_preview_test.go @@ -6,6 +6,7 @@ import ( "testing" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" ) // openedPreview presses space on the first sender and lands the email it reads. @@ -122,6 +123,25 @@ func TestScreenerHistoryHasNoPreview(t *testing.T) { } } +// A sender whose name and address fill the line still gets a header of one line each, +// the date included: a line that wrapped would make the preview taller than the screen. +func TestScreenerPreviewHeaderFitsTheWidth(t *testing.T) { + view, _ := openedPreview(t) + view.preview.row.name = strings.Repeat("Maria Fernanda Gonzalez de la Cruz ", 3) + view.preview.row.email = "maria.fernanda.gonzalez.delacruz@example.com" + view.preview.row.trailing = "Sep 29, 2026" + view.preview.row.subject = strings.Repeat("Following up on the quarterly planning agenda ", 3) + + for index, line := range strings.Split(ansi.Strip(view.previewHeader()), "\n") { + if width := displayWidth(line); width > view.vc.width { + t.Errorf("header line %d is %d wide on an %d-wide screen: %q", index, width, view.vc.width, line) + } + } + if header := plainText(view.previewHeader()); !strings.Contains(header, "Sep 29, 2026") { + t.Errorf("the date was cut off: %q", header) + } +} + // A long email scrolls inside the preview; the header stays put. func TestScreenerPreviewScrolls(t *testing.T) { view, _ := openedPreview(t) From 812e02e697ad20b759ec4544fef3faffe06e1583 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 22:15:03 +0200 Subject: [PATCH 3/3] Use the shared pgdown key in the preview test Co-Authored-By: Claude Opus 5.5 --- internal/tui/screener_preview_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/tui/screener_preview_test.go b/internal/tui/screener_preview_test.go index b22677186..ebda85233 100644 --- a/internal/tui/screener_preview_test.go +++ b/internal/tui/screener_preview_test.go @@ -5,7 +5,6 @@ import ( "strings" "testing" - tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) @@ -149,7 +148,7 @@ func TestScreenerPreviewScrolls(t *testing.T) { view.preview.body = htmlToMarkdown(strings.Repeat("

Another paragraph about the quarterly budget.

", 30)) view.preview.width = 0 - view.HandleContentKey(tea.KeyPressMsg(tea.Key{Code: tea.KeyPgDown})) + view.HandleContentKey(keyPress("pgdown")) if view.preview.viewport.YOffset() == 0 { t.Error("PgDn did not scroll the preview")