Skip to content

feat(mobile): links open from a tap, text can be copied, long prompts stay visible, wrapped links open whole - #321

Merged
Ark0N merged 5 commits into
Ark0N:masterfrom
rounakdatta:fix/mobile-link-taps
Aug 19, 2026
Merged

feat(mobile): links open from a tap, text can be copied, long prompts stay visible, wrapped links open whole#321
Ark0N merged 5 commits into
Ark0N:masterfrom
rounakdatta:fix/mobile-link-taps

Conversation

@rounakdatta

@rounakdattarounakdatta commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Three things a phone could not do. Separate causes, separate fixes, kept as three clean commits so any one of them can be taken or dropped on its own:

commit
1f2d3a7e3links open from a tap, in the terminal and the chat
2756728e5terminal text can be selected and copied
3ba843bb2a long prompt stays visible instead of hiding behind the keyboard
42e58da74docs for all three, and zh-CN for the new bar
5aae90599a wrapped line stitches through the indent its continuation carries

Rebased on master as of d4ccff07.

Both were verified by hand on Android + Chrome against a live 1.19.3 instance, not just in tests — and in both cases the device is what found the real bug.


1. fix(mobile): no link opens from a tap

Not the URLs an agent prints in the terminal, not the links in the response viewer. One symptom, two independent causes.

Terminal links are structurally unreachable on touch

xterm's Linkifier resolves the link under the pointer on mousemove (the only thing that populates _currentLink) and activates it on mouseup, both bound to the screen element. A tap delivers neither:

  • touch-action: none on the terminal subtree plus touchstart's preventDefault() for a 'content' tap suppress the compatibility mouse events;
  • _installMobileTapMouseGuard drops the trusted ones that still arrive inside the 450 ms post-tap window;
  • the synthetic mousedown/mouseup pair _dispatchSyntheticTerminalClick sends for mouse reporting carries no mousemove, and goes to terminal.element (.xterm) — an ancestor of the node the linkifier listens on, so it cannot reach it even in principle.

Every URL and file path in terminal output has therefore been inert on phones and tablets. Claude Code's own /login URL is in that set, which makes that flow unfinishable from a phone.

Fix: the tap path activates the link itself through the same provider that feeds the hover linkifier (_terminalLinkAtPoint), so a tap and a desktop click can never disagree about what is a link or where it ends — containment is xterm's own _linkAtPosition rule. It runs synchronously inside touchend, preserving the user gesture that lets window.open past the popup blocker, and before any mouse report — mirroring _handleDesktopTerminalClick, which already skips the SGR tap for a hovered link.

Two kinds of row keep their existing meaning: the caret's logical line (a URL the user typed must stay editable) and TUI-owned rows (_isActionableMobileTerminalTap — a numbered choice or expandable readback is answering a dialog, and those rows routinely carry the very path the tap would otherwise open). The caret line is the boundary rather than the tap intent, because a plain shell short-circuits _classifyMobileTerminalTap to 'input' for the whole screen, and gating on intent would leave every URL in shell output inert.

Response-viewer links replace the app

marked emits a bare <a href> and the sanitizer's ALLOWED_ATTR has no target, so a tap navigated the current tab away — unloading the dashboard, SSE, terminal buffers and unsent composer text, with no middle-click or open-in-new-tab affordance to work around it.

Fix:_renderMarkdown decorates anchors in the template pass it already makes for code blocks. That pass runs after sanitizing, so it is the only source of both attributes: an agent-authored target/rel is already stripped by DOMPurify, and rel="noopener noreferrer" is set on the same element in the same breath. Fragment links stay in-page; mailto:/tel: are left to the OS. The sanitizer config is untouched.


2. feat(mobile): no way to copy terminal text

Not a word, not a line. Three layers rule it out independently:

  1. CSS forbids selection on touchuser-select: none !important + -webkit-touch-callout: none across the terminal subtree under body.touch-device. Correct for the tap-to-position gesture that owns taps there.
  2. There'd be nothing to selectwebglRendererEnabled defaults true, so glyphs are GPU-drawn pixels and the DOM underneath is the accessibility tree.
  3. Nothing drives xterm's selection from touchterminal.select( / selectLines / selectionStart appear zero times in any touch path, and there's no long-press handler at all. xterm's selection is a mouse DRAG; the touch path dispatches a zero-movement pair, i.e. a click.

Fix: drive xterm's public select() — renderer-independent, and xterm draws the highlight itself. Long-press is free real estate: tap and swipe are taken, long-press and double-tap are used by nothing.

  • Long-press (350 ms, finger still within the shared tap slop) selects the run of non-whitespace under the finger. Whitespace-only delimiting is deliberate: a punctuation-aware word rule cuts a path, URL or hash in half.
  • Drag while held extends it; tap while the bar is up extends it too — picking up a 4 px handle with a fingertip is a coin flip, tapping the other end is not. Dismissal stays explicit ( or Copy).
  • Copy goes through the existing copyTerminalSelection(), inheriting the execCommand fallback that is the only route that works on the plain-HTTP LAN install install.sh offers.
  • Line takes the whole logical line, wraps included, trailing pad trimmed.
  • The bar is built in JS (index.html is read once at server start) and its styles live in styles.css, not mobile.css — the gesture is touch-driven, not width-driven, so a touch tablet in landscape would otherwise get the gesture with no bar to copy from.

Three guards, each fixing a symptom measured on the device

  1. The compat mouse pair after touchendCoreBrowserTerminal focuses from its screen-element mousedown and SelectionService resets the model there, so lifting your finger popped the keyboard and dissolved the selection together. The tap path already owned a guard for those events; the selection path never armed it. Armed now, and the touchend is preventDefaulted so the synthesis stops at the source (that listener is no longer passive).
  2. The platform's own long-press — Android Chrome runs its handling at ~500 ms and focuses the nearest editable element, xterm's helper textarea parked at the cursor, which no touch handler can preventDefault because it never sees an event. A focus guard blurs the terminal input for the duration of the gesture, whatever focused it, bounded by a self-expiring deadline so a stuck flag can never leave the keyboard unreachable. contextmenu is suppressed for the same window, and the 350 ms threshold sits clear of the platform's.
  3. Copy re-focusing the terminalcopyTerminalSelection() ends with terminal.focus(), right on a desktop and wrong on a phone, where the keyboard covers what was just copied with nothing waiting to be typed.

Design rationale, alternatives and the two adjacent bugs I did not fold in (copyTerminal() is wired to no button, and calls navigator.clipboard directly so it cannot work on plain HTTP) are in #322.

No setting, deliberately

Every new UI surface in this project ships opt-in and OFF — approvalsInboxEnabled, readMyMindEnabled, the entrance animations — so the absence of a toggle here is a choice, not an oversight.

The reasoning: this is not a new capability competing for screen space, it is the only way to get text out of the terminal on a phone. There is no prior behaviour for it to regress either — long-press was an unclaimed gesture, and every path that handled a tap before still handles it, which is exactly what the three guards above are protecting. A fix nobody finds is a fix nobody has, and a terminalTouchSelection checkbox defaulting ON would mostly add a way to end up without copying again.

Happy to add one if you disagree — it would want both displayKeys membership and deliberate absence from the strict settings schema, per the per-device/synced split in CLAUDE.md.


3. fix(mobile): a long prompt hides behind the keyboard

Type a prompt long enough to wrap and its tail — the part being typed, where the cursor is — ends up behind the on-screen keyboard. You are typing blind. Two independent causes again.

The local-echo overlay has no bottom bound

On touch devices keystrokes are buffered in the overlay and do not reach the PTY until Enter, so the CLI never learns the prompt is long and nothing scrolls or reflows to make room. Meanwhile the renderer lays its wrapped lines out straight downward from the prompt row (top = promptRow * cellH, each line at i * cellH) with nothing clamping it to the visible rows — and with the keyboard up there are only a handful of those.

Fix: the block grows upward once it would pass the last visible row, lifted so its final line lands on that row. Every line div is opaque (makeLine paints font.backgroundColor), so it covers transcript above rather than vanishing under the keyboard below — the same thing a real terminal does when a composer expands. A prompt taller than the whole viewport keeps its tail, for the same reason the fix exists at all. startCol indents only the line that begins at the prompt marker, so it is dropped along with that line when only the tail fits, and the cursor follows the last visible line.

rows joins the render key: the layout now depends on it, so a keyboard opening — which changes rows without changing the text — must not be skipped as a redundant render.

Overlay behaviour is single-source in packages/xterm-zerolag-input/, so the fix lives in the package with the row count passed in as an optional totalRows. Absent, the layout is exactly what it was.

_shrinkPaddingToFit() reclaims the bars' own space

On phones the toolbar and accessory bar are position: fixed, so they occupy no layout space and main's padding-bottom is the only thing reserving room for them. Shrinking it by the full sub-row slack pulled the terminal's bottom edge down underneath them, and the row the following re-fit gained was painted behind them — clipping the last line of a long prompt.

Fix: the shrink now has a floor, the measured height of the currently-visible fixed bars. Genuine over-reservation of the hard-coded 84px is still reclaimed; a device that needs those pixels keeps them. The floor is Math.min(currentPadding, measured), so it can only ever prevent a shrink — never cause a grow that would resize the terminal as a side effect of a function whose job is reclaiming slack.


4. fix(terminal): a wrapped link opens a prefix of itself

Found by using part 1 on a phone. An agent prints a numbered list, the URL wraps, and the link opens the part on screen:

1. https://github.com/users/someone/packages/container/p
ackage/thing

opened …/container/p.

The provider already stitched hard wraps — Ink emits a real newline, so nothing is flagged isWrapped, and a row that fills the last column is taken as continuing. But it joined the row texts verbatim, and the continuation carries the list's own three-space indent. That whitespace lands in the middle of the token, which is precisely where urlPattern stops. Flush-left wrapped URLs — Claude Code's own /login link, the case the stitching was built for — have no indent, which is why this survived.

The touch-selection helpers from part 2 had the shallower version of the same bug: they walked isWrapped only, so Line grabbed the single row on screen rather than the logical line, and a long-press on a wrapped token selected only its visible half.

Fix: the reconstruction moves into one place — terminalLogicalLine in constants.js — and both consumers use it: the provider matches its patterns over that text, and the selection helpers measure words and lines with it. A link that stops at a wrap and a Line that stops at the screen edge were the same bug twice, so they can no longer disagree. The helper drops the leading whitespace of a hard continuation (the program's indent) and keeps that of a soft one (the emulator inserts nothing, so those spaces are content), records the dropped width per segment so the offset↔cell mapping stays exact in both directions, trims only the final row so earlier offsets stay aligned to cells, and keeps the 12-row bound that stops a screenful of full-width output being re-scanned on every hover.

⚠️ Selection spans are computed in cells, not text offsets: an xterm selection is one contiguous run, so a token spanning a hard wrap also covers the indent cells between its halves. A run that skipped them cannot be expressed and would not match what is highlighted. Consequence worth knowing: opening a wrapped link now yields the whole URL, while copying one still yields the two rows as they exist in the buffer.

This half also fixes the desktop hover-click, which truncated identically.


Tests

npm run test:ci5343 passing. (cron-service.test.ts has one failure that reproduces on a clean checkout of master, unrelated to this branch.) typecheck, lint, format:check, check:frontend-syntax and check:public-assets all clean, as is the package's own tsc --noEmit. The package suite passes 238, including the codex byte-identity and replay tests — which matters because part 3 touches shared overlay code.

  • test/terminal-touch-tap.test.ts — 22 new cases (51 in the file) across both halves: URL, file path, log path, scrollback, no-double-mouse-report, the composer guard, shell mode and the dialog-row guard for links; the word rule, forward/backward extension, cross-row selection, Line, tap-to-extend, the copy path and each of the three guards including the focus guard's expiry for selection. Every guard has a test that fails without it.

  • test/response-viewer-external-links.test.ts — new, drives the shipped marked + vendored DOMPurify + sanitize-html.js + app.js under jsdom, including an agent-supplied target="_self" rel="opener" being overridden and a javascript: href staying undecorated.

  • packages/xterm-zerolag-input/test/overlay-renderer.test.ts — 7 new cases: the upward lift, tail retention, the dropped prompt indent, the cursor riding the last visible line, and the two fallbacks that pin the old layout when totalRows is absent.

  • test/mobile-keyboard-bottom-padding.test.ts — new file, 7 cases driving KeyboardHandler._shrinkPaddingToFit in a vm with fake bars: reclaim, floor, partial reclaim, no-grow, a hidden bar, the CJK strip, and whole-row slack. Deliberately outside test/mobile/, which is Playwright-driven and excluded from test:ci, so a guard living only there is invisible to CI.

  • test/terminal-logical-line.test.ts — new file, 8 cases on the shared reconstruction: the indent drop, resolving from either row, both mapping directions, soft continuations kept verbatim, no over-reach past a short row, the row bound, final-row trimming, and a missing row. Plus 5 in terminal-touch-tap.test.ts driving the tap and Line paths against a wrapped, indented URL.

Across the four fixes, 29 of the new tests fail without their corresponding fix; the rest are guards pinning unchanged behaviour. CI runs the package suite too (947ff6f6), so the overlay tests are covered there and not only locally.

The harness in terminal-touch-tap.test.ts now also loads the real constants.js, so the tap path is exercised against the shipped link patterns rather than a copy.

Device verification

Android + Chrome, live 1.19.3 instance, patched assets swapped into a running dist/web/public so the checks ran against real sessions and real agent output:

  • tapping a URL in terminal output opens a new tab; tapping the prose beside it still positions the cursor; a file path opens the preview overlay; links in scrolled-up transcript work; response-viewer links open in a new tab with the dashboard left in place;
  • long-press selects, drag and tap extend, Copy lands on the clipboard with the keyboard staying down, and a swipe still scrolls without selecting;
  • a ~460-character prompt wrapping about twelve rows stays on screen while being typed, and reaches the PTY intact;
  • a wrapped, indented URL in an agent's numbered list opens in full from either of its rows, and Line selects the whole wrapped line.

Most of the guards in parts 2 and 3 exist because the device found them: the platform's own long-press focusing the helper textarea, and both halves of part 3, were invisible to reasoning and to tests — they only appeared on a real phone with a real keyboard.

Documentation

docs/wiki/Mobile-Guide.md gains a "Tapping, links and copying" section and the long-prompt behaviour, since all three change what a gesture means on a phone. CLAUDE.md gains the touch-gesture invariants next to the scrollback/wheel material, the overlay's new bottom bound beside the single-source note, and the selection bar in the z-index registry. i18n.js gains zh-CN for the bar's three labels — it is a sibling of .xterm, not a descendant, so SKIP_SELECTOR does not cover it and the entries apply.

No changeset and no CHANGELOG.md edit, per CONTRIBUTING.

Scope

Three commits in one PR because they came out of one stretch of using Codeman from a phone, and each is small and independently revertible. Squash them, take one, or ask me to split — whatever reviews best. Part 2's rationale, the alternatives weighed, and two adjacent bugs I deliberately did not fold in are in #322.

🤖 Generated with Claude Code

@rounakdattarounakdatta changed the title fix(mobile): links open in a new tab from a tap, in the terminal and the chatfeat(mobile): links open from a tap, and terminal text can be selected and copiedAug 19, 2026
@rounakdattarounakdatta changed the title feat(mobile): links open from a tap, and terminal text can be selected and copiedfeat(mobile): links open from a tap, text can be copied, and long prompts stay visibleAug 19, 2026
rounakdattaand others added 4 commits August 19, 2026 19:14
…the chat
On a phone no link was openable, on either surface, for two unrelated reasons.
**Terminal.** xterm resolves the link under the pointer on `mousemove` and
activates it on `mouseup` over its SCREEN element. A touch tap delivers neither:
`touch-action: none` on the terminal subtree plus touchstart's preventDefault for
a 'content' tap suppress the browser's compatibility mouse events,
`_installMobileTapMouseGuard` drops the trusted ones that still arrive inside the
450ms tap window, and the synthetic mousedown/mouseup pair dispatched for mouse
REPORTING goes to the `.xterm` root — an ancestor of the node the linkifier
listens on, so it cannot reach it — and carries no mousemove either way. Every
URL and file path in the terminal was therefore inert on phones and tablets,
Claude Code's own `/login` URL included.
The tap path now activates the link itself, through the SAME provider that feeds
the hover linkifier (`_terminalLinkAtPoint`), so a tap and a desktop click can
never disagree about what is a link or where it ends — containment mirrors
xterm's own `_linkAtPosition`. It runs synchronously inside the touchend handler,
which is what keeps the user gesture that lets `window.open` past the popup
blocker, and before any mouse report, exactly as `_handleDesktopTerminalClick`
already skips the SGR tap for a hovered link.
Two kinds of row keep their existing meaning: the caret's logical line, where a
tap places the cursor and a URL the user typed must stay editable, and TUI-owned
rows, where a numbered choice or an expandable readback is answering a dialog and
routinely carries the very path the tap would otherwise open. The caret line is
the boundary rather than the tap intent, because a plain shell classifies EVERY
tap as 'input' and gating on that would leave every URL in shell output inert.
**Chat.** `marked` emits a bare `<a href>` and the markdown sanitizer's allowlist
carries no `target`, so a tap in the response viewer navigated the current tab
away: on a phone that unloads the whole dashboard — SSE, terminal buffers, unsent
composer text — and there is no middle-click or open-in-new-tab affordance to
work around it. `_renderMarkdown` now decorates anchors in the template pass it
already makes for code blocks. That pass runs AFTER sanitizing, so it is the only
source of both attributes: an agent-authored `target`/`rel` is already stripped,
and `rel="noopener noreferrer"` is set on the same element in the same breath, so
no page Codeman opens gets a `window.opener` handle back. Fragment links stay
in-page; mailto:/tel: are left to the OS rather than stranding an empty tab.
Tests: 10 cases in `terminal-touch-tap.test.ts` (URL, file path, log path,
scrollback, no-double-report, composer, shell mode, dialog row, no provider) and
a new `response-viewer-external-links.test.ts` driving the shipped marked +
DOMPurify + app.js. 7 of them fail without the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was no way to copy terminal text from a phone at all, and three layers
ruled it out independently: `user-select: none` across the whole terminal subtree
on touch devices (taps are cursor gestures there, so the OS callout had to go),
the WebGL renderer drawing glyphs as pixels with only the accessibility tree
behind them, and xterm's own selection being a mouse DRAG while the touch path
dispatches a zero-movement mousedown/mouseup pair — a click. `copyTerminal()`
exists but is wired to no button and calls `navigator.clipboard` directly, which
is undefined on the plain-HTTP LAN install the installer offers.
So the gesture drives xterm's `select()` directly: public API, renderer-
independent, and the highlight is drawn by xterm itself. Long-press is free real
estate — tap and swipe are taken, long-press and double-tap are used by nothing.
- **Long-press** (350ms, finger still within the shared tap slop) selects the
run of non-whitespace under the finger. Whitespace is the only delimiter on
purpose: every punctuation-aware word rule cuts a path, URL or hash in half,
which is what you came to copy.
- **Drag** while held extends the selection; touchmove diverts from scrolling.
- **Tap** while the bar is up extends it too. That is the ergonomic core:
picking up a 4px handle with a fingertip is a coin flip, tapping the other end
is not. Dismissal stays explicit (✕ or Copy), so no tap is spent leaving a mode
the user is still using.
- **Copy** goes through the existing `copyTerminalSelection()`, so it inherits
the execCommand fallback that is the only route that works on plain HTTP.
- **Line** takes the whole logical line, wraps included, trailing pad trimmed.
Three guards are what make the gesture survive contact with a real phone, and
each fixes a symptom measured on Android Chrome:
1. **The compat mouse pair after touchend.** xterm focuses from its screen-element
mousedown and SelectionService resets the model there, so lifting your finger
popped the keyboard and dissolved the selection in one go. The tap path already
had a guard for those events; the selection path simply never armed it. Armed
now, and the touchend is `preventDefault`ed so the synthesis is stopped at the
source (that listener is no longer passive).
2. **The platform's own long-press.** Android Chrome runs its handling at ~500ms
and focuses the nearest editable element — xterm's helper textarea, parked at
the cursor — which no touch handler can preventDefault because it never sees an
event. A focus guard blurs the terminal input for the duration of the gesture,
whatever focused it, bounded by a self-expiring deadline so a stuck flag can
never leave the keyboard unreachable. `contextmenu` is suppressed for the same
window, and the threshold sits at 350ms so it lands clear of the platform's.
3. **Copy re-focusing the terminal.** `copyTerminalSelection()` ends with
`terminal.focus()`, which is right on a desktop and wrong on a phone: the
keyboard covers what was just copied with nothing waiting to be typed.
The bar is built in JS because index.html is read once at server start, and its
styles live in styles.css rather than mobile.css because the gesture is
touch-driven, not width-driven — a touch tablet in landscape gets the gesture and
would otherwise have no bar to copy from.
12 tests in `terminal-touch-tap.test.ts` cover the word rule, forward and
backward extension, cross-row selection, Line, tap-to-extend, the copy path, and
each of the three guards including the focus guard's expiry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he keyboard
Typing a prompt long enough to wrap ran the text off the bottom of the screen: the
tail — the part being typed, where the cursor is — sat behind the on-screen
keyboard, so the user was typing blind. Two independent causes.
**The overlay had no bottom bound.** On touch devices keystrokes are buffered in
the local-echo overlay and do not reach the PTY until Enter, so the CLI never
learns the prompt is long and nothing scrolls or reflows to make room. Meanwhile
the renderer lays its wrapped lines out straight DOWNWARD from the prompt row
(`top = promptRow * cellH`, each line at `i * cellH`) with nothing clamping it to
the visible rows — and with the keyboard up there are only a handful of those.
The block now grows UPWARD once it would pass the last visible row: it is lifted
so its final line lands ON that row. Every line div is opaque, so it covers
transcript above rather than vanishing under the keyboard below — the same thing a
real terminal does when a composer expands. A prompt taller than the whole
viewport keeps its TAIL, for the same reason the fix exists: the end is what the
user is looking at. `startCol` indents only the line that starts at the prompt
marker, so it is dropped along with that line when only the tail fits, and the
cursor follows the last VISIBLE line.
`rows` joins the render key: the layout depends on it, so a keyboard opening —
which changes rows without changing the text — must not be skipped as a redundant
render.
**`_shrinkPaddingToFit()` was reclaiming the bars' own space.** On phones the
toolbar and accessory bar are `position: fixed`, so they occupy no layout space
and `main`'s padding-bottom is the ONLY thing reserving room for them. Shrinking
it by the full sub-row slack pulled the terminal's bottom edge down underneath
them, and the row the following re-fit gained was painted behind them — clipping
the last line of a long prompt. The shrink now has a floor: the MEASURED height of
the currently-visible fixed bars, so genuine over-reservation of the hard-coded
84px is still reclaimed while a device that needs those pixels keeps them. The
floor is `Math.min(currentPadding, measured)`, so it can only ever prevent a
shrink, never cause a grow that would resize the terminal as a side effect.
Overlay behaviour lives in `packages/xterm-zerolag-input/` (single-source; the
vendor bundles are generated), so the fix is in the package with the row count
passed in as an optional `totalRows` — absent, the layout is exactly as before.
Tests: 7 cases in the package's `overlay-renderer.test.ts` (upward lift, tail
retention, indent drop, cursor on the last visible line, and the unclamped
fallbacks) and 7 in a new `test/mobile-keyboard-bottom-padding.test.ts` (reclaim,
floor, partial reclaim, no-grow, hidden bars, CJK strip, whole-row slack). 5 and 4
of them respectively fail without the fix. Package suite 238 pass, including the
codex byte-identity and replay tests.
Verified on Android + Chrome against a live instance: a ~460-character prompt
wrapping ~12 rows stays on screen while typing and arrives at the PTY intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n bar
The three fixes in this branch change what a tap and a long-press MEAN on a
phone, and add a UI surface with its own z-index — all of which this repo keeps
written down rather than discoverable only by reading the handlers.
- `docs/wiki/Mobile-Guide.md` (the published user manual): a new "Tapping, links
and copying" section, and the long-prompt behaviour in the keyboard section
where the existing scroll/tap rules live.
- `CLAUDE.md`: the touch-gesture invariants next to the scrollback/wheel material
(why the caret line is the boundary rather than the tap intent; why all three
selection guards exist), the overlay's new bottom bound alongside the
single-source note, and the selection bar in the z-index registry — 900, above
terminal content and the local-echo overlay and deliberately below floating
agent windows so it can never cover their controls.
- `i18n.js`: zh-CN for the bar's `Copy` / `Line` / `Clear selection`. The bar is a
SIBLING of `.xterm`, not a descendant, so `SKIP_SELECTOR` does not cover it and
the entries actually apply.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion carries
An agent's numbered list wraps its URL, and the link opened a PREFIX of it:
1. https://github.com/users/someone/packages/container/p
ackage/thing
opened `…/container/p`. The provider already stitched hard wraps — Ink emits a real
newline, so nothing is flagged `isWrapped` and a row that fills the last column is
taken as continuing — but it joined the row texts VERBATIM, and the continuation
carries the list's own three-space indent. That whitespace lands in the middle of
the token, which is exactly where the URL pattern stops. Flush-left wrapped URLs
(Claude Code's own `/login`) worked, which is why this survived.
The touch-selection helpers had the shallower version of the same bug: they walked
`isWrapped` only, so `Line` grabbed the single row on screen rather than the
logical line, and a long-press on a wrapped token selected only its visible half.
So the reconstruction now lives in ONE place, `terminalLogicalLine` in
constants.js, and both consumers use it — the link provider matching patterns over
its text and the selection helpers measuring words and lines with it. A link that
spans a wrap and a `Line` that stops at the screen edge were the same bug twice.
The helper drops the leading whitespace of a HARD continuation (the program's
indent) and keeps that of a SOFT one (the emulator inserts nothing, so it is real
content), records the dropped width per segment so the offset↔cell mapping stays
exact in both directions, trims only the final row so earlier offsets stay aligned
to cells, and keeps the 12-row bound that stops a screenful of full-width output
from being re-scanned on every hover.
⚠️ Selection spans are computed in CELLS, not text offsets: an xterm selection is
one contiguous run, so a token spanning a hard wrap also covers the indent cells
between its halves. A run that skipped them cannot be expressed, and would not
match what is highlighted.
Tests: `test/terminal-logical-line.test.ts` (8 cases: the indent drop, resolving
from either row, both mapping directions, soft continuations kept verbatim, no
over-reach past a short row, the row bound, final-row trimming, a missing row) and
5 in `terminal-touch-tap.test.ts` (the whole URL from either row, a token selected
across the wrap, `Line` spanning both rows, no reach into the next line). Removing
either half of the fix reds 5 and 8 of them respectively.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rounakdattarounakdatta changed the title feat(mobile): links open from a tap, text can be copied, and long prompts stay visiblefeat(mobile): links open from a tap, text can be copied, long prompts stay visible, wrapped links open wholeAug 19, 2026
@Ark0N
Ark0N merged commit ede3b05 into Ark0N:masterAug 19, 2026
@Ark0N

Copy link
Copy Markdown
Owner

Merged, thank you! This is one of the best PRs this repo has received: three real phone problems, each traced to its actual causes, guards that were found on a real device instead of reasoned into existence, and tests that fail without their fix. The wrapped-link stitching through the indent was a great catch on top, and filing #322 first for the selection design was exactly the right move.

Since CI didn't run on your fork's branch, I verified the merge result with current master locally before merging: typecheck, lint, format, syntax checks, the full test gate (5351 passing) and the package suite (238 passing), all green. Your cron-service flake didn't reproduce here either.

This ships with the next release and you'll be credited in the release notes. Hope to see more from you!

Ark0N pushed a commit that referenced this pull request Aug 19, 2026
…gotcha
The new local-echo-overlay gotcha landed as a list item but left the
xterm-zerolag-input entry below it without its leading '- ', splitting
the Common Gotchas bullet list in two.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@rounakdatta

Copy link
Copy Markdown
ContributorAuthor

Thanks for the note @Ark0N!

Sign up for freeto 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.

2 participants

@rounakdatta@Ark0N