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
+22
View File
@@ -2053,6 +2053,28 @@ class CodemanApp {
wrap.appendChild(actions);
wrap.appendChild(pre);
});
// Links open in a NEW tab.
//
// marked emits a bare `<a href>` and the sanitizer's allowlist has no
// `target`, so a tap in the chat NAVIGATED THE APP AWAY: on a phone that
// unloads the whole dashboard — SSE, terminal buffers, unsent composer
// text — and the OS back gesture reloads it from scratch, which is what
// "links don't open" reads as on mobile, with no middle-click or
// open-in-new-tab affordance to work around it.
//
// This pass runs AFTER sanitizing, so it is the only source of these two
// attributes: whatever an agent wrote is already gone, and `rel` is set on
// the same element in the same breath, so no page Codeman opens ever gets
// a `window.opener` handle back (reverse tabnabbing).
//
// A fragment link stays in-page, and mailto:/tel: are handed to the OS —
// giving those a target just strands an empty tab.
tmpl.content.querySelectorAll('a[href]').forEach((a) => {
const href = a.getAttribute('href') || '';
if (!href || href.startsWith('#') || /^(?:mailto|tel):/i.test(href)) return;
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
return tmpl.innerHTML;
} catch { /* fall through */ }
}
+105
View File
@@ -1074,7 +1074,112 @@ function previewsInFileViewer(filePath) {
return FILE_PREVIEW_EXTENSIONS.has(ext);
}
/**
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
* string, and a two-way map between that string and terminal cells.
*
* One definition, two consumers: the link provider matches its patterns over this
* text (`registerFilePathLinkProvider`) and touch selection measures words and
* whole lines with it (`_touchSelectionLogicalLine`). They MUST agree — a link that
* spans a wrap and a "Line" that stops at the screen edge is the same bug twice.
*
* Two 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`. It inserts nothing, so the row's text is joined verbatim.
* 2. **Hard wrap** — the program wrapped the text itself and emitted a real
* newline, so nothing is flagged. A row that fills the last column is taken
* as continuing into the next; that is the only trace a hard wrap leaves.
*
* ⚠️ A hard-wrapped continuation may carry the program's own INDENT, and joining
* that verbatim puts whitespace in the middle of the token being stitched. That is
* why an agent's numbered list —
*
* 1. https://github.com/users/someone/packages/container/p
* ackage/thing
*
* — opened only `…/container/p`: the URL pattern stops at the space the indent
* contributed. So the leading whitespace of a HARD continuation is dropped, and
* `colStart` on that segment records how much, keeping the cell mapping exact. A
* soft continuation keeps its leading whitespace, since the terminal never adds
* any and it is therefore real content.
*
* ⚠️ Only the final row is trimmed. Continuation rows are read UNTRIMMED so each
* contributes exactly `cols` cells; trimming one would shift every later offset.
*
* The row span is bounded by `maxRows` (12 by default): this runs on every hover,
* and a screenful of full-width output would otherwise re-scan the viewport each
* time.
*
* @param {{getLine: (row: number) => any, length: number}} buffer xterm buffer.
* @param {number} row 0-based ABSOLUTE buffer row to expand around.
* @param {number} cols Terminal width.
* @param {number} [maxRows] Row-span bound.
* @returns {{startRow: number, endRow: number, text: string,
* offsetToCell: (offset: number) => {row: number, col: number},
* cellToOffset: (row: number, col: number) => number} | null}
* 0-based rows and columns throughout; null when the row does not exist.
*/
function terminalLogicalLine(buffer, row, cols, maxRows) {
if (!buffer || typeof buffer.getLine !== 'function') return null;
const width = Math.max(1, cols || 1);
const bound = Math.max(1, maxRows || 12);
const lineAt = (r) => (r >= 0 ? buffer.getLine(r) : undefined);
if (!lineAt(row)) return null;
const continuesPrevious = (r) => {
if (r <= 0) return false;
if (lineAt(r)?.isWrapped) return true;
const prev = lineAt(r - 1);
return !!prev && (prev.translateToString(true) || '').length >= width;
};
let startRow = row;
while (startRow > 0 && row - startRow < bound && continuesPrevious(startRow)) startRow--;
let endRow = row;
const length = Number.isFinite(buffer.length) ? buffer.length : endRow + 1;
while (endRow + 1 < length && endRow - startRow < bound && continuesPrevious(endRow + 1)) endRow++;
const segments = [];
let text = '';
for (let r = startRow; r <= endRow; r++) {
const line = lineAt(r);
if (!line) break;
let rowText = line.translateToString(r === endRow) || '';
let colStart = 0;
if (r > startRow && !line.isWrapped) {
const indent = rowText.length - rowText.replace(/^\s+/, '').length;
colStart = indent;
rowText = rowText.slice(indent);
}
segments.push({ row: r, textStart: text.length, colStart, length: rowText.length });
text += rowText;
}
const offsetToCell = (offset) => {
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (offset >= seg.textStart || i === 0) {
return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
}
}
return { row: startRow, col: offset };
};
const cellToOffset = (targetRow, targetCol) => {
for (const seg of segments) {
if (seg.row !== targetRow) continue;
return seg.textStart + Math.max(0, targetCol - seg.colStart);
}
return -1;
};
return { startRow, endRow, text, offsetToCell, cellToOffset };
}
if (typeof window !== 'undefined') {
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
window.CodemanTerminalLines = { terminalLogicalLine };
}
+5
View File
@@ -504,6 +504,11 @@
'Respawn Blocked': '重生已阻止',
'Task Complete': '任务完成',
'Copied to clipboard': '已复制到剪贴板',
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
Copy: '复制',
Line: '整行',
'Clear selection': '清除选择',
'Failed to copy': '复制失败',
'Checking…': '正在检查…',
'Starting…': '正在启动…',
+38 -1
View File
@@ -573,6 +573,42 @@ const KeyboardHandler = {
* space below the last row. After fitAddon.fit(), measure the gap and
* reduce padding by that amount so the terminal sits flush against the bars.
*/
/**
* Combined height of the fixed bars that overlay the terminal's bottom edge.
*
* On phones the toolbar and the accessory bar are `position: fixed`, so they
* occupy no layout space of their own — `main`'s padding-bottom is the only
* thing reserving room for them, and any pixel taken out of it is a pixel of
* terminal painted underneath them.
*/
_fixedBottomBarsHeight() {
let px = 0;
for (const selector of ['.toolbar', '.keyboard-accessory-bar', '#cjkInput.cjk-input-visible']) {
const el = document.querySelector(selector);
if (!el) continue;
const style = window.getComputedStyle?.(el);
if (style && (style.display === 'none' || style.visibility === 'hidden')) continue;
px += el.offsetHeight || 0;
}
return px;
},
/**
* Reclaim sub-row slack at the bottom of the terminal — but never the space the
* fixed bars stand in.
*
* Shrinking the padding by the whole slack pulled the terminal's bottom edge
* DOWN under those bars, and the row the following re-fit then gained was
* painted behind them: on a long wrapped prompt the last line was clipped by
* the accessory bar, i.e. the bottom half of the text being typed. The floor is
* now the bars' MEASURED height, so a device where the hard-coded 84px
* over-reserves still reclaims the difference, while one that genuinely needs
* it keeps every pixel.
*
* ⚠️ The floor can only ever prevent a shrink, never cause a grow
* (`Math.min(currentPadding, …)`): a measured height LARGER than the current
* padding makes this a no-op rather than silently resizing the terminal.
*/
_shrinkPaddingToFit() {
try {
const container = document.getElementById('terminalContainer');
@@ -583,7 +619,8 @@ const KeyboardHandler = {
const gap = container.clientHeight - app.terminal.rows * cellH;
if (gap > 0 && gap < cellH) {
const currentPadding = parseInt(main.style.paddingBottom) || 0;
main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px';
const floor = Math.min(currentPadding, this._fixedBottomBarsHeight());
main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px';
if (app.fitAddon)
try {
app.fitAddon.fit();
+51
View File
@@ -3408,6 +3408,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
-webkit-touch-callout: none !important;
}
/* Touch text-selection bar (long-press → select → Copy).
Lives in styles.css, NOT mobile.css: the gesture is touch-driven, not
width-driven, and mobile.css is media-gated to ≤1023px — a touch tablet in
landscape would get the gesture with no bar to copy from.
Built in JS (index.html is read once at server start, so markup added there
would need a restart to appear). z-index 900 sits above terminal content and
the local-echo overlay (7) and deliberately BELOW floating agent windows
(1000), so it can never cover their controls. */
.term-select-bar {
position: absolute;
z-index: 900;
display: none;
gap: 2px;
padding: 3px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
}
.term-select-bar.visible {
display: flex;
}
.term-select-btn {
min-height: 38px;
min-width: 46px;
padding: 0 0.7rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
/* The bar is the one place in the terminal subtree a tap must land on a
control rather than a cell, so it opts out of the gesture styles above. */
touch-action: manipulation;
}
.term-select-btn:active {
background: var(--bg-hover);
}
.term-select-btn--close {
min-width: 38px;
padding: 0;
color: var(--text-muted);
}
/* Welcome Overlay */
.welcome-overlay {
position: absolute;
+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();