Files
lam-agent-vicinae/README.md
T
adrianbonpin cfb9d33ecf feat: newest message first, Raycast-style conversation sidebar
Three changes from comparing against Raycast's AI chat.

1. The transcript now reads newest message first, so an answer sits above
   the question it responds to. The search bar is pinned to the top of
   the window, so the most recent thing belongs directly under it rather
   than below the fold. buildThreadMarkdown regroups messages back into
   exchanges, so the horizontal rule separates one exchange from the next
   instead of dividing every single message.

2. The sidebar is titles only, grouped into recency sections (Today,
   Yesterday, This week, Older). Raycast does this, and it fixes rows
   eliding down to "Hello l..." -- a subtitle and a timestamp on a 38px
   row left the title almost no width. The check icon is what marks the
   open conversation now.

3. The status bar names the open conversation, which is the nearest thing
   Vicinae has to the header Raycast puts above its transcript.

Also removes dead code found on the way: ThreadMessage.preview was no
longer read by anything once the rows became conversations. Rather than
delete the plumbing outright, the activity line it carried ("Searching
the web") moved into the transcript placeholder and the search bar, so
tool activity is still visible -- it just is not a per-row preview any
more.

Verified on screen: answer above question, one rule per exchange, the
sidebar showing a single "Today" section with a title-only row, and the
status bar naming the conversation. typecheck, eslint, 113 tests and
vici build all pass.
2026-10-04 05:15:17 +08:00

10 KiB

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 and the web tools work. Anything 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 and can be reopened later.
  • 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 account and API key — create one here

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.

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
⌘ ⌫ Delete a conversation (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'
# Hyprland
bind = $mainAlt, Space, exec, vicinae 'vicinae://launch/lam-agent/chat?toggle=true'

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

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.
Chat Sessions Browse, reopen, and delete past conversations.
Set Up Lam Agent Verify the connection and list available models.

Configuration

Preference Default Notes
Ollama API Key — Required. Stored by Vicinae as a password preference.
Model — Required. Any model your key can reach.
Ollama Host https://ollama.com Set to http://localhost:11434 for a local server.
System Prompt Lam's default Replaces the built-in prompt entirely.
Web Tools on Enables web_search and web_fetch.
Reasoning Effort Off low/medium/high for models that support thinking.

Tools

Lam calls these on its own when they would improve an answer. All four are read-only.

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
  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
    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
    preferences.ts            # preferences, normalised
    hooks/useChat.ts          # the chat state machine
    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 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.

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-only. Speaker labels are plain You / Lam, with the question dimmed as a blockquote and the answer as body text. Icons are not possible there: MdImage renders images block-level, centred, and capped at 200px tall, and every candidate source was tried — icon://person, a raw icon slug, and a file:// asset path — and each fell back to rendering its alt text as a caption.

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.

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.
  • Image input, which Ollama's chat API already supports.

License

MIT — see LICENSE.