diff --git a/src/__tests__/code.test.ts b/src/__tests__/code.test.ts index 3eb7a9d..d774ef7 100644 --- a/src/__tests__/code.test.ts +++ b/src/__tests__/code.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { inferLanguage, labelCodeFences } from "../lib/code"; +import { + inferLanguage, + labelCodeFences, + promoteStandaloneCodeSpans, + repairCodeBlocks, +} from "../lib/code"; describe("inferLanguage", () => { it("returns nothing for empty or whitespace-only code", () => { @@ -79,6 +84,71 @@ describe("inferLanguage", () => { }); }); +describe("promoteStandaloneCodeSpans", () => { + it("promotes a paragraph that is one inline span", () => { + // The reported case: asking for a command and getting a lone chip back. + expect(promoteStandaloneCodeSpans("`pwd`")).toBe("```bash\npwd\n```"); + }); + + it("promotes the same span on its own line within a larger answer", () => { + const promoted = promoteStandaloneCodeSpans("Here you go:\n\n`pwd`\n\nThat prints the path."); + + expect(promoted).toContain("```bash\npwd\n```"); + expect(promoted).toContain("Here you go:"); + expect(promoted).toContain("That prints the path."); + }); + + it("tolerates a sentence-ending period", () => { + expect(promoteStandaloneCodeSpans("`pwd`.")).toBe("```bash\npwd\n```"); + }); + + it("leaves a span embedded in a sentence alone", () => { + const input = "Run `pwd` to see where you are."; + expect(promoteStandaloneCodeSpans(input)).toBe(input); + }); + + it("leaves a bulleted span alone", () => { + const input = "- `pwd -P` resolves symlinks"; + expect(promoteStandaloneCodeSpans(input)).toBe(input); + }); + + it("never rewrites a span-like line inside an existing fence", () => { + const input = "```bash\n`not-a-span`\n```"; + expect(promoteStandaloneCodeSpans(input)).toBe(input); + }); + + it("fences a span whose language cannot be inferred, without guessing one", () => { + expect(promoteStandaloneCodeSpans("`Get-Location`")).toBe("```\nGet-Location\n```"); + }); + + it("is idempotent", () => { + const once = promoteStandaloneCodeSpans("`pwd`"); + expect(promoteStandaloneCodeSpans(once)).toBe(once); + }); + + it("is a no-op on ordinary prose", () => { + const input = "There is no code here at all."; + expect(promoteStandaloneCodeSpans(input)).toBe(input); + }); +}); + +describe("repairCodeBlocks", () => { + it("promotes a lone command and labels it in one pass", () => { + expect(repairCodeBlocks("`pwd`")).toBe("```bash\npwd\n```"); + }); + + it("still labels a bare multi-line fence", () => { + expect(repairCodeBlocks("```\ngit status\n```")).toBe("```bash\ngit status\n```"); + }); + + it("handles a promoted block and a plain fence in the same answer", () => { + const repaired = repairCodeBlocks("First:\n\n`pwd`\n\nThen:\n\n```\nls -la\n```"); + + expect(repaired).toContain("```bash\npwd\n```"); + expect(repaired).toContain("```bash\nls -la\n```"); + }); +}); + describe("labelCodeFences", () => { it("labels a bare fence", () => { const labelled = labelCodeFences("```\ngit status\n```"); @@ -123,6 +193,27 @@ describe("labelCodeFences", () => { expect(labelCodeFences(once)).toBe(once); }); + it("does not mistake a closing fence for a new opening one", () => { + // A closing ``` and an opening bare ``` are identical lines. Treating the + // closing one as an opening fence swallowed everything after it, so the + // second block never got labelled. + const input = "```bash\npwd\n```\n\n```\nls -la\n```"; + expect(labelCodeFences(input)).toContain("```bash\nls -la\n```"); + }); + + it("labels a later block even when an earlier one is already labelled", () => { + const input = "```rust\nfn main() {}\n```\n\n```\npwd\n```"; + const labelled = labelCodeFences(input); + + expect(labelled).toContain("```rust"); + expect(labelled).toContain("```bash\npwd\n```"); + }); + + it("preserves a language the model chose, including multi-word freeform tags", () => { + expect(labelCodeFences("```c++\nint main() {}\n```")).toContain("```c++"); + expect(labelCodeFences("```objective-c\nint x;\n```")).toContain("```objective-c"); + }); + it("preserves indentation of a nested fence", () => { expect(labelCodeFences(" ```\n ls\n ```")).toContain(" ```bash"); }); diff --git a/src/lib/code.ts b/src/lib/code.ts index 617f3a7..418a544 100644 --- a/src/lib/code.ts +++ b/src/lib/code.ts @@ -1,5 +1,5 @@ /** - * Labels fenced code blocks the model left unlabelled. + * Fixups for code the model wrote as markdown. * * Vicinae renders fences through `MdCodeBlock.qml`, which gives a dark panel, a * header showing the language, a Copy button, and syntax colouring resolved by @@ -9,8 +9,7 @@ * 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. + * and inference stays deliberately conservative. */ /** First words that mark a block as shell rather than a programming language. */ @@ -75,6 +74,20 @@ const SHELL_COMMANDS = new Set([ "zip", ]); +const FENCE = "```"; +/** + * An opening fence, with an optional language. + * + * The language has to be part of this pattern. A closing ``` and an opening + * bare ``` are the same two lines apart from context, so a pattern that only + * matched bare fences treated every closing fence as a new opening one and + * swallowed the rest of the answer. + */ +const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*([^\s`~]*)\s*$/; +/** A closing fence is the marker and nothing else. */ +const CLOSING_FENCE = /^\s*(`{3,}|~{3,})\s*$/; +const STANDALONE_CODE_SPAN = /^\s*`([^`\n]+)`\s*[.!]?\s*$/; + function significantLines(code: string): string[] { return code .split("\n") @@ -153,7 +166,65 @@ export function inferLanguage(code: string): string | undefined { return undefined; } -const OPENING_FENCE = /^(\s*)(`{3,}|~{3,})\s*$/; +/** Copies a fenced block through verbatim, returning the index just past it. */ +function copyFence(lines: string[], out: string[], start: number): number { + let index = start; + + out.push(lines[index]); + index += 1; + + while (index < lines.length && !CLOSING_FENCE.test(lines[index])) { + out.push(lines[index]); + index += 1; + } + + if (index < lines.length) { + out.push(lines[index]); + index += 1; + } + + return index; +} + +/** + * Turns a paragraph that is nothing but one inline code span into a fence. + * + * Models reliably fence multi-line code and just as reliably answer "what is the + * command for X" with a lone `` `pwd` ``. A single inline chip is the worst + * presentation for that answer: no Copy button, no language, no colouring. The + * rule is narrow on purpose, a whole line that is exactly one span, so + * ``run `pwd` to check`` is left alone while a standalone command is promoted. + * + * Existing fences are copied through untouched, so a span-like line inside a + * code block is never rewritten. + */ +export function promoteStandaloneCodeSpans(markdown: string): string { + const lines = markdown.split("\n"); + const out: string[] = []; + let index = 0; + + while (index < lines.length) { + const fence = OPENING_FENCE.exec(lines[index]); + + if (fence) { + index = copyFence(lines, out, index); + continue; + } + + const span = STANDALONE_CODE_SPAN.exec(lines[index]); + if (span) { + const code = span[1].trim(); + out.push(FENCE + (inferLanguage(code) ?? ""), code, FENCE); + index += 1; + continue; + } + + out.push(lines[index]); + index += 1; + } + + return out.join("\n"); +} /** * Adds a language to fences that have none, leaving labelled fences untouched. @@ -175,11 +246,11 @@ export function labelCodeFences(markdown: string): string { continue; } - const [, indent, fence] = opener; + const [, indent, marker, existing] = opener; const body: string[] = []; index += 1; - while (index < lines.length && !lines[index].trimStart().startsWith(fence)) { + while (index < lines.length && !CLOSING_FENCE.test(lines[index])) { body.push(lines[index]); index += 1; } @@ -187,11 +258,26 @@ export function labelCodeFences(markdown: string): string { const closing = index < lines.length ? lines[index] : ""; if (closing) index += 1; - const language = inferLanguage(body.join("\n")); - out.push(language ? `${indent}${fence}${language}` : openingLine); + // A fence the model already labelled is left exactly as written. + if (existing) out.push(openingLine); + else { + const language = inferLanguage(body.join("\n")); + out.push(language ? `${indent}${marker}${language}` : openingLine); + } + out.push(...body); if (closing) out.push(closing); } return out.join("\n"); } + +/** + * The full pre-render pass, in order. + * + * Promotion runs first so a promoted block is seen as an ordinary fence by the + * labeller, which is also harmless when promotion already inferred a language. + */ +export function repairCodeBlocks(markdown: string): string { + return labelCodeFences(promoteStandaloneCodeSpans(markdown)); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index a892c89..07af547 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,20 +20,23 @@ export const MAX_STORED_CHARS_PER_TURN = 20_000; /** How much of a prompt becomes the session title in the session list. */ export const TITLE_MAX_LENGTH = 60; -export const DEFAULT_SYSTEM_PROMPT = `You are Lam, a concise assistant in the Vicinae launcher on the user's desktop. +export const DEFAULT_SYSTEM_PROMPT = `You are Lam, a friendly assistant in the Vicinae launcher on the user's desktop. -Answer first, detail after. No preamble. +Sound like a person, not a document. Use contractions, talk about "you" and "I", and a short natural remark is welcome ("Sure, easy one", "Ah, that trips people up"). Performed enthusiasm and filler added to seem helpful are not. + +Lead with the answer, then the detail. This renders in a narrow window, so stay brief: a tight sentence or two, or a short list, then stop. Keep paragraphs short and skip large headings and wide tables. Write plainly: - Never use an em dash. Use a comma, a colon, or a full stop. -- No generated-text tells: "it's not just X, it's Y", "not only... but also", "the real question is". No openers like "Great question" or "Certainly". No "I hope this helps". +- Skip stock service-desk phrasing: "Certainly", "I'd be happy to help", "Great question", "I hope this helps", "Let me know if you need anything else". Warmth that means something beats politeness that means nothing. +- No generated-text tells: "it's not just X, it's Y", "not only... but also", "the real question is". - Avoid delve, tapestry, landscape, leverage, robust, seamless, realm, testament. - Do not restate the question or narrate your process. - Bold only the direct answer to a factual question, never for emphasis in prose. -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 name the language: bash for shell and terminal commands, then json, sql, yaml, or the language for code (ts, js, python, rust, go). Never leave code inline in a sentence, and never open a fence without a language. Put no $ or shell prompt inside the block so it can be copied and run as-is. Ignore the formatting of earlier answers in this conversation: if one put a command in inline code, fence it anyway. -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. +A fenced block is never a whole answer on its own. Say in one plain sentence what the code does or when to reach for it, so the reply reads like something a person wrote rather than a bare snippet. 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. diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 8a9b0df..eb41750 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,4 +1,4 @@ -import { labelCodeFences } from "./code"; +import { repairCodeBlocks } from "./code"; import type { ChatTurn, ThreadMessage } from "./types"; /** @@ -55,9 +55,10 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri // stands in and the user can see what Lam is actually doing. const placeholder = message.status ? `_${message.status}…_` : "_Thinking…_"; 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()) + ? // Fences without a language render as a plain dark panel, and a lone + // inline command renders as a chip with no Copy button at all. Both are + // repaired before the markdown reaches the renderer. + repairCodeBlocks(message.text.trim()) : message.isStreaming ? placeholder : "_No response._";