fix: fence lone commands and give the prompt some warmth #12
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { inferLanguage, labelCodeFences } from "../lib/code";
|
import {
|
||||||
|
inferLanguage,
|
||||||
|
labelCodeFences,
|
||||||
|
promoteStandaloneCodeSpans,
|
||||||
|
repairCodeBlocks,
|
||||||
|
} from "../lib/code";
|
||||||
|
|
||||||
describe("inferLanguage", () => {
|
describe("inferLanguage", () => {
|
||||||
it("returns nothing for empty or whitespace-only code", () => {
|
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", () => {
|
describe("labelCodeFences", () => {
|
||||||
it("labels a bare fence", () => {
|
it("labels a bare fence", () => {
|
||||||
const labelled = labelCodeFences("```\ngit status\n```");
|
const labelled = labelCodeFences("```\ngit status\n```");
|
||||||
@@ -123,6 +193,27 @@ describe("labelCodeFences", () => {
|
|||||||
expect(labelCodeFences(once)).toBe(once);
|
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", () => {
|
it("preserves indentation of a nested fence", () => {
|
||||||
expect(labelCodeFences(" ```\n ls\n ```")).toContain(" ```bash");
|
expect(labelCodeFences(" ```\n ls\n ```")).toContain(" ```bash");
|
||||||
});
|
});
|
||||||
|
|||||||
+94
-8
@@ -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
|
* Vicinae renders fences through `MdCodeBlock.qml`, which gives a dark panel, a
|
||||||
* header showing the language, a Copy button, and syntax colouring resolved by
|
* 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,
|
* 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
|
* 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,
|
* 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
|
* and inference stays deliberately conservative.
|
||||||
* a clear signal, and leaves anything ambiguous alone.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** First words that mark a block as shell rather than a programming language. */
|
/** First words that mark a block as shell rather than a programming language. */
|
||||||
@@ -75,6 +74,20 @@ const SHELL_COMMANDS = new Set([
|
|||||||
"zip",
|
"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[] {
|
function significantLines(code: string): string[] {
|
||||||
return code
|
return code
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -153,7 +166,65 @@ export function inferLanguage(code: string): string | undefined {
|
|||||||
return 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.
|
* Adds a language to fences that have none, leaving labelled fences untouched.
|
||||||
@@ -175,11 +246,11 @@ export function labelCodeFences(markdown: string): string {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [, indent, fence] = opener;
|
const [, indent, marker, existing] = opener;
|
||||||
const body: string[] = [];
|
const body: string[] = [];
|
||||||
index += 1;
|
index += 1;
|
||||||
|
|
||||||
while (index < lines.length && !lines[index].trimStart().startsWith(fence)) {
|
while (index < lines.length && !CLOSING_FENCE.test(lines[index])) {
|
||||||
body.push(lines[index]);
|
body.push(lines[index]);
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
@@ -187,11 +258,26 @@ export function labelCodeFences(markdown: string): string {
|
|||||||
const closing = index < lines.length ? lines[index] : "";
|
const closing = index < lines.length ? lines[index] : "";
|
||||||
if (closing) index += 1;
|
if (closing) index += 1;
|
||||||
|
|
||||||
const language = inferLanguage(body.join("\n"));
|
// A fence the model already labelled is left exactly as written.
|
||||||
out.push(language ? `${indent}${fence}${language}` : openingLine);
|
if (existing) out.push(openingLine);
|
||||||
|
else {
|
||||||
|
const language = inferLanguage(body.join("\n"));
|
||||||
|
out.push(language ? `${indent}${marker}${language}` : openingLine);
|
||||||
|
}
|
||||||
|
|
||||||
out.push(...body);
|
out.push(...body);
|
||||||
if (closing) out.push(closing);
|
if (closing) out.push(closing);
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.join("\n");
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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. */
|
/** How much of a prompt becomes the session title in the session list. */
|
||||||
export const TITLE_MAX_LENGTH = 60;
|
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:
|
Write plainly:
|
||||||
- Never use an em dash. Use a comma, a colon, or a full stop.
|
- 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.
|
- Avoid delve, tapestry, landscape, leverage, robust, seamless, realm, testament.
|
||||||
- Do not restate the question or narrate your process.
|
- Do not restate the question or narrate your process.
|
||||||
- Bold only the direct answer to a factual question, never for emphasis in prose.
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -1,4 +1,4 @@
|
|||||||
import { labelCodeFences } from "./code";
|
import { repairCodeBlocks } from "./code";
|
||||||
import type { ChatTurn, ThreadMessage } from "./types";
|
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.
|
// 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()
|
const body = message.text.trim()
|
||||||
? // Fences without a language render as a plain dark panel; labelling them
|
? // Fences without a language render as a plain dark panel, and a lone
|
||||||
// lets Vicinae colour the code (`MdCodeBlock.qml` + KSyntaxHighlighting).
|
// inline command renders as a chip with no Copy button at all. Both are
|
||||||
labelCodeFences(message.text.trim())
|
// repaired before the markdown reaches the renderer.
|
||||||
|
repairCodeBlocks(message.text.trim())
|
||||||
: message.isStreaming
|
: message.isStreaming
|
||||||
? placeholder
|
? placeholder
|
||||||
: "_No response._";
|
: "_No response._";
|
||||||
|
|||||||
Reference in New Issue
Block a user