From f6c847104532ec3068857239a9d2f6fa3bbfb02e Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 4 Oct 2026 05:36:21 +0800 Subject: [PATCH] feat: label code fences so Vicinae can colour them Nothing needed building for the dark code panel: MdCodeBlock.qml already provides a rounded secondaryBackground panel, a header naming the language, a Copy button with "Copied!" feedback, and horizontal scroll. The language string is the only missing piece, and it is also 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. Two changes: - The default prompt now requires a language on every fence, and forbids leaving commands inline in a sentence or padding them with a leading $, so commands stay copy-pasteable. - code.ts labels fences the model left bare. It is deliberately conservative, because a wrong label is worse than none: it claims bash only on a clear shell signal, json only when the body actually parses, ts only on TypeScript-specific syntax, and leaves plain JavaScript alone rather than guessing. Verified by rendering a probe with four fences and reading the result: ```bash -> "bash" label, "git" coloured ``` -> no label, no colouring ```ts -> "ts" label, const/number/length coloured ```terminal -> "terminal" label, no colouring `bash` is used over `terminal` for shell commands precisely because `terminal` costs the colouring. Also fixes an index bug in labelCodeFences found by its own test: the unlabelled branch pushed the wrong line back, duplicating code instead of preserving the opening fence. typecheck, eslint, 156 tests and vici build all pass. --- README.md | 3 + src/__tests__/code.test.ts | 129 +++++++++++++++++++++ src/__tests__/markdown.test.ts | 19 ++++ src/lib/code.ts | 197 +++++++++++++++++++++++++++++++++ src/lib/constants.ts | 2 + src/lib/markdown.ts | 9 +- 6 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/code.test.ts create mode 100644 src/lib/code.ts diff --git a/README.md b/README.md index 5c6538b..8198b93 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts new file mode 100644 index 0000000..3eb7a9d --- /dev/null +++ b/src/__tests__/code.test.ts @@ -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"); + }); +}); diff --git a/src/__tests__/markdown.test.ts b/src/__tests__/markdown.test.ts index c04c3d6..1503eac 100644 --- a/src/__tests__/markdown.test.ts +++ b/src/__tests__/markdown.test.ts @@ -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({ diff --git a/src/lib/code.ts b/src/lib/code.ts new file mode 100644 index 0000000..617f3a7 --- /dev/null +++ b/src/lib/code.ts @@ -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"); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 6a9bfe4..a892c89 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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.`; diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 784236c..8a9b0df 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -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