/** * Fixups for code the model wrote as markdown. * * Vicinae renders fences through `MdCodeBlock.qml`, which gives a dark panel, a * header showing the language, a Copy button, and syntax colouring resolved by * KSyntaxHighlighting. That last part is why the label matters beyond * cosmetics: `syntax::highlight` looks the language up with * `definitionForName(language)` and then `definitionForFileName("file." + language)`, * so `ts` resolves through `file.ts` and `bash` through its definition name, * while an unrecognised string such as `terminal` still renders as a label but * produces no highlighting at all. A wrong label is therefore worse than none, * and inference stays deliberately conservative. */ /** First words that mark a block as shell rather than a programming language. */ const SHELL_COMMANDS = new Set([ "apt", "brew", "bun", "cat", "cd", "chmod", "chown", "code", "cp", "curl", "df", "docker", "du", "echo", "export", "find", "gh", "git", "grep", "htop", "jq", "kill", "kubectl", "ln", "ls", "make", "man", "mkdir", "mv", "nano", "node", "npm", "npx", "open", "pacman", "pip", "pnpm", "ps", "psql", "pwd", "python", "rm", "rsync", "scp", "sed", "source", "ssh", "sudo", "systemctl", "tar", "top", "touch", "unzip", "vim", "wget", "which", "yarn", "zip", ]); const FENCE = "```"; /** * An opening fence, with an optional language. * * The language has to be part of this pattern. A closing ``` and an opening * bare ``` are the same two lines apart from context, so a pattern that only * matched bare fences treated every closing fence as a new opening one and * swallowed the rest of the answer. */ const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/; /** A closing fence is the marker and nothing else. */ 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 ``, 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") .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("#") && !line.startsWith("//")); } function looksLikeShell(code: string): boolean { const lines = significantLines(code); if (lines.length === 0) return false; // An explicit prompt marker, or a leading `~` path, is about as clear as it gets. if (lines.some((line) => /^[$>] /.test(line) || line.startsWith("~/"))) return true; // A shell command as the first word of the first meaningful line. const [firstWord] = lines[0].split(/\s+/); if (SHELL_COMMANDS.has(firstWord)) return true; // Failing that, a pipeline or chained command between two known commands. if (/[|&]{1,2}/.test(code)) { return lines.some((line) => { const [word] = line.split(/\s+/); return SHELL_COMMANDS.has(word.replace(/[|&].*$/, "")); }); } return false; } function looksLikeJson(code: string): boolean { const trimmed = code.trim(); if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false; try { const parsed: unknown = JSON.parse(trimmed); return typeof parsed === "object" && parsed !== null; } catch { return false; } } /** TypeScript-specific syntax. Plain JS is left unlabelled rather than guessed at. */ function looksLikeTypeScript(code: string): boolean { return ( /\binterface\s+\w+\s*\{/.test(code) || /\btype\s+\w+\s*=/.test(code) || /\bas\s+const\b/.test(code) || /:\s*(string|number|boolean|void|unknown|never)\b/.test(code) || /<\w+>(\s*\(|\s*=|\s*=>)/.test(code) || /\b(implements|readonly)\s+/.test(code) ); } function looksLikePython(code: string): boolean { return ( /^\s*def\s+\w+\s*\(/m.test(code) || /^\s*from\s+[\w.]+\s+import\s+/m.test(code) || /^\s*import\s+\w+$/m.test(code) || /\bself\./.test(code) || /^\s*(async\s+)?def\s+/m.test(code) ); } /** * Best-effort language tag, or `undefined` when nothing is clear enough. * Order matters: a shell pipeline can contain what look like code fragments. */ export function inferLanguage(code: string): string | undefined { if (code.trim().length === 0) return undefined; if (looksLikeShell(code)) return "bash"; if (looksLikeJson(code)) return "json"; if (looksLikeTypeScript(code)) return "ts"; if (looksLikePython(code)) return "python"; return undefined; } /** Copies a fenced block through verbatim, returning the index just past it. */ function copyFence(lines: string[], out: string[], start: number): number { let index = start; out.push(lines[index]); index += 1; while (index < lines.length && !CLOSING_FENCE.test(lines[index])) { out.push(lines[index]); index += 1; } if (index < lines.length) { out.push(lines[index]); index += 1; } return index; } /** * Turns a paragraph that is nothing but one inline code span into a fence. * * Models reliably fence multi-line code and just as reliably answer "what is the * command for X" with a lone `` `pwd` ``. A single inline chip is the worst * presentation for that answer: no Copy button, no language, no colouring. The * rule is narrow on purpose, a whole line that is exactly one span, so * ``run `pwd` to check`` is left alone while a standalone command is promoted. * * Existing fences are copied through untouched, so a span-like line inside a * code block is never rewritten. */ export function promoteStandaloneCodeSpans(markdown: string): string { const lines = markdown.split("\n"); const out: string[] = []; let index = 0; while (index < lines.length) { const fence = OPENING_FENCE.exec(lines[index]); if (fence) { index = copyFence(lines, out, index); continue; } const span = STANDALONE_CODE_SPAN.exec(lines[index]); if (span) { const code = span[1].trim(); out.push(FENCE + (inferLanguage(code) ?? ""), code, FENCE); index += 1; continue; } out.push(lines[index]); index += 1; } return out.join("\n"); } /** * Adds a language to fences that have none, leaving labelled fences untouched. * * Only bare opening fences are rewritten, so the model's own choice always wins. */ export function labelCodeFences(markdown: string): string { const lines = markdown.split("\n"); const out: string[] = []; let index = 0; while (index < lines.length) { const openingLine = lines[index]; const opener = OPENING_FENCE.exec(openingLine); if (!opener) { out.push(openingLine); index += 1; continue; } const [, indent, marker, existing] = opener; const body: string[] = []; index += 1; while (index < lines.length && !CLOSING_FENCE.test(lines[index])) { body.push(lines[index]); index += 1; } const closing = index < lines.length ? lines[index] : ""; if (closing) index += 1; // A fence the model already labelled is left exactly as written. if (existing) out.push(openingLine); else { const language = inferLanguage(body.join("\n")); out.push(language ? `${indent}${marker}${language}` : openingLine); } out.push(...body); if (closing) out.push(closing); } 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. * * 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(restoreUrlLinks(separateTables(markdown)))); }