chore: sweep em dashes from our own source and README (#13)
This commit was merged in pull request #13.
This commit is contained in:
@@ -2,10 +2,10 @@
|
||||
|
||||
Agentic AI chat inside the [Vicinae](https://vicinae.com) launcher, powered by [Ollama](https://ollama.com).
|
||||
|
||||
Hit a global shortcut, type a question, get a streamed answer — without leaving whatever you were doing.
|
||||
Hit a global shortcut, type a question, get a streamed answer without leaving whatever you were doing.
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
> **Status:** early. Chat, streaming, persistent sessions and the web tools work. Anything that *acts* on your machine (opening links, clipboard, launching apps) is deliberately not here yet — see [Roadmap](#roadmap).
|
||||
> **Status:** early. Chat, streaming, persistent sessions and the web tools work. Anything that *acts* on your machine (opening links, clipboard, launching apps) is deliberately not here yet. See [Roadmap](#roadmap).
|
||||
|
||||
---
|
||||
|
||||
@@ -13,15 +13,15 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Vicinae with the extension runtime enabled (Node.js 20+ on `PATH`)
|
||||
- An [Ollama](https://ollama.com) account and API key — [create one here](https://ollama.com/settings/keys)
|
||||
- An [Ollama](https://ollama.com) account and API key, which you can [create here](https://ollama.com/settings/keys)
|
||||
|
||||
Ollama Cloud is the default. A local Ollama server works too: point **Ollama Host** at `http://localhost:11434`. Note that the web tools are a cloud feature and are not available from a local server.
|
||||
|
||||
@@ -43,7 +43,7 @@ npm run build
|
||||
Setup is two prompts on first launch, then one command you can revisit any time.
|
||||
|
||||
1. **The API key.** Vicinae prompts for required preferences before a command runs, so the very first launch asks for your Ollama API key.
|
||||
2. **The model.** The next prompt asks which model to use — type it exactly, e.g. `deepseek-v4.1-flash:cloud` or `gpt-oss:120b`.
|
||||
2. **The model.** The next prompt asks which model to use. Type it exactly, e.g. `deepseek-v4.1-flash:cloud` or `gpt-oss:120b`.
|
||||
3. **`Set Up Lam Agent`** verifies the connection, lists every model your key can actually reach, and copies a model name to the clipboard so you can paste it into preferences.
|
||||
|
||||
To change the model later, either edit the **Model** preference directly, or run **Set Up Lam Agent** and copy the name of a model you would rather use.
|
||||
@@ -59,7 +59,7 @@ To change the model later, either edit the **Model** preference directly, or run
|
||||
| `⌘ N` | New chat |
|
||||
| `⌘ ⇧ ⌫` | Delete the highlighted conversation (also in **Chat Sessions**) |
|
||||
|
||||
Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed — see [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher. The `Tab` shortcut went with it: the left column *is* the conversation switcher now, so an accessory dropdown would only duplicate it.
|
||||
Prompts are single line. An earlier `⌘ ↩` multi-line composer was removed. See [Architecture](#architecture) for why a `Form.TextArea` cannot be laid out cleanly in a launcher. The `Tab` shortcut went with it: the left column *is* the conversation switcher now, so an accessory dropdown would only duplicate it.
|
||||
|
||||
### Suggested global shortcuts
|
||||
|
||||
@@ -95,8 +95,8 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
|
||||
|
||||
| Preference | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| **Ollama API Key** | — | Required. Stored by Vicinae as a password preference. |
|
||||
| **Model** | — | Required. Any model your key can reach. |
|
||||
| **Ollama API Key** | None | Required. Stored by Vicinae as a password preference. |
|
||||
| **Model** | None | 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. 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`. |
|
||||
@@ -124,7 +124,7 @@ Web search results and fetched pages can be large. Lam truncates them before the
|
||||
|
||||
```
|
||||
src/
|
||||
chat.tsx # "chat" command entrypoint — thin wrapper
|
||||
chat.tsx # "chat" command entrypoint, thin wrapper
|
||||
sessions.tsx # browse, reopen, delete conversations
|
||||
setup.tsx # connection check + live model list
|
||||
lib/
|
||||
@@ -143,29 +143,29 @@ src/
|
||||
|
||||
A few decisions worth knowing about before you change things:
|
||||
|
||||
**The search bar is the composer.** It is the only always-focused text input in a `List`, which is what makes "hotkey, then just type" work. It is single-line and there is no API to change that. A multi-line `Form.TextArea` composer was built and then removed: Vicinae always renders it inside `FormField`, which reserves two ninths of the width for a label gutter and two ninths as an empty right spacer, so it shows up as a small box floating in a large empty form. `filtering` is off, because the left column is a conversation switcher — the builtin fuzzy filter would hide rows as you type a question.
|
||||
**The search bar is the composer.** It is the only always-focused text input in a `List`, which is what makes "hotkey, then just type" work. It is single-line and there is no API to change that. A multi-line `Form.TextArea` composer was built and then removed: Vicinae always renders it inside `FormField`, which reserves two ninths of the width for a label gutter and two ninths as an empty right spacer, so it shows up as a small box floating in a large empty form. `filtering` is off, because the left column is a conversation switcher. The builtin fuzzy filter would hide rows as you type a question.
|
||||
|
||||
**The two panes show different things on purpose.** Left is the list of conversations; right is the transcript of the open one. Both are required rather than a layout preference: `Detail` accepts no typed input (`onSearchTextChange` exists only on `List`, `Grid`, `Form` and `Dropdown`), and list rows are a fixed single line, so full message text cannot render in the list. Input needs a `List`; full text needs a `Detail`.
|
||||
|
||||
**Every sidebar row carries the same detail, and that is deliberate.** The right pane shows the *open* conversation, not the highlighted one. Vicinae exposes no way to set the selected row, so when ⌘N starts a new chat the selection can stay where it was and the pane would keep rendering the previous conversation — which is exactly the bug this replaced. Deriving the detail from the active session rather than the row makes the pane follow the chat the user is actually in, whatever the selection happens to be. It also means only summaries are read on mount, not every transcript. Switching conversations is explicit: Return on an empty search bar, or the action panel.
|
||||
**Every sidebar row carries the same detail, and that is deliberate.** The right pane shows the *open* conversation, not the highlighted one. Vicinae exposes no way to set the selected row, so when ⌘N starts a new chat the selection can stay where it was and the pane would keep rendering the previous conversation, which is exactly the bug this replaced. Deriving the detail from the active session rather than the row makes the pane follow the chat the user is actually in, whatever the selection happens to be. It also means only summaries are read on mount, not every transcript. Switching conversations is explicit: Return on an empty search bar, or the action panel.
|
||||
|
||||
**Reopening may deliberately start a fresh chat.** `resumeTimeout` is checked against the newest conversation's `updatedAt` before resuming. Past that window the command opens a new chat instead; the old one is untouched and sorted into its recency section in the sidebar.
|
||||
|
||||
**Return adapts to the search bar.** With text in it, `↩` sends. Empty — where sending would be a no-op anyway — `↩` opens the highlighted conversation. Every row carries that conversation's transcript as its detail, so moving the selection previews it.
|
||||
**Return adapts to the search bar.** With text in it, `↩` sends. Empty (where sending would be a no-op anyway), `↩` opens the highlighted conversation. Every row carries that conversation's transcript as its detail, so moving the selection previews it.
|
||||
|
||||
**Conversations are ordered most-recently-updated first, and the sidebar shows titles only, grouped by recency.** Vicinae resets list selection to the first row on every model change (`GenericListView.selectFirstOnReset`), and streaming changes the model on every flush. Ordering by recency keeps the conversation being chatted in at row zero, which is where the selection lands. Raycast's sidebar works the same way, and grouping by day buys the recency cue that a timestamp on every row was trying to convey — while leaving the title room to actually be readable.
|
||||
**Conversations are ordered most-recently-updated first, and the sidebar shows titles only, grouped by recency.** Vicinae resets list selection to the first row on every model change (`GenericListView.selectFirstOnReset`), and streaming changes the model on every flush. Ordering by recency keeps the conversation being chatted in at row zero, which is where the selection lands. Raycast's sidebar works the same way, and grouping by day buys the recency cue that a timestamp on every row was trying to convey, while leaving the title room to actually be readable.
|
||||
|
||||
**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 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, 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.
|
||||
**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, so 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.
|
||||
|
||||
**Sessions are capped twice over** — per turn and per session — because `LocalStorage` has no documented ceiling.
|
||||
**Sessions are capped twice over**, once per turn and once per session, because `LocalStorage` has no documented ceiling.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -180,11 +180,11 @@ npm test
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Actions.** Opening links, clipboard access, launching apps. These need an approval step first — an assistant that can silently act on your machine is a different risk profile from one that answers questions, and this release does not pretend otherwise.
|
||||
- **Actions.** Opening links, clipboard access, launching apps. These need an approval step first. An assistant that can silently act on your machine is a different risk profile from one that answers questions, and this release does not pretend otherwise.
|
||||
- **`@` mentions** for files and clipboard content.
|
||||
- **Per-conversation model** overrides.
|
||||
- **Image input**, which Ollama's chat API already supports.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const EM_DASH = "\u2014";
|
||||
const SRC = path.resolve(__dirname, "..");
|
||||
const README = path.resolve(__dirname, "../../README.md");
|
||||
|
||||
function sourceFiles(directory: string): string[] {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (entry.isDirectory()) {
|
||||
// Tests are skipped: unicode test data legitimately contains an em dash,
|
||||
// and the rule is about what we ship rather than about our fixtures.
|
||||
return entry.name === "__tests__" ? [] : sourceFiles(path.join(directory, entry.name));
|
||||
}
|
||||
|
||||
return /\.tsx?$/.test(entry.name) ? [path.join(directory, entry.name)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe("voice", () => {
|
||||
it("ships no em dashes in the source", () => {
|
||||
// The default prompt tells the model never to write one, so the extension
|
||||
// should not ship one either. Guarding the whole tree rather than picking
|
||||
// out UI strings by hand keeps the rule simple enough to actually hold, and
|
||||
// comment rewrites are cheap next to a documented standard that quietly
|
||||
// drifts.
|
||||
const offenders = sourceFiles(SRC)
|
||||
.filter((file) => readFileSync(file, "utf8").includes(EM_DASH))
|
||||
.map((file) => path.relative(SRC, file));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("ships no em dashes in the README", () => {
|
||||
expect(readFileSync(README, "utf8")).not.toContain(EM_DASH);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import { recencyGroup, truncate } from "../utils";
|
||||
*
|
||||
* - The search bar is the composer. It is the only always-focused text input in
|
||||
* a `List`, which is what makes "hit a hotkey, then just start typing" work.
|
||||
* - `filtering` is off, because the left column is a conversation switcher — the
|
||||
* - `filtering` is off, because the left column is a conversation switcher. The
|
||||
* builtin fuzzy filter would hide rows as the user types their question.
|
||||
* - `Detail` accepts no typed input (`onSearchTextChange` exists only on `List`,
|
||||
* `Grid`, `Form` and `Dropdown`), so the left column cannot be dropped: it is
|
||||
@@ -87,7 +87,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
/**
|
||||
* Reopening the command continues the most recent conversation, but only if it
|
||||
* was used recently. Past the configured idle window it starts a fresh chat
|
||||
* instead — the previous conversation is never removed, it just drops down the
|
||||
* instead. The previous conversation is never removed, it just drops down the
|
||||
* sidebar. Setting the preference to "Never" keeps the last chat open forever.
|
||||
*/
|
||||
useEffect(() => {
|
||||
@@ -218,7 +218,7 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
|
||||
/**
|
||||
* The primary action adapts to the search bar. With text in it, Return sends;
|
||||
* with it empty — where sending would be a no-op anyway — Return opens the
|
||||
* with it empty (where sending would be a no-op anyway), Return opens the
|
||||
* highlighted conversation instead.
|
||||
*/
|
||||
const rowActions = (row: SessionRow) => {
|
||||
@@ -249,8 +249,8 @@ export function ChatView({ initialSession, initialPrompt }: ChatViewProps) {
|
||||
}
|
||||
|
||||
const placeholder = chat.status
|
||||
? `${chat.status} — press ⏎ to stop`
|
||||
: "Lam is thinking — press ⏎ to stop";
|
||||
? `${chat.status}. Press ⏎ to stop`
|
||||
: "Lam is thinking. Press ⏎ to stop";
|
||||
|
||||
return (
|
||||
<List
|
||||
|
||||
+5
-5
@@ -15,8 +15,8 @@ import type { ChatTurn, ThreadMessage } from "./types";
|
||||
* 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
|
||||
* Raycast solves the same problem by aligning messages: right-aligned bubbles
|
||||
* for the user. Markdown offers no alignment control, so styling is the only
|
||||
* lever available.
|
||||
*/
|
||||
|
||||
@@ -66,7 +66,7 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
|
||||
|
||||
// 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 (message.error) sections.push("", `**Error:** ${message.error}`);
|
||||
|
||||
if (options.showToolCalls && message.toolCalls.length > 0) {
|
||||
sections.push("", renderTools(message));
|
||||
@@ -129,8 +129,8 @@ 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 needs labels because the transcript's styling — blockquotes and dimmed
|
||||
* text — is gone by the time it reaches a clipboard.
|
||||
* 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 "";
|
||||
|
||||
@@ -58,7 +58,7 @@ export function resolveResumeTimeout(value: string | undefined): number | undefi
|
||||
|
||||
/** Human-readable form of {@link ResolvedPreferences.resumeTimeoutMs}, for the setup screen. */
|
||||
export function describeResumeTimeout(milliseconds: number | undefined): string {
|
||||
if (milliseconds === undefined) return "Never — always reopen the last chat";
|
||||
if (milliseconds === undefined) return "Never (always reopen the last chat)";
|
||||
|
||||
const minutes = Math.round(milliseconds / MINUTE);
|
||||
if (minutes < 60) return `After ${minutes} minutes`;
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ export function buildThread(turns: ChatTurn[], live: LiveState): ThreadMessage[]
|
||||
const messages: ThreadMessage[] = [];
|
||||
|
||||
// Strictly newest message first. The search bar sits at the top of the window,
|
||||
// so the most recent thing — an answer, once one has been written — should be
|
||||
// so the most recent thing, an answer once one has been written, should be
|
||||
// the first line under it. A consequence is that within an exchange the answer
|
||||
// comes before its question, which is the intended reading order here.
|
||||
for (let index = turns.length - 1; index >= 0; index--) {
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ export type RecencyGroup = "Today" | "Yesterday" | "This week" | "Older";
|
||||
* Buckets a timestamp for the conversation sidebar.
|
||||
*
|
||||
* Raycast groups its sidebar this way, and it buys the grouping that per-row
|
||||
* timestamps were trying to convey — without putting a timestamp on every row.
|
||||
* timestamps were trying to convey, without putting a timestamp on every row.
|
||||
*/
|
||||
export function recencyGroup(timestamp: number, now: number = Date.now()): RecencyGroup {
|
||||
const startOfToday = new Date(now);
|
||||
|
||||
Reference in New Issue
Block a user