feat: label code fences so Vicinae can colour them (#10)

This commit was merged in pull request #10.
This commit is contained in:
2026-10-03 21:37:11 +00:00
parent c49ffeb274
commit ac2da73669
6 changed files with 358 additions and 1 deletions
+197
View File
@@ -0,0 +1,197 @@
/**
* Labels fenced code blocks the model left unlabelled.
*
* 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 this stays deliberately conservative: it only labels a block when there is
* a clear signal, and leaves anything ambiguous alone.
*/
/** 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",
]);
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;
}
const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*$/;
/**
* 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, fence] = opener;
const body: string[] = [];
index += 1;
while (index < lines.length && !lines[index].trimStart().startsWith(fence)) {
body.push(lines[index]);
index += 1;
}
const closing = index < lines.length ? lines[index] : "";
if (closing) index += 1;
const language = inferLanguage(body.join("\n"));
out.push(language ? `${indent}${fence}${language}` : openingLine);
out.push(...body);
if (closing) out.push(closing);
}
return out.join("\n");
}
+2
View File
@@ -33,6 +33,8 @@ Write plainly:
You render in a narrow window: keep paragraphs short, prefer compact lists, and skip large headings and wide tables.
Fence every command and every snippet, and always give the fence a language: bash for shell and terminal commands, then json, sql, yaml, or the language name for code (ts, js, python, rust, go). Never leave code inline in a sentence, and never open a fence without a language. Put commands on their own lines, with no leading $ or shell prompt, so they can be copied and run as-is.
Call a tool when it makes the answer more accurate. Anything time-sensitive or past your cutoff should be checked rather than guessed, and never invent tool output. If a tool fails, say so and answer with what you know.
Say what you are unsure about rather than hedging.`;
+8 -1
View File
@@ -1,3 +1,4 @@
import { labelCodeFences } from "./code";
import type { ChatTurn, ThreadMessage } from "./types";
/**
@@ -53,7 +54,13 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
// While a tool is running there is no answer text yet, so the activity line
// stands in and the user can see what Lam is actually doing.
const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_";
const body = message.text.trim() || (message.isStreaming ? placeholder : "_No response._");
const body = message.text.trim()
? // Fences without a language render as a plain dark panel; labelling them
// lets Vicinae colour the code (`MdCodeBlock.qml` + KSyntaxHighlighting).
labelCodeFences(message.text.trim())
: message.isStreaming
? placeholder
: "_No response._";
const sections = [message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
// The one thing allowed to break the plain-text rule, because it means