feat: snip a region into chat, and make links clickable (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-10-05 08:38:50 +00:00
parent f5d1ced4d6
commit 576c5946f7
44 changed files with 3890 additions and 120 deletions
+190 -3
View File
@@ -88,6 +88,35 @@ const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/;
const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/;
const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/;
/**
* A line that is nothing but a bare http(s) URL, give or take a bullet.
*
* Vicinae only makes a URL clickable when markdown presents it as a link. Its
* parser attaches the `table` and `strikethrough` GFM extensions and nothing
* else, so the `autolink` extension is off: a bare URL renders as plain dead
* text. `MarkdownModel` turns `CMARK_NODE_LINK` into `<a href=...>`, and the
* selection controller opens that anchor on release, so `[label](url)` is the
* one form the user can actually click.
*/
const BARE_URL = /^https?:\/\/\S+$/;
/** Sentence punctuation a model appends to a URL that is not part of it. */
const TRAILING_SENTENCE_PUNCTUATION = /[.,;:!?]+$/;
/** One inline code span that holds nothing but a URL. */
const STANDALONE_URL_SPAN = /^`([^`\n]+)`[.!]?$/;
/** A bullet or numbered-list marker, kept so a list of links stays a list. */
const LIST_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+)(.*)$/;
/** A GFM table row: it opens and closes with a pipe. */
const TABLE_ROW = /^\s*\|.*\|\s*$/;
/**
* The delimiter row under a header.
*
* The test is not how many dashes there are, because GFM wants only one per
* column. It is that the line could be nothing else: pipes, dashes, colons and
* spaces, with at least one dash to tell it apart from a row of empty cells.
*/
const TABLE_DELIMITER_ROW = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
function significantLines(code: string): string[] {
return code
.split("\n")
@@ -272,12 +301,170 @@ export function labelCodeFences(markdown: string): string {
return out.join("\n");
}
/** A URL with the sentence punctuation after it removed. */
function asBareUrl(candidate: string): string | undefined {
const trimmed = candidate.trim();
if (!BARE_URL.test(trimmed)) return undefined;
return trimmed.replace(TRAILING_SENTENCE_PUNCTUATION, "");
}
/**
* Wraps a URL as a markdown link, using the address itself as the label so the
* destination stays readable rather than turning into a generic word.
*
* A destination containing parentheses (plenty of Wikipedia articles) has to
* be wrapped in pointy brackets or the parser reads the first `)` as the end of
* the destination and leaves the rest as trailing prose.
*/
function asLink(url: string): string {
// Square brackets in the *label* would end the link text early, which is why
// an IPv6 literal such as `http://[::1]/` needs them escaped. The destination
// tolerates brackets, but not parentheses, which is what the pointy form is
// for.
const label = url.replace(/[[\]]/g, "\\$&");
const destination = /[()]/.test(url) ? `<${url}>` : url;
return `[${label}](${destination})`;
}
/**
* The URLs of a fence whose every non-empty line is one, or `undefined` when
* the block is real code.
*
* Deliberately narrow: one line that is not a URL disqualifies the whole block,
* so a `curl` invocation that happens to contain an address is never touched.
*/
function urlOnlyBlock(lines: string[]): string[] | undefined {
const urls: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const url = asBareUrl(trimmed);
if (!url) return undefined;
urls.push(url);
}
return urls.length > 0 ? urls : undefined;
}
/** Rewrites a line that is only a URL, or only a span holding one, into a link. */
function linkifyLine(line: string): string {
const marker = LIST_MARKER.exec(line);
const prefix = marker ? marker[1] : (line.match(/^[ \t]*/)?.[0] ?? "");
const rest = marker ? marker[2] : line.slice(prefix.length);
const span = STANDALONE_URL_SPAN.exec(rest.trim());
const url = asBareUrl(span ? span[1] : rest);
return url ? `${prefix}${asLink(url)}` : line;
}
/**
* Turns URLs the model wrote as code back into links.
*
* This is the reported bug: asked for a few GitHub projects, the model answers
* with a fenced block of bare addresses. A fence renders through
* `MdCodeBlock.qml` as a dark panel with a Copy button and no link handling at
* all, so the one thing the user wants to do with a URL is the one thing they
* cannot. The same applies to a lone `` `https://...` `` span.
*
* Three shapes are recovered, and only these:
*
* 1. A fence whose entire body is URLs.
* 2. A line that is exactly one inline span holding a URL.
* 3. A line that is exactly a URL, bulleted or not.
*
* Existing fences holding anything else are copied through untouched, so real
* code is never rewritten.
*/
export function restoreUrlLinks(markdown: string): string {
const lines = markdown.split("\n");
const out: string[] = [];
let index = 0;
while (index < lines.length) {
if (!OPENING_FENCE.test(lines[index])) {
out.push(linkifyLine(lines[index]));
index += 1;
continue;
}
const body: string[] = [];
let cursor = index + 1;
while (cursor < lines.length && !CLOSING_FENCE.test(lines[cursor])) {
body.push(lines[cursor]);
cursor += 1;
}
const closing = cursor < lines.length;
const urls = urlOnlyBlock(body);
if (urls) out.push(...urls.map(asLink));
else {
out.push(lines[index], ...body);
if (closing) out.push(lines[cursor]);
}
index = cursor + (closing ? 1 : 0);
}
return out.join("\n");
}
/**
* Inserts the blank line a table needs to start at all.
*
* A GFM table cannot interrupt a paragraph, so a table written straight after a
* line of prose is not a table: it renders as a paragraph of pipes and dashes,
* which is how a perfectly good answer turns into noise. The blank line looks
* unnecessary, so models leave it out.
*
* Only a real table start is touched, which is a row followed by a delimiter
* row, so prose that happens to contain a pipe is left alone. Fences are copied
* through untouched for the same reason, since a table shown as example code
* must stay exactly as written.
*/
export function separateTables(markdown: string): string {
const lines = markdown.split("\n");
const out: string[] = [];
let index = 0;
while (index < lines.length) {
if (OPENING_FENCE.test(lines[index])) {
index = copyFence(lines, out, index);
continue;
}
const startsTable =
TABLE_ROW.test(lines[index]) && index + 1 < lines.length && TABLE_DELIMITER_ROW.test(lines[index + 1]);
// Only when something would otherwise swallow it. A table already at a block
// boundary, after a blank line or after a fenced block, needs nothing: the
// fence has already ended the preceding block.
const previous = out[out.length - 1];
const atBlockBoundary =
previous === undefined || previous.trim().length === 0 || CLOSING_FENCE.test(previous);
if (startsTable && !atBlockBoundary) out.push("");
out.push(lines[index]);
index += 1;
}
return out.join("\n");
}
/**
* The full pre-render pass, in order.
*
* Promotion runs first so a promoted block is seen as an ordinary fence by the
* labeller, which is also harmless when promotion already inferred a language.
* Tables are separated first because they change block boundaries: a blank line
* has to exist before anything else looks at the line. Links are restored next
* so a recovered URL leaves no span for the promoter to fence again. Promotion
* then runs before labelling, so a promoted block is seen as an ordinary fence
* by the labeller, which is also harmless when promotion already inferred a
* language.
*/
export function repairCodeBlocks(markdown: string): string {
return labelCodeFences(promoteStandaloneCodeSpans(markdown));
return labelCodeFences(promoteStandaloneCodeSpans(restoreUrlLinks(separateTables(markdown))));
}