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
add Tab and Shift-Tab navigation for safe links in open mail threads, with wrapping, viewport reveal, selected styling, and the full destination shown before opening
open selected HTTP, HTTPS, and mailto destinations through a validated platform launcher without a shell
preserve terminal-native OSC 8 links, existing global Tab behavior outside linked thread content, message and attachment keys, and Escape/q behavior
document the thread link controls
This is the link-opening half of the issue. In-message search remains separate.
go test ./internal/markdown -run '^TestRenderLinked' -count=1
go test ./internal/tui -run '^(TestRootModelNavigatesThreadLinksAndOpensExactDestination|TestRootModelKeepsGlobalTabForALinklessThread|TestLinkedThreadUsesGlobalTabOutsideContent|TestEnterWithoutASelectedLinkDoesNotOpen|TestThreadLinkNavigationYieldsToAModal|TestThreadLinkSelectionSurvivesRebuildsAndClearsWhenOccurrenceDisappears|TestThreadMessageAndAttachmentKeysRemainAvailableWithLinks|TestQLeavesAThreadWithASelectedLink|TestOpenURLCommand|TestOpenExternalURLReturnsLauncherStartupFailure)$' -count=1
go test ./internal/markdown ./internal/tui
make test
make lint
make build
go test ./internal/markdown -run=^$ -fuzz=FuzzContainment -fuzztime=10s
git diff --check main...HEAD
All passed. The root-model flow opens a realistic three-message thread from a fake HEY server and verifies navigation, wrapping, destination display, off-screen reveal, exact opener delivery, errors, and Escape behavior.
The TUI suite also passed with the default environment, a dark environment, and NO_COLOR=1.
Real-terminal validation
HEY authentication is available on the validation machine, but interactive terminal validation was blocked:
Kitty is not installed and no Kitty session is running.
Ghostty is installed at /usr/bin/ghostty, but no Ghostty session is running and the agent session has no TTY.
No browser was opened and no account state was changed. Therefore, mouse opening, live theme switching, and real default-handler opening could not be exercised in Kitty or Ghostty in this environment.
Summary by cubic
Adds keyboard navigation for links in open thread content, so links can be selected and opened without a mouse.
Tab and Shift+Tab select the next/previous link (wrapping), with reverse-video styling and viewport scrolling to keep the whole link visible.
Selection is tracked per message and occurrence, so it survives resize, restyle, and attachment rebuilds.
A stable reserved footer row shows the complete selected destination without changing the thread's viewport height; Enter opens it only when the terminal fits the whole footer.
Enter starts the platform launcher (open, xdg-open, or rundll32) without a shell or blocking the TUI; only HTTP, HTTPS, and mailto destinations are accepted.
Launcher startup failures surface as a sanitized "Could not open link" notice in the thread.
Escape clears the selection first, then exits the thread as before; q still exits directly, and threads with no selectable links keep the existing global Tab focus behavior.
Adds RenderLinked in internal/markdown to render links while recording their line ranges; named anchors that Glamour splits into two OSC 8 spans stay one selectable link, and existing OSC 8 mouse links are preserved.
Adds keyboard link navigation and safe URL launching to TUI mail threads.
Changes:
Adds wrapped Tab/Shift+Tab link selection, highlighting, and viewport reveal.
Opens validated HTTP(S) and mailto URLs through platform launchers.
Adds documentation and comprehensive tests.
[!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 link navigation keys and updates help.
internal/tui/tui_test.go
Tests end-to-end thread link behavior.
internal/tui/section_view.go
Adds link navigation and URL opener interfaces.
internal/tui/open_url.go
Validates and launches URLs.
internal/tui/open_url_test.go
Tests launchers and URL validation.
internal/tui/mail.go
Implements link selection, rendering, and opening.
The reason will be displayed to describe this comment to others. Learn more.
Stale comment
Not approved: Cursor Security Agent was present but still pending after the wait window, so the required security-review signal did not complete. Human review is needed; no reviewers were assigned (assignment is not available in this run). Bugbot was not present after the first poll and was skipped.
Sent by Cursor Approval Agent: Pull Request Approver
The reason will be displayed to describe this comment to others. Learn more.
Copilot review overview
🟡 Changes recommended
Link notices can hide permanent partial-thread warnings, and each navigation key synchronously rerenders the entire bounded-but-potentially-large thread.
Get a fresh assessment by requesting another Copilot review.
Cache rendered entry links to prevent Tab navigation stalls
internal/tui/mail.go:2211
Every Tab/Shift-Tab synchronously rerenders the entire thread through Glamour, and the selected entry is rendered a second time in renderEntriesWithLinks. TUI threads allow up to 2,000 entries and 64 MiB, so link traversal can block the event loop on each keystroke. Cache the per-entry rendered text/link metadata and invalidate it on body, width, or style changes, then update only the previously/currently selected occurrences.
Fixed - link selection now uses a fixed footer without displacing thread notices, and navigation updates only the affected cached entry body instead of rerendering the full thread.
The reason will be displayed to describe this comment to others. Learn more.
Approved: Cursor Security Agent completed successfully with no findings comment (treated as clean), and Bugbot was not present after the first poll so that signal was skipped. No reviewers were assigned (assignment is not available in this run). No approval-policy files applied.
Sent by Cursor Approval Agent: Pull Request Approver
Avoid splitting anchors when label ends with target URL
internal/markdown/linked.go:68
The suffix heuristic splits one anchor into two selectable links when its label ends with its target, for example [Read https://example.com](https://example.com). Glamour renders the label and visible href as separate OSC 8 pairs; the label prematurely sets currentComplete, so the href starts another LinkOccurrence. Tab then stops twice on one anchor and styles only half each time. Determine completion from the renderer's href phase rather than label text, and add this case as a regression test.
Propagate launcher exit errors instead of reporting startup success
internal/tui/open_url.go:78
This reports success as soon as the launcher process starts and discards its exit status. Launchers such as xdg-open can start normally and then exit non-zero when no default handler exists, so the TUI never receives the failure and silently leaves the link unopened. Since this function already runs inside a Bubble Tea command, wait for the launcher and return its exit error instead.
Fixed - link labels that contain their destination now remain one selectable occurrence, and URL launchers now report non-zero exit failures to the TUI.
Reserve the link footer row only when selectable links exist
internal/tui/mail.go:1696
LinkFooter reserves this extra row for every open thread, even when v.links is empty. Linkless threads therefore lose one viewport row and render a blank footer despite having no link-selection state; this is an unrelated regression for ordinary thread reading. Only reserve the row when the thread actually has selectable links.
Avoid duplicate links for whitespace-normalized matching labels
internal/markdown/linked.go:69
This can split one anchor into two selectable occurrences when its label differs from the destination only by whitespace. For example, a label such as https://example. com is emitted as a named link, so Glamour produces a label span and a shown-destination span; withoutWhitespace marks the label span complete, causing the destination span to append a second link instead of merging. Detect Glamour’s generated destination span from its surrounding link rendering (or track anchors before rendering) rather than treating any whitespace-normalized matching label as completion.
Fixed - rendered destination spans are now identified separately from link labels, including whitespace-normalized labels and wrapped duplicate destinations.
This branch has not been deployed
No deployments
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.
Summary
This is the link-opening half of the issue. In-message search remains separate.
Refs #445
Automated validation
go test ./internal/markdown -run '^TestRenderLinked' -count=1go test ./internal/tui -run '^(TestRootModelNavigatesThreadLinksAndOpensExactDestination|TestRootModelKeepsGlobalTabForALinklessThread|TestLinkedThreadUsesGlobalTabOutsideContent|TestEnterWithoutASelectedLinkDoesNotOpen|TestThreadLinkNavigationYieldsToAModal|TestThreadLinkSelectionSurvivesRebuildsAndClearsWhenOccurrenceDisappears|TestThreadMessageAndAttachmentKeysRemainAvailableWithLinks|TestQLeavesAThreadWithASelectedLink|TestOpenURLCommand|TestOpenExternalURLReturnsLauncherStartupFailure)$' -count=1go test ./internal/markdown ./internal/tuimake testmake lintmake buildgo test ./internal/markdown -run=^$ -fuzz=FuzzContainment -fuzztime=10sgit diff --check main...HEADAll passed. The root-model flow opens a realistic three-message thread from a fake HEY server and verifies navigation, wrapping, destination display, off-screen reveal, exact opener delivery, errors, and Escape behavior.
The TUI suite also passed with the default environment, a dark environment, and
NO_COLOR=1.Real-terminal validation
HEY authentication is available on the validation machine, but interactive terminal validation was blocked:
/usr/bin/ghostty, but no Ghostty session is running and the agent session has no TTY.No browser was opened and no account state was changed. Therefore, mouse opening, live theme switching, and real default-handler opening could not be exercised in Kitty or Ghostty in this environment.
Summary by cubic
Adds keyboard navigation for links in open thread content, so links can be selected and opened without a mouse.
open,xdg-open, orrundll32) without a shell or blocking the TUI; only HTTP, HTTPS, and mailto destinations are accepted.qstill exits directly, and threads with no selectable links keep the existing global Tab focus behavior.RenderLinkedininternal/markdownto render links while recording their line ranges; named anchors that Glamour splits into two OSC 8 spans stay one selectable link, and existing OSC 8 mouse links are preserved.Refs #445.
Written for commit 8f23fa3. Summary will update on new commits.