diff --git a/README.md b/README.md index 0a91987..5c6538b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this' | **Ollama API Key** | — | Required. Stored by Vicinae as a password preference. | | **Model** | — | Required. Any model your key can reach. | | **Ollama Host** | `https://ollama.com` | Set to `http://localhost:11434` for a local server. | -| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. | +| **System Prompt** | Lam's default | Replaces the built-in prompt entirely. The default asks for plain prose: no em dashes, no generated-text tells, no restating the question. | | **Web Tools** | on | Enables `web_search` and `web_fetch`. | | **Reasoning Effort** | Off | `low`/`medium`/`high` for models that support thinking. | | **Show Tool Calls** | on | Lists the tools Lam called under each answer. | @@ -156,7 +156,7 @@ A few decisions worth knowing about before you change things: **The transcript reads newest message first.** The search bar sits at the top of the window, so the most recent message — an answer, once one has been written — is the first line under it rather than below the fold. Within a single exchange that means the answer appears above the question it responds to. `buildThread` produces that order, and `buildThreadMarkdown` regroups messages back into exchanges so the horizontal rule separates one exchange from the next instead of dividing every message. -**The transcript is text-only.** Speaker labels are plain `You` / `Lam`, with the question dimmed as a blockquote and the answer as body text. Icons are not possible there: `MdImage` renders images block-level, centred, and capped at 200px tall, and every candidate source was tried — `icon://person`, a raw icon slug, and a `file://` asset path — and each fell back to rendering its alt text as a caption. +**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. **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. diff --git a/src/__tests__/markdown.test.ts b/src/__tests__/markdown.test.ts index 930ed19..c04c3d6 100644 --- a/src/__tests__/markdown.test.ts +++ b/src/__tests__/markdown.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "vitest"; import { - ASSISTANT_LABEL, STREAMING_CURSOR, - USER_LABEL, buildThreadMarkdown, quote, sessionToPlainText, @@ -38,12 +36,29 @@ describe("buildThreadMarkdown", () => { message({ role: "assistant", text: "4" }), ]); - expect(markdown).toContain("**You**"); expect(markdown).toContain("> What is 2+2?"); - expect(markdown).toContain("**Lam**"); expect(markdown).toContain("4"); }); + it("labels no speaker, so only the answer carries weight", () => { + // A `**You**` / `**Lam**` header on each exchange gave both speakers the + // same visual weight and pushed the answer behind chrome. + const markdown = buildThreadMarkdown([ + message({ role: "user", text: "q" }), + message({ role: "assistant", text: "a" }), + ]); + + expect(markdown).not.toContain("You"); + expect(markdown).not.toContain("Lam"); + // Nothing bold at all, other than an error. + expect(markdown).not.toContain("**"); + }); + + it("identifies the question by styling alone", () => { + const markdown = buildThreadMarkdown([message({ role: "user", text: "just a question" })]); + expect(markdown.trim()).toBe("> just a question"); + }); + it("groups an answer with its question, dividing only between exchanges", () => { const markdown = buildThreadMarkdown([ message({ id: "t2:assistant", role: "assistant", text: "second answer" }), @@ -59,17 +74,6 @@ describe("buildThreadMarkdown", () => { expect(markdown.indexOf("second question")).toBeLessThan(markdown.indexOf("first answer")); }); - it("labels each speaker, in order", () => { - const markdown = buildThreadMarkdown([ - message({ role: "user", text: "q" }), - message({ role: "assistant", text: "a" }), - ]); - - expect(markdown).toContain(`**${USER_LABEL}**`); - expect(markdown).toContain(`**${ASSISTANT_LABEL}**`); - expect(markdown.indexOf(USER_LABEL)).toBeLessThan(markdown.indexOf(ASSISTANT_LABEL)); - }); - it("renders no images, because markdown cannot resolve them", () => { // Images render block-level and centred at up to 200px (`MdImage.qml`), and // every candidate icon source fails. Keep the transcript text-only. @@ -123,7 +127,7 @@ describe("buildThreadMarkdown", () => { expect(markdown).toContain("Ollama rejected the API key."); }); - it("lists tools that were used, flagging failures", () => { + it("lists tools on one quiet line, flagging failures", () => { const markdown = buildThreadMarkdown([ message({ role: "assistant", @@ -135,8 +139,9 @@ describe("buildThreadMarkdown", () => { }), ]); - expect(markdown).toContain("`web_search`"); - expect(markdown).toContain("`web_fetch` — failed"); + expect(markdown).toContain("_Tools: `web_search`, `web_fetch` (failed)_"); + // Italic, not a bold header, so it stays below the answer. + expect(markdown).not.toContain("**Tools"); }); it("omits tools and reasoning when the preferences are off", () => { @@ -151,7 +156,7 @@ describe("buildThreadMarkdown", () => { const hidden = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: false }); - expect(hidden).not.toContain("Tools used"); + expect(hidden).not.toContain("_Tools:"); expect(hidden).not.toContain("web_search"); expect(hidden).not.toContain("Reasoning"); expect(hidden).not.toContain("let me think"); @@ -170,11 +175,11 @@ describe("buildThreadMarkdown", () => { ]; const toolsOnly = buildThreadMarkdown(messages, { showToolCalls: true, showReasoning: false }); - expect(toolsOnly).toContain("Tools used"); + expect(toolsOnly).toContain("_Tools:"); expect(toolsOnly).not.toContain("Reasoning"); const reasoningOnly = buildThreadMarkdown(messages, { showToolCalls: false, showReasoning: true }); - expect(reasoningOnly).not.toContain("Tools used"); + expect(reasoningOnly).not.toContain("_Tools:"); expect(reasoningOnly).toContain("Reasoning"); }); @@ -198,12 +203,25 @@ describe("sessionToPlainText", () => { it("keeps every question and answer", () => { const text = sessionToPlainText([turn("q1", "a1"), turn("q2", "a2")]); - expect(text).toContain("## q1"); + expect(text).toContain("q1"); expect(text).toContain("a1"); - expect(text).toContain("## q2"); + expect(text).toContain("q2"); expect(text).toContain("a2"); }); + it("labels the speakers, because the transcript's styling does not survive a clipboard", () => { + const text = sessionToPlainText([turn("q1", "a1")]); + + expect(text).toContain("**You**"); + expect(text).toContain("**Lam**"); + expect(text.indexOf("q1")).toBeLessThan(text.indexOf("a1")); + }); + + it("reads oldest first, unlike the transcript", () => { + const text = sessionToPlainText([turn("first", "a1"), turn("second", "a2")]); + expect(text.indexOf("first")).toBeLessThan(text.indexOf("second")); + }); + it("includes the failure when a turn errored", () => { expect(sessionToPlainText([turn("q", "", "Ollama rejected the API key.")])).toContain( "Ollama rejected the API key.", diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 704e1c9..6a9bfe4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,13 +20,22 @@ 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 built into the Vicinae launcher on the user's desktop. +export const DEFAULT_SYSTEM_PROMPT = `You are Lam, a concise assistant in the Vicinae launcher on the user's desktop. -- Lead with the answer, then add supporting detail. Do not pad with pleasantries. -- You render inside a narrow launcher window: keep paragraphs short and prefer tight markdown lists. Avoid wide tables and large headings. -- Call the tools you are given when they would make your answer more accurate. Anything time-sensitive, or after your training cutoff, should be checked with a tool rather than guessed. -- Never invent tool output. If a tool fails, say so plainly and answer with what you already know. -- When you are unsure, say what you are unsure about instead of hedging vaguely.`; +Answer first, detail after. No preamble. + +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". +- 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. + +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.`; /** Extra instruction appended when the user has web tools enabled and the question looks time-sensitive. */ export const RECENCY_HINT = diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 6b7785d..784236c 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -6,28 +6,21 @@ import type { ChatTurn, ThreadMessage } from "./types"; * Kept as pure functions so the transcript layout can be tested without * mounting a component. * - * The user/assistant distinction is carried by markdown itself: user messages - * render as blockquotes, which the renderer draws in a dimmed italic style, - * while answers render as normal body text. Raycast's chat uses right-aligned - * bubbles for the same job, but markdown offers no alignment control, so the - * dim/bright split is the available equivalent. + * ## Emphasis + * + * Nothing here is bold except an error. The answer is what is being read, so it + * is plain body text and carries no label. The question is a blockquote, which + * the renderer draws dimmed, italic, and with a left rule. Putting a `**You**` + * and a `**Lam**` header on each exchange gave both speakers the same visual + * weight and made the reader scan past chrome to reach the answer. + * + * Raycast solves the same problem with alignment — right-aligned bubbles for + * the user — but markdown offers no alignment control, so styling is the only + * lever available. */ export const STREAMING_CURSOR = "▍"; -/** - * Speaker labels. - * - * Plain text, not icons. Vicinae's markdown renderer draws images as - * block-level, centred elements capped at 200px tall (`MdImage.qml`), and none - * of the candidate sources resolve inside markdown — `icon://person`, a raw - * icon slug, and a `file://` asset path were all tried and each one fell back - * to rendering the alt text as a caption. An icon therefore cannot sit inline - * beside a message the way a text label can. - */ -export const USER_LABEL = "You"; -export const ASSISTANT_LABEL = "Lam"; - /** Which optional sections the transcript includes. Driven by preferences. */ export interface TranscriptOptions { showToolCalls: boolean; @@ -44,26 +37,31 @@ export function quote(text: string): string { .join("\n"); } +/** One italic line, well below the weight of the answer it belongs to. */ +function renderTools(message: ThreadMessage): string { + const names = message.toolCalls + .map((call) => `\`${call.name}\`${call.isError ? " (failed)" : ""}`) + .join(", "); + + return `_Tools: ${names}_`; +} + function renderMessage(message: ThreadMessage, options: TranscriptOptions): string { - if (message.role === "user") { - return `**${USER_LABEL}**\n\n${quote(message.text)}`; - } + // The question is identified by its styling alone. + if (message.role === "user") return quote(message.text); // 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 sections = [`**${ASSISTANT_LABEL}**`, "", message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body]; + const sections = [message.isStreaming ? `${body} ${STREAMING_CURSOR}` : body]; - if (message.error) { - sections.push("", `**Error** — ${message.error}`); - } + // The one thing allowed to break the plain-text rule, because it means + // something went wrong and should not be skimmed past. + if (message.error) sections.push("", `**Error** — ${message.error}`); if (options.showToolCalls && message.toolCalls.length > 0) { - const lines = message.toolCalls.map( - (call) => `- \`${call.name}\`${call.isError ? " — failed" : ""}`, - ); - sections.push("", "**Tools used**", ...lines); + sections.push("", renderTools(message)); } if (options.showReasoning && message.thinking.trim().length > 0) { @@ -85,7 +83,8 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri * Messages are grouped back into exchanges for layout: an answer and the * question it responds to belong to the same block, so the horizontal rule only * separates one exchange from the next rather than dividing every single - * message. + * message. That rule earns its place with newest-first ordering, where the + * boundary between two answers would otherwise be easy to miss. */ export function buildThreadMarkdown( messages: ThreadMessage[], @@ -122,19 +121,18 @@ export function buildThreadMarkdown( * Unlike the transcript this reads oldest-first: as a standalone document, a * conversation is easier to read in the order it happened. * - * This has to load the full transcript rather than reuse the index summary — - * copying a truncated preview and calling it "the conversation" would be a - * quiet lie. + * This needs labels because the transcript's styling — blockquotes and dimmed + * text — is gone by the time it reaches a clipboard. */ export function sessionToPlainText(turns: ChatTurn[]): string { if (turns.length === 0) return ""; return turns .map((turn) => { - const header = `## ${turn.prompt}`; - const body = turn.response.trim() || (turn.error ? "" : "_(no response)_"); + const question = `**You**\n\n${turn.prompt}`; + const answer = turn.response.trim() || (turn.error ? "" : "_(no response)_"); const failure = turn.error ? `\n\n**[error]** ${turn.error}` : ""; - return `${header}\n\n${body}${failure}`; + return `${question}\n\n**Lam**\n\n${answer}${failure}`; }) .join("\n\n---\n\n"); }