feat: label code fences so Vicinae can colour them #10
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,25 @@ describe("buildThreadMarkdown", () => {
|
||||
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", () => {
|
||||
const markdown = buildThreadMarkdown([
|
||||
message({
|
||||
|
||||
+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