2026-03-01 08:29:43 -08:00
|
|
|
/**
|
|
|
|
|
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
|
|
|
|
|
*
|
|
|
|
|
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
|
2026-03-01 09:15:29 -08:00
|
|
|
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
|
2026-08-11 10:36:30 -07:00
|
|
|
* the buffer (rejoining hard wraps, treating every other break as a
|
|
|
|
|
* terminator) and matches URLs with a single regex, firing a callback for ones
|
|
|
|
|
* >= 100 chars.
|
|
|
|
|
*
|
|
|
|
|
* ## Which line breaks may be deleted
|
|
|
|
|
*
|
|
|
|
|
* Only the ones the *terminal* inserted. A hard wrap happens at exactly the
|
|
|
|
|
* column width, so a line that reached the width was cut mid-token and its
|
|
|
|
|
* break must be removed to put the token back together; a line that stopped
|
|
|
|
|
* short ended for its own reasons and its break is a real separator.
|
|
|
|
|
*
|
|
|
|
|
* Deleting every break instead — which this did — glues unrelated output onto
|
|
|
|
|
* the end of a URL. Observed for real: a wrapped paragraph following a link
|
|
|
|
|
* became `…/tag/preview-63f3c54Butitprovesyournitpick…`, because a terminal
|
|
|
|
|
* that wraps at a space emits the break *instead of* the space, so removing
|
|
|
|
|
* the break removes the separator too. That candidate is a different URL from
|
|
|
|
|
* the one on screen, and the user is the one who has to notice.
|
2026-03-01 08:42:52 -08:00
|
|
|
*
|
2026-03-01 09:15:29 -08:00
|
|
|
* When a URL match extends to the end of the flattened buffer, emission is
|
|
|
|
|
* deferred (more chunks may still be arriving). A confirmation timer emits
|
|
|
|
|
* the pending URL if no further data arrives within 500 ms.
|
2026-08-23 08:31:39 -07:00
|
|
|
*
|
|
|
|
|
* ## OSC 8 comes first, and the scraping is the fallback
|
|
|
|
|
*
|
|
|
|
|
* Everything above is guesswork over what a terminal *painted*. When the
|
|
|
|
|
* program emits an **OSC 8 hyperlink** there is no guesswork to do: the
|
|
|
|
|
* complete URL is in the sequence's parameter, contiguous and exact, however
|
|
|
|
|
* the visible text was sliced.
|
|
|
|
|
*
|
|
|
|
|
* That distinction is the whole reason this file grew a second branch. Claude
|
|
|
|
|
* Code prints its sign-in link as an OSC 8 hyperlink whose *visible* text is
|
|
|
|
|
* cut into terminal-width pieces on separate lines — measured against 2.1.226,
|
|
|
|
|
* a 346-character URL arrives as five emissions, each carrying the whole URL in
|
|
|
|
|
* its parameter and 80 characters of it on screen. `ANSI_RE` strips OSC
|
|
|
|
|
* sequences wholesale, so the scraper never saw the parameter and reassembled
|
|
|
|
|
* the visible pieces instead: a URL that parses, that points at claude.com, and
|
|
|
|
|
* that cannot authorise anything. The backend hit this first and solved it the
|
|
|
|
|
* same way — see `commands/auth_token_commands.rs`, whose `osc8_target` and
|
|
|
|
|
* `usable_sign_in_link` this mirrors.
|
|
|
|
|
*
|
|
|
|
|
* So each emitted candidate is tagged with where it came from, and the consumer
|
|
|
|
|
* refuses to let a `heuristic` candidate displace an `osc8` one.
|
2026-08-23 11:11:43 -07:00
|
|
|
*
|
|
|
|
|
* ## …and the exact copy keeps winning after the prompt is gone
|
|
|
|
|
*
|
|
|
|
|
* The consumer's precedence rule only compares a new candidate against what is
|
|
|
|
|
* *currently* in the prompt slot. Empty the slot — the user dismisses the
|
|
|
|
|
* toast, or its 30 s auto-dismiss fires — and it has nothing to compare
|
|
|
|
|
* against, so the next truncated guess walks straight in. Meanwhile the OSC 8
|
|
|
|
|
* target is deduped for the life of the session and cannot come back to
|
|
|
|
|
* displace it. The user is then holding a URL that parses, points at
|
|
|
|
|
* claude.ai, and authorises nothing, which is the exact bug the OSC 8 branch
|
|
|
|
|
* was added to kill.
|
|
|
|
|
*
|
|
|
|
|
* That is fixed *here* rather than in the consumer, because this is the side
|
|
|
|
|
* that knows both halves: {@link UrlDetector} remembers every exact URL it has
|
|
|
|
|
* seen and refuses to emit a heuristic candidate that is a strict prefix of
|
|
|
|
|
* one — see `truncatesKnownExact`. The rule then holds however often the slot
|
|
|
|
|
* is emptied, and needs no cooperation from whoever owns it.
|
2026-03-01 08:29:43 -08:00
|
|
|
*/
|
|
|
|
|
|
2026-08-23 11:11:43 -07:00
|
|
|
import { extendsUrl } from "./urlRelay";
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
const ANSI_RE =
|
|
|
|
|
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
|
|
|
|
|
2026-08-23 08:31:39 -07:00
|
|
|
/**
|
|
|
|
|
* OSC 8 hyperlink: `ESC ] 8 ; <params> ; <uri> (BEL | ESC \\)`.
|
|
|
|
|
*
|
|
|
|
|
* The params field is `key=value` pairs separated by `:`, never `;`, so the
|
|
|
|
|
* first `;` after the `8;` ends it — the same split `osc8_target` makes in
|
|
|
|
|
* Rust. The closing half of a hyperlink is `8;;` with an empty uri.
|
|
|
|
|
*/
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
const OSC8_RE = /\x1b\]8;([^;\x07\x1b]*);([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
|
|
|
|
|
const DEBOUNCE_MS = 300;
|
2026-03-01 08:42:52 -08:00
|
|
|
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
|
2026-03-01 08:29:43 -08:00
|
|
|
const MIN_URL_LENGTH = 100;
|
|
|
|
|
|
2026-08-23 08:31:39 -07:00
|
|
|
/** Mirrors `MAX_LINK_LENGTH` in `commands/auth_token_commands.rs`. */
|
|
|
|
|
const MAX_LINK_LENGTH = 8192;
|
|
|
|
|
|
|
|
|
|
/** Bound on remembered OSC 8 targets, so a program printing a fresh hyperlink
|
|
|
|
|
* every frame cannot grow this without limit. */
|
|
|
|
|
const MAX_REMEMBERED_LINKS = 32;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Where a candidate came from, which is the same thing as how much it can be
|
|
|
|
|
* trusted to be *complete*.
|
|
|
|
|
*
|
|
|
|
|
* `osc8` is lifted verbatim out of a hyperlink parameter; `heuristic` was
|
|
|
|
|
* reassembled from painted text and may be a truncated guess. The consumer
|
|
|
|
|
* uses this to decide precedence — see `promptUrl` in `TerminalView.tsx`.
|
|
|
|
|
*/
|
|
|
|
|
export type UrlSource = "osc8" | "heuristic";
|
|
|
|
|
|
|
|
|
|
export type UrlCallback = (url: string, source: UrlSource) => void;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Whether an OSC 8 target is worth offering as a candidate at all.
|
|
|
|
|
*
|
|
|
|
|
* A direct port of `usable_sign_in_link` in
|
|
|
|
|
* `commands/auth_token_commands.rs`, and deliberately just as shallow: this is
|
|
|
|
|
* a junk filter, not the security decision. `sanitizeRelayUrl` is still the
|
|
|
|
|
* only thing standing between any of this and `openUrl`, and duplicating its
|
|
|
|
|
* rules here would be a second place for them to go stale.
|
|
|
|
|
*
|
|
|
|
|
* The one rule from the Rust that is not ported is its `sk-ant-` check: that
|
|
|
|
|
* exists because the backend's link path bypasses `SecretRedactor`, and there
|
|
|
|
|
* is no redactor on this side to bypass.
|
|
|
|
|
*/
|
|
|
|
|
export function usableLink(uri: string): boolean {
|
|
|
|
|
if (!uri.startsWith("https://") && !uri.startsWith("http://")) return false;
|
|
|
|
|
if (uri.length > MAX_LINK_LENGTH) return false;
|
|
|
|
|
// Printable ASCII only. Control characters and whitespace are exactly how a
|
|
|
|
|
// URL is smuggled past a display, and `new URL()` strips some of them
|
|
|
|
|
// silently; a real authorize URL is percent-encoded anyway.
|
|
|
|
|
for (let i = 0; i < uri.length; i++) {
|
|
|
|
|
const code = uri.charCodeAt(i);
|
|
|
|
|
if (code < 0x21 || code > 0x7e) return false;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Every usable OSC 8 hyperlink target in `raw`, in the order they were emitted.
|
|
|
|
|
*
|
|
|
|
|
* Takes the *unstripped* stream: `ANSI_RE` deletes OSC sequences wholesale, so
|
|
|
|
|
* by the time the buffer is clean the parameter this reads is already gone.
|
|
|
|
|
*/
|
|
|
|
|
export function osc8Targets(raw: string): string[] {
|
|
|
|
|
const out: string[] = [];
|
|
|
|
|
OSC8_RE.lastIndex = 0;
|
|
|
|
|
let m: RegExpExecArray | null;
|
|
|
|
|
while ((m = OSC8_RE.exec(raw)) !== null) {
|
|
|
|
|
const uri = m[2];
|
|
|
|
|
// `8;;` closes a hyperlink and carries no target.
|
|
|
|
|
if (uri && usableLink(uri)) out.push(uri);
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
2026-03-01 08:29:43 -08:00
|
|
|
|
2026-08-11 10:36:30 -07:00
|
|
|
/**
|
|
|
|
|
* How wide the terminal is right now.
|
|
|
|
|
*
|
|
|
|
|
* A getter, not a number: the width changes with every window resize, and a
|
|
|
|
|
* stale one silently turns joining back into guesswork.
|
|
|
|
|
*/
|
|
|
|
|
export type ColumnsGetter = () => number;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Rejoin the line breaks the terminal inserted; turn the rest into spaces.
|
|
|
|
|
*
|
|
|
|
|
* A line of exactly `columns` visible characters was cut by the terminal, so
|
|
|
|
|
* its break is deleted and the two halves are put back together. Anything
|
|
|
|
|
* shorter ended on its own and becomes a space — a URL cannot contain one, so
|
|
|
|
|
* that is also what stops a match running into whatever followed.
|
|
|
|
|
*
|
|
|
|
|
* `columns` of 0 or less means "not known yet"; nothing is rejoined, which
|
|
|
|
|
* costs a wrapped URL rather than inventing one.
|
|
|
|
|
*
|
|
|
|
|
* One case stays ambiguous and cannot be resolved here: a token that happens to
|
|
|
|
|
* end exactly at the width is indistinguishable from one the terminal cut, so
|
|
|
|
|
* the following line is joined to it. The candidate is still shown in full and
|
|
|
|
|
* confirmed by the user before anything opens.
|
|
|
|
|
*/
|
|
|
|
|
export function flatten(clean: string, columns: number): string {
|
2026-08-23 08:31:39 -07:00
|
|
|
// A bare `\r` is a break too. A TUI repaints a frame by returning to column
|
|
|
|
|
// 0 without a line feed, so splitting on `\r?\n` alone leaves a whole
|
|
|
|
|
// frame's worth of text on one "line" — which is then far longer than
|
|
|
|
|
// `columns`, so the `===` test below says "not wrapped" and a URL the
|
|
|
|
|
// terminal really did cut is never rejoined. Splitting here is also what the
|
|
|
|
|
// backend does with a lone CR (`strip_ansi_prefix`), for the same reason.
|
|
|
|
|
const lines = clean.split(/\r\n|\r|\n/);
|
2026-08-11 10:36:30 -07:00
|
|
|
let out = "";
|
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
out += lines[i];
|
|
|
|
|
if (i === lines.length - 1) break;
|
|
|
|
|
// `===`, not `>=`. A line *longer* than the width was never cut by the
|
|
|
|
|
// terminal — the stream simply contained no break there, so the break that
|
|
|
|
|
// follows it is the application's own and separates two things.
|
|
|
|
|
const wrapped = columns > 0 && lines[i].length === columns;
|
|
|
|
|
if (!wrapped) out += " ";
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
export class UrlDetector {
|
|
|
|
|
private decoder = new TextDecoder();
|
|
|
|
|
private buffer = "";
|
|
|
|
|
private timer: ReturnType<typeof setTimeout> | null = null;
|
2026-03-01 08:42:52 -08:00
|
|
|
private confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
2026-03-01 08:29:43 -08:00
|
|
|
private lastEmitted = "";
|
2026-03-01 08:42:52 -08:00
|
|
|
private pendingUrl: string | null = null;
|
2026-03-01 08:29:43 -08:00
|
|
|
private callback: UrlCallback;
|
2026-08-11 10:36:30 -07:00
|
|
|
private columns: ColumnsGetter;
|
2026-08-23 08:31:39 -07:00
|
|
|
/**
|
|
|
|
|
* The width in effect when the buffered bytes were *printed*, sampled in
|
|
|
|
|
* `feed`.
|
|
|
|
|
*
|
|
|
|
|
* Not read in `scan`, which is where it used to be read: the scan happens
|
|
|
|
|
* 300 ms after the print, and a resize inside that window would reassemble
|
|
|
|
|
* text wrapped at 80 columns using a width of 120 — every join decision
|
|
|
|
|
* wrong, and a fabricated URL out the other end. `-1` means "nothing fed
|
|
|
|
|
* yet".
|
|
|
|
|
*/
|
|
|
|
|
private feedColumns = -1;
|
|
|
|
|
/** OSC 8 targets already offered, so a hyperlink repainted every frame does
|
|
|
|
|
* not re-prompt. Bounded by {@link MAX_REMEMBERED_LINKS}. */
|
|
|
|
|
private emittedLinks = new Set<string>();
|
2026-08-23 11:11:43 -07:00
|
|
|
/**
|
|
|
|
|
* Every *exact* URL this session has seen — OSC 8 parameters, plus whatever
|
|
|
|
|
* the consumer reports through {@link noteExactUrl} (the OSC 7777 relay).
|
|
|
|
|
*
|
|
|
|
|
* Kept separately from `emittedLinks` because the two answer different
|
|
|
|
|
* questions: that one is "have I already prompted for this?", this one is "do
|
|
|
|
|
* I know the full text of a link some guess might be a prefix of?". The
|
|
|
|
|
* second answer must survive the prompt being dismissed; the whole defect is
|
|
|
|
|
* that a truncated guess fills the slot the moment it is empty.
|
|
|
|
|
*
|
|
|
|
|
* Bounded the same way, and cleared wholesale rather than evicted one by one:
|
|
|
|
|
* a program printing a fresh hyperlink every frame is not a program whose
|
|
|
|
|
* older links are still on screen to be mis-scraped.
|
|
|
|
|
*/
|
|
|
|
|
private exactUrls = new Set<string>();
|
2026-03-01 08:29:43 -08:00
|
|
|
|
2026-08-11 10:36:30 -07:00
|
|
|
constructor(callback: UrlCallback, columns: ColumnsGetter) {
|
2026-03-01 08:29:43 -08:00
|
|
|
this.callback = callback;
|
2026-08-11 10:36:30 -07:00
|
|
|
this.columns = columns;
|
2026-03-01 08:29:43 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Feed raw PTY output chunks. */
|
|
|
|
|
feed(data: Uint8Array): void {
|
2026-08-23 08:31:39 -07:00
|
|
|
const columns = this.columns();
|
|
|
|
|
if (this.feedColumns !== -1 && columns !== this.feedColumns) {
|
|
|
|
|
// The buffered text was wrapped at a width that no longer applies, and
|
|
|
|
|
// the new text will be wrapped at this one. There is no single width
|
|
|
|
|
// that reassembles both, so the older half is dropped rather than
|
|
|
|
|
// joined by a rule that is now wrong for it. Costs a URL that was
|
|
|
|
|
// mid-print across a resize; never invents one.
|
|
|
|
|
this.buffer = "";
|
|
|
|
|
this.pendingUrl = null;
|
|
|
|
|
}
|
|
|
|
|
this.feedColumns = columns;
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
this.buffer += this.decoder.decode(data, { stream: true });
|
|
|
|
|
|
|
|
|
|
// Cap buffer to avoid unbounded growth
|
|
|
|
|
if (this.buffer.length > MAX_BUFFER) {
|
|
|
|
|
this.buffer = this.buffer.slice(-MAX_BUFFER);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 08:42:52 -08:00
|
|
|
// Cancel pending timers — new data arrived, rescan from scratch
|
2026-03-01 08:29:43 -08:00
|
|
|
if (this.timer !== null) clearTimeout(this.timer);
|
2026-03-01 08:42:52 -08:00
|
|
|
if (this.confirmTimer !== null) {
|
|
|
|
|
clearTimeout(this.confirmTimer);
|
|
|
|
|
this.confirmTimer = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Debounce — scan after 300 ms of silence
|
2026-03-01 08:29:43 -08:00
|
|
|
this.timer = setTimeout(() => {
|
|
|
|
|
this.timer = null;
|
|
|
|
|
this.scan();
|
|
|
|
|
}, DEBOUNCE_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private scan(): void {
|
2026-08-23 08:31:39 -07:00
|
|
|
// 0. The exact copy first. An OSC 8 parameter needs no reassembly, so
|
|
|
|
|
// anything found here beats whatever the steps below reconstruct — and
|
|
|
|
|
// it has to be read from the raw buffer, because step 1 deletes the
|
|
|
|
|
// sequence that carries it.
|
|
|
|
|
this.scanLinks();
|
|
|
|
|
|
2026-03-01 09:15:29 -08:00
|
|
|
// 1. Strip ANSI escape sequences
|
2026-03-01 08:29:43 -08:00
|
|
|
const clean = this.buffer.replace(ANSI_RE, "");
|
|
|
|
|
|
2026-08-11 10:36:30 -07:00
|
|
|
// 2. Flatten the buffer: rejoin hard wraps, terminate on everything else.
|
2026-08-23 08:31:39 -07:00
|
|
|
// The width is the one that was in effect when these bytes were
|
|
|
|
|
// printed, not the one the terminal happens to have now.
|
|
|
|
|
const flat = flatten(clean, this.feedColumns);
|
2026-03-01 08:42:52 -08:00
|
|
|
|
2026-03-01 09:15:29 -08:00
|
|
|
if (!flat) return;
|
2026-03-01 08:29:43 -08:00
|
|
|
|
2026-08-09 19:35:39 -07:00
|
|
|
// 3. Match URLs on the flattened string — spans across wrapped lines naturally.
|
|
|
|
|
// The negated class stops at anything illegal in a URL, which must
|
|
|
|
|
// include the *whole* C0 range and DEL, not just BEL: an escape or a NUL
|
|
|
|
|
// swallowed into the middle of a match becomes a URL that renders as one
|
|
|
|
|
// thing in the toast and resolves as another. Everything emitted here is
|
|
|
|
|
// still re-validated by `sanitizeRelayUrl` before it can reach `openUrl`;
|
|
|
|
|
// stopping the match early only means the legitimate prefix survives
|
|
|
|
|
// instead of the whole candidate being thrown away.
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
const urlRe = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g;
|
2026-03-01 09:15:29 -08:00
|
|
|
let m: RegExpExecArray | null;
|
2026-03-01 08:29:43 -08:00
|
|
|
|
2026-03-01 09:15:29 -08:00
|
|
|
while ((m = urlRe.exec(flat)) !== null) {
|
|
|
|
|
const url = m[0];
|
2026-03-01 08:29:43 -08:00
|
|
|
|
2026-03-01 09:15:29 -08:00
|
|
|
// 4. Filter by length
|
2026-03-01 08:42:52 -08:00
|
|
|
if (url.length < MIN_URL_LENGTH) continue;
|
|
|
|
|
|
2026-03-01 09:15:29 -08:00
|
|
|
// 5. If the match extends to the very end of the flattened string,
|
|
|
|
|
// more chunks may still be arriving — defer emission.
|
|
|
|
|
if (m.index + url.length >= flat.length) {
|
2026-03-01 08:42:52 -08:00
|
|
|
this.pendingUrl = url;
|
|
|
|
|
this.confirmTimer = setTimeout(() => {
|
|
|
|
|
this.confirmTimer = null;
|
|
|
|
|
this.emitPending();
|
|
|
|
|
}, CONFIRM_MS);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 09:15:29 -08:00
|
|
|
// 6. URL is clearly complete (more content follows) — dedup + emit
|
2026-03-01 08:42:52 -08:00
|
|
|
this.pendingUrl = null;
|
2026-08-23 11:11:43 -07:00
|
|
|
if (url !== this.lastEmitted && !this.truncatesKnownExact(url)) {
|
2026-03-01 08:29:43 -08:00
|
|
|
this.lastEmitted = url;
|
2026-08-23 08:31:39 -07:00
|
|
|
this.callback(url, "heuristic");
|
2026-03-01 08:29:43 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-01 08:42:52 -08:00
|
|
|
|
2026-03-01 09:15:29 -08:00
|
|
|
// Scan finished without a URL at the buffer end.
|
2026-03-01 08:42:52 -08:00
|
|
|
// If we had a pending URL from a previous scan, it's now confirmed complete.
|
|
|
|
|
if (this.pendingUrl) {
|
|
|
|
|
this.emitPending();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-23 08:31:39 -07:00
|
|
|
/**
|
|
|
|
|
* Offer every OSC 8 target in the buffer that has not been offered before.
|
|
|
|
|
*
|
|
|
|
|
* `lastEmitted` is moved along with them so an identical string arriving on
|
|
|
|
|
* the heuristic path a moment later is recognised as the same candidate
|
|
|
|
|
* rather than fired a second time.
|
2026-08-23 11:11:43 -07:00
|
|
|
*
|
|
|
|
|
* Every target is remembered as exact whether or not it is offered — a
|
|
|
|
|
* hyperlink repainted a second time is the same known link, and the dedup
|
|
|
|
|
* that stops it re-prompting must not also stop it counting as something a
|
|
|
|
|
* later guess can be a truncation of.
|
|
|
|
|
*
|
|
|
|
|
* The alternative fix considered here was to make this dedup *releasable*,
|
|
|
|
|
* so the consumer could hand the exact URL back and have it re-offered once
|
|
|
|
|
* the prompt slot emptied. Rejected: it re-offers on the very next repaint,
|
|
|
|
|
* so dismissing the toast would put it straight back on screen — and it
|
|
|
|
|
* still would not establish the invariant, because a truncated guess and the
|
|
|
|
|
* released exact URL would simply race for the empty slot.
|
2026-08-23 08:31:39 -07:00
|
|
|
*/
|
|
|
|
|
private scanLinks(): void {
|
|
|
|
|
for (const uri of osc8Targets(this.buffer)) {
|
|
|
|
|
if (uri.length < MIN_URL_LENGTH) continue;
|
2026-08-23 11:11:43 -07:00
|
|
|
this.rememberExact(uri);
|
2026-08-23 08:31:39 -07:00
|
|
|
if (this.emittedLinks.has(uri)) continue;
|
|
|
|
|
if (this.emittedLinks.size >= MAX_REMEMBERED_LINKS) {
|
|
|
|
|
this.emittedLinks.clear();
|
|
|
|
|
}
|
|
|
|
|
this.emittedLinks.add(uri);
|
|
|
|
|
this.lastEmitted = uri;
|
|
|
|
|
this.callback(uri, "osc8");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 08:42:52 -08:00
|
|
|
private emitPending(): void {
|
2026-08-23 11:11:43 -07:00
|
|
|
if (
|
|
|
|
|
this.pendingUrl &&
|
|
|
|
|
this.pendingUrl !== this.lastEmitted &&
|
|
|
|
|
!this.truncatesKnownExact(this.pendingUrl)
|
|
|
|
|
) {
|
2026-03-01 08:42:52 -08:00
|
|
|
this.lastEmitted = this.pendingUrl;
|
2026-08-23 08:31:39 -07:00
|
|
|
this.callback(this.pendingUrl, "heuristic");
|
2026-03-01 08:42:52 -08:00
|
|
|
}
|
|
|
|
|
this.pendingUrl = null;
|
2026-03-01 08:29:43 -08:00
|
|
|
}
|
|
|
|
|
|
2026-08-23 11:11:43 -07:00
|
|
|
/**
|
|
|
|
|
* Whether `url` is a strict prefix of an exact URL already seen — i.e. a
|
|
|
|
|
* truncated guess at a link whose full text is known.
|
|
|
|
|
*
|
|
|
|
|
* {@link extendsUrl} is the predicate, used in the direction that asks "does
|
|
|
|
|
* the link I already have *extend* this guess?". It is the same rule the
|
|
|
|
|
* prompt slot uses to let a candidate grow into its complete form, which is
|
|
|
|
|
* the point: the two must agree about what "the same link, only shorter"
|
|
|
|
|
* means, so there is one implementation of it.
|
|
|
|
|
*
|
|
|
|
|
* Deliberately *not* symmetric. A candidate that is longer than a known exact
|
|
|
|
|
* URL and starts with it is a different problem (text glued onto the end by a
|
|
|
|
|
* wrap that was not a wrap), and it is still shown in full and confirmed by
|
|
|
|
|
* the user before anything opens.
|
|
|
|
|
*/
|
|
|
|
|
private truncatesKnownExact(url: string): boolean {
|
|
|
|
|
for (const exact of this.exactUrls) {
|
|
|
|
|
if (extendsUrl(exact, url)) return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Record a URL that arrived somewhere exact, outside this detector.
|
|
|
|
|
*
|
|
|
|
|
* The OSC 7777 relay hands `TerminalView` a base64-encoded URL — exact by
|
|
|
|
|
* construction, and never seen here. Without this the suppression rule above
|
|
|
|
|
* would cover hyperlinks and miss the relay, and a dismissed relay prompt
|
|
|
|
|
* could still be replaced by a truncated scrape of the same link.
|
|
|
|
|
*/
|
|
|
|
|
noteExactUrl(url: string): void {
|
|
|
|
|
this.rememberExact(url);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private rememberExact(url: string): void {
|
|
|
|
|
if (this.exactUrls.size >= MAX_REMEMBERED_LINKS) this.exactUrls.clear();
|
|
|
|
|
this.exactUrls.add(url);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-01 08:29:43 -08:00
|
|
|
dispose(): void {
|
|
|
|
|
if (this.timer !== null) {
|
|
|
|
|
clearTimeout(this.timer);
|
|
|
|
|
this.timer = null;
|
|
|
|
|
}
|
2026-03-01 08:42:52 -08:00
|
|
|
if (this.confirmTimer !== null) {
|
|
|
|
|
clearTimeout(this.confirmTimer);
|
|
|
|
|
this.confirmTimer = null;
|
|
|
|
|
}
|
2026-03-01 08:29:43 -08:00
|
|
|
}
|
|
|
|
|
}
|