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

Merged
adrianbonpin merged 1 commits from feat/code-blocks into prod 2026-10-03 21:37:12 +00:00
6 changed files with 358 additions and 1 deletions
Showing only changes of commit f6c8471045 - Show all commits
+3
View File
@@ -14,6 +14,7 @@ Hit a global shortcut, type a question, get a streamed answer — without leavin
- **Global hotkey, instant input.** Bind a shortcut to the chat command and the search bar is focused before you finish the keystroke. - **Global hotkey, instant input.** Bind a shortcut to the chat command and the search bar is focused before you finish the keystroke.
- **Streaming answers.** Tokens render as they arrive, in a markdown side panel. - **Streaming answers.** Tokens render as they arrive, in a markdown side panel.
- **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off — unless it has been idle longer than you allow. - **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off — unless it has been idle longer than you allow.
- **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
- **Tools.** Lam decides when to call them — current time, location context, web search, and page fetching. - **Tools.** Lam decides when to call them — current time, location context, web search, and page fetching.
- **Chat only.** Lam answers questions. It does not touch your files or run commands. - **Chat only.** Lam answers questions. It does not touch your files or run commands.
@@ -158,6 +159,8 @@ A few decisions worth knowing about before you change things:
**The transcript is text-only, and nothing in it is bold except an error.** The answer is what gets read, so it is plain body text carrying no label; the question is a blockquote, which the renderer draws dimmed, italic, and with a left rule. A `**You**` / `**Lam**` header on each exchange gave both speakers identical weight and made the answer sit behind chrome. Tool calls collapse to one italic line (_Tools: `web_search`_) rather than a bold header plus a list, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control. **The transcript is text-only, and nothing in it is bold except an error.** The answer is what gets read, so it is plain body text carrying no label; the question is a blockquote, which the renderer draws dimmed, italic, and with a left rule. A `**You**` / `**Lam**` header on each exchange gave both speakers identical weight and made the answer sit behind chrome. Tool calls collapse to one italic line (_Tools: `web_search`_) rather than a bold header plus a list, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control.
**Code fences are labelled so Vicinae can colour them.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all — a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, and the default prompt asks for a language on every fence.
**Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky. **Streamed deltas are buffered in refs and flushed to state on a timer** (`FLUSH_INTERVAL_MS`). Re-rendering the transcript per token makes the launcher visibly janky.
**The logic layer has no Vicinae imports.** `agent.ts`, `ollama.ts`, `tools.ts` and friends are plain TypeScript, which is why they unit test without a runtime. A test enforces this. **The logic layer has no Vicinae imports.** `agent.ts`, `ollama.ts`, `tools.ts` and friends are plain TypeScript, which is why they unit test without a runtime. A test enforces this.
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { inferLanguage, labelCodeFences } from "../lib/code";
describe("inferLanguage", () => {
it("returns nothing for empty or whitespace-only code", () => {
expect(inferLanguage("")).toBeUndefined();
expect(inferLanguage(" \n ")).toBeUndefined();
});
describe("shell", () => {
it("recognises a command as the first word", () => {
expect(inferLanguage("pwd")).toBe("bash");
expect(inferLanguage("git status")).toBe("bash");
expect(inferLanguage("brew install ripgrep")).toBe("bash");
});
it("recognises an explicit prompt marker", () => {
expect(inferLanguage("$ git status")).toBe("bash");
expect(inferLanguage("> ls -la")).toBe("bash");
});
it("recognises a home-relative path", () => {
expect(inferLanguage("~/Documents/Code")).toBe("bash");
});
it("recognises a pipeline between known commands", () => {
expect(inferLanguage("git log --oneline | head -20")).toBe("bash");
});
it("ignores comments when looking at the first word", () => {
expect(inferLanguage("# check the branch\ngit branch --show-current")).toBe("bash");
});
});
describe("json", () => {
it("recognises a parseable object", () => {
expect(inferLanguage('{\n "model": "gpt-oss"\n}')).toBe("json");
});
it("recognises a parseable array", () => {
expect(inferLanguage('["a", "b"]')).toBe("json");
});
it("does not claim malformed braces", () => {
expect(inferLanguage("{ not actually json")).not.toBe("json");
});
});
describe("typescript", () => {
it("recognises type annotations", () => {
expect(inferLanguage("function add(a: number, b: number) {\n return a + b;\n}")).toBe("ts");
});
it("recognises interfaces and type aliases", () => {
expect(inferLanguage("interface User {\n id: string;\n}")).toBe("ts");
expect(inferLanguage("type Id = string | number;")).toBe("ts");
});
it("does not label plain JavaScript, which is ambiguous", () => {
// Guessing wrong costs the highlighting, so this stays unlabelled.
expect(inferLanguage("console.log('hi')")).toBeUndefined();
expect(inferLanguage("const x = [1, 2, 3].map((n) => n * 2);")).toBeUndefined();
});
});
describe("python", () => {
it("recognises a function definition", () => {
expect(inferLanguage("def greet(name):\n return f'hi {name}'")).toBe("python");
});
it("recognises imports and self", () => {
expect(inferLanguage("from pathlib import Path")).toBe("python");
expect(inferLanguage("class A:\n def go(self):\n pass")).toBe("python");
});
});
it("leaves genuinely ambiguous content alone", () => {
expect(inferLanguage("some plain text\nacross two lines")).toBeUndefined();
});
});
describe("labelCodeFences", () => {
it("labels a bare fence", () => {
const labelled = labelCodeFences("```\ngit status\n```");
expect(labelled).toBe("```bash\ngit status\n```");
});
it("leaves an already labelled fence exactly as the model wrote it", () => {
expect(labelCodeFences("```rust\nfn main() {}\n```")).toBe("```rust\nfn main() {}\n```");
});
it("labels each block independently", () => {
const labelled = labelCodeFences(["```", "pwd", "```", "", "```", '{"a": 1}', "```"].join("\n"));
expect(labelled).toContain("```bash");
expect(labelled).toContain("```json");
});
it("leaves an unlabellable block bare rather than guessing", () => {
const input = "```\njust some words\n```";
expect(labelCodeFences(input)).toBe(input);
});
it("preserves surrounding prose", () => {
const labelled = labelCodeFences("Before:\n\n```\nls\n```\n\nAfter.");
expect(labelled).toContain("Before:");
expect(labelled).toContain("After.");
expect(labelled).toContain("```bash");
});
it("handles a fence that is never closed", () => {
expect(labelCodeFences("```\ngit log")).toContain("```bash");
});
it("is a no-op on markdown with no fences", () => {
const input = "Just a paragraph with `inline code` in it.";
expect(labelCodeFences(input)).toBe(input);
});
it("is idempotent", () => {
const once = labelCodeFences("```\nls\n```");
expect(labelCodeFences(once)).toBe(once);
});
it("preserves indentation of a nested fence", () => {
expect(labelCodeFences(" ```\n ls\n ```")).toContain(" ```bash");
});
});
+19
View File
@@ -127,6 +127,25 @@ describe("buildThreadMarkdown", () => {
expect(markdown).toContain("Ollama rejected the API key."); expect(markdown).toContain("Ollama rejected the API key.");
}); });
it("labels a bare code fence inside an answer", () => {
// Vicinae gives a dark panel, a language header and a Copy button
// (`MdCodeBlock.qml`); the label is what turns on syntax colouring.
const markdown = buildThreadMarkdown([
message({ role: "assistant", text: "Run this:\n\n```\ngit status\n```" }),
]);
expect(markdown).toContain("```bash");
});
it("leaves a fence the model already labelled alone", () => {
const markdown = buildThreadMarkdown([
message({ role: "assistant", text: "```rust\nfn main() {}\n```" }),
]);
expect(markdown).toContain("```rust");
expect(markdown).not.toContain("```bash");
});
it("lists tools on one quiet line, flagging failures", () => { it("lists tools on one quiet line, flagging failures", () => {
const markdown = buildThreadMarkdown([ const markdown = buildThreadMarkdown([
message({ message({
+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. 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. 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.`; 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"; 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 // 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. // stands in and the user can see what Lam is actually doing.
const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_"; 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]; const sections = [message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body];
// The one thing allowed to break the plain-text rule, because it means // The one thing allowed to break the plain-text rule, because it means