Merge pull request #321 from rounakdatta/fix/mobile-link-taps

feat(mobile): links open from a tap, text can be copied, long prompts stay visible, wrapped links open whole
This commit is contained in:
Ark0N
2026-08-19 23:23:51 +02:00
committed by GitHub
16 changed files with 1896 additions and 70 deletions
+565 -54
View File
@@ -38,6 +38,19 @@
// a gesture the terminal treats as a scroll but the dismiss handler treats as
// a tap would close the keyboard mid-scroll and drop the composer.
const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8;
// Hold this long, finger still, before a press becomes a text selection.
//
// ⚠️ It must fire well BEFORE the platform's own long-press threshold (~500ms on
// Android), not just under it: the guards this gesture installs are armed when it
// fires, and at 450ms they were still being armed as Chrome ran its own handling
// — which focuses the nearest editable element, so the keyboard shot up over the
// selection the moment it appeared. 350ms is still far above a tap (~100-150ms).
const TOUCH_SELECT_LONG_PRESS_MS = 350;
// How long after a selection gesture the terminal input stays un-focusable. Long
// enough to cover the platform's long-press handling and the compatibility events
// that trail a touchend; short and self-expiring, so a stuck flag can never leave
// the keyboard unreachable.
const TOUCH_SELECT_FOCUS_GUARD_MS = 800;
// Regions where a tap must NOT dismiss the on-screen keyboard
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
// focus itself, and the accessory bar, which is built to be used while the
@@ -206,6 +219,8 @@
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
TOUCH_SELECT_LONG_PRESS_MS,
TOUCH_SELECT_FOCUS_GUARD_MS,
};
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
global.codemanCurrentXtermTheme = currentXtermTheme;
@@ -270,6 +285,7 @@ Object.assign(CodemanApp.prototype, {
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._installMobileTapMouseGuard();
this._installTouchSelectionFocusGuard();
// Suppress xterm key handling during CJK IME composition.
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
@@ -567,6 +583,18 @@ Object.assign(CodemanApp.prototype, {
// Register link provider for clickable file paths in Bash tool output
this.registerFilePathLinkProvider();
// Bar visible ⟺ a selection exists. xterm drops the selection on any keypress,
// on reset and on a tab switch, and a Copy button floating over nothing is a
// trap — one that would copy the PREVIOUS session's text if it still worked.
this.terminal.onSelectionChange?.(() => {
if (!this.terminal?.hasSelection?.()) {
this._touchSelecting = false;
this._touchSelectionActive = false;
this._touchSelectionAnchor = null;
this._hideTouchSelectionBar();
}
});
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
@@ -686,6 +714,9 @@ Object.assign(CodemanApp.prototype, {
let pixelAccum = 0;
let didScroll = false; // track whether touchmove fired (tap vs scroll)
let longPressTimer = null; // armed on touchstart, becomes a text selection
let longPressStartX = 0;
let longPressStartY = 0;
let touchStartY = 0;
let tapStartedWithTerminalFocus = false;
let tapStartIntentCache = null;
@@ -695,6 +726,13 @@ Object.assign(CodemanApp.prototype, {
container.addEventListener(
'touchstart',
(ev) => {
// The selection bar is a child of this container: its buttons own their
// own taps and must not arm a gesture on the terminal underneath.
if (ev.target?.closest?.('.term-select-bar')) return;
if (ev.touches.length !== 1) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
if (ev.touches.length === 1) {
touchLastX = ev.touches[0].clientX;
touchLastY = ev.touches[0].clientY;
@@ -725,6 +763,15 @@ Object.assign(CodemanApp.prototype, {
ev.preventDefault();
this._blurMobileTerminalInput();
}
// Hold still and this press becomes a text selection. Cancelled by any
// travel past the shared tap slop below, so a scroll can never become one.
longPressStartX = touchLastX;
longPressStartY = touchLastY;
clearTimeout(longPressTimer);
longPressTimer = setTimeout(() => {
longPressTimer = null;
this._beginTouchSelection(longPressStartX, longPressStartY);
}, window.CodemanTerminalInput.TOUCH_SELECT_LONG_PRESS_MS);
lastTime = 0;
if (scrollFrame) {
cancelAnimationFrame(scrollFrame);
@@ -738,6 +785,24 @@ Object.assign(CodemanApp.prototype, {
container.addEventListener(
'touchmove',
(ev) => {
// A drag that follows the long press grows the selection instead of
// scrolling; preventDefault keeps the page from taking the gesture back.
if (this._touchSelecting) {
ev.preventDefault();
const selTouch = ev.touches[0];
if (selTouch) this._extendTouchSelection(selTouch.clientX, selTouch.clientY);
return;
}
if (longPressTimer && ev.touches.length === 1) {
const t = ev.touches[0];
if (
Math.abs(t.clientX - longPressStartX) > TAP_THRESHOLD ||
Math.abs(t.clientY - longPressStartY) > TAP_THRESHOLD
) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
if (ev.touches.length === 1 && isTouching) {
const touchY = ev.touches[0].clientY;
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
@@ -779,7 +844,21 @@ Object.assign(CodemanApp.prototype, {
container.addEventListener(
'touchend',
(ev) => {
if (ev.target?.closest?.('.term-select-bar')) return;
clearTimeout(longPressTimer);
longPressTimer = null;
isTouching = false;
if (this._touchSelecting) {
// Lifting ends the DRAG, not the selection: the bar stays up so the
// range can still be extended by tapping, or copied. preventDefault
// cancels the compatibility mouse events this touchend would otherwise
// synthesize — see _endTouchSelectionGesture.
ev.preventDefault();
velocity = 0;
this._endTouchSelectionGesture();
tapStartedWithTerminalFocus = false;
return;
}
if (!scrollFrame && Math.abs(velocity) > 0.3) {
scrollFrame = requestAnimationFrame(scrollLoop);
}
@@ -798,13 +877,28 @@ Object.assign(CodemanApp.prototype, {
}
tapStartedWithTerminalFocus = false;
},
{ passive: true }
// NOT passive: the selection branch above must be able to preventDefault
// the compatibility mouse events. Every other path leaves the event alone.
{ passive: false }
);
// Android Chrome fires `contextmenu` at its long-press threshold and then runs
// its default long-press behaviour. Suppressed ONLY while a selection gesture
// is in flight — a desktop right-click keeps its menu, since the timer is null
// and no gesture is active there.
container.addEventListener('contextmenu', (ev) => {
if (longPressTimer !== null || this._touchSelecting || this._touchSelectionActive) {
ev.preventDefault();
}
});
container.addEventListener(
'touchcancel',
() => {
clearTimeout(longPressTimer);
longPressTimer = null;
isTouching = false;
this._touchSelecting = false;
velocity = 0;
pixelAccum = 0;
tapStartedWithTerminalFocus = false;
@@ -1306,7 +1400,7 @@ Object.assign(CodemanApp.prototype, {
// Debug: Track if provider is being invoked
let lastInvokedLine = -1;
this.terminal.registerLinkProvider({
const provider = {
provideLinks(bufferLineNumber, callback) {
// Debug logging - only log if line changed to avoid spam
if (bufferLineNumber !== lastInvokedLine) {
@@ -1325,63 +1419,32 @@ Object.assign(CodemanApp.prototype, {
// Stitch the LOGICAL line back together.
//
// xterm invokes this provider per visible ROW, and translateToString returns
// that row alone (the old comment here claimed otherwise). A URL or path
// longer than the terminal is wide therefore matched only as far as the row
// boundary, and the link opened a PREFIX of the real target. Walk out to both
// ends of the continuation, match against the joined text, and map offsets
// back to (x, y) so a link can span rows.
//
// Two different kinds of continuation, and handling only the first is not
// enough:
// 1. SOFT wrap: the emulator ran out of columns and flags the next row
// `isWrapped`.
// 2. HARD wrap: the program did its own wrapping and emitted a real
// newline, so nothing is flagged. Ink does this, which is why Claude
// Code's own `/login` URL was cut at the window edge, and why the
// clickable part grew when the window was widened.
// A row that fills the full width is treated as continuing into the next:
// that is the signal a hard wrap leaves behind, and a line that genuinely
// ended would stop short of the last column.
const cols = self.terminal.cols;
const rowAt = (r) => buffer.getLine(r - 1);
const continuesPrevious = (r) => {
if (r <= 1) return false;
if (rowAt(r)?.isWrapped) return true;
const prev = rowAt(r - 1);
return !!prev && prev.translateToString(true).length >= cols;
};
// xterm invokes this provider per visible ROW and translateToString returns
// that row alone, so a URL or path longer than the terminal is wide matched
// only as far as the row boundary and the link opened a PREFIX of the real
// target. `terminalLogicalLine` (constants.js) owns the reconstruction —
// both continuation kinds, the indent a hard wrap leaves on its
// continuation, and the offset↔cell mapping — because touch selection
// measures the SAME lines and the two must not disagree.
// Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport.
const MAX_STITCHED_ROWS = 12;
let startRow = bufferLineNumber;
while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) {
startRow--;
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
buffer,
bufferLineNumber - 1,
self.terminal.cols,
MAX_STITCHED_ROWS
);
if (!logical) {
callback(undefined);
return;
}
let endRow = bufferLineNumber;
while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) {
endRow++;
}
const rowTexts = [];
for (let r = startRow; r <= endRow; r++) {
const row = rowAt(r);
if (!row) break;
// Only the final row may be trimmed. Continuation rows fill the width by
// definition, and trimming one would shift every later offset.
rowTexts.push(row.translateToString(r === endRow));
}
const lineText = rowTexts.join('');
const lineText = logical.text;
/** Map an offset in the stitched text back to a 1-based terminal cell. */
const coordAt = (index) => {
let rest = index;
for (let i = 0; i < rowTexts.length - 1; i++) {
if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i };
rest -= rowTexts[i].length;
}
return { x: rest + 1, y: startRow + rowTexts.length - 1 };
const cell = logical.offsetToCell(index);
return { x: cell.col + 1, y: cell.row + 1 };
};
if (!lineText || !lineText.includes('/')) {
@@ -1523,11 +1586,424 @@ Object.assign(CodemanApp.prototype, {
}
callback(links.length > 0 ? links : undefined);
},
});
};
// Keep the provider reachable: on touch devices xterm's linkifier never
// resolves a link (it is driven by mousemove/mouseup, which a tap does not
// produce), so the tap path asks this SAME provider what is under the finger
// rather than growing a second, driftable copy of the patterns.
// See _terminalLinkAtPoint.
this._terminalLinkProvider = provider;
this.terminal.registerLinkProvider(provider);
console.log('[LinkProvider] File path link provider registered');
},
/**
* The terminal link under a viewport point, or null.
*
* Resolved through the provider registered above, 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` — flattened `y * cols + x`, inclusive
* at both ends — for the same reason.
*
* ⚠️ The provider answers its callback SYNCHRONOUSLY (every path in
* `registerFilePathLinkProvider` does, including the empty ones). xterm's
* ILinkProvider contract permits an async reply, so this reads whatever
* arrived by the time the call returns and answers null otherwise: a tap then
* keeps its normal meaning instead of opening a link late, after the gesture
* that made `window.open` permissible is gone.
*/
_terminalLinkAtPoint(clientX, clientY) {
const provider = this._terminalLinkProvider;
const buffer = this.terminal?.buffer?.active;
if (!provider || !buffer) return null;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return null;
// Link ranges are 1-based ABSOLUTE buffer lines (xterm adds ydisp to the
// viewport row before asking), which is what the provider's coordAt() emits.
const y = (buffer.viewportY || 0) + pos.row;
let links = null;
try {
provider.provideLinks(y, (result) => {
links = result || [];
});
} catch {
return null;
}
if (!links || links.length === 0) return null;
const cols = Math.max(1, this.terminal.cols || 1);
const current = y * cols + pos.col;
return (
links.find((link) => {
const start = link?.range?.start;
const end = link?.range?.end;
if (!start || !end) return false;
return start.y * cols + start.x <= current && current <= end.y * cols + end.x;
}) || null
);
},
/**
* Is this point on the caret's logical line — the editable composer?
*
* There a tap means "put the cursor here", so a URL the USER typed or pasted
* into a prompt must stay editable rather than opening itself. The caret is the
* signal that works for every CLI: claude's composer row carries it, and in a
* plain shell it sits on the prompt line while output scrolls above, so the
* same test covers both without asking what mode is running (tap
* classification cannot answer this — a shell session classifies EVERY tap as
* 'input', which would leave every URL in shell output inert).
*
* The caret's line is walked out through soft wraps, since a long prompt spans
* rows.
*/
_tapIsOnCaretLine(clientX, clientY) {
const buffer = this.terminal?.buffer?.active;
if (!buffer?.getLine) return false;
const pos = this._clientPointToCell(clientX, clientY);
if (!pos) return false;
const rows = Math.max(1, this.terminal.rows || 1);
const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0));
const tappedRow = pos.row - 1;
if (tappedRow === cursorRow) return true;
let start = cursorRow;
while (start > 0 && buffer.getLine(buffer.viewportY + start)?.isWrapped) start--;
let end = cursorRow;
while (end + 1 < rows && buffer.getLine(buffer.viewportY + end + 1)?.isWrapped) end++;
return tappedRow >= start && tappedRow <= end;
},
/**
* Activate the terminal link under a touch point. Returns true when one was.
*
* xterm activates a link from a `mousemove` that resolves what is under the
* pointer, followed by a `mouseup` on its SCREEN element — and on a touch
* device it receives neither: `touch-action: none` plus touchstart's
* preventDefault suppress the browser's compatibility mouse events,
* _installMobileTapMouseGuard drops the ones that still arrive, and the
* synthetic 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, which is unfinishable from a phone without this.
*
* Activating here, synchronously inside the touchend handler, is what keeps
* the user gesture that lets the URL branch's `window.open` through the popup
* blocker; a later activation (a timer, a promise) is silently swallowed.
*/
_activateTerminalLinkAtPoint(clientX, clientY) {
const link = this._terminalLinkAtPoint(clientX, clientY);
if (!link || typeof link.activate !== 'function') return false;
try {
link.activate(null, link.text);
} catch (err) {
console.warn('[LinkProvider] tap activation failed:', err);
return false;
}
return true;
},
// ═══════════════════════════════════════════════════════════════
// Touch text selection — long-press to select, tap to extend, Copy
// ═══════════════════════════════════════════════════════════════
//
// There was no way to copy terminal text from a phone at all. Three layers
// ruled it out at once: `user-select: none` on the whole terminal subtree
// (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 tap path
// dispatches a zero-movement mousedown/mouseup pair, i.e. a click.
//
// So the gesture drives xterm's selection API directly (`select`, public and
// 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.
/**
* While a selection gesture is in flight, the terminal input must not hold focus.
*
* ⚠️ This is the guard that actually fixes "the keyboard pops up the moment the
* selection appears". The mouse-event guard cannot: the focus does not arrive
* through a mouse event at all. Android Chrome runs its own long-press handling
* at ~500ms and focuses the nearest editable element — xterm's helper textarea,
* a real <textarea> parked at the cursor — and nothing in the touch path can
* preventDefault an event it never sees. Blurring on focus is the one move that
* works regardless of which path did the focusing.
*
* Bounded by a self-expiring deadline rather than by the bar's visibility, so a
* flag left set can never make the keyboard permanently unreachable.
*/
_installTouchSelectionFocusGuard() {
const textarea = this.terminal?.textarea;
if (!textarea || textarea._codemanSelectionFocusGuard) return;
textarea._codemanSelectionFocusGuard = true;
textarea.addEventListener('focus', () => {
if (!this._touchSelectionFocusGuarded()) return;
// Same task as the focus: a keyboard that opens and closes still shoves the
// viewport, and the selection with it.
this._blurMobileTerminalInput();
});
},
_touchSelectionFocusGuarded() {
if (this._touchSelecting) return true;
return performance.now() < (this._touchSelectionFocusGuardUntil || 0);
},
/** Re-arm the focus guard; called at every step of the gesture. */
_armTouchSelectionFocusGuard() {
this._touchSelectionFocusGuardUntil =
performance.now() + (window.CodemanTerminalInput?.TOUCH_SELECT_FOCUS_GUARD_MS || 800);
},
/** The absolute 0-based buffer cell under a viewport point, or null. */
_touchSelectionCellAt(clientX, clientY) {
const pos = this._clientPointToCell(clientX, clientY);
const buffer = this.terminal?.buffer?.active;
if (!pos || !buffer) return null;
return { col: pos.col - 1, row: (buffer.viewportY || 0) + pos.row - 1 };
},
/**
* The logical line a buffer row belongs to — the SAME reconstruction the link
* provider matches against (`terminalLogicalLine`, constants.js).
*
* ⚠️ Walking only `isWrapped` was not enough: Claude Code and every other Ink CLI
* wrap their own output and emit real newlines, so nothing is flagged and "Line"
* grabbed the one row on screen instead of the whole wrapped line. The shared
* helper treats a row that fills the last column as continuing, and drops the
* indent such a continuation carries.
*/
_touchSelectionLogicalLine(row) {
const buffer = this.terminal?.buffer?.active;
const cols = Math.max(1, this.terminal?.cols || 1);
if (!buffer || typeof window.CodemanTerminalLines?.terminalLogicalLine !== 'function') return null;
return window.CodemanTerminalLines.terminalLogicalLine(buffer, row, cols);
},
/**
* The run of NON-WHITESPACE around a cell, as {index, length} in absolute cells.
*
* Whitespace is the only delimiter on purpose: in a terminal the thing worth
* grabbing is a path, a URL, a container id or a hash, and every punctuation-
* aware word rule cuts those in half.
*
* ⚠️ Bounds are found in the reconstructed TEXT (so a token is not cut at a wrap)
* and then converted to CELLS, because an xterm selection is one contiguous run of
* cells. A token spanning a hard wrap therefore also covers the indent cells
* between its halves — the alternative, a selection that skips them, cannot be
* expressed and would not match what is highlighted.
*/
_touchSelectionWordAt(cell) {
const cols = Math.max(1, this.terminal?.cols || 1);
const line = this._touchSelectionLogicalLine(cell.row);
if (!line) return null;
const offset = line.cellToOffset(cell.row, cell.col);
if (offset < 0) return null;
const ch = line.text[offset];
if (!ch || !ch.trim()) return null; // pressed on blank space: nothing to select
let from = offset;
while (from > 0 && line.text[from - 1] && line.text[from - 1].trim()) from--;
let to = offset;
while (to + 1 < line.text.length && line.text[to + 1] && line.text[to + 1].trim()) to++;
const startCell = line.offsetToCell(from);
const endCell = line.offsetToCell(to);
const index = startCell.row * cols + startCell.col;
const length = endCell.row * cols + endCell.col - index + 1;
return length > 0 ? { index, length } : null;
},
/** Apply a selection given absolute cell indices; `select()` wraps a length across rows. */
_applyTouchSelection(index, length) {
const cols = Math.max(1, this.terminal?.cols || 1);
if (length <= 0) return;
this.terminal?.select?.(index % cols, Math.floor(index / cols), length);
},
/** Long-press fired: select the word under the finger and arm drag-to-extend. */
_beginTouchSelection(clientX, clientY) {
const cell = this._touchSelectionCellAt(clientX, clientY);
if (!cell) return false;
const word = this._touchSelectionWordAt(cell);
if (!word) return false;
// The keyboard must not sit on top of the thing being selected, and the
// composer would eat the selection on its next keystroke anyway.
this._blurMobileTerminalInput();
this._touchSelectionAnchor = word;
this._touchSelecting = true;
this._touchSelectionActive = true;
// From here until the gesture ends, no trusted mouse event may reach xterm —
// see _endTouchSelectionGesture for why — and the terminal input may not take
// focus. Both are re-armed as the gesture continues, since their windows are
// short and a press can be held for much longer.
this._suppressTrustedTapMouseEvents();
this._armTouchSelectionFocusGuard();
this._applyTouchSelection(word.index, word.length);
// Android answers; iOS ignores it silently. Both are fine.
try {
navigator.vibrate?.(15);
} catch {
/* vibration is a nicety, never a requirement */
}
this._showTouchSelectionBar();
return true;
},
/**
* Extend the selection to a point, from the anchor word.
*
* Used by BOTH the drag that follows the long-press and a tap while the bar is
* up. The tap form is the one that makes this usable on a phone: picking up a
* 4px handle with a fingertip is a coin flip, tapping the other end is not.
*/
_extendTouchSelection(clientX, clientY) {
const anchor = this._touchSelectionAnchor;
const cell = this._touchSelectionCellAt(clientX, clientY);
if (!anchor || !cell) return;
const cols = Math.max(1, this.terminal?.cols || 1);
const point = cell.row * cols + cell.col;
// Grow from whichever END of the anchor word is further away, so the word the
// press landed on always stays inside the selection.
const from = Math.min(anchor.index, point);
const to = Math.max(anchor.index + anchor.length, point + 1);
this._applyTouchSelection(from, to - from);
this._armTouchSelectionFocusGuard();
this._positionTouchSelectionBar();
},
/**
* Finger up: end the DRAG, keep the selection and the bar.
*
* ⚠️ The browser synthesizes a trusted `mousedown`/`mouseup` pair after this
* touchend, and xterm acts on BOTH: `CoreBrowserTerminal` calls `focus()` from
* its screen-element mousedown — so the on-screen keyboard springs up over the
* text you just selected — and `SelectionService` resets the model there, so the
* selection, and with it the Copy bar (hidden when `hasSelection()` goes false),
* vanishes the instant you lift your finger. That is exactly what long-press-to-
* copy did before this: keyboard up, selection gone, nothing to copy.
*
* The tap path already owns a guard for those events; it simply never armed it
* here. Arming it is the fix, and the caller additionally `preventDefault()`s the
* touchend so the synthesis is stopped at the source rather than swatted after.
*/
_endTouchSelectionGesture() {
this._touchSelecting = false;
this._suppressTrustedTapMouseEvents();
this._armTouchSelectionFocusGuard();
this._positionTouchSelectionBar();
},
/** Whole logical line under the anchor — the common case a word selection just missed. */
_selectTouchSelectionLine() {
const anchor = this._touchSelectionAnchor;
const cols = Math.max(1, this.terminal?.cols || 1);
if (!anchor) return;
const line = this._touchSelectionLogicalLine(Math.floor(anchor.index / cols));
if (!line) return;
// Every row of the logical line, wraps included — that is the whole point of
// the button. The end is the last row's last non-blank cell: trailing cells are
// padding, and copying them would put a wall of spaces on the clipboard.
const buffer = this.terminal?.buffer?.active;
const lastRow = (buffer?.getLine(line.endRow)?.translateToString(true) || '').length;
const index = line.startRow * cols;
const length = line.endRow * cols + Math.max(0, lastRow - 1) - index + 1;
if (length <= 0) return;
this._touchSelectionAnchor = { index, length };
this._applyTouchSelection(index, length);
this._positionTouchSelectionBar();
},
/** Copy through the shared path: Clipboard API, else execCommand (plain-HTTP installs). */
async _copyTouchSelection() {
const ok = await this.copyTerminalSelection();
this._clearTouchSelection();
// copyTerminalSelection hands focus back to the terminal, which is right on a
// desktop and wrong on a phone: it opens the on-screen keyboard over whatever
// was just copied, with nothing waiting to be typed. The execCommand fallback
// focuses its own temp textarea on the way through, so this runs after both.
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.()) {
this._blurMobileTerminalInput();
}
return ok;
},
_clearTouchSelection() {
this._touchSelecting = false;
this._touchSelectionActive = false;
this._touchSelectionAnchor = null;
this.terminal?.clearSelection?.();
this._hideTouchSelectionBar();
},
/** The Copy/Line/dismiss bar. Built in JS — index.html is read once at server start. */
_ensureTouchSelectionBar() {
if (this._touchSelectionBar?.isConnected) return this._touchSelectionBar;
const container = document.getElementById('terminalContainer');
if (!container) return null;
const bar = document.createElement('div');
bar.className = 'term-select-bar';
bar.setAttribute('role', 'toolbar');
bar.innerHTML =
'<button type="button" class="term-select-btn" data-act="copy">Copy</button>' +
'<button type="button" class="term-select-btn" data-act="line">Line</button>' +
'<button type="button" class="term-select-btn term-select-btn--close" data-act="close" aria-label="Clear selection">✕</button>';
// Pointer events only: the container's touch handlers are what own gestures in
// this subtree, and they skip anything inside the bar (see initTerminal).
bar.addEventListener('click', (ev) => {
const act = ev.target?.closest?.('[data-act]')?.dataset?.act;
if (!act) return;
ev.preventDefault();
ev.stopPropagation();
if (act === 'copy') void this._copyTouchSelection();
else if (act === 'line') this._selectTouchSelectionLine();
else this._clearTouchSelection();
});
container.appendChild(bar);
this._touchSelectionBar = bar;
return bar;
},
_showTouchSelectionBar() {
const bar = this._ensureTouchSelectionBar();
if (!bar) return;
bar.classList.add('visible');
this._positionTouchSelectionBar();
},
_hideTouchSelectionBar() {
this._touchSelectionBar?.classList.remove('visible');
},
/**
* Park the bar just above the selection, or below it when the selection starts
* at the top of the screen. Clamped to the container so it can never sit
* off-screen with the only Copy button on it.
*/
_positionTouchSelectionBar() {
const bar = this._touchSelectionBar;
const container = document.getElementById('terminalContainer');
const screen = this.terminal?.element?.querySelector('.xterm-screen');
const cell = this.terminal?._core?._renderService?.dimensions?.css?.cell;
const range = this.terminal?.getSelectionPosition?.();
const buffer = this.terminal?.buffer?.active;
if (!bar || !container || !screen || !cell?.height || !range || !buffer) return;
const screenRect = screen.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const viewportRow = (row) => row - (buffer.viewportY || 0);
const topPx = screenRect.top - containerRect.top + viewportRow(range.start.y) * cell.height;
const bottomPx = screenRect.top - containerRect.top + (viewportRow(range.end.y) + 1) * cell.height;
const barHeight = bar.offsetHeight || 36;
const gap = 6;
let top = topPx - barHeight - gap;
if (top < 0) top = bottomPx + gap;
top = Math.max(0, Math.min(top, containerRect.height - barHeight));
const left = screenRect.left - containerRect.left + range.start.x * (cell.width || 8);
const barWidth = bar.offsetWidth || 150;
bar.style.top = `${Math.round(top)}px`;
bar.style.left = `${Math.round(Math.max(0, Math.min(left, containerRect.width - barWidth)))}px`;
},
showWelcome() {
// Phones get the session overview instead of the welcome screen: on a small
// screen "which session is blocked on me" beats "how do I start one". The
@@ -3704,7 +4180,42 @@ Object.assign(CodemanApp.prototype, {
if (!touch || !this.terminal) return null;
// touchstart already classified this exact point; reuse it rather than paying
// a second full-viewport scan for the same gesture.
// While a selection is up, a tap EXTENDS it instead of doing its usual job —
// picking up a 4px handle with a fingertip is a coin flip, tapping the other
// end is not. Dismissal stays explicit (the bar's ✕, or Copy), so no tap is
// ever spent on getting out of a mode the user is still using.
if (this._touchSelectionActive) {
this._extendTouchSelection(touch.clientX, touch.clientY);
return 'select';
}
const intent = cachedIntent ?? this._classifyMobileTerminalTap(touch.clientX, touch.clientY);
// Computed once and reused by the keyboard decision at the tail of this
// method: both ask the same question, and the pane cannot change in between
// (a mouse report only reaches the PTY; its output lands on a later turn).
const actionable = this._isActionableMobileTerminalTap(touch.clientX, touch.clientY);
// A tap that lands ON a link activates it, at any scroll position and before
// any mouse report — exactly what a desktop click does, where the provider's
// activate() runs and _handleDesktopTerminalClick deliberately skips the SGR
// tap for a hovered link so the CLI never also sees a click there.
//
// Two kinds of row keep their existing meaning instead: the composer, where a
// tap places the caret in text the USER typed (_tapIsOnCaretLine), 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 — on a phone the dialog is the only interaction that
// matters, so it wins.
if (
!actionable &&
!this._tapIsOnCaretLine(touch.clientX, touch.clientY) &&
this._activateTerminalLinkAtPoint(touch.clientX, touch.clientY)
) {
// No focus change: a 'content' tap was already blurred by touchstart, and
// popping the keyboard behind a tab that is about to take over is noise.
return 'link';
}
if (intent === 'history') {
// Scrolled up: send NO mouse report — a tap on old output must not be
// delivered to the CLI as a click on whatever row now occupies that cell.
@@ -3728,7 +4239,7 @@ Object.assign(CodemanApp.prototype, {
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
}
if (intent === 'content' && this._isActionableMobileTerminalTap(touch.clientX, touch.clientY)) {
if (intent === 'content' && actionable) {
// A synthetic xterm click can focus its helper textarea. Blur after the
// report so collapsing a readback never opens or retains the keyboard.
this._blurMobileTerminalInput();