mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02: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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user