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
+178
View File
@@ -0,0 +1,178 @@
// Port: none (pure logic in a vm context — no browser, no server).
//
// On phones the toolbar and the keyboard accessory bar are `position: fixed`, so
// they take no layout space: `main`'s padding-bottom is the ONLY thing reserving
// room for them, and every pixel taken out of it is a pixel of terminal painted
// underneath them.
//
// `_shrinkPaddingToFit` reclaims the sub-row slack left after a keyboard-driven
// re-fit. It used to take the whole slack, which pulled the terminal's bottom edge
// down under those bars — and the row the following re-fit gained was painted
// behind them, clipping the last line of a long wrapped prompt: the bottom half of
// the text being typed. The floor is now the bars' MEASURED height.
//
// Lives outside test/mobile/ deliberately — that suite is Playwright-driven and
// excluded from `npm run test:ci`, so a regression guarded only there is invisible
// to CI (same reasoning as terminal-scroll-intent.test.ts).
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8');
interface Bar {
offsetHeight: number;
hidden?: boolean;
}
interface Setup {
paddingBottom: string;
containerHeight: number;
rows: number;
cellH: number;
bars: Partial<Record<'.toolbar' | '.keyboard-accessory-bar' | '#cjkInput.cjk-input-visible', Bar>>;
}
/**
* Load mobile-handlers.js and hand back its KeyboardHandler plus the fake `main`
* whose inline padding the function edits.
*
* `const KeyboardHandler = {...}` is a lexical binding that does not survive to a
* second `vm.runInContext`, so the export is appended to the SAME script.
*/
function loadHandler(setup: Setup) {
const main = { style: { paddingBottom: setup.paddingBottom } };
const container = { clientHeight: setup.containerHeight };
let fits = 0;
const app = {
terminal: {
rows: setup.rows,
_core: { _renderService: { dimensions: { css: { cell: { height: setup.cellH } } } } },
},
fitAddon: {
fit: () => {
fits++;
},
},
};
const context = vm.createContext({
console,
app,
navigator: { userAgent: 'test', maxTouchPoints: 1 },
window: {
addEventListener: () => {},
matchMedia: () => ({ matches: false }),
scrollTo: () => {},
getComputedStyle: (el: Bar) => ({ display: el.hidden ? 'none' : 'block', visibility: 'visible' }),
},
document: {
body: { classList: { add: () => {}, remove: () => {} } },
addEventListener: () => {},
getElementById: (id: string) => (id === 'terminalContainer' ? container : null),
querySelector: (sel: string) =>
sel === '.main' ? main : (setup.bars as Record<string, Bar | undefined>)[sel] || null,
},
setTimeout: () => 1,
clearTimeout: () => {},
});
vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' });
return { handler: (context as { __KH: any }).__KH, main, fits: () => fits };
}
// 10 rows × 19px = 190 in a 200px container → 10px of slack, less than one row.
const BASE: Setup = {
paddingBottom: '84px',
containerHeight: 200,
rows: 10,
cellH: 19,
bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44 } },
};
describe('_shrinkPaddingToFit', () => {
it('reclaims the slack when the reservation over-reserves', () => {
// Bars really need 60px, 84 is reserved → the 10px of slack is free to take.
const { handler, main } = loadHandler({
...BASE,
bars: { '.toolbar': { offsetHeight: 30 }, '.keyboard-accessory-bar': { offsetHeight: 30 } },
});
handler._shrinkPaddingToFit();
expect(main.style.paddingBottom).toBe('74px');
});
it('never shrinks into the space the bars actually occupy', () => {
// 40 + 44 = 84: the reservation is exactly right, so there is nothing to take
// even though the terminal has 10px of slack.
const { handler, main } = loadHandler(BASE);
handler._shrinkPaddingToFit();
expect(main.style.paddingBottom).toBe('84px');
});
it('stops part-way when only some of the slack is free', () => {
// Bars need 78px of the reserved 84 → 6px may be reclaimed, not the full 10.
const { handler, main } = loadHandler({
...BASE,
bars: { '.toolbar': { offsetHeight: 34 }, '.keyboard-accessory-bar': { offsetHeight: 44 } },
});
handler._shrinkPaddingToFit();
expect(main.style.paddingBottom).toBe('78px');
});
it('is a no-op, never a grow, when the bars are taller than the reservation', () => {
// Growing the padding here would resize the terminal as a side effect of a
// function that exists to reclaim slack.
const { handler, main } = loadHandler({
...BASE,
bars: { '.toolbar': { offsetHeight: 60 }, '.keyboard-accessory-bar': { offsetHeight: 60 } },
});
handler._shrinkPaddingToFit();
expect(main.style.paddingBottom).toBe('84px');
});
it('does not count a hidden bar', () => {
// The accessory bar is display:none until the keyboard opens; counting it
// would block a reclaim that is genuinely free.
const { handler, main } = loadHandler({
...BASE,
bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44, hidden: true } },
});
handler._shrinkPaddingToFit();
expect(main.style.paddingBottom).toBe('74px');
});
it('counts the CJK input strip when it is on screen', () => {
const { handler, main } = loadHandler({
...BASE,
bars: {
'.toolbar': { offsetHeight: 30 },
'.keyboard-accessory-bar': { offsetHeight: 30 },
'#cjkInput.cjk-input-visible': { offsetHeight: 20 },
},
});
handler._shrinkPaddingToFit();
expect(main.style.paddingBottom).toBe('80px');
});
it('leaves the padding alone when the slack is a whole row or more', () => {
// A full row of slack means the re-fit will claim it as a row; padding is not
// the lever here.
const { handler, main, fits } = loadHandler({ ...BASE, containerHeight: 190 + 19 });
handler._shrinkPaddingToFit();
expect(main.style.paddingBottom).toBe('84px');
expect(fits()).toBe(0);
});
});
+177
View File
@@ -0,0 +1,177 @@
/**
* @fileoverview Response-viewer links open in a NEW tab (`CodemanApp._renderMarkdown`).
*
* `marked` emits a bare `<a href>` and the markdown sanitizer's allowlist carries no
* `target`, so every link in the chat used to navigate the CURRENT tab. On a phone that
* unloads the whole dashboard — SSE, terminal buffers, unsent composer text — and the OS
* back gesture reloads it from scratch, with no middle-click or open-in-new-tab affordance
* to work around it. That is the "links don't open on mobile" report.
*
* `_renderMarkdown` therefore decorates anchors AFTER sanitizing, which makes it the single
* source of both attributes: whatever an agent wrote is already stripped by then, and `rel`
* is set on the same element in the same pass, so no page Codeman opens can reach back
* through `window.opener` (reverse tabnabbing).
*
* Drives the SHIPPING artifacts — vendored `marked`, vendored DOMPurify + `sanitize-html.js`,
* and `app.js` itself — in a `vm` with a jsdom document injected (the technique from
* markdown-sanitizer.test.ts / response-viewer-file-links.test.ts; a per-file jsdom
* environment would externalize node:fs under vite).
*
* No port / server needed.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const { document, NodeFilter } = dom.window;
/** The shipping sanitizer: vendored DOMPurify bound to our jsdom window + the real config. */
function loadShippingSanitizer(): (html: string) => string {
const dpModule: { exports: unknown } = { exports: {} };
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', publicFile('vendor/dompurify.min.js'))(dpModule, dpModule.exports);
const DOMPurify = (dpModule.exports as (win: unknown) => unknown)(jsdomWindow);
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
exports: {},
};
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', publicFile('sanitize-html.js'))(sanModule, sanModule.exports);
const create = sanModule.exports.createMarkdownSanitizer;
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
return create(DOMPurify);
}
/** The vendored `marked` build the page loads, evaluated as CommonJS. */
function loadShippingMarked(): { parse: (src: string, opts?: unknown) => string } {
const module: { exports: unknown } = { exports: {} };
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
new Function('module', 'exports', publicFile('vendor/marked.min.js'))(module, module.exports);
return module.exports as { parse: (src: string, opts?: unknown) => string };
}
type RenderApp = { _renderMarkdown(text: string): string };
function loadCodemanAppClass(): { prototype: RenderApp } {
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
fetch: vi.fn(),
document,
NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
// The page wires the sanitizer onto window; _sanitizeHtml fails closed without it,
// and a closed-failing render would make every assertion below vacuous.
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), sanitizeMarkdownHtml: loadShippingSanitizer() },
marked: loadShippingMarked(),
MobileDetection: {},
});
vm.runInContext(
`${publicFile('constants.js')}\n${publicFile('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
return (context as { __CodemanApp: { prototype: RenderApp } }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
/** Render markdown the way the response viewer does and return the resulting element. */
function render(markdown: string): HTMLElement {
const app = Object.create(CodemanApp.prototype) as RenderApp;
const root = document.createElement('div');
root.className = 'rv-text';
root.innerHTML = app._renderMarkdown(markdown);
return root as unknown as HTMLElement;
}
const anchor = (root: HTMLElement, index = 0) => Array.from(root.querySelectorAll('a'))[index];
describe('response viewer external links', () => {
it('opens a markdown link in a new tab, with rel set in the same pass', () => {
const root = render('See [the docs](https://example.com/docs?a=1&b=2) for details.');
const a = anchor(root);
expect(a, 'the link survived sanitizing').toBeDefined();
expect(a.getAttribute('href')).toBe('https://example.com/docs?a=1&b=2');
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
});
it('opens an autolinked bare URL in a new tab too', () => {
// gfm autolinks a bare URL, which is how an agent usually prints one.
const root = render('Login at https://claude.ai/oauth/authorize?code=true&client_id=abc to continue.');
const a = anchor(root);
expect(a.getAttribute('href')).toBe('https://claude.ai/oauth/authorize?code=true&client_id=abc');
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
});
it('sends a same-origin path to a new tab as well — it is still a navigation away', () => {
const root = render('Check [status](/api/status).');
expect(anchor(root).getAttribute('target')).toBe('_blank');
});
it('leaves an in-page fragment link alone', () => {
// A target here would open a second copy of the app to scroll it.
const root = render('Jump to [the section](#results).');
const a = anchor(root);
expect(a.getAttribute('href')).toBe('#results');
expect(a.hasAttribute('target')).toBe(false);
expect(a.hasAttribute('rel')).toBe(false);
});
it('leaves mailto: and tel: to the OS instead of stranding an empty tab', () => {
const root = render('Mail [me](mailto:a@example.com) or call [now](tel:+15551234).');
for (const a of Array.from(root.querySelectorAll('a'))) {
expect(a.hasAttribute('target'), a.getAttribute('href') || '').toBe(false);
}
});
it('is the ONLY source of target/rel: an agent cannot ask for an opener', () => {
// The sanitizer's allowlist has neither attribute, so agent-authored ones are gone
// before this pass runs — and the pass sets both together, so `rel` can never end up
// weaker than the target it accompanies.
const root = render('<a href="https://evil.example/" target="_self" rel="opener">click</a>');
const a = anchor(root);
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
});
it('still drops a javascript: link rather than decorating it', () => {
const root = render('[x](javascript:alert(1))');
const a = anchor(root);
// DOMPurify strips the unsafe href; whatever is left must not carry a target either,
// which would turn a hollow anchor into a window-opening one.
expect(a?.getAttribute('href') ?? null).toBeNull();
expect(a?.hasAttribute('target') ?? false).toBe(false);
});
it('keeps code blocks and their copy affordance intact', () => {
// The anchor pass shares the one template walk with the code-block wrapper; a mistake
// there would silently drop the toolbar rather than fail loudly.
const root = render('```\nconst a = 1;\n```');
expect(root.querySelector('.rv-code-wrap')).not.toBeNull();
expect(root.querySelector('.rv-copy-btn')).not.toBeNull();
expect(root.querySelector('pre code')?.textContent).toContain('const a = 1;');
});
});
+137
View File
@@ -0,0 +1,137 @@
// Port: none (a pure function in a vm context — no browser, no server).
//
// `terminalLogicalLine` (constants.js) reconstructs the logical line a terminal row
// belongs to. Two consumers depend on it and must not disagree: the link provider
// matches its patterns over this text, and touch selection measures words and whole
// lines with it.
//
// The bug it exists to fix, reported from a phone: an agent printing a numbered list
//
// 1. https://github.com/users/someone/packages/container/p
// ackage/thing
//
// opened only `…/container/p`. Ink wraps its own output and emits a real newline, so
// nothing is flagged `isWrapped`, and the continuation carries the list's indent —
// joining the rows verbatim put whitespace in the middle of the URL, where the
// pattern stops. "Line" was broken by the same shape: it walked `isWrapped` only, so
// it grabbed the single row on screen.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
type Row = { text: string; wrapped?: boolean };
type Logical = {
startRow: number;
endRow: number;
text: string;
offsetToCell: (offset: number) => { row: number; col: number };
cellToOffset: (row: number, col: number) => number;
} | null;
const COLS = 60;
function load(): (buffer: unknown, row: number, cols: number, maxRows?: number) => Logical {
const context = vm.createContext({ console, window: undefined });
vm.runInContext(`${SOURCE}\nglobalThis.__fn = terminalLogicalLine;`, context, { filename: 'constants.js' });
return (context as { __fn: never })['__fn'] as never;
}
/** An xterm-shaped buffer: untrimmed rows pad to the full width, as xterm's do. */
function buffer(rows: Row[]) {
return {
length: rows.length,
getLine: (r: number) =>
r >= 0 && r < rows.length
? {
isWrapped: !!rows[r].wrapped,
translateToString: (trim?: boolean) => (trim === false ? rows[r].text.padEnd(COLS) : rows[r].text),
}
: undefined,
};
}
const terminalLogicalLine = load();
const URL_RE = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/;
describe('terminalLogicalLine', () => {
// Row 0 runs to the last column — the only trace a hard wrap leaves — and row 1
// carries the three spaces the list indent put there.
const HEAD = '1. https://example.com/';
const ROW0 = HEAD + 'a'.repeat(COLS - HEAD.length);
const ROW1 = ' ackage/thing';
const HARD = [{ text: ROW0 }, { text: ROW1 }];
it('stitches a hard wrap and drops the indent the continuation carries', () => {
const line = terminalLogicalLine(buffer(HARD), 0, COLS)!;
expect(line.startRow).toBe(0);
expect(line.endRow).toBe(1);
expect(line.text).toBe(ROW0 + 'ackage/thing');
// The whole URL now matches, which is the entire point.
expect(line.text.match(URL_RE)![0]).toBe('https://example.com/' + 'a'.repeat(COLS - HEAD.length) + 'ackage/thing');
});
it('finds the same line from the continuation row', () => {
// A tap or hover lands on either row; both must resolve the whole thing.
const line = terminalLogicalLine(buffer(HARD), 1, COLS)!;
expect([line.startRow, line.endRow]).toEqual([0, 1]);
expect(line.text).toBe(ROW0 + 'ackage/thing');
});
it('maps offsets back to the right cell across the dropped indent', () => {
const line = terminalLogicalLine(buffer(HARD), 0, COLS)!;
const at = line.text.indexOf('ackage/thing');
// 'a' of 'ackage' is the 4th cell of row 1 (0-based col 3), after the indent.
expect(line.offsetToCell(at)).toEqual({ row: 1, col: 3 });
// …and the reverse direction agrees.
expect(line.cellToOffset(1, 3)).toBe(at);
// Row 0 is unshifted.
expect(line.offsetToCell(3)).toEqual({ row: 0, col: 3 });
expect(line.cellToOffset(0, 3)).toBe(3);
});
it('keeps a SOFT continuation verbatim, indent and all', () => {
// The emulator inserts nothing when it wraps, so leading spaces there are real
// content and dropping them would corrupt the text.
const soft = [{ text: 'x'.repeat(COLS) }, { text: ' tail', wrapped: true }];
const line = terminalLogicalLine(buffer(soft), 1, COLS)!;
expect(line.text).toBe('x'.repeat(COLS) + ' tail');
expect(line.offsetToCell(COLS)).toEqual({ row: 1, col: 0 });
});
it('does not stitch a row that stops short of the last column', () => {
// A line that genuinely ended is not a wrap, and over-reaching would glue
// unrelated output into one link.
const rows = [{ text: 'short line' }, { text: 'next line' }];
const line = terminalLogicalLine(buffer(rows), 0, COLS)!;
expect([line.startRow, line.endRow]).toEqual([0, 0]);
expect(line.text).toBe('short line');
});
it('bounds the span so a screenful of full-width output cannot be re-scanned per hover', () => {
const rows = Array.from({ length: 40 }, () => ({ text: 'y'.repeat(COLS) }));
const line = terminalLogicalLine(buffer(rows), 30, COLS, 4)!;
expect(line.endRow - line.startRow).toBeLessThanOrEqual(4);
});
it('trims only the final row, so every earlier offset stays aligned to a cell', () => {
const rows = [{ text: 'z'.repeat(COLS) }, { text: 'tail' }];
const line = terminalLogicalLine(buffer(rows), 0, COLS)!;
// Row 0 contributes exactly COLS characters; the last row is trimmed.
expect(line.text).toBe('z'.repeat(COLS) + 'tail');
expect(line.offsetToCell(COLS)).toEqual({ row: 1, col: 0 });
});
it('answers null for a row that does not exist', () => {
expect(terminalLogicalLine(buffer([{ text: 'a' }]), 5, COLS)).toBeNull();
});
});
+453 -3
View File
@@ -8,8 +8,11 @@ function loadTerminalUiHarness() {
let now = 1_000;
let keyboardVisible = false;
let activeElement: unknown = null;
// The module hangs its constants off window (CodemanTerminalInput) and the URL branch of
// the link provider opens through window.open, so tests need a handle on the same object.
const windowRef: Record<string, any> = {};
const context = vm.createContext({
window: {},
window: windowRef,
document: {
body: { classList: { contains: () => false } },
get activeElement() {
@@ -18,7 +21,7 @@ function loadTerminalUiHarness() {
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => now },
requestAnimationFrame: (_fn: () => void) => 1,
@@ -43,12 +46,18 @@ function loadTerminalUiHarness() {
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
// constants.js first: the link provider calls absoluteFilePathPattern() and
// previewsInFileViewer() at scan time, and the SHIPPED definitions are what keep a tap and
// a hover resolving the same links.
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
vm.runInContext(constants, context, { filename: 'constants.js' });
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as any)();
return {
app,
windowRef,
setNow: (value: number) => {
now = value;
},
@@ -88,13 +97,23 @@ function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new
active: {
viewportY: 0,
baseY: 0,
length: lines.length,
cursorY,
getLine: (row: number) =>
row >= 0 && row < lines.length
? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] }
? {
isWrapped: wrappedRows.has(row),
// xterm pads an UNTRIMMED row to the full width; the selection offset
// math is linear over joined rows and would shift without it.
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
}
: undefined,
},
},
select: vi.fn(),
clearSelection: vi.fn(),
hasSelection: () => false,
getSelectionPosition: () => undefined,
element: {
querySelector: (selector: string) =>
selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null,
@@ -732,3 +751,434 @@ describe('terminal touch tap mouse guard', () => {
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
});
});
describe('terminal link tap', () => {
// xterm resolves a link from mousemove and activates it on mouseup over its SCREEN element.
// A touch tap produces none of those (touch-action:none and touchstart's preventDefault
// suppress the compatibility mouse events, the post-tap guard drops the rest, and the
// synthetic pair this app dispatches for mouse REPORTING lands on the .xterm root, an
// ancestor of the node the linkifier listens on). So the tap path activates the link
// itself, through the same provider, or every URL and path in the terminal stays inert on
// a phone.
//
// Grid geometry from createTerminalGrid: 8×16 cells, screen rect at (0,0), viewportY 0 —
// so 0-based character index i on 0-based row r sits at (i * 8 + 4, r * 16 + 8).
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
/** A claude-mode app with the shipped link provider registered over `lines`. */
function linkHarness(lines: string[], cursorY = lines.length - 1) {
const harness = loadTerminalUiHarness();
const { app, windowRef } = harness;
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
app.terminal = createTerminalGrid(lines, cursorY);
app.terminal.registerLinkProvider = vi.fn();
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = () => false;
windowRef.open = vi.fn();
app.registerFilePathLinkProvider();
return { app, windowRef, sent };
}
it('opens a URL under the finger in a new tab', () => {
const line = 'Login at https://claude.ai/oauth/authorize?code=true&client_id=abc to finish';
const { app, windowRef } = linkHarness([line, '', '❯ ']);
expect(app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content')).toBe('link');
expect(windowRef.open).toHaveBeenCalledWith(
'https://claude.ai/oauth/authorize?code=true&client_id=abc',
'_blank',
'noopener,noreferrer'
);
});
it('sends no mouse report for the tap it just spent on a link', () => {
// The CLI must not also see a click there: that is how a tap on a URL printed inside a
// permission dialog would answer the dialog. Desktop already skips the SGR tap for a
// hovered link (_handleDesktopTerminalClick).
const line = 'see https://example.com/x for more';
const { app, sent } = linkHarness([line, '', '❯ ']);
expect(app._sessionUsesServerMouseStrip()).toBe(true);
app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content');
expect(sent).toEqual([]);
});
it('leaves a tap beside the link as an ordinary tap', () => {
// Containment is xterm's own rule (flattened cell index), so tap and click agree on
// where a link ends; a tap on the prose around it keeps its mouse report.
const line = 'see https://example.com/x for more';
const { app, windowRef, sent } = linkHarness([line, '', '❯ ']);
expect(app._handleMobileTerminalTap(at(line.indexOf('for more') + 3), false, 'content')).toBe('content');
expect(windowRef.open).not.toHaveBeenCalled();
expect(sent).toHaveLength(1);
});
it('opens a tapped file path in the preview overlay', () => {
const line = 'wrote the chart to /tmp/out/chart.png just now';
const { app } = linkHarness([line, '', '❯ ']);
expect(app._handleMobileTerminalTap(at(line.indexOf('/tmp')), false, 'content')).toBe('link');
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/out/chart.png', 'sess-1');
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
});
it('sends a tapped log path to the log viewer', () => {
const line = 'tail -f /var/log/app.log';
const { app } = linkHarness([line, '', '❯ ']);
expect(app._handleMobileTerminalTap(at(line.indexOf('/var')), false, 'content')).toBe('link');
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'sess-1');
});
it('activates a link in scrollback, where the tap sends no report at all', () => {
// A scrolled-up tap deliberately reports nothing (it would land on whatever row now
// occupies the cell), but reading old output and tapping a URL in it is the common case.
const line = 'docs at https://example.com/guide';
const { app, windowRef, sent } = linkHarness([line, '', '']);
expect(app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'history')).toBe('link');
expect(windowRef.open).toHaveBeenCalledOnce();
expect(sent).toEqual([]);
});
it('never hijacks a TUI-owned choice row that happens to carry a path', () => {
// On a phone the dialog is the only interaction that matters, and its rows routinely
// name the very file a link would open — answering it must keep winning.
// ⚠️ The caret is parked on the QUESTION row, not the choice: with the caret on the
// tapped row this would pass through _tapIsOnCaretLine and pin nothing.
const line = '❯ 1. Yes, edit /home/user/src/app.ts';
const { app, windowRef, sent } = linkHarness(['Do you want to make this edit?', line, ' 2. No, keep it as is'], 0);
expect(app._handleMobileTerminalTap(at(line.indexOf('/home'), 1), false, 'content')).toBe('content');
expect(windowRef.open).not.toHaveBeenCalled();
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(sent).toHaveLength(1); // the choice still reaches the CLI
});
it('leaves a URL the user typed in the composer editable', () => {
// Tapping your own prompt text means "put the caret here". Opening it instead would
// punish the phone gesture for fixing a typo in a pasted link.
const composer = '❯ summarize https://example.com/guide for me';
const { app, windowRef, sent } = linkHarness(['earlier output', '', composer], 2);
expect(app._handleMobileTerminalTap(at(composer.indexOf('https'), 2), true, 'input')).toBe('input');
expect(windowRef.open).not.toHaveBeenCalled();
expect(sent).toHaveLength(1); // the tap still positions the caret via the mouse report
});
it('activates a link in a plain shell session, where every tap classifies as input', () => {
// A shell has no TUI to own taps, so _classifyMobileTerminalTap short-circuits to
// 'input' for the whole screen — gating link taps on the intent would leave every URL
// in shell output (curl, npm, git remote) inert. The caret line is the real boundary.
const line = 'remote: https://github.com/Ark0N/Codeman.git';
const harness = loadTerminalUiHarness();
const { app, windowRef } = harness;
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
app._sendInputAsync = vi.fn();
app.terminal = createTerminalGrid([line, '', 'bash-5.3$ '], 2);
app.terminal.registerLinkProvider = vi.fn();
windowRef.open = vi.fn();
app.registerFilePathLinkProvider();
// No cachedIntent below: real classification runs, and for a shell it answers 'input'.
const point = at(line.indexOf('https'));
expect(app._classifyMobileTerminalTap(point.clientX, point.clientY)).toBe('input');
expect(app._handleMobileTerminalTap(point, false)).toBe('link');
expect(windowRef.open).toHaveBeenCalledWith(
'https://github.com/Ark0N/Codeman.git',
'_blank',
'noopener,noreferrer'
);
});
it('keeps taps working when no provider was ever registered', () => {
const harness = loadTerminalUiHarness();
const { app } = harness;
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['plain output', '', '❯ '], 2);
expect(app._terminalLinkAtPoint(4, 8)).toBeNull();
expect(app._activateTerminalLinkAtPoint(4, 8)).toBe(false);
});
});
describe('terminal touch selection', () => {
// Copying from a phone was impossible in three layers at once: `user-select: none`
// on the whole terminal subtree, a WebGL renderer that draws glyphs as pixels with
// only the accessibility tree behind them, and xterm's selection being a mouse DRAG
// while the tap path dispatches a zero-movement mousedown/mouseup pair. The gesture
// therefore drives xterm's own `select()`, which is renderer-independent.
//
// Grid geometry (createTerminalGrid): 80 cols, 8×16 cells, screen rect at (0,0),
// viewportY 0 — 0-based index i on 0-based row r sits at (i * 8 + 4, r * 16 + 8).
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
const press = (app: any, index: number, row = 0) =>
app._beginTouchSelection(at(index, row).clientX, at(index, row).clientY);
const dragTo = (app: any, index: number, row = 0) =>
app._extendTouchSelection(at(index, row).clientX, at(index, row).clientY);
const COLS = 80;
const LINE = 'wrote the chart to /tmp/out/chart.png just now';
const PATH_AT = LINE.indexOf('/tmp');
function selectionHarness(lines = [LINE, '', '❯ '], cursorY = 2, wrapped = new Set<number>()) {
const { app, setNow } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = vi.fn();
app.terminal = createTerminalGrid(lines, cursorY, wrapped);
return { app, setNow, select: app.terminal.select as ReturnType<typeof vi.fn> };
}
it('selects the whitespace-delimited token under a long press', () => {
// Whitespace is the only delimiter on purpose: a punctuation-aware word rule
// cuts a path, a URL or a hash in half, which is exactly what you came to copy.
const { app, select } = selectionHarness();
expect(press(app, PATH_AT + 4)).toBe(true);
expect(select).toHaveBeenCalledWith(PATH_AT, 0, '/tmp/out/chart.png'.length);
});
it('selects nothing when the press lands on blank space', () => {
const { app, select } = selectionHarness();
expect(press(app, LINE.length + 10)).toBe(false);
expect(select).not.toHaveBeenCalled();
});
it('grows the selection as the finger drags past the anchor word', () => {
const { app, select } = selectionHarness();
press(app, PATH_AT + 4);
select.mockClear();
dragTo(app, LINE.length - 1);
// From the word's start through the cell under the finger, inclusive.
expect(select).toHaveBeenCalledWith(PATH_AT, 0, LINE.length - PATH_AT);
});
it('keeps the anchor word inside the selection when the drag goes backwards', () => {
const { app, select } = selectionHarness();
press(app, PATH_AT + 4);
select.mockClear();
dragTo(app, 6);
const wordEnd = PATH_AT + '/tmp/out/chart.png'.length;
expect(select).toHaveBeenCalledWith(6, 0, wordEnd - 6);
});
it('extends across rows, where a linear length is what xterm wants', () => {
const { app, select } = selectionHarness(['first row text', 'second row text', '❯ '], 2);
press(app, 0);
select.mockClear();
dragTo(app, 5, 1);
// Row 1 cell 5 is absolute cell 85; the selection runs from 0 through it.
expect(select).toHaveBeenCalledWith(0, 0, COLS + 6);
});
it('Line takes the whole logical line, wraps included, without the padding', () => {
const wrappedTail = 'tail';
const { app, select } = selectionHarness(['x'.repeat(COLS), wrappedTail, '❯ '], 2, new Set([1]));
press(app, 2, 1);
select.mockClear();
app._selectTouchSelectionLine();
expect(select).toHaveBeenCalledWith(0, 0, COLS + wrappedTail.length);
});
it('a tap while a selection is up extends it instead of moving the cursor', () => {
const { app, select } = selectionHarness();
press(app, PATH_AT + 4);
select.mockClear();
expect(app._handleMobileTerminalTap(at(LINE.length - 1), false, 'content')).toBe('select');
expect(select).toHaveBeenCalledWith(PATH_AT, 0, LINE.length - PATH_AT);
// and the CLI never sees a click it would act on
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('copies through the shared clipboard path and drops the selection', () => {
// copyTerminalSelection is the one that falls back to execCommand, which is the
// only route that works on the plain-HTTP LAN install the installer offers.
const { app } = selectionHarness();
app.copyTerminalSelection = vi.fn().mockResolvedValue(true);
press(app, PATH_AT + 4);
return app._copyTouchSelection().then(() => {
expect(app.copyTerminalSelection).toHaveBeenCalledOnce();
expect(app._touchSelectionActive).toBe(false);
expect(app._touchSelectionAnchor).toBeNull();
expect(app.terminal.clearSelection).toHaveBeenCalled();
});
});
it('lifting the finger cannot let a compat mousedown steal focus and drop the selection', () => {
// The bug this pins: on lift the browser synthesizes a trusted mousedown, xterm
// focuses on it (keyboard up) and SelectionService resets the model (bar gone).
// Ending the gesture arms the same guard the tap path uses.
const { app } = selectionHarness();
const { element, dispatch } = createElementHarness();
app.terminal.element = { ...app.terminal.element, addEventListener: element.addEventListener };
app._installMobileTapMouseGuard();
press(app, PATH_AT + 4);
app._endTouchSelectionGesture();
const ev = { isTrusted: true, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() };
dispatch('mousedown', ev);
expect(ev.preventDefault).toHaveBeenCalledOnce();
expect(ev.stopImmediatePropagation).toHaveBeenCalledOnce();
expect(app._touchSelecting).toBe(false);
expect(app._touchSelectionActive).toBe(true); // the selection outlives the gesture
});
it('copying does not pop the on-screen keyboard back over the text', () => {
// copyTerminalSelection hands focus to the terminal, which on a phone means the
// keyboard covers what you just copied with nothing waiting to be typed.
const { app } = selectionHarness();
app.copyTerminalSelection = vi.fn().mockResolvedValue(true);
press(app, PATH_AT + 4);
app._blurMobileTerminalInput = vi.fn(); // stubbed AFTER the press, which blurs too
return app._copyTouchSelection().then(() => {
expect(app._blurMobileTerminalInput).toHaveBeenCalledOnce();
});
});
it('blurs the terminal input if anything focuses it during the gesture', () => {
// The one that matters on Android: Chrome runs its own long-press handling at
// ~500ms and focuses the helper textarea directly — no mouse event to guard, so
// the keyboard shot up over the selection the instant it appeared.
const { app, setNow } = selectionHarness();
const listeners = new Map<string, () => void>();
app.terminal.textarea = {
addEventListener: (type: string, fn: () => void) => listeners.set(type, fn),
classList: { contains: (n: string) => n === 'xterm-helper-textarea' },
blur: vi.fn(),
};
app._installTouchSelectionFocusGuard();
press(app, PATH_AT + 4);
app._endTouchSelectionGesture();
// Whatever focused it, the guard takes the focus straight back off.
app._blurMobileTerminalInput = vi.fn();
listeners.get('focus')?.();
expect(app._blurMobileTerminalInput).toHaveBeenCalledOnce();
// …and the guard expires on its own, so a stuck flag can never make the
// keyboard permanently unreachable.
setNow(1_000 + 5_000);
app._blurMobileTerminalInput = vi.fn();
listeners.get('focus')?.();
expect(app._blurMobileTerminalInput).not.toHaveBeenCalled();
});
it('survives a missing bar container instead of throwing mid-gesture', () => {
// index.html is read once at server start, so the bar is built in JS — and a
// solo popup or an early gesture can run before the container exists.
const { app } = selectionHarness();
expect(() => app._showTouchSelectionBar()).not.toThrow();
expect(app._ensureTouchSelectionBar()).toBeNull();
});
});
describe('terminal wrapped-line handling', () => {
// Reported from a phone against the shipped fix: an agent's numbered list wraps its
// URL, and tapping it opened only the part on screen. Ink emits a real newline (so
// nothing is flagged isWrapped) and indents the continuation under the list marker,
// so joining the rows verbatim put whitespace inside the URL. "Line" broke the same
// way, grabbing the one visible row.
//
// Grid: 80 cols, 8×16 cells, screen rect at (0,0), viewportY 0.
const COLS = 80;
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
const press = (app: any, index: number, row = 0) =>
app._beginTouchSelection(at(index, row).clientX, at(index, row).clientY);
const HEAD = '1. https://example.com/';
// Row 0 runs to the last column, which is the only trace a hard wrap leaves.
const ROW0 = HEAD + 'a'.repeat(COLS - HEAD.length);
const ROW1 = ' ackage/thing';
const FULL_URL = 'https://example.com/' + 'a'.repeat(COLS - HEAD.length) + 'ackage/thing';
function wrappedHarness() {
const { app, windowRef } = loadTerminalUiHarness();
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app._sendInputAsync = vi.fn();
app.terminal = createTerminalGrid([ROW0, ROW1, '❯ '], 2);
app.terminal.registerLinkProvider = vi.fn();
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._isExternalPreviewPath = () => false;
windowRef.open = vi.fn();
app.registerFilePathLinkProvider();
return { app, windowRef, select: app.terminal.select as ReturnType<typeof vi.fn> };
}
it('opens the WHOLE wrapped URL, not the part on screen', () => {
const { app, windowRef } = wrappedHarness();
expect(app._handleMobileTerminalTap(at(HEAD.length + 5), false, 'content')).toBe('link');
expect(windowRef.open).toHaveBeenCalledWith(FULL_URL, '_blank', 'noopener,noreferrer');
});
it('opens the whole URL from the continuation row too', () => {
// Tapping the second half is the natural gesture when that is what you can see.
const { app, windowRef } = wrappedHarness();
expect(app._handleMobileTerminalTap(at(5, 1), false, 'content')).toBe('link');
expect(windowRef.open).toHaveBeenCalledWith(FULL_URL, '_blank', 'noopener,noreferrer');
});
it('selects a token that spans the wrap, across both rows', () => {
const { app, select } = wrappedHarness();
press(app, 5, 1); // inside 'ackage/thing' on the continuation row
// From the URL's first cell (row 0, col 3) through the token's last cell
// (row 1, col 14). The run covers the indent cells between the halves, because
// an xterm selection is one contiguous run and a gap cannot be expressed.
const index = 3;
const end = COLS + ROW1.length - 1;
expect(select).toHaveBeenCalledWith(3, 0, end - index + 1);
});
it('Line takes every row of a HARD-wrapped line, not just the visible one', () => {
const { app, select } = wrappedHarness();
press(app, HEAD.length + 5);
select.mockClear();
app._selectTouchSelectionLine();
// Row 0 col 0 through row 1's last non-blank cell.
expect(select).toHaveBeenCalledWith(0, 0, COLS + ROW1.length);
});
it('does not reach into the next line when a row stops short of the edge', () => {
// Over-reaching would glue unrelated output into one link or one "Line".
const { app, select } = loadTerminalUiHarness();
void select;
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
app.terminal = createTerminalGrid(['short output', 'https://example.com/next', '❯ '], 2);
app.terminal.registerLinkProvider = vi.fn();
app.registerFilePathLinkProvider();
press(app, 2); // inside 'short'
app._selectTouchSelectionLine();
expect(app.terminal.select).toHaveBeenCalledWith(0, 0, 'short output'.length);
});
});