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.
- Maths, real HTML, and diagrams. LaTeX renders, so an equation arrives as an equation instead of a wall of backslashes. A small amount of HTML draws as well, for the status chip or tiny grid a sentence is worse at, and a diagram drawn with box characters keeps its alignment instead of being reflowed. See Rendering.
- Tools. Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped.
- Sources you can check. A search result and a fetched page each get a number, and those numbers are the ones Lam cites, so
[1]in the answer and[1]under it are the same page. The list sits under the answer it belongs to, each number opens the page it names, and a number Lam was never shown stays as plain text rather than becoming a link to somewhere else. - Tool calls you can watch and stop. What Lam is doing right now stays on screen, including when it asks the same question twice, the round limit is a preference, Force Stop is always one keystroke away, and a call that repeats itself with identical arguments ends the turn instead of running forever.
- Models, from a list. Browse every model your host can reach, pick one and start using it immediately, and on a server you run, pull new ones or delete old ones. Tag names are picked, never typed.
- Optional auto-deletion. Set a retention window and Lam clears out the conversations you have stopped using the next time you open a command, taking their screenshots with them and collecting any capture you never sent. Off by default, and nothing at all is read or written while it is off. The setup screen also has both tidy-ups as explicit actions, confirmed first, for when you would rather press the button yourself.
- 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.
- The API key. Vicinae prompts for required preferences before a command runs, so the very first launch asks for your Ollama API key.
- The model. The next prompt asks which model to use. Type it exactly, e.g.
deepseek-v4.1-flash:cloudorgpt-oss:120b. Typing it by hand is the fragile part, so run Ollama Models afterwards and pick the real one from the list. Set Up Lam Agentverifies the connection, lists every model your key can actually reach, and reports which one is in use and where that came from.
To change the model later, run Ollama Models and pick one, or edit the Model preference directly.
An extension can read preferences but not write them, which is why a pick made in Ollama Models is stored by the extension and layered over the preference rather than saved into it. See Models for how the two are reconciled, and for what a reset does.
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. |
| Ollama Models | Browse the models the configured host can reach, pick the one Lam uses, and pull or delete models on a server you run. See Models. |
| Set Up Lam Agent | Verify the connection, list available models, check whether the selected model can see images, and clear out chats or unattached photos. |
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 Models can pick one for you, and a pick takes precedence over this value until you reset it from the setup screen. |
| Ollama Host | https://ollama.com |
Set to http://localhost:11434 for a local server. |
| Location | None | The place Lam should treat as yours, for example Cebu City, Philippines. Optional. The only exact location available, and the only one that stops anything being inferred. |
| IP Geolocation | off | Off by default. When on, and only when Location is empty, your IP is sent to ipwho.is to resolve a rough area. |
| 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 which tools Lam called, each named once, with a count of any that failed. Off hides the names; a failed or repeating call is still noted in one line. |
| Show Reasoning | on | Includes the model's thinking trace under each answer. Turn off to keep replies short. Either way the trace streams live during a turn and disappears when the turn ends. |
| 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. |
| Delete Old Chats After | Disabled | Permanently deletes a conversation once you have not opened it for this long, along with any screenshots it captured. Nothing is ever deleted while this is off. See Auto-deletion. |
| Tool Call Limit | 8 rounds | How many rounds of tool calls Lam may make before it has to answer with what it has. Unlimited removes the cap. A call that repeats itself with identical arguments ends the turn either way. |
Models
Ollama Models lists everything the configured host can reach, filtered by the search bar. Each row shows the parameter count and how much disk it takes, with tags for the model in use and for anything that reports image input. Picking one writes it to LocalStorage and loadPreferences layers it over the Model preference, so it takes effect on the next question with no restart.
| Action | Notes |
|---|---|
| Use This Model | The primary action. Immediate, and survives restarts. |
| Unload Model | Only on a row that is loaded. No confirmation, because nothing is deleted. |
| Copy Model Name | For pasting into the Model preference. |
| Delete Model | Confirmed first. A server you run only. |
| Pull a Model… | Confirmed by the download itself. A server you run only. |
Loaded models
Installed and loaded are different states, and the list shows both. GET /api/ps reports what is holding memory right now, and those rows get a green loaded tag, so "why is my GPU busy" has an answer in the same place as "what do I have".
Unload frees the memory without deleting anything, which is why it needs no confirmation: the worst case is that the next question pays to load the model again. It is POST /api/generate with keep_alive: 0 and no prompt, which is how Ollama expresses "release this now", and what its own ollama stop does. The reply reports done_reason: "unload", and the toast says how much was freed.
A host that does not serve /api/ps (older builds answer 404) simply shows no loaded tags. That check resolving to nothing is not an error, and it must not take the model list down with it.
Why a pick is not written to the preference
It cannot be. @vicinae/api exposes getPreferenceValues and nothing that sets one, so the only way to change the preference would be to write Vicinae's own config file. That does not work: preferences are read once at startup and held in memory, so an edit to ~/.config/vicinae/settings.json is invisible until Vicinae restarts. This was measured rather than assumed. Pointing ollamaHost at a local listener that logged every request, then launching a command, showed the requests still going to the old host.
Writing that file would also be a lost update, because Vicinae owns it and rewrites it (its own header warns as much), and it is undocumented, so it would break on any release that changes the layout.
So the pick lives in LocalStorage and the preference is left alone. The cost is stated rather than hidden: while a pick is active, editing the Model preference has no effect. That is why the setup screen shows both values, ornith-1.5:9b (chosen in Models; the preference says gpt-oss:120b), and offers Reset to the Preference.
Local servers versus Ollama Cloud
Pull and delete only appear when the host is not ollama.com, because they are operations on a machine you control.
A local daemon's list is often mixed, and the two kinds are worth telling apart:
| local | Real weights on disk. Deleting frees the gigabytes the size column shows. |
| cloud | A :cloud entry the daemon proxies to ollama.com (remote_model/remote_host in the API). Only a few hundred bytes are stored, so there is no size to show, and deleting drops the reference rather than any weights. |
Deleting is confirmed with wording that matches the kind, since "delete this model" means something different in each case. Deleting the model that is currently in use clears the pick, so the next question does not fail on a name that no longer exists. If the preference is what pointed at it, that is said instead: a preference cannot be edited from here.
Pull
POST /api/pull streams its progress, and the toast follows it: the manifest status first, then downloading · 42% · 1.2 GB / 2.9 GB, then success. A bad name comes back as HTTP 200 with the error in the body, so the stream is checked and not just the status, which is the same trap the chat stream already had.
The download runs inside this command's worker, so closing the launcher cancels a pull. The command description says so, and the form says so again next to the field.
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 |
The place you configured, or a rough city from your IP if you enabled that, plus timezone, UTC offset, locale and region. |
web_search |
Searches the web; returns titles, URLs, and snippets. |
web_fetch |
Fetches one page and returns its content as markdown. |
get_location reports the Location you set, if you set one, because that is the only answer here that is actually about where you are. Everything else is inferred from your device, and the inference is coarser than it looks.
A timezone is not a city. This is the trap the tool used to fall into: it reported City: Manila, taken from the last segment of the IANA zone name. That name is the database's representative place, not yours, and a single zone is often a whole country. Asia/Manila is the Philippines, Asia/Kolkata is India, Europe/Oslo is Norway, Australia/Brisbane is Queensland. Reporting the zone's namesake as your city was wrong for anyone outside it, and confidently so, because a model asked about the weather will use it without hesitating. The zone's label is still reported, as Timezone area, and named for what it is.
The region comes from your locale, which is a declaration rather than an inference: a locale of en-PH says the Philippines because that is what you set. It costs nothing and it cannot be wrong the way a zone name can.
If you turn on IP Geolocation, and only then, get_location resolves a rough area from your IP address via ipwho.is. That is a third party learning approximately where you are, so it is off by default, it is skipped entirely whenever you have set a Location (the better answer is already there, and there is no reason to spend your privacy on a worse one), and the tool output names the provider and calls the result a rough guess rather than a city.
It is coarser than "city-level" suggests, and that is worth knowing before trusting it. An IP address identifies the point where the traffic left the ISP, not the person. Measured from one machine on Globe Telecom, an IPv4 lookup resolved to Cebu City and an IPv6 lookup moments later resolved to Talisay in Calabarzon, roughly 500km apart. If you want a place you can rely on, set Location and turn this off.
The answer is cached for 6 hours rather than looked up per question, because an area does not change between questions and every lookup hands over another copy of your address. Set Up Lam Agent clears that cache, and the capability cache, when you re-test.
This extension does not use GPS. That would mean CoreLocation on macOS, which has no command-line interface, or GeoClue on Linux, whose usual provider resolves nearby Wi-Fi networks through a Mozilla service anyway. Neither is worth it for "is it late where you are".
The provider has to be one that answers an unauthenticated caller. The first choice was ipapi.co, which no longer does: a request with no User-Agent now returns HTTP 429 with "Please sign up for a paid plan", and adding a browser User-Agent gets an HTTP 403 Cloudflare bot challenge instead. ipwho.is answers without a key and signals failures with success: false on an HTTP 200, so the body is checked as well as the status. GEO_PROVIDER is a single constant, and swapping it means changing the parser beside it.
Web search results and fetched pages can be large. Lam truncates them before they reach the model.
The web tools use a different path per host, and there is no path that works for both. Ollama Cloud serves /api/web_search and /api/web_fetch; a local daemon serves the same operations under /api/experimental/. Both were probed directly: the cloud route answers 401 without a key, which means it exists, and its experimental alias is a 404; a local 0.35.1 does the exact opposite. This was a live bug, because the extension posted to the cloud path on every host, so pointing it at a local server turned every web call into a 404 and the model, handed a failure and no explanation, told the user its web tools were not configured. The other path is now tried when the first answers 404, since that means the route is absent rather than the request being refused.
Lam is told what it is. describeRuntime in history.ts appends a short paragraph naming the model, the server it is served by, whether a :cloud name means the conversation leaves the machine, which tools are actually available, and whether it can read images. It is appended even when the user has written their own system prompt, because none of it is a matter of style. Without it the model guessed, and the guesses were confident and wrong: a local model described Vicinae as "a desktop launcher for Ollama", reported its own web tools as unavailable, and answered a question about an attached screenshot by reasoning out loud about whether it was able to see, instead of describing it.
A turn that runs out of tool calls still ends in an answer. The loop stops after 8 rounds by default, and the first version of it stopped by throwing away every preamble the model had written and returning a notice about the loop: asked for the fish in every Valheim biome, it researched for five rounds and then said nothing useful at all. The budget is now spent on a closing round with the tools taken away and an instruction to answer from what was gathered and to name what it could not confirm. So the worst case is a partial answer that says what is missing, rather than an app-generated message about its own control flow. If the closing round produces nothing either, the last preamble is used, and only then a notice. The closing round is why the default cap is 8 rather than 5: the extra round is not more searching, it is the answer.
The cap is a preference rather than a constant, because how many rounds a question needs is a property of the question and of the model. Unlimited is there for research that genuinely needs a dozen pages.
Two identical calls run, a third refuses to. No round cap can be a complete guard, because one of the settings is Unlimited. What a stuck model actually does is repeat itself: the same tool, the same arguments, the same non-answer. So agent.ts counts the tool name together with its sorted arguments, per turn, allows the call twice (a retry after a search that failed is reasonable), and on the third occurrence does not run it. The round finishes, the loop breaks, and the closing round is reached with an instruction that names the repeated call instead of claiming a budget ran out. The refusal is recorded on the turn with repeated: true, so the transcript can say what happened.
Arguments are compared with their keys sorted, because models are not consistent about key order and a call that differs only by key order is still the same call.
What Lam is doing is visible while it is doing it, even with tool calls hidden. Show Tool Calls governs the finished transcript, where a list of names adds little. The live activity line is separate and always shown, because during a turn the difference between working and circling is the entire question: _Reading a page…_ for a first call, and _Reading a page · the same call again (2)…_ once it has come round. Hiding that would leave the user watching a spinner they cannot interpret, which is the situation the repeat guard exists to fix.
When tool calls are off, nothing is listed. A call that failed, or that was cut short for repeating, still gets one quiet sentence (_2 tool calls failed._, _a call kept repeating, so the turn was cut short._) rather than the names: those are the two cases where hiding the line leaves an answer the user cannot account for, and naming the tools was never the part that mattered.
That line is rendered in exactly one place, on the live message. It is not a view-level note above the transcript, and it is not a substitute for missing text either: it is shown whether or not tokens have arrived, because a tool that starts mid-answer would otherwise be invisible for the rest of the turn. The first version had both, a note in the view and the placeholder in the transcript, and a tool running before any text arrived printed "Reading a page" twice, one line above the other. The cursor rides on whichever line is written last, so it always trails the text actually arriving.
Auto-deletion
Off by default. When it is on, Lam deletes a conversation once you have not used it for the configured period, and deletes the screenshots that belonged to it.
It is checked when any Lam command opens (chat, sessions, setup) and after every answer, so an expired conversation goes the next time you use Lam. The alternative is a timer, which would mean waking up to do work while nothing is open.
Age is counted from the last time you used a conversation, not from when you started it. A chat started last month and continued this morning is not an old chat, and deleting it because of its birthday would be a surprise.
The conversation you are in is never deleted, even if it is expired. It is about to be rendered, and being the one the command opens by default is the clearest possible sign that it is still in use. The practical consequence is that a conversation you keep returning to outlives the window indefinitely, which is the intended reading of "conversations you have not opened". Sweeping after the startup resolution rather than before is what makes this work: sweeping first would delete the very conversation on its way to the screen.
A screenshot the user never sent is collected too. Snips are written to the support directory before the chat view opens, so a capture you abandon belongs to no conversation and no amount of session deletion would ever reach it. The orphan pass reads the snip directory directly, compares it against the paths recorded on the conversation index, and deletes anything unreferenced that is more than a day old. That wait is the safety story: a snip is on disk seconds after it is taken and stays unreferenced until you send it, so without a grace period the sweep would delete the file out from under a question you were still typing. Files belonging to a conversation that has just been deleted go immediately with it instead of waiting, because the conversation they belonged to is already gone.
The index entry for a conversation carries its image paths (SessionSummary.attachmentPaths), so deciding what is still referenced does not mean reading every transcript, which the sweep would otherwise do after every single answer. An index written before that field existed is repaired from its transcripts before anything is deleted, because a missing list is not the same as an empty one: reading it as empty would mark every screenshot in your history as an orphan.
The whole design leans towards deleting nothing. Retention off means nothing is read and nothing is written. An unrecognised value for the preference resolves to off. The orphan pass is skipped entirely when the referenced set cannot be established, because an incomplete set of references looks exactly like a pile of orphans and guessing wrong destroys real images. A path is checked to be inside the snip directory before anything is removed, so a hand-edited entry in storage cannot reach anything else on the disk. And the sweep never throws, so a failed tidy-up cannot break the command you opened.
Doing it by hand
Set Up Lam Agent has a Maintenance section with the same two jobs as explicit actions:
| Action | What it deletes |
|---|---|
| Clear Out All Chats | Every conversation, and every screenshot they captured. |
| Clear Out Unattached Photos | Captures that no conversation refers to any more. |
Both ask for confirmation first, and both say what they did even when the answer is nothing, because a button that appears to do nothing reads as a bug. That is deliberately the opposite of the background sweep, which stays silent unless it deleted something.
The photo clean-up applies exactly the same rule as the sweep, including the one-day wait, and it works whether or not Delete Old Chats After is switched on. So it covers the case that setting exists for: you would rather decide when your history is cleared than have Lam decide it for you.
When a conversation cannot be read there is no way to know which captures are still in use, so that action says it could not check and deletes nothing, rather than risking a screenshot a surviving chat still shows.
Rendering
The answer is model output, and Vicinae draws a fixed set of things. Everything between the model and the pane happens in one pass, in render.ts, and every step of it is there because the renderer cannot do that part alone.
LaTeX
Vicinae has no maths renderer. MarkdownModel maps cmark nodes to a fixed set of block components (MdHeading, MdCodeBlock, MdTable, MdImage and friends) and there is no maths block among them, so $x^2$ used to reach the pane as literal TeX. latex.ts converts it instead.
Two facts about the renderer decided the design. A paragraph is a TextEdit with textFormat: RichText (MdParagraph.qml), and markdown-model.cpp passes CMARK_NODE_HTML_INLINE through untouched, so sup, sub, b and i are available. And the pane is a launcher-width column, so a formula has to read linearly: a stacked fraction could not survive a narrow column anyway.
So this is a linearisation rather than a typesetter. \frac{a}{b} becomes a/b, \sqrt{x+1} becomes √(x+1), \sum_{i=1}^{n} becomes ∑ᵢ₌₁ⁿ, and only what Unicode has no form for falls back to a tag, so x^{a/b} becomes x<sup>a/b</sup>. $...$, \(...\), $$...$$ or \[...\] on a line of its own, and a fence labelled math, are all read.
Brackets appear only where the slash would otherwise change what the formula means. \frac{n(n+1)}{2} is n(n+1)/2 and \frac{\sqrt{\pi}}{2} is √π/2, because one product either side of a slash needs nothing, while \frac{1}{2a} is 1 / (2a) because 1/2a is read two ways, and a sum is always bracketed so the slash cannot divide only its last term: (−b ± √(b² − 4ac)) / (2a). The space around the slash shows up only when something beside it is bracketed, which is where the brackets would otherwise crowd it.
The one guard is worth knowing about. A lone $ pair counts as maths only when the inside looks like maths, because x=$a$b and cd $A; echo $B are not formulas, and turning them into one is the single way this feature can make an answer worse than the dollar signs it replaced. $5 and $10 is left exactly as written.
Custom HTML
Raw HTML is not merely tolerated by the renderer, it is the only route to anything past what GFM offers: a <span> with a colour or a size draws, and so does a table. That is the extension point for a model that would rather show a status chip than describe one, and the default prompt says the capability exists.
A block of HTML has to be moved before it can draw anything. markdown-model.cpp has no HTML renderer. An HTML block goes through processHtmlNodes, which pulls out every <img> as a block image and keeps the trimmed text of everything else with the markup thrown away, so <table><tr><td>a</td><td>b</td></tr></table> arrives as the word ab. A paragraph, by contrast, keeps its raw HTML, because CMARK_NODE_HTML_INLINE is passed straight through to the TextEdit. So a well-formed block is joined onto one line and wrapped in a <span>, which is not on CommonMark's list of block tags, and it arrives as the paragraph the rich text engine can read. Two rules keep that rewrite from doing damage: markup that does not balance is left to the block path, which at least keeps the words, and a block holding an <img> is left alone entirely, because the extractor is the one place an image written as HTML is loaded for real.
It is also a hole, because the same route carries whatever else the model writes, so html.ts filters it. The tags and style declarations the renderer acts on are kept, script, style and the other element types whose body is not content are removed with it, and an event handler, a url() in a style value and a javascript: destination are dropped. An <a href> is rewritten as a markdown link, because that is the only form Vicinae makes clickable. An <img> keeps its tag and has its source judged instead, since the block extractor reads the attribute rather than any markdown this pass could write, and data: counts only when it carries an image.
What the renderer cannot draw is kept as text rather than dropped, which is the deliberate choice in there. A tag that is not on the list, <foo> say, is shown literally: ugly, but honest, and better than a mystery about where the markup went.
Everything else
The rest of the pass is defensive, and stays away from guessing what the model meant. A diagram drawn with box characters is fenced so it keeps its alignment, because MdParagraph.qml wraps and a directory tree reflowed is a directory tree scrambled. A fence label KSyntaxHighlighting would print but never resolve is renamed (console and terminal become bash) and one that carries no information at all (text, plaintext) is dropped, which is what stops a header naming a language nobody highlighted. A fence whose body is all URLs still becomes links, a real GFM table is left to the table renderer, and markup inside a code span is shown rather than applied.
The whole pass is plain TypeScript with no Vicinae imports, so it unit tests without a runtime, and copying a conversation to the clipboard uses the source text rather than the rendering.
Sources
Footnotes deserve a note of their own, because the window cannot draw them. Vicinae parses markdown with CMARK_OPT_DEFAULT and attaches only the table and strikethrough extensions, so a [^1] arrives as literal text. The citation pass therefore does the whole job: it links the marker, drops a footnote definition that would only repeat an entry the tools already registered, and builds the list from those entries, which is also what stops the list from being something a model can quietly invent.
In a sentence the marker is raised, so a claim resting on a search result reads as rain is likely¹ rather than with the brackets at full size. It is a Unicode superscript digit rather than a <sup> tag, because a link label is plain text that nothing downstream can break, and a digit is raised by design in every font. The entry under the answer keeps the plain number, [1] Rain in Cebu, where being able to scan a list matters more than the convention.
Markers the model wrote touching are separated as they are drawn, because ¹² reads as twelve, or as twenty one, and either way as one number. The prompt asks for exactly that touching shape, so [1][2] and the same numbers written apart both arrive as two raised digits with a space between them. A space rather than a comma: a comma sits at the baseline, so between raised digits it drops away from them and reads as a stray mark.
The pair is ordered ascending and each page is named once, because a model lists sources by rank rather than by number. [2][1] is what a real answer sent for a sentence resting on both, and drawn as ¹ ² it reads as the answer's first two sources rather than as "two, one". The numbers themselves are never renumbered: [3] is still the third source the turn found.
Numbers are handed out where the tools run, once per turn, and printed in the tool result the model reads. So [3] means one page for the model and for the reader, three searches in one turn do not each start again at 1, and a page found by a search and read again later keeps the number it already had. A marker stays as written unless every number in it is a source with a destination this window should open: half a link reads as a citation that works, and a javascript: address out of a search result is a real vulnerability rather than a rendering quirk.
The list shows the sources the answer cited, falling back to everything the turn consulted when it cited nothing, since that is the one case where the list is the only evidence of what was read. It waits for a citation before appearing at all, so it never arrives in full and then shrinks.
Architecture
src/
chat.tsx # "chat" command entrypoint, thin wrapper
snip.tsx # "snip" command entrypoint, no-view: captures then hands over
models.tsx # "models" command: browse, pick, pull, delete
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
retention.ts # what gets deleted, and when
cleanup.ts # the sweep + its notification
history.ts # turns -> API messages
location.ts # time/place, derived locally
thread.ts # turns -> flat message list
markdown.ts # messages -> detail-panel markdown
render.ts # the rendering pass: fences, maths, markup
latex.ts # TeX -> readable inline markup
html.ts # raw HTML -> the subset the renderer draws
code.ts # code fences, diagrams and links, repaired
preferences.ts # preferences, normalised
hooks/useChat.ts # the chat state machine
hooks/usePreferences.ts # preferences + the model pick, loaded once
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 naming each tool once, with what went wrong counted rather than repeated, so they stay below the answer too. Raycast solves the same problem with alignment, but markdown offers no alignment control.
The reasoning is live while a turn is in flight, and a preference only governs where it settles. A model that is thinking rather than writing has nothing else on screen, so the trace streams into the transcript whatever Show Reasoning says; hiding it would leave a spinner and no way to tell thinking from hanging. When the turn ends the trace either stays as an appendix under the answer (on) or disappears (off), which is what the preference actually controls. useChat keeps two views of the stream for the same reason: the full trace is what gets stored on the turn, and only the segment since the last tool call is shown live, cleared each time a tool starts. Otherwise a research turn that thinks before each of a dozen calls stacks every round's reasoning on screen at once, and the tool it just asked for runs underneath a wall of text that is no longer about anything.
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.
Fences are labelled so Vicinae can colour them, and a diagram is fenced so it stays readable. 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, the default prompt asks for a language on every fence, and an alias that resolves to nothing (console, terminal) is renamed to one that does. The same module fences a paragraph drawn with box characters, because a paragraph wraps and a boxed diagram that reflows is a boxed diagram that no longer says anything.
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.
Stopping a turn is an action, not a keybinding. The stop action is labelled Force Stop and stays the primary action for the whole turn, because the failure it exists for is a model spending a long time on a question you have given up on. It aborts the request, which propagates through the tool loop and into the tool that is running, so it works mid-search rather than only between rounds. There is no separate stop command: an extension cannot reach into a view running in another command, so a stop command would have to poll a flag in LocalStorage and could only stop at round boundaries, which is both slower and less reliable than the thing it would replace.
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.
Preferences have exactly one reader. loadPreferences applies the model chosen in Ollama Models on top of what Vicinae holds, and it is asynchronous because that pick lives in LocalStorage. getLamPreferences is deliberately not exported, because a second, synchronous reader would look correct and silently ignore the pick. Views take preferences through hooks/usePreferences, and the chat view is split in two so that useChat is only ever created once the model is known, rather than being pinned to whatever the preference said a moment earlier.
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.
Whether the model can see is a tri-state, and the prompt gets the whole answer. canSeeImages stays fail-closed, because offering the snip action to a model that cannot read would waste the selection gesture. But the system prompt needs "unknown" kept apart from "no": telling a model it cannot see when the check merely failed is a false claim about the user's setup, and telling it nothing is what made it reason about its own capability instead of describing the image it had been sent. A model name that does not exist on the server is the common way to end up at "unknown", which is one more reason the Models command picks names from the list rather than taking them typed.
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.