feat: label code fences so Vicinae can colour them (#10)
This commit was merged in pull request #10.
This commit is contained in:
+197
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user