mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
16 files changed
+1896
-70
No files matched your search
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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…': '正在启动…',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user