Skip to content

Suggest recipients as you type, and ask before losing a message - #536

Merged
robzolkos merged 12 commits into
mainfrom
compose-recipient-autocomplete
Oct 3, 2026
Merged

robzolkos merged 12 commits into
mainfrom
compose-recipient-autocomplete

Conversation

@robzolkos

@robzolkos robzolkos commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

The TUI's To, Cc and Bcc fields only took addresses typed out by hand. Now typing in any of them, in a new message, a reply or a forward, opens a short list of people under the field. Esc on a message you've started asks before throwing it away.

Before

before

Nothing is suggested, and Esc throws away the message without asking.

After

after

A new message picks Andrea, one of two addresses for David, and the Executives list in Cc, then saves as a draft on Esc. A reply suggests Aaron in Cc, then is discarded.

How it behaves

  • Where the suggestions come from. The same list HEY's web composer uses:
    • people you've emailed recently come first, then your contacts by name;
    • "Everyone at" your company and your contact groups follow.
  • Matching.
    • It matches names and addresses. A word starting with what you typed ranks ahead of a match in the middle of a word, so "an" finds Annie Bryan before Joanna.
    • Anyone already on that line is left out.
    • A group is found by its name only, and adds all of its addresses.
  • Keys.
    • ↑/↓ (or ctrl+p/ctrl+n) move through the list.
    • Tab or Enter replaces what you were typing with Name <address>, , ready for the next address. A name that needs quoting ("Bryan, Annie") is quoted.
    • Esc closes the list and nothing else. Moving the cursor closes it too.
    • With the list closed, Tab and Enter move between fields as they always have.
    • Backspace right after a picked recipient (Name <address>) removes it and its comma in one press, the way HEY's composer removes a recipient token. A bare address you typed still deletes a letter at a time, so fixing a typo works as before.
  • Shift+Tab with a lock key on. Every form now moves back a field on Shift+Tab when Num Lock or Caps Lock is on. Before, it moved forward.
    • Terminals using the kitty keyboard protocol report lock keys as modifiers, so the key arrived as Shift plus Num Lock.
    • Each form checked for exactly Shift, missed it, and treated the key as plain Tab.
    • The forms now match the key name, which leaves lock keys out.
  • Leaving an edited message. Esc now asks: s (or Enter) saves it to HEY's drafts, d discards it, and Esc goes back to editing. An untouched form still closes straight away; a reply holding only its prefilled recipients counts as untouched.
    • HEY's web composer never asks, because it saves a draft every few seconds. Offering the draft here is the closest the TUI gets to that.
    • A draft with an address HEY would drop is refused, the same way a send is.

Research

We looked at how other terminal mail clients do this: Matcha, aerc, NeoMutt, nmail, alot and Epist. There are two styles:

  • a popover that appears as you type (Matcha, aerc, Epist);
  • a key that opens a lookup menu (NeoMutt's Tab or ctrl+t, nmail's ctrl+t).

All of them complete only the address under the cursor and match on names. This follows Matcha, the one other Bubble Tea client: while the list is open it takes Tab, Enter and Esc, and otherwise leaves them alone. Unlike Matcha, the address under the cursor is found with the same quote-aware splitting the send uses (mail.AddressAt), so a quoted name with a comma in it doesn't split in two.

Performance

  • Reading the list. The list is read in the background each time a composer opens. The SDK's ETag cache turns a repeat read into a 304.
  • Typing never waits on the network. Filtering happens in memory, against lower-cased copies made once when the list arrives.
  • Measured costs:
    • A keystroke over 20,000 contacts takes about 0.36 ms with no allocations, when the query matches almost nobody. That's the worst case, because the scan stops early once it has enough matches.
    • With the list open, drawing the form goes from about 0.30 ms to 0.66 ms.
  • Arriving late. The list is routed to Mail from any section, so a read that finishes after you've left Mail isn't lost.

Review fixes

Copilot and cubic reviews turned up these, each fixed with a test:

  • A selected row measured its › marker in bytes, so its name was cut short.
  • A contact group already on the line was still suggested.
  • The help bar didn't follow a list that opened because the recipients arrived mid-word.
  • A suggestion whose address sanitizing would change (an invisible character, say) is now left out instead of offered under a different address.
  • A paste into a recipient field narrows or closes the list the way typing does.
  • Backspace takes a recipient whole only at its real end, not after a > inside a quoted name.
  • A paste is held off while the close question is up or a draft save is on its way.

SDK dependency

This uses Contacts().Addressable, added in basecamp/hey-sdk#231 and released in SDK v0.32.0. The branch is on main's pin of v0.32.0, which #507 brought in, so it changes neither go.mod, the MCP model snapshot nor the Nix vendorHash.


Summary by cubic

The compose form now suggests recipients as you type in To, Cc, and Bcc, and asks before discarding a message that holds something you wrote. Suggestions come from HEY's own composer list, read in the background when a form opens.

Behavior

  • Tab or Enter replaces the partially typed address with Name <address>, ; groups add all their addresses; Esc closes the list without touching the form.
  • Backspace takes a picked recipient written Name <address> and its comma in one press; a bare address still deletes a letter at a time.
  • Esc on an edited form now prompts s to save a draft, d to discard, or Esc to keep editing; untouched forms close directly. The prompt wraps to fit a narrow terminal.
  • Matching favors name/address word starts and excludes recipients already on that line — even written with a comment or a quoted local part, "jane doe" and "jane,doe" alike — and groups whose members are all present.
  • An address typed out in full opens no list, even when other rows merely mention it, so Tab never swaps it for a different mailbox.
  • Suggestions whose address sanitizing would change are left out; names are still sanitized for display.
  • Pasting narrows or closes the list the way typing does, and a pick afterwards replaces the whole recipient. Pastes are held while the close question is up or a draft save is on its way.
  • Shift+Tab goes back a field even with Num Lock or Caps Lock on, which the kitty keyboard protocol reports as modifiers.
  • When the list is read again behind an open composer, the person picked with the arrows stays highlighted wherever the fresh list puts them; if the fresh list pushes them out of the rows shown, the list closes instead.

Migration

  • Depends on Contacts().Addressable, new in @basecamp/hey-sdk/go, pinned to the merged SDK commit; bump to the release once available.
  • The MCP model snapshot and Nix vendorHash were synced to that pin.

Written for commit 63dc422. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings September 30, 2026 20:03
@robzolkos
robzolkos requested a review from a team as a code owner September 30, 2026 20:03

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Recipient UI issues and required API/Nix metadata updates remain unresolved.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity · 1 Low severity

Open (4)
What changed in this PR

Adds TUI recipient autocomplete and protects edited messages from accidental discard.

Changes:

  • Adds background recipient loading, matching, selection, and safe formatting.
  • Adds save/discard confirmation and draft persistence.
  • Adds tests, documentation, and the required SDK dependency.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
internal/​tui/​tui.go Routes completed recipient reads to Mail.
internal/​tui/​recipients.go Implements recipient autocomplete.
internal/​tui/​recipients_test.go Tests matching, selection, loading, and sanitization.
internal/​tui/​mail.go Stores recipients and handles draft results.
internal/​tui/​compose.go Integrates suggestions and leave confirmation.
internal/​tui/​compose_test.go Updates form-routing coverage.
internal/​tui/​compose_leave_test.go Tests draft, discard, and failure flows.
internal/​mail/​address.go Adds cursor-aware parsing and address formatting.
internal/​mail/​address_at_test.go Tests address range and formatting helpers.
go.mod Pins the SDK commit providing autocomplete.
go.sum Updates SDK checksums.
docs/​tui.md Documents autocomplete and draft prompts.
AGENTS.md Records recipient-suggestion architecture.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread go.mod Outdated
Comment thread internal/tui/recipients.go Outdated
Comment thread internal/tui/tui.go Outdated
Comment thread internal/tui/recipients.go
@robzolkos
robzolkos force-pushed the compose-recipient-autocomplete branch from 9e6b865 to 768791c Compare October 1, 2026 21:26

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 17 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread internal/tui/mail.go
Comment thread internal/tui/compose.go
Comment thread internal/tui/recipients.go Outdated
Comment thread internal/tui/recipients_test.go Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: Cursor Security Agent completed with no findings that need human review, and Cursor Bugbot was not running on this PR. No approval policy required human review, and this automation had no prior approval to keep or dismiss.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Approver

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 16 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread go.mod Outdated
Comment thread internal/tui/contact_form.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Recipient sanitization can alter destination mailboxes, while paste and backspace handling can corrupt recipient fields.

Review effort: Balanced
Findings: 1 High severity · 2 Medium severity

Open (3)
Resolved since last review (4)

Comment thread internal/tui/recipients.go Outdated
Comment thread internal/tui/compose.go
Comment thread internal/tui/recipients.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Pasting during a pending draft save can silently lose text when the form closes.

Review effort: Balanced
Findings: None

Resolved since last review (3)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Block paste input while confirming leave or saving draft

internal/​tui/​compose.go:426

Unlike handleKey, this path accepts paste input while confirmLeave or sending is set. Pasting can edit a field behind the close dialog. During a draft save, it changes the form after saveDraft has captured its values; the successful response then closes the form and loses the pasted text. Consume paste messages in both states, and add regression tests for confirmation and an in-flight draft save.

Typing in To, Cc or Bcc — in a new message, a reply or a forward — now
opens a short list of people under the field, from the same list HEY's web
composer suggests: recently addressed people first, then contacts, then
"Everyone at" an account and contact groups. It matches names and
addresses, leaves out anyone already on the line, and Tab or Enter writes
the pick as Name <address> in place of what was being typed. With the list
closed, Tab and Enter move between fields as before.

The list is read in the background when a composer opens (a 304 through
the SDK's cache after the first time) and filtered in memory on each
keystroke, so typing never waits on the network.

Esc on a message that holds more than it opened with now asks first: save
it as a HEY draft, discard it, or keep editing. An untouched form still
closes straight away.

Needs the SDK's new Contacts().Addressable, pinned here to its commit
until the SDK is released.
…th a lock key on

A recipient written Name <address>, the form the picker writes, now
leaves in one Backspace with its comma, the way HEY's composer removes a
recipient token. A bare address still deletes a letter at a time, since
that is how a typo in one is fixed.

Shift+Tab never went back a field with Num Lock or Caps Lock on. A
terminal speaking the kitty keyboard protocol reports the lock keys as
modifiers, every form compared the modifiers to Shift exactly, and the
key fell through to plain Tab. Match the keystroke, which leaves the lock
keys out, and ask whether Shift is held rather than whether it is all
that is.
- A selected row measured its "› " marker in bytes, four, rather than
  the two cells it takes, so selecting a row cut its name short.
- A contact group already on the line was still suggested; it is left
  out once every one of its addresses is there.
- The help bar now follows a list that opens because the recipients
  arrived while a name was being typed.
- API-COVERAGE.md lists /autocompletable/contacts/addressable.
- The tests read and set the test server's recorder under its lock.
- Shift+Tab with a lock key on is tested in the contact and habit forms
  as well as the composer.
- A suggestion whose address sanitizing would change is left out rather
  than offered under an address it doesn't have: an invisible character
  removed from jo<U+200B>anna@example.com leaves a different mailbox.
  Names are still sanitized for display.
- A paste into a recipient field narrows or closes the list the way
  typing does, so a pick afterwards replaces the whole recipient.
- Backspace takes a recipient whole only at its real end, not after a >
  inside a quoted name.
A paste reached the form through handleMsg while keys were held, so it
could edit the message behind the close question, or change it after a
draft save had taken its values and be lost when the saved form closed.
@robzolkos
robzolkos force-pushed the compose-recipient-autocomplete branch from 233682e to 041b850 Compare October 3, 2026 00:59
@github-actions github-actions Bot removed the deps label Oct 3, 2026
@robzolkos
robzolkos requested a balanced review from Copilot October 3, 2026 01:00
@robzolkos
robzolkos requested a balanced review from Copilot and removed request for Copilot October 3, 2026 01:05

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A background reload can replace the selected recipient, and address comments bypass duplicate filtering.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Parse commented addresses correctly for duplicate filtering

internal/​tui/​recipients.go:145

Valid address comments defeat the already-chosen filter. Jane Doe <jane@example.com> (team) is stored as jane@example.com> (team), while jane@example.com (Jane Doe) retains its comment. Typing ja after either therefore offers Jane again. Extract the bare mailbox with comment-aware parsing, preferably sharing the existing addrSpec logic in internal/mail/address.go, and test both forms.

Comment thread internal/tui/recipients.go
The list shows six people. When the fresh list put the highlighted person
seventh, the highlight fell back to the first row, so Tab would add
someone nobody picked. The list now closes instead, and the next key
opens it again from the top.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Duplicate exclusion still misses valid commented addresses, allowing already-entered recipients to be suggested again.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Recognize commented addresses when excluding already chosen recipients

internal/​tui/​recipients.go:147

Commented addresses are not recognized as already chosen. With jane@example.com (Jane Doe), ja in To, this stores jane@example.com (jane doe) instead of jane@example.com, so Jane is offered again. Jane Doe <jane@example.com> (work) also fails, and groups containing these already-entered recipients can remain suggested. The shared address parser supports comments (internal/mail/address.go:144-175; internal/mail/address_test.go:84). Expose and reuse its bare-address extraction here, and add regression tests for both commented forms and group exclusion.

jane@example.com (Jane Doe) and Jane Doe <jane@example.com> (work) were
read as different addresses from jane@example.com, so Jane was offered
again, and a group they completed stayed suggested. mail.BareAddress
exposes the parser that already understands comments, and both the
line's recipients and a group's members are compared through it.
@robzolkos
robzolkos requested a balanced review from Copilot October 3, 2026 01:31
@robzolkos
robzolkos requested a balanced review from Copilot and removed request for Copilot October 3, 2026 01:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Recipient completion can replace a fully typed address with a different mailbox when multiple suggestions match.

Review effort: Balanced
Findings: 1 High severity

Open (1)

Comment thread internal/tui/recipients.go Outdated
A full address someone in the list has needs nothing suggested, but the
guard only held when it was the one match. With another row mentioning
it — jane.work@example.com labelled "jane@example.com (work)" — the list
stayed open on that row and Tab swapped the typed address for it. Any
exact match in the whole list now keeps the list shut.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The close-confirmation dialog clips its question and choices in narrow terminals.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Wrap modal content to prevent clipping in narrow terminals

internal/​tui/​compose.go:523

At 40 columns, this dialog extends beyond the screen: modalFrame limits only the title, while the question and action labels remain unwrapped. The fixed-width canvas in overlayAt then clips the right border and part of “esc keep editing.” Wrap the body to modalContentWidth(f.width) before framing it so the close choices remain readable in narrow terminals.

The frame fits only its title to the screen, so at 40 columns the
question and its choices ran past the edge and the overlay cut off the
border and part of "esc keep editing". The body now wraps to the room
inside the frame.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Inconsistent quoted-address normalization breaks recipient exclusion and completed-address detection.

Review effort: Balanced
Findings: None

Previously missed (1)

In code that hasn't changed since last review

Medium severity Normalize quoted email addresses before comparison

internal/​tui/​recipients.go:96

For a contact whose address is "jane doe"@example.com, this comparison key retains the quotes, while chosenAddresses and knownAddress use BareAddress, which returns jane doe@example.com through net/mail. The contact can therefore be suggested again after being added, and typing its complete quoted address does not suppress matching suggestions. Normalize this key with BareAddress too, leaving the destination value unchanged. Add regression coverage for duplicate exclusion and completed-address detection with a quoted local-part.

A person's comparison key kept a quoted local part as written, while the
recipients on the line went through the parser, so "jane doe"@example.com
never matched itself: it was offered again once added, and typing it out
didn't close the list. The key now goes through BareAddress too; the
address written into the field is still exactly HEY's.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

Quoted-comma mailboxes are misclassified as groups, which can cause completion to substitute a different recipient.

Review effort: Balanced
Findings: None

Previously missed (1)

In code that hasn't changed since last review

Medium severity Quoted-comma mailbox misclassified as group and loses address matching

internal/​tui/​recipients.go:54

A valid single mailbox such as "jane,doe"@example.com is classified as a group here. Its searchable address stays empty, disabling address matching and the knownAddress guard, and picking it omits its display name. If another contact's label mentions this mailbox, typing it in full and pressing Tab can replace it with that other contact. Use mail.SplitAddresses to distinguish multiple recipients from a quoted comma. Add regression coverage for address matching and for a completed quoted-comma address alongside another matching label.

A comma in the value made a row a group, so "jane,doe"@example.com lost
its address matching and the guard against swapping a typed-out address.
Whether a row is a group is now decided once, from HEY's detail or from
the value holding more than one address. Typing is matched against the
address as written, quotes and all, and the parsed address is what
recognises it on the line.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approved

Static review found no unresolved blocking issues, and targeted regression coverage addresses the new behavior and prior fixes.

Review effort: Balanced
Findings: None

@robzolkos
robzolkos merged commit cef450c into main Oct 3, 2026
26 checks passed
@robzolkos
robzolkos deleted the compose-recipient-autocomplete branch October 3, 2026 02:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants