adrianbonpin 35d88f635c feat: let the model answer with tables, lists and headings
The model refused to write a table and described one instead: a bulleted
list of rows with the column names in inline code. That was our instruction,
not a rendering limit. The default prompt asked for brevity in a narrow
window and then said "skip large headings and wide tables", so the model
obeyed and flattened the answer.

Vicinae renders tables properly. The screenshot that prompted this is a
rendered one: `MdTable.qml` is wired to block type 5, and `markdown-model.cpp`
attaches the `table` GFM extension. Nothing was missing.

- The prompt now names what actually renders (tables, bulleted and numbered
  lists, headings, blockquotes, bold, italics, links, code) and asks for the
  shape that fits, with a cap of about four narrow columns because the detail
  pane really is narrow. It also says outright not to hand back a table as a
  bulleted list of rows.
- `separateTables` adds the blank line a table needs in front of it. GFM tables
  cannot interrupt a paragraph, so a table written straight under a line of
  prose renders as a paragraph of pipes and dashes. Only a genuine table start
  is touched, which is a row followed by a delimiter row; prose with a pipe, a
  table already at a block boundary, and a fenced example are all left alone.
- A regression test asserts a real table survives the whole repair pass
  untouched: pipes are not fences, not links, and not lone spans.

Verified: 304 tests, tsc, eslint, prettier, and vici build.
2026-10-04 15:44:29 +08:00
2026-10-03 20:32:40 +00:00

Lam Agent for Vicinae

Agentic AI chat inside the Vicinae launcher, powered by Ollama.

Hit a global shortcut, type a question, get a streamed answer without leaving whatever you were doing.

Status: early. Chat, streaming, persistent sessions, the web tools and screen capture work. Anything else that acts on your machine (opening links, clipboard, launching apps) is deliberately not here yet. See Roadmap.


What it does

  • 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.
  • Real markdown. Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
  • Tools. Lam decides when to call them: current time, location context, web search, and page fetching.
  • Screenshots, two ways. Snip a region into the conversation you are already in from the action panel, or run Snip to Lam to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
  • Chat only. Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.

Requirements

  • Vicinae with the extension runtime enabled (Node.js 20+ on PATH)
  • An Ollama account and API key, which you can create here
  • A screenshot tool, if you want to snip. A vision model, if you want Lam to read the result

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.

Snipping needs a capture tool

Vicinae has no screenshot API, so the extension shells out. It auto-detects the first of these that is installed, in this order:

Platform Tools, in preference order
Linux (Wayland) grimblast, then grim + slurp, then spectacle
Linux (X11) maim + slop, then scrot, then flameshot
macOS screencapture, which ships with the system

If none is found, the chat says so and names what to install. Nothing to configure: the detection happens per platform at capture time.

On macOS, Vicinae itself needs Screen Recording permission. macOS attributes a capture request to the app responsible for launching the process that made it, which is Vicinae rather than the extension, so the grant goes on Vicinae: System Settings, Privacy & Security, Screen & System Audio Recording, then quit and reopen Vicinae (a TCC change does not apply to a running app).

Without it, screencapture prints could not create image from rect and writes nothing. That is awkward, because a cancelled selection also writes nothing, and screencapture -i has been reported to exit 0 when the permission is missing. So the file alone cannot tell the two apart. capture.ts therefore reads stderr as well: that specific message becomes a permission error naming the setting to change, and only silence with no image is treated as a cancel.

Install

npm install
npm run dev      # build + hot reload into a running Vicinae

Or build once:

npm run build

Setup

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.
  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.

An extension can read preferences but not write them, which is why setup hands you the exact name to paste rather than saving it for you. It also means the model list can never drift out of sync with your account.

Keyboard shortcuts

Keys Action
↩ Send the message in the search bar
↩ with an empty bar Open the highlighted conversation
⌘ N New chat
⌘ ⇧ S Snip a region and attach it to this chat (only with a vision model)
⌘ ⇧ ⌫ Delete the highlighted conversation (also in Chat Sessions)

Prompts are single line. An earlier ⌘ ↩ multi-line composer was removed. See 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

Assign these in Vicinae Settings → Extensions → Lam Agent, or bind them in your compositor/window manager with deeplinks:

vicinae 'vicinae://launch/lam-agent/chat?toggle=true'
vicinae 'vicinae://launch/lam-agent/snip'
# Hyprland
bind = $mainAlt, Space, exec, vicinae 'vicinae://launch/lam-agent/chat?toggle=true'
bind = $mainAlt, S, exec, vicinae 'vicinae://launch/lam-agent/snip'

# niri
Mod+Shift+Space repeat=false { spawn "vicinae" "vicinae://launch/lam-agent/chat?toggle=true"; }
Mod+Shift+S repeat=false { spawn "vicinae" "vicinae://launch/lam-agent/snip"; }

fallbackText pre-fills the prompt, which makes a handy "ask about this" command:

vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'

Commands

Command Purpose
Chat with Lam The chat surface. Bind this to your global shortcut.
Snip to Lam Capture a region and start a new chat about it. A no-view command, so the launcher window closes before the selector appears. Bind this to a second shortcut for one-key "snip and ask".
Chat Sessions Browse, reopen, and delete past conversations.
Set Up Lam Agent Verify the connection, list available models, and check whether the selected model can see images.

Configuration

Preference Default Notes
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.
Reasoning Effort Off low/medium/high for models that support thinking.
Show Tool Calls on Lists the tools Lam called under each answer.
Show Reasoning on Includes the model's thinking trace. Turn off to keep replies short.
Start New Chat After 1 hour Idle time past which opening the command starts a fresh chat. Never always reopens the last one. The old chat is never deleted either way.

Tools

Lam calls these on its own when they would improve an answer. All four are read-only, and all four are the entire list of things Lam is allowed to do without being asked twice. Screen capture is deliberately not among them.

Tool What it does
get_datetime Current local date, time, weekday, timezone, and UTC offset.
get_location Timezone, UTC offset, locale, region, and city.
web_search Searches the web; returns titles, URLs, and snippets.
web_fetch Fetches one page and returns its content as markdown.

get_location is derived from your device timezone rather than GPS or an IP lookup, so no third-party geolocation service sees your address. Treat it as "which region am I in", not "where am I".

Web search results and fetched pages can be large. Lam truncates them before they reach the model, and gives up after 5 rounds of tool calls rather than looping.

Architecture

src/
  chat.tsx                    # "chat" command entrypoint, thin wrapper
  snip.tsx                    # "snip" command entrypoint, no-view: captures then hands over
  sessions.tsx                # browse, reopen, delete conversations
  setup.tsx                   # connection check + live model list
  lib/
    ollama.ts                 # the only file that talks HTTP
    agent.ts                  # the tool-calling loop
    tools.ts                  # tool schemas + dispatch
    capture.ts                # screen capture backends, shelled out
    attachments.ts            # image files -> base64 payloads
    vision.ts                 # cached "can this model see?" answer
    sessions.ts               # LocalStorage persistence
    history.ts                # turns -> API messages
    location.ts               # time/place, derived locally
    thread.ts                 # turns -> flat message list
    markdown.ts               # messages -> detail-panel markdown
    code.ts                   # code fences and links, repaired
    preferences.ts            # preferences, normalised
    hooks/useChat.ts          # the chat state machine
    hooks/useVision.ts        # capability check, for gating the snip action
    snip.ts                   # the snip -> chat handoff contract
    components/ChatView.tsx   # the chat surface

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 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.

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.

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 is text plus the attached images, 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.

An attached screenshot is rendered, not described. This is a correction: the transcript used to say _Screenshot attached_ and leave it at that, on the belief that Vicinae could not resolve a local image. It can. imageProviderUrl in markdown-model.cpp routes file:// (and a scheme-less string) through ImageURL::local, and MdImage.qml draws it block-level and centred with the alt text as an italic caption underneath.

The preview is asked for as an exact box, which is why the PNG header is parsed. MdImage.qml sizes its container to the request rather than to the image: with no hint it is the full pane width and 200px tall, and the image is aspect-fitted inside that, so a wide capture sits in a letterbox with dead space above and below. Measured on real captures here, a 4:1 crop wasted about 106px of a 200px container and a 6.7:1 crop wasted about 140px. previewBox therefore computes a box from the image's real dimensions, scales it to fit 400x200, and emits it as the raycast-width and raycast-height hints the renderer reads, so the container matches the image and nothing is wasted. The scale is capped at 1 so a small capture is not blown up.

The rendered height stays capped at 200px whatever is asked for (Math.min(_rawH, 200, maxImageHeight)), so this is a thumbnail rather than something readable, which is why the action panel still offers Open Screenshot for the full image. The alt text is short for the same reason it is not empty: whatever is there is drawn centred under the image, so a sentence reads as a stray line of prose, and it is the only thing left if the capture file has since been deleted. A preview of an unsent attachment is shown the same way, so a snip is visible before the question about it exists.

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.

A URL is only clickable as a markdown link, so code.ts takes them back out of code. Vicinae's parser attaches the table and strikethrough GFM extensions and nothing else, so the autolink extension is off: a bare URL is plain dead text. Clickability comes from MarkdownModel turning CMARK_NODE_LINK into an <a href>, which the selection controller opens on release. A URL the model fenced is therefore the worst possible presentation, a dark panel with a Copy button and no way to open the address. restoreUrlLinks recovers three narrow shapes (a fence whose entire body is URLs, a lone `https://...` span, and a line that is only a URL) into [url](url) links, and the default prompt now says never to fence a URL in the first place. Anything that is not one of those three shapes is copied through, so a curl invocation containing an address is never rewritten.

A table needs a blank line in front of it, and the prompt alone does not guarantee one. Vicinae renders tables properly (MdTable.qml, block type 5), but GFM tables cannot interrupt a paragraph, so a table written directly under a line of prose is not a table at all: it renders as a paragraph of pipes and dashes. The blank line looks unnecessary, so models leave it out. separateTables adds it, and only in front of a genuine table start, which is a row followed by a delimiter row. Prose containing a pipe is left alone, a table already at a block boundary is left alone, and a fenced example is never touched.

The prompt used to forbid tables, which is worth not repeating. It asked for brevity in a narrow window and then said "skip large headings and wide tables", and a model follows that instruction by describing the table it was about to write instead of writing it: a bulleted list of rows, with the column names in inline code. Brevity is about length, not about which markdown is allowed, so the guidance now names what renders and asks for the shape that fits, with a cap of about four narrow columns because the detail pane really is narrow.

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, once per turn and once per session, because LocalStorage has no documented ceiling.

Screen capture is owned, not borrowed. There is no screenshot call anywhere in @vicinae/api, and Clipboard moves text, html, urls and a file path and nothing binary, so a bitmap can never be read back out of the clipboard either. The only way to get a region of the screen is to shell out, which is fair game because extensions run under Node. The useful consequence is that the extension picks the output path and reads the file it wrote, rather than guessing what some other application left on the clipboard.

Deciding whether a capture worked takes two signals, not one. The image landing on disk is what makes it a success, because the backends disagree about exit codes and at least one reports success on a cancelled selection. But the file cannot distinguish a cancel from a refused permission, since both leave nothing behind and macOS can exit 0 on the refusal, so the refusal is recognised by its stderr message instead. Silence with no image is the user pressing Escape; a complaint with no image is a failure the user is told about. Snips go in the extension's support directory rather than a temp directory, because a stored conversation keeps the path and a path that survives a reboot is the difference between an old chat showing its screenshot and pointing at nothing.

Capture runs in a no-view command, because the chat view owns a window. This is the part that is easy to get wrong. activateEntrypoint calls showWindow() when it activates a command from a closed launcher, so the window is already on screen before any extension code runs, and a region selector started from a view is dragging a rectangle around a panel parked in the middle of the screen. So snip is declared no-view, calls closeMainWindow() as its first statement, captures with nothing in the way, and only then brings the window back by launching chat with the file. That command is also the single capture path: the chat's own action launches snip rather than capturing inline, so there is one implementation and one place that deals with the window.

The hand-off goes through snip specifically rather than straight to chat on purpose. activateEntrypoint treats a launch of the command that is already active as a toggle and closes the window instead of reopening it (toggleIfAlreadyActive defaults to true, and a launch cannot override it). Relaying through a different, no-view command means never asking for chat while chat is active, so the toggle branch is unreachable. The chat's own snip action is also hidden while a turn is streaming, because the hand-off unloads the view and would abandon an answer mid-sentence.

Image support is a fact from the API, not a guess from the model name, and it is cached. GET /api/tags returns names, sizes and quantization levels and says nothing about capability, so the check is a POST /api/show that reads the vision flag. That is a round trip per model, so the answer is persisted under a key built from the host and model name, which means switching model invalidates it by construction. The state is a tri-state rather than a boolean: a failed request is "unknown", never "no", because telling someone their model cannot see when the network merely dropped is a false claim about their setup that sends them shopping for a model they do not need. The snip command settles this before the selector appears, so a model that cannot read images never costs the user a selection gesture, and the two outcomes get different HUD wording. The in-chat action still fails closed: only a confirmed "yes" offers it.

The capture is never a tool. It stays in the action panel and on a keybinding, so it only ever runs because a person asked for it. A model that could decide to screenshot the screen is a different risk profile from one that answers questions, which is the same line the read-only tools already sit behind.

An attached image is sent once and never replayed. It is the largest thing a request can carry, so buildMessages attaches it to the question it belongs to and drops it afterwards, exactly as it already drops tool output. The cost is that a follow-up about a screenshot needs the image attached again; the alternative is re-sending every screenshot in the conversation on every turn. Only the path and mime type go into storage: MAX_STORED_CHARS_PER_TURN exists so one turn cannot dominate LocalStorage, and base64 would blow past it on a single 1080p capture.

Development

npm run dev        # watch + reload
npm run typecheck
npm run lint:code
npm test

vicinae-env.d.ts is generated by the toolchain and gitignored; src/lib/preferences.ts declares the preference shape by hand so the logic layer needs no codegen step.

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.
  • @ mentions for files and clipboard content.
  • Per-conversation model overrides.
  • Annotations on a snip. The capture backends are there, but drawing on the result before sending it needs a second tool and a wait for the editor to save.
  • Follow-ups that keep the image. Replaying an attachment is a token cost that has to be made opt-in first.

License

MIT. See LICENSE.

S
Description
Agentic AI chat for the Vicinae launcher, powered by Ollama (cloud + local). Custom global hotkey, streaming responses, persistent sessions, and tool use: web search, web fetch, time, and location.
Readme MIT
763 KiB
Languages
TypeScript 99.9%
JavaScript 0.1%