diff --git a/CLAUDE.md b/CLAUDE.md index de020024..68de089e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,7 +224,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens and whole email addresses never continue a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers, version tokens, whole email addresses, labels ending in `:`, measurements and clock times (`45%`, `12.3ms`, `10:00:00`) and a `name=value` field under a URL ending in `/` never continue a URL; a token with only `-`, `_` or `.` is also refused when it carries a call paren (`client.get()`) or ends a sentence (`Done.`). A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1d0e124e..e06bb19c 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,7 +409,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it returns the rooted matches first, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. -The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted.` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. +The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently, gated by `pathContinuation()` and the per-cell row-length rule; accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. **Folder grid: a folder is granted, then served by bare file name.** A folder path (rooted or `~/` whose last segment has no extension, relative only with a trailing `/`; `FOLDER_PATH_LINK_PATTERN` + `folderLinkPath()`) opens `openFolderGrid()` (panels-ui.js), as does the preview's "Show folder" button. `POST /api/sessions/:id/folders {path}` (`src/folder-listing.ts`) resolves the folder (locally a bounded `probePathKind(…, { pastCap: true })` first, then async `fs.realpath`; remotely one ssh round trip via `remoteListFolder`, through `runWithRemoteSshLimit`), runs the attachment guard on it (blocklist, `confineToWorkspace`, and in multi-user mode `folderAllowedForUser`: a non-admin only inside their own `userSpacePath` or the session's `workingDir`, as the filesystem path picker), locally BEFORE reading it, lists non-hidden subfolders and files of a supported attachment type (each file also blocklist-checked, newest first, capped at `MAX_FOLDER_ENTRIES`), and GRANTS it an id. Files are then fetched as `GET …/folders/:folderId/files/:name/(raw|preview|thumbnail)`: `:name` must be a bare file name of a supported type (no `/`, `\`, NUL, no leading `.`, since the listing hides those), the local file is probed bounded (`unknown` → 503, never 404), and the joined path goes through the SAME `resolveServableAttachmentPath` as an attachment by id (symlink re-resolution, remote re-probe, guard), then the same gate registration applies to what a symlink RESOLVES to (a supported extension on the resolved path and a regular file: locally `fs.stat`, remotely the probe kind; else 400, so `shot.png -> ../secret.bin` is not served and a FIFO or device behind a supported name never reaches `open`), then `folderAllowedForUser` once more on the resolved file, before `serveServableRaw` (the attachment raw route's body, shared) streams it. So browser requests still never carry a path. ⚠️ Grants live in their own registry (`folderGrants`, 32 per session, cleared with the session's attachments), NOT the attachment registry: that one is capped at 200 per session, and paging a folder of 300 renders would evict the session's real attachments. ⚠️ A local folder is never touched unbounded (#516 rule): a folder on a network mount that stopped answering answers 503 with `describeUnknownPath()`, not 404 and not a frozen server. ⚠️ The remote listing script tells "missing" (`E|n`, 404) from "is a file" (`E|f`, 400 `Not a folder`), because the client falls back to the file preview on `Not a folder`; a remote extensionless file otherwise answered "Folder not found". ⚠️ `.folder-grid-overlay` sits at `z-index: 5090`, one step BELOW the preview (5100) and above the response viewer (5000): a tile's preview opens on top and closing it lands back on the grid. ⚠️ ←/→/Esc are handled in a document CAPTURE listener with `stopPropagation`, because the terminal keeps keyboard focus under the overlays and an arrow that reached xterm would be typed into the session; inputs other than xterm's helper textarea keep their keys. ⚠️ A route PARAMETER is capped by the router (Fastify's default is 100 characters, answered as 404 "Route not found"), and a file name goes up to 255, percent-encoded: the server sets `routerOptions.maxParamLength` to `MAX_ROUTE_PARAM_LENGTH` (route-helpers.ts), the route test harness uses the same constant, and a wiring guard pins it. ⚠️ `~/` in a Docker case is refused with 400 (attachment POST and `POST …/folders`): the session is local for these routes, but `~` inside the container is the container's own home, and expanding it on the host would open a different file than the one the agent printed. Tests: `test/routes/folder-grid-routes.test.ts` (incl. the listing script on a real `sh`), `test/folder-grid-ui.test.ts`. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index deb5cf2d..78e04721 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2575,9 +2575,12 @@ function findTerminalUrls(text) { * - that row's first token looks like URL material (contains one of * `% & = / ? . - _`), is not a bare number/version or list marker, * and is not itself a new `scheme://`, a shell prompt (`user@host:`), - * a home path (`~/`), an email address or an HTTP status line; without - * `% & = / ?`, the token must finish its cell (row end or a gap of at - * least two spaces). + * a home path (`~/`), an email address, an HTTP status line, a label + * ending in `:`, a measurement or clock time (`45%`, `12.3ms`, + * `10:00:00`), or a `name=value` field under a URL ending in `/`; + * without `% & = / ?`, the token must finish its cell (row end or a + * gap of at least two spaces) and must not be code with a call paren + * or a word ending a sentence (`Done.`). * The last rule is what stops prose such as "…/files/" + "then run it" from * being glued into one link. * @@ -2643,10 +2646,27 @@ function terminalUrlContinuationExtent(buffer, row, cols, maxRows) { // Nor is a token that is a whole email address. if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break; if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break; + // A label ends with a colon (`Failure/Error:`, `prometheus.io/scrape:`); + // the rest of a URL does not. + if (token.endsWith(':')) break; + // After a `/`, a `name=value` token is the next assignment or log field + // (`API_KEY=...`, `level=info`), not the URL: a query starts with `?`. + if (urlText.endsWith('/') && /^[A-Za-z_][\w.-]*=/.test(token)) break; + // Nor is a measurement or a clock time: `45%` and `45%|####` progress, + // `12.3MB/45.0MB`, `12.3ms`, `10:00:00` (`%7B` stays percent-encoding). + if (/^\d+(?:\.\d+)?(?:%(?![0-9A-Fa-f]{2})|(?:ms|s|[kKMGT]i?B)(?![\w-]))|^\d{1,2}:\d{2}/.test(token)) break; // Hyphens, underscores and dots also occur in prose. Without stronger // URL punctuation, the token must finish its cell, not introduce a sentence. const rest = nextText.slice(indent + token.length); - if (!/[%&=/?]/.test(token) && rest.trim() !== '' && !/^ {2,}/.test(rest)) break; + if (!/[%&=/?]/.test(token)) { + // Code (`client.get()`, `expect(x).toBe(y)`) is not a URL piece, and a + // word that ends a sentence (`ready.`, `Done.`) is prose. A wiki title + // (`Mercury_(planet)`) and a file name before its period + // (`install.html.`) still continue the URL. + if (/[^_]\(/.test(token)) break; + if (/[.,;!]$/.test(token) && !/\.[A-Za-z][A-Za-z0-9]{1,4}[.,;!]+$/.test(token)) break; + if (rest.trim() !== '' && !/^ {2,}/.test(rest)) break; + } const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound); if (!nextLogical) break; if (trailingColumns) firstRowUrlEnd = last.rawEnd; diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts index ad6fc98f..9f8cd847 100644 --- a/test/terminal-url-continuation.test.ts +++ b/test/terminal-url-continuation.test.ts @@ -171,7 +171,7 @@ const TABLE_LAST_COLUMN_ROWS: [string, string, string, string, string | null][] // Known trade-offs: a row end counts as a cell end, so a short indented word // with URL punctuation still continues the URL. Pinned so a change is deliberate. const TRADE_OFF_ROWS: [string, string, string][] = [ - ['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/self-hosted'], + ['⏺ Docs live at https://example.com/guide/', ' self-hosted', 'https://example.com/guide/self-hosted'], [ '⏺ The docs are at https://example.com/guide/', ' and/or the API reference covers it.', @@ -179,6 +179,69 @@ const TRADE_OFF_ROWS: [string, string, string][] = [ ], ]; +// Indented rows under a URL ending in `/` that carry URL punctuation but are +// code, a label, a `name=value` field, a measurement or a clock time, or a word +// ending a sentence. Each third element is the URL on the first row. +const FIELD_AND_CODE_ROWS: [string, string, string][] = [ + [' └ Listening on http://127.0.0.1:8080/', ' ready.', 'http://127.0.0.1:8080/'], + ['⏺ Deployed to https://app.example.com/', ' Done.', 'https://app.example.com/'], + ['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/'], + [' Fetching https://example.com/files/', ' done...', 'https://example.com/files/'], + [ + ' ● fetch › handles https://api.example.com/v1/', + ' expect(received).toBe(expected)', + 'https://api.example.com/v1/', + ], + [' See https://example.com/api/', ' client.get()', 'https://example.com/api/'], + [ + '/home/u/.venv/lib/urllib3/__init__.py:35: Warning: see https://github.com/urllib3/urllib3/issues/', + ' warnings.warn(', + 'https://github.com/urllib3/urllib3/issues/', + ], + [ + ' 1) Client fetches https://api.example.com/v1/', + ' Failure/Error: expect(res.code).to eq(200)', + 'https://api.example.com/v1/', + ], + [ + 'Annotations: link.argocd.argoproj.io/external-link: https://grafana.example.com/d/', + ' prometheus.io/scrape: true', + 'https://grafana.example.com/d/', + ], + [' BASE_URL=https://api.example.com/', ' API_KEY=your-key-here', 'https://api.example.com/'], + [' AUTH_URL=https://auth.example.com/', ' LOG_LEVEL=debug', 'https://auth.example.com/'], + [ + ' level=info msg="request" url=https://api.example.com/v1/', + ' level=info msg="done" status=200', + 'https://api.example.com/v1/', + ], + [' url=https://api.example.com/v1/', ' request_id=abc-123', 'https://api.example.com/v1/'], + [ + ' Callback: https://example.com/oauth/callback/', + ' state=abc123 must match', + 'https://example.com/oauth/callback/', + ], + [ + ' Downloading https://huggingface.co/org/model/resolve/main/', + ' 45%|████▌ | 450M/1.00G [00:10<00:12, 45.0MB/s]', + 'https://huggingface.co/org/model/resolve/main/', + ], + [' Downloading https://example.com/files/', ' 12.3MB/45.0MB', 'https://example.com/files/'], + [' GET https://example.com/api/', ' 12.3ms', 'https://example.com/api/'], + [' https://example.com/blog/', ' 10:00:00.123', 'https://example.com/blog/'], +]; +// Continuations at the edge of those rules that are still the rest of the URL: +// a wiki title with parens, a file name before a sentence period, a query +// parameter after `?`, `&` or `_`, and percent-encoding that starts with digits. +const STILL_CONTINUES_ROWS: [string, string, string][] = [ + [' https://en.wikipedia.org/wiki/', ' Mercury_(planet)', 'https://en.wikipedia.org/wiki/Mercury_(planet)'], + ['⏺ The guide is at https://example.com/docs/', ' install.html.', 'https://example.com/docs/install.html'], + [' https://example.com/oauth?client_', ' id=abc&scope=read', 'https://example.com/oauth?client_id=abc&scope=read'], + [' https://example.com/cb?code=abc&', ' state=xyz', 'https://example.com/cb?code=abc&state=xyz'], + [' Docs: https://example.com/guide?', ' x=1&y=2 more text', 'https://example.com/guide?x=1&y=2'], + [' https://example.com/files/', ' 2026%20Plan.xlsx', 'https://example.com/files/2026%20Plan.xlsx'], +]; + describe('findTerminalUrls (grammar)', () => { const { findTerminalUrls } = loadPure(); const urls = (text: string) => findTerminalUrls(text).map((u) => u.url); @@ -291,6 +354,20 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); }); + it.each(FIELD_AND_CODE_ROWS)( + 'does not join code, a field, a measurement or a sentence end after %s', + (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); + } + ); + + it.each(STILL_CONTINUES_ROWS)('still joins the continuation after %s', (first, next) => { + const buffer = fakeBuffer([{ text: first }, { text: next }], 100); + for (const row of [0, 1]) + expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + }); + it('does not join an indented email address', () => { const buffer = fakeBuffer([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100); for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull(); @@ -323,16 +400,13 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => { } ); - it.each(['part%20two', 'x=1&y=2', 'path/slug', 'query?x=1'])( - 'keeps stronger URL punctuation followed by prose: %s', - (token) => { - const buffer = fakeBuffer( - [{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }], - 100 - ); - expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); - } - ); + it.each(['part%20two', 'path/slug', 'query?x=1'])('keeps stronger URL punctuation followed by prose: %s', (token) => { + const buffer = fakeBuffer( + [{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }], + 100 + ); + expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 }); + }); it('does not glue a following prose line onto a URL that ends a line', () => { const buffer = fakeBuffer( @@ -485,6 +559,30 @@ describe('terminal link provider with wrapped table URLs', () => { } ); + it.each(FIELD_AND_CODE_ROWS)( + 'keeps code, a field, a measurement or a sentence end separate: %s', + (first, next, url) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + const links = provide(1).filter((link) => /^https?:/.test(link.text)); + expect(links.map((link) => link.text)).toEqual([url]); + links[0].activate(null, links[0].text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer'); + expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]); + } + ); + + it.each(STILL_CONTINUES_ROWS)('links the whole URL from both rows after %s', (first, next, joined) => { + const { provide, openSpy } = harness([{ text: first }, { text: next }], 100); + for (const line of [1, 2]) { + // One link per row the URL covers, each opening the whole URL. + const links = provide(line).filter((link) => /^https?:/.test(link.text)); + expect(new Set(links.map((link) => link.text))).toEqual(new Set([joined])); + } + const [link] = provide(2).filter((l) => /^https?:/.test(l.text)); + link.activate(null, link.text); + expect(openSpy).toHaveBeenCalledExactlyOnceWith(joined, '_blank', 'noopener,noreferrer'); + }); + it('keeps an indented email address out of the URL above it', () => { const { provide } = harness([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100); expect(