Skip to content

Open an explicit OSC 8 link on a plain click under mouse reporting - #6

Merged
bharathm03 merged 1 commit into
masterfrom
feat/plain-click-opens-osc8-links
Aug 27, 2026
Merged

Open an explicit OSC 8 link on a plain click under mouse reporting#6
bharathm03 merged 1 commit into
masterfrom
feat/plain-click-opens-osc8-links

Conversation

@bharathm03

Copy link
Copy Markdown
Collaborator

#5 let a touch tap open an explicit OSC 8 link even when the program has the mouse. The same case on desktop still needed Shift, and nothing on screen said so — so in the one situation the OSC 8 work was written for (a link printed by a full-screen agent) a plain click did nothing while the cell painted as a link.

Measured on Claude Code's PTY: it enables mouse modes 1000, 1002, 1003, 1006 in its first frame and emits OSC 8 hyperlinks. So _handleTapUp returned at the mouse-capture guard before hyperlink resolution on every click.

What changes

A primary press that lands on a cell carrying an explicit OSC 8 URI arms _tapHyperlinkUri and withholds the whole press-motion-release span, so the program is never handed half a click; _handleTapUp then opens it. This is the trade #5 already made for touch — OSC 8 is the program declaring these exact cells are a link, a more specific statement of intent than its claim on the mouse — so the collision is confined to programs contradicting their own markup.

Unchanged, and tested as such:

  • bare-URL regex matches stay with the program (a guess about visible text is not grounds for taking a click)
  • cells outside the link stay with the program
  • right/middle click stays with the program, link cell or not
  • Shift keeps its meaning, and is now what reaches a bare-URL match

Hover follows the click instead of the bypass, so the pointer stops advertising a link the program is about to swallow.

A drag off a link cell now sends the program nothing rather than a release with no press — same trade the Shift bypass already makes, and drag-select is unavailable under mouse reporting either way.

Tests

Six added in terminal_view_test.dart, each negative-checked against a reverted terminal_view.dart: plain click opens and records neither PRESS nor RELEASE; plain click beside the link records PRESS; right click on the link records PRESS; plain click on a bare URL records PRESS; hover marks an OSC 8 cell; hover does not mark a bare URL.

ghostty_vte_flutter: +208 -28, the 28 pre-existing on master (baseline re-measured this run). flutter analyze clean, dart format --set-exit-if-changed . clean across 104 files.

… mouse reporting

A full-screen agent takes the mouse the moment it starts — Claude Code enables 1000/1002/1003/1006 in its first frame — so `_handleTapUp` handed every click to the program and returned before resolving a hyperlink. The cell still painted as a link, because the render path marks `hasHyperlink` unconditionally. Shift+click reached it, but nothing on screen said so.

Take the same trade the touch tap already takes: an OSC 8 link is the program declaring these exact cells ARE a link, which is a more specific statement of intent than its claim on the mouse, so the collision is confined to programs contradicting their own markup. A primary press on such a cell arms the URI and withholds the whole press-motion-release span; everything else — bare-URL matches, ordinary cells, other buttons — still reaches the program, and Shift keeps its meaning.

Hover follows the click rather than the bypass, so the pointer stops advertising a link the program is about to swallow.
@bharathm03
bharathm03 merged commit f8e2e82 into master Aug 27, 2026
17 checks passed
@bharathm03
bharathm03 deleted the feat/plain-click-opens-osc8-links branch August 27, 2026 10:50
bharathm03 added a commit that referenced this pull request Aug 27, 2026
PR #6 put `_explicitHyperlinkAt` in the `onHover` guard so a link under a full-screen agent would paint as reachable. That call resolves a cell through `_positionForOffset`, which asked `controller.snapshot` whether the buffer was empty — and reading the snapshot settles the styled formatter: three full-buffer passes plus a re-parse of the styled output.

Under `GhosttyTerminalRendererMode.renderState` the painter never reads that snapshot (`snapshotOf` is a lazy getter behind `_paintsTranscript`), so nothing else was paying for it and the hover paid the whole cost alone. Any output since the last read marks it stale, which in an agent session is every frame — so moving the mouse over a live terminal cost a full transcript rebuild per hover event. Measured at ~17ms per hover on a 1800-line scrollback, on the surface where the pointer spends all its time. Selection drags resolve cells the same way and paid it too.

Ask the engine instead. `_scrollableLineCount` already prefers the native scrollback total and falls back to the formatter only where there is no engine geometry to ask — web, and before the terminal exists, which is where the formatter is what renders anyway. It was already being called a few lines below for `maxRow`, so this also drops a duplicate read.

The guard is timed against the same loop with the pointer held still: both take the same output and repaint the same frames, so what separates them is the hover alone, and a ratio survives a machine an absolute bound would not. 862ms vs 179ms with the old guard; even under the bound with it gone.
bharathm03 added a commit that referenced this pull request Aug 27, 2026
* feat(vte_flutter): report the hovered OSC 8 URI so a host can show where a link goes

The view paints an underline on every OSC 8 cell and swaps the cursor on hover, but the URI itself never leaves it -- so a host has no way to disclose a destination before opening it, and OSC 8 lets a link's text disagree with its target.

Fires only on a real change, so a host may rebuild on it: moving within one link is silent. A controller swap reports for itself, deferred to after the frame because didUpdateWidget runs mid-build and a host rebuilding there would be setting state during build. dispose deliberately does not fire, for the same reason.

* perf(vte_flutter): stop hovering from rebuilding the whole transcript

PR #6 put `_explicitHyperlinkAt` in the `onHover` guard so a link under a full-screen agent would paint as reachable. That call resolves a cell through `_positionForOffset`, which asked `controller.snapshot` whether the buffer was empty — and reading the snapshot settles the styled formatter: three full-buffer passes plus a re-parse of the styled output.

Under `GhosttyTerminalRendererMode.renderState` the painter never reads that snapshot (`snapshotOf` is a lazy getter behind `_paintsTranscript`), so nothing else was paying for it and the hover paid the whole cost alone. Any output since the last read marks it stale, which in an agent session is every frame — so moving the mouse over a live terminal cost a full transcript rebuild per hover event. Measured at ~17ms per hover on a 1800-line scrollback, on the surface where the pointer spends all its time. Selection drags resolve cells the same way and paid it too.

Ask the engine instead. `_scrollableLineCount` already prefers the native scrollback total and falls back to the formatter only where there is no engine geometry to ask — web, and before the terminal exists, which is where the formatter is what renders anyway. It was already being called a few lines below for `maxRow`, so this also drops a duplicate read.

The guard is timed against the same loop with the pointer held still: both take the same output and repaint the same frames, so what separates them is the hover alone, and a ratio survives a machine an absolute bound would not. 862ms vs 179ms with the old guard; even under the bound with it gone.
bharathm03 added a commit to antgrid-ai/antgrid that referenced this pull request Aug 27, 2026
…hows where it goes (#25)

A full-screen agent takes the mouse before it prints anything -- Claude Code enables 1000/1002/1003/1006 in its first frame, measured on its PTY -- so every click went to the agent and the link stayed dead, in exactly the case the OSC 8 work was written for. The cell still painted as a link. Bumps the fork pin to antgrid-ai/dart_terminal#6, which opens an explicit OSC 8 link on a plain primary click and makes hover mark only what a click will actually open.

The desktop path skipped the confirm sheet because the terminal was said to reveal a link's destination on hover. It does not, and never did: the hovered URI only swaps the mouse cursor to a pointer, and nothing in the view or this app ever paints it. What actually discloses on desktop is the browser's address bar -- which is why the sheet still belongs on mobile alone, where a verified App Link can open its own app instead of any browser. Renamed to say the true reason.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant