You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Suggest recipients as you type, and ask before losing a message - #536
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
Nothing is suggested, and Esc throws away the message without asking.
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.
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.
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.
Sent by Cursor Approval Agent: Pull Request Approver
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.
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.
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.
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.
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.
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.
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
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.
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Nothing is suggested, and Esc throws away the message without asking.
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
Name <address>,, ready for the next address. A name that needs quoting ("Bryan, Annie") is quoted.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.s(or Enter) saves it to HEY's drafts,ddiscards it, and Esc goes back to editing. An untouched form still closes straight away; a reply holding only its prefilled recipients counts as untouched.Research
We looked at how other terminal mail clients do this: Matcha, aerc, NeoMutt, nmail, alot and Epist. There are two styles:
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
Review fixes
Copilot and cubic reviews turned up these, each fixed with a test:
›marker in bytes, so its name was cut short.>inside a quoted name.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 neithergo.mod, the MCP model snapshot nor the NixvendorHash.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
Name <address>,; groups add all their addresses; Esc closes the list without touching the form.Name <address>and its comma in one press; a bare address still deletes a letter at a time.sto save a draft,dto discard, orEscto keep editing; untouched forms close directly. The prompt wraps to fit a narrow terminal."jane doe"and"jane,doe"alike — and groups whose members are all present.Migration
Contacts().Addressable, new in@basecamp/hey-sdk/go, pinned to the merged SDK commit; bump to the release once available.vendorHashwere synced to that pin.Written for commit 63dc422. Summary will update on new commits.