This is the full developer documentation for vsceasy
# vsceasy
> Build VS Code extensions fast. React UI, typed RPC, file-based routing, and a mini-ORM — scaffolded from one CLI.
import { Card, CardGrid } from '@astrojs/starlight/components';
## Why vsceasy
Drop a file in `panels/`, `commands/`, `menus/`, `treeViews/` — the generator
wires the registry and `package.json#contributes` for you.
Call `api.method(args)` from the webview. One shared interface types both
sides. No manual `postMessage`.
Panels and subpanels ship a React UI wired to the RPC client, themed with VS
Code tokens. Start blank or from a `form` / `list` / `dashboard` template.
A mini-ORM, CRUD scaffolding, jobs, helpers, a component library, a test
harness, and publish tooling — each one command away.
Completions, ghost text, hovers, typing guards, decorations and terminals —
same one-file-per-feature convention. [Read the guide](/guides/editor-surface/).
Ollama or any OpenAI-compatible endpoint over `fetch`. Streaming, JSON mode,
model auto-resolution, user settings. [Read the guide](/guides/llm/).
`create --type language` scaffolds a grammar, snippets, a file icon and
scoped colors. [Language extensions](/guides/language-extensions/).
[Code Trainer](https://github.com/jairoFernandez/code-coach) and a
[TOML extension](https://github.com/jairoFernandez/toml_extension) — see the
[showcase](/showcase/).
## 30-second tour
```bash
bunx @vsceasy/cli create my-extension
cd my-extension && bun install
bun run dev # build + watch
# press F5 in VS Code to launch the Extension Development Host
```
Then add features as you go. Install the binary globally
(`bun add -g @vsceasy/cli`) to use the short `vsceasy` form below — or prefix
each with `bunx @vsceasy/cli`:
```bash
vsceasy panel add --name dashboard --template dashboard
vsceasy db init && vsceasy model add --name user --fields "id:string!,name:string,email?:string@"
vsceasy crud add --model user --menu new:admin
```
Or run the guided flow and let it ask:
```bash
vsceasy wizard
```
# Commands overview
> Every vsceasy CLI command, grouped by what it does.
Structure: `vsceasy [flags]`. Every command runs interactively
when flags are omitted (banner + per-param prompts) or fully scripted via flags.
New to a project? Start with **[`wizard`](/commands/wizard/)** — it detects your
context and walks you through the rest.
## Scaffolding
| Command | What it does |
| ------- | ------------ |
| [`create`](/commands/create/) | Scaffold a new extension project — `--type ui` / `language` / `empty` |
| [`wizard`](/commands/wizard/) | Interactive guided flow |
## UI features
| Command | What it does |
| ------- | ------------ |
| [`panel add`](/commands/panel-add/) | Webview panel + optional typed RPC; `--template` for ready UIs |
| [`subpanel add`](/commands/subpanel-add/) | Inline sidebar webview section |
| [`menu add` / `edit`](/commands/menu/) | Activity-bar menu + items |
| [`treeview add`](/commands/treeview-add/) | Data-driven tree view |
| [`command add`](/commands/command-add/) | Palette command |
| [`statusBar add`](/commands/statusbar-add/) | Status bar item |
| [`rpc add`](/commands/rpc-add/) | Typed RPC method on a panel |
| [`components add`](/commands/components-add/) | Themed React component library |
## Data
| Command | What it does |
| ------- | ------------ |
| [`db init`](/commands/db-init/) | Initialize the mini-ORM |
| [`model add`](/commands/model-add/) | Typed entity + repo |
| [`crud add`](/commands/crud-add/) | Full CRUD UI for a model |
## Operations
| Command | What it does |
| ------- | ------------ |
| [`job add`](/commands/job-add/) | Recurring / event-triggered job |
| [`helper add`](/commands/helper-add/) | Runtime helper (secrets/config/state/notifications/cache/colorize) |
| [`test setup`](/commands/test-setup/) | Vitest config + sample test |
| [`publish init`](/commands/publish-init/) | Marketplace preflight |
| [`doctor`](/commands/doctor/) | Diagnose project drift |
| [`upgrade`](/commands/upgrade/) | Sync framework-owned files |
| [`ai-guide`](/commands/ai-guide/) | Machine-readable CLI spec for AI agents |
:::note[No generator yet]
The editor-surface primitives — completions, inline completions, hovers, typing
guards, decorations and terminals — have no `add` command yet. Create the file by
hand in the matching directory and run `bun run gen`; the shapes are documented in
[Editor surface](/guides/editor-surface/).
:::
:::tip[Driving vsceasy with an AI agent]
[`ai-guide`](/commands/ai-guide/) prints this whole command surface as JSON or
markdown, so an agent working in your project knows exactly what it can run.
Pair it with [`llms.txt`](/llms.txt), which gives the agent the conceptual
documentation in a single fetch.
:::
# ai-guide
> Print a machine-readable spec of the whole CLI, for AI agents and tooling.
Print the full command surface — every command with its parameters, types,
defaults and options — in a form an agent or a script can consume directly.
```bash
vsceasy ai-guide
```
Point a coding agent at this when it is already working inside a project and
needs the exact command surface without fetching anything over the network. For
the conceptual documentation, use [`llms.txt`](/llms.txt) instead — the two are
complementary: `llms.txt` explains *what the framework is*, `ai-guide` states
*what you can run*.
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--format` | list | `json` (default) or `markdown`. |
| `--command` | text | Limit the output to a single top-level command. |
| `--pretty` | boolean | Pretty-print JSON. Ignored for markdown. |
## Scoping the output
The full JSON spec covers 21 commands and runs about 36 KB. When the agent only
needs one command, `--command` cuts that to a few KB:
```bash
vsceasy ai-guide --command panel # ~4 KB instead of ~36 KB
```
## Markdown output
`--format markdown` emits the same spec as prose, which reads better when it is
being pasted into a chat rather than parsed:
```bash
vsceasy ai-guide --format markdown
```
## Piping into a parser
:::caution[Trailing banner]
The output ends with a "Star us on GitHub" banner printed to stdout *after* the
JSON, so piping straight into a parser fails with a JSON syntax error. Strip it
first:
```bash
vsceasy ai-guide | sed -n '1,/^}$/p' | jq
```
:::
# command add
> Add a palette command, optionally wired into a menu with a keybinding.
Add a command registered in the command palette, with optional menu entry,
keybinding, and `when` clause.
```bash
vsceasy command add --name sayHello --title "Say Hello"
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Command id (camelCase). |
| `--title` | text | Palette title. Defaults to PascalCase of name. |
| `--category` | text | Optional category prefix shown in the palette. |
| `--menuEntry` | text | Insert into this menu (file basename in `src/menus/`). |
| `--group` | text | Parent group label inside the menu. |
| `--icon` | codicon | Icon shown next to the menu entry. |
| `--keybinding` | text | Keyboard shortcut, e.g. `ctrl+shift+h`. |
| `--when` | text | `when` clause controlling palette enablement. |
## Examples
```bash
# simple palette command
vsceasy command add --name sayHello --title "Say Hello"
# with a menu entry, icon, and keybinding
vsceasy command add \
--name doStuff \
--title "Do Stuff" \
--menuEntry main \
--group Actions \
--icon play \
--keybinding "ctrl+alt+d"
# only enabled when an editor has focus
vsceasy command add --name format --title "Format" --when editorTextFocus
```
## `when` clause cheatsheet
| Clause | Meaning |
| ------ | ------- |
| `editorTextFocus` | active text editor |
| `editorHasSelection` | text is selected |
| `resourceLangId == typescript` | current file language |
| `resourceExtname == .json` | current file extension |
| `explorerResourceIsFolder` | folder selected in Explorer |
| `workspaceFolderCount != 0` | a workspace is open |
| `view == myExt-settings` | inside a specific tree view |
| `viewItem == myCtx` | tree item with that contextValue |
| `!virtualWorkspace` | exclude github.dev / codespaces |
Operators: `&&` `||` `!` `==` `!=` `=~` `in`. Full reference:
[when-clause contexts](https://code.visualstudio.com/api/references/when-clause-contexts).
```ts title="src/commands/sayHello.ts"
import { defineCommand } from '../shared/vsceasy';
export default defineCommand({
title: 'Say Hello',
icon: 'megaphone', // codicon; '$(megaphone)' also accepted
run: async (vscode) => {
await vscode.window.showInformationMessage('Hello!');
},
});
```
## `icon` on the definition
`icon` is written to `contributes.commands[].icon`. It's optional for a palette
command, but **required** for one pinned to a view's title row via
`titleActions` — without it VS Code renders the command's title as plain text
instead of a button. See [Sidebar views](/guides/sidebar-views/#title-bar-buttons).
Menus, status bar items and tree nodes can reference a command by **either** its
filename or the `id` declared on the def, so
`src/commands/refresh.ts` exporting `defineCommand({ id: 'refreshCatalog' })`
resolves under both names.
# components add
> Generate a theme-aware React component library for webviews.
Write a small library of theme-aware React components into
`src/webview/components/`, styled with VS Code theme tokens. Panel
[`--template`](/commands/panel-add/) UIs import from here.
```bash
vsceasy components add
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--force` | boolean | Overwrite existing component files. Idempotent without it. |
## What it generates
`src/webview/components/` with `Button`, `Input`, `Field`, `Card`, `List`, a
barrel `index.ts`, and `components.css`.
```tsx
import { Button, Input, Field, Card, List } from '../../components';
import '../../components/components.css';
setName(e.target.value)} />
Save
```
Everything is styled with `var(--vscode-*)` tokens, so components match the
user's theme in light and dark mode. See [Webview components](/guides/components/).
# create
> Scaffold a new VS Code extension project.
Scaffold a new vsceasy extension project into `./` (or `--dir`).
```bash
vsceasy create my-extension
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Package name, e.g. `my-extension` or `@scope/my-ext`. |
| `--displayName` | text | Human-readable name. Defaults to a title-cased name. |
| `--description` | text | Short description. |
| `--publisher` | text | VS Code publisher id. Defaults to `your-publisher`. |
| `--type` | `ui` \| `language` \| `empty` | Extension shape. Prompts when omitted in a terminal; defaults to `ui`. |
| `--ui` | `react` | UI framework. Only `react` for now. `--type ui` only. |
| `--preset` | `minimal` \| `full` | `full` (default) adds a sample panel + RPC; `minimal` is empty. `--type ui` only. |
| `--dir` | text | Target directory. Defaults to `./`. |
| `--git` | boolean | Initialize a git repository. Skips the prompt; set `--git=false` to opt out. |
| `--install` | boolean | Install dependencies (bun, falling back to npm). Skips the prompt; set `--install=false` to opt out. |
## Extension types
`--type` picks the shape of the project. Run `create` without it in a terminal
and it asks.
| Type | You get |
| ---- | ------- |
| `ui` (default) | React webview + typed RPC + Vite build + a sample panel (`--preset full`). |
| `language` | Grammar, language configuration, snippets, file icon theme, scoped token colors, `contributes.extra.json`. No React. |
| `empty` | Bare `activate` / `deactivate`. No React, no Vite, no sample panel. |
`language` and `empty` strip React, Vite and the `dev:ui` / `build:ui` scripts
from `package.json` — the extension build, `gen`, and every convention directory
stay, so you can add a panel later with [`panel add`](/commands/panel-add/).
See [Language extensions](/guides/language-extensions/) for what the language
scaffold contains and how to grow it.
## Examples
```bash
# interactive — prompts for the type and the rest
vsceasy create my-extension
# a language extension (syntax + snippets + icon + scoped colors)
vsceasy create my-lang --type language
# a bare extension, no UI
vsceasy create my-tool --type empty
# fully scripted
vsceasy create \
--name my-extension \
--displayName "My Extension" \
--publisher my-publisher \
--ui react \
--preset full
# scoped name, custom directory
vsceasy create --name @acme/cool-tool --dir tools/cool
```
## After scaffolding
When run in an interactive terminal, `create` then offers to:
- **Initialize a git repository** (`git init` in the project).
- **Install dependencies** with the first available package manager (`bun`, falling back to `npm`).
Both default to yes. Pass `--git` / `--install` (or `--git=false` / `--install=false`) to skip the prompts — handy for scripting and CI:
```bash
vsceasy create --name my-extension --preset full --git --install
```
In non-interactive contexts (CI, piped input) without those flags the prompts are skipped and you run the steps yourself:
```bash
cd my-extension
bun install
bun run dev # press F5 in VS Code
```
For `--type language` the first run is `gen` first, since the contributions come
from `contributes.extra.json`:
```bash
cd my-lang
bun install
bun run gen # merge contributes.extra.json into package.json#contributes
bun run launch # open the dev host and open a matching file
```
See [Quick start](/quick-start/) for the full first-run walkthrough.
# crud add
> Scaffold a full CRUD UI (service + list + form + RPC) for a model.
Rails-style scaffolding. For an existing [model](/commands/model-add/), generate a
service, a list panel, a form panel, the RPC contracts, and an optional menu wire.
```bash
vsceasy crud add --model user --menu new:admin
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--model` | model name | **Required.** Model to scaffold over. |
| `--menu` | `none` \| `existing:` \| `new:` | Menu wiring policy. |
| `--newMenuId` | text | Menu id when `--menu new:` is chosen interactively. |
## What it generates
- `src/services/Service.ts` — business logic over the repo.
- `src/services/FormNav.ts` — list→form edit hand-off.
- `src/panels/List.ts` + its webview — the list UI.
- `src/panels/Form.ts` + its webview — the create/edit form.
- `ListApi` and `FormApi` appended to `src/shared/api.ts`.
- Optional menu entries for the list and form.
## Behavior worth knowing
- **List refreshes on reveal.** Webviews retain state when hidden, so the list
reloads on focus/visibility and after a save in the form. There's also a manual
**Refresh** button.
- **Delete confirms in the host.** Browser `confirm()` is disabled in webviews, so
delete uses a native `showWarningMessage` modal.
- **Edit pre-loads.** Clicking Edit stashes the row id; the form pulls it on mount
and pre-fills via `get(id)`. Creating a new row clears the form afterward.
- **Relations become dropdowns.** A `ref(Model)` field (see
[`model add`](/commands/model-add/#relations)) renders as a `` populated
from the related model's rows — loaded over RPC via a generated `options()`
handler. The form stores the related row's id.
## Examples
```bash
# no menu
vsceasy crud add --model user --menu none
# into an existing menu
vsceasy crud add --model user --menu existing:settings
# create a new menu for it
vsceasy crud add --model user --menu new:admin
```
See the [CRUD guide](/guides/crud/) for a full walkthrough.
# db init
> Initialize the project database (mini-ORM).
Scaffold the mini-ORM at `src/helpers/db.ts`. Idempotent — safe to run again.
```bash
vsceasy db init
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--provider` | `storage` \| `global` | Where data lives. Default `storage`. |
| `--force` | boolean | Overwrite an existing `db.ts`. |
## Providers
- **`storage`** — writes under the workspace storage dir. Falls back to global
storage automatically when no folder is open (so activation never fails).
- **`global`** — writes under the global storage dir, shared across workspaces.
## Wire it on activate
```ts title="src/extension/extension.ts"
import { bootstrap } from '../shared/vsceasy';
import { registry } from './_registry';
import { initDb } from '../helpers/db';
export const activate = bootstrap(registry, { onActivate: [initDb] });
```
Next: define a [model](/commands/model-add/), then use it via its repo. See the
[mini-ORM guide](/guides/orm/) for the full API.
# doctor
> Diagnose a vsceasy project and optionally apply safe fixes.
Diagnose common project issues: scripts, RPC contracts, menu references,
codicons, and `contributes` sync.
```bash
vsceasy doctor
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--fix` | boolean | Apply the safe automatic fixes. |
## What it checks
- `package.json` scripts match the expected vsceasy set.
- RPC contracts in `shared/api.ts` line up with panel handlers.
- Menu items reference panels/commands that exist.
- Codicon names are valid.
- `package.json#contributes` is in sync with the files on disk.
- **Language assets** — every file referenced by `languages`, `grammars`,
`snippets` and `iconThemes` exists, reading both `contributes.extra.json` and
the merged `package.json`. Silent on projects that declare none, and it flags
a `contributes.extra.json` that isn't valid JSON.
```bash
# report only
vsceasy doctor
# report + apply safe fixes
vsceasy doctor --fix
```
Run it after manual edits, or when something doesn't show up after a `gen`.
# helper add
> Generate a typed runtime helper into src/helpers/.
Generate a typed helper for a common runtime concern.
```bash
vsceasy helper add --kind secrets
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--kind` | `secrets` \| `config` \| `state` \| `notifications` \| `cache` \| `colorize` | **Required.** Which helper. |
| `--force` | boolean | Overwrite an existing helper file. |
## Kinds
| Kind | Wraps |
| ---- | ----- |
| `secrets` | `context.secrets` (typed get/set/delete). Wire `initSecrets(context)`. |
| `config` | `workspace.getConfiguration(...)` with typed keys. |
| `state` | `globalState` / `workspaceState`. Wire `initState(context)`. |
| `notifications` | info / warning / error message helpers. |
| `cache` | in-memory TTL + LRU cache with a `wrap(key, fn)` helper. |
| `colorize` | `applyTokenColors` / `removeTokenColors` — scoped TextMate colors for your own language. |
The `config` helper exposes its settings prefix as `config.section`, so callers
that need the fully-qualified key (opening the Settings UI at a section, for
instance) don't hardcode the prefix twice.
## Examples
```bash
vsceasy helper add --kind config
vsceasy helper add --kind secrets
vsceasy helper add --kind cache
vsceasy helper add --kind colorize
```
```ts title="src/helpers/cache.ts (usage)"
import { createCache } from '../helpers/cache';
const cache = createCache({ ttlMs: 60_000, max: 200 });
const u = await cache.wrap('user:' + id, () => orm(User).findById(id));
```
:::tip
For the database, use the dedicated [`db init`](/commands/db-init/) +
[`model add`](/commands/model-add/) commands rather than a helper.
:::
# job add
> Add a recurring or event-triggered job.
A job runs on a schedule or in response to an event. Pick exactly one trigger.
```bash
vsceasy job add --name sync --every 30s
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Job id. |
| `--title` | text | Display title. |
| `--every` | duration | Interval: ms number or `30s` / `5m` / `2h` / `1d`. |
| `--dailyAt` | `HH:MM` | Once per day at local time. |
| `--on` | event | `startup` \| `saveDocument` \| `openDocument` \| `changeActiveEditor` \| `changeConfig`. |
| `--onFile` | glob | Filesystem watcher (create / change / delete). |
| `--minIntervalMs` | number | Throttle re-runs across triggers (persisted in globalState). |
Provide exactly one of `--every`, `--dailyAt`, `--on`, `--onFile`.
## Examples
```bash
# every 30s, also runs on startup
vsceasy job add --name sync --every 30s
# daily at 02:30 local time
vsceasy job add --name nightly --dailyAt "02:30"
# on document save, at most once per hour
vsceasy job add --name index --on saveDocument --minIntervalMs 3600000
# on markdown changes
vsceasy job add --name docs --onFile "**/*.md"
```
```ts title="src/jobs/sync.ts"
import { defineJob } from '../shared/vsceasy';
export default defineJob({
title: 'Sync',
schedule: { every: '30s' },
minIntervalMs: 5000,
run: async (vscode, ctx) => {
console.log('[sync] tick', new Date().toISOString());
},
});
```
# menu add / edit
> Create an activity-bar menu and add items to it.
A menu is an activity-bar icon with a tree view of items (panels, commands, URLs,
and groups). Use `menu add` to create one, `menu edit` to add items.
## `menu add`
```bash
vsceasy menu add --name settings --title "Settings" --icon settings-gear
```
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Menu id (file basename in `src/menus/`). |
| `--title` | text | Title shown on the view container. |
| `--icon` | codicon | **Required.** Activity-bar icon. |
## `menu edit`
Add one item to an existing menu.
```bash
vsceasy menu edit --name settings --kind panel --panel usersList --label Users --icon account
```
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | menu id | **Required.** Which menu to edit. |
| `--kind` | `panel` \| `command` \| `url` \| `group` | **Required.** Item type. |
| `--group` | group label | Parent group. Defaults to root. |
| `--panel` | panel id | Required when `--kind panel`. |
| `--command` | command id | Required when `--kind command`. |
| `--url` | url | Required when `--kind url`. |
| `--label` | text | Item label. Defaults from the target. |
| `--icon` | codicon | Item icon. |
## Examples
```bash
# create a menu
vsceasy menu add --name settings --title "Settings" --icon settings-gear
# add a panel item
vsceasy menu edit --name settings --kind panel --panel dashboard --label Dashboard --icon play
# add a command item under a group
vsceasy menu edit --name settings --kind command --command sayHello --group Actions --icon play
# add an external link
vsceasy menu edit --name settings --kind url --url https://example.com --label Docs --icon book
```
```ts title="src/menus/settings.ts"
import { defineMenu } from '../shared/vsceasy';
export default defineMenu({
title: 'Settings',
icon: 'settings-gear',
items: [
{
label: 'Panels',
children: [
{ label: 'Dashboard', icon: 'play', panel: 'dashboard' },
{ label: 'Users', icon: 'account', panel: 'usersList' },
],
},
],
});
```
# model add
> Add a typed model (entity + repo) under src/models/.
Define a typed entity and its repo. Requires [`db init`](/commands/db-init/) first.
```bash
vsceasy model add --name user --fields "id:string!,name:string,email?:string@,active:boolean"
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Model name (singular, e.g. `user`). |
| `--fields` | spec | Compact field spec. Omit to use the interactive loop. |
| `--plural` | text | Repo handle. Defaults to `s`. |
| `--collection` | text | On-disk collection name. Defaults to the lowercased plural. |
## Field spec
Comma-separated `name:type` entries with flags:
| Flag | Position | Meaning |
| ---- | -------- | ------- |
| `!` | after type | primary key |
| `@` | after type | indexed |
| `?` | after name | optional |
```text
id:string! primary key
name:string required
email?:string@ optional + indexed
role:"admin"|"user" literal union
score:number!@ primary key + indexed
```
If no `!` is set, `id` (or the first field) becomes the primary key.
## Relations
Point a field at another model with `ref(Model)` — Symfony-style. The field
becomes a `Id` foreign key, and the relation is recorded so
[`crud add`](/commands/crud-add/) renders a **populated dropdown** for it.
```text
category:ref(Category) FK categoryId, dropdown of Category rows
category:ref(Category, label=name) show Category.name in the dropdown
```
The referenced model must already exist (`model add` errors otherwise, naming the
model to create first). In the interactive loop, the prompt lists the models you
can relate to.
```bash
vsceasy model add --name category --fields "id:string!,name:string"
vsceasy model add --name todo \
--fields "id:string!,title:string,category:ref(Category)"
```
```ts title="src/models/Todo.ts" {4,12-14}
export interface Todo {
id: string;
title: string;
categoryId: string; // → Category
}
export const Todos = defineEntity('todos', { primaryKey: 'id' });
export const TodosRepo = () => db()(Todos);
/** Relation metadata — used by `vsceasy crud add` to populate pickers. */
export const TodoRelations = {
categoryId: { model: 'Category' },
} as const;
```
The default dropdown label is the related model's first string field; override it
with `label=`.
:::note
Relations are **ManyToOne** (a foreign key on this model). The FK stores the
related row's id; there's no join table or cascade — the mini-ORM stays simple.
:::
## Examples
```bash
# one-shot spec
vsceasy model add --name user --fields "id:string!,name:string,email?:string@"
# interactive — type one field per line, blank to finish
vsceasy model add --name post
```
```ts title="src/models/User.ts"
import { defineEntity, db } from '../helpers/db';
export interface User {
id: string;
name: string;
email?: string;
}
export const Users = defineEntity('users', {
primaryKey: 'id',
indexes: ['email'],
});
export const UsersRepo = () => db()(Users);
```
Use the repo anywhere after `initDb` ran:
```ts
await UsersRepo().insert({ id: 'u1', name: 'Jane' });
const u = await UsersRepo().findById('u1');
```
# panel add
> Add a webview panel (React UI + typed RPC) to your extension.
Add a panel: a webview that opens in the editor area, with a React UI and an
optional typed RPC bridge.
```bash
vsceasy panel add --name settings --title "Settings"
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Panel id, e.g. `settings`. |
| `--title` | text | Tab title. Defaults to PascalCase of name. |
| `--template` | `blank` \| `form` \| `list` \| `dashboard` | Starter UI. Default `blank`. |
| `--withApi` | `yes` \| `no` | Generate a typed RPC interface. Forced on for non-blank templates. |
## What it generates
- `src/panels/.ts` — the panel definition.
- `src/webview/panels//{App.tsx,main.tsx}` — the React UI.
- Appends `Api` to `src/shared/api.ts` when the API is on.
- An auto command `.open` to open it.
## Templates
`--template` starts you from a working screen built on the shared
[component library](/commands/components-add/) (auto-generated on first use) and
wires the matching RPC method.
```bash
vsceasy panel add --name signup --template form # inputs + save() RPC
vsceasy panel add --name items --template list # list + load() RPC
vsceasy panel add --name stats --template dashboard # stat cards + stats() RPC
```
| Template | UI | RPC method added |
| -------- | -- | ---------------- |
| `blank` | empty `App.tsx` | none |
| `form` | inputs + Save | `save(input)` |
| `list` | list + Refresh | `list()` |
| `dashboard` | stat cards | `stats()` |
## Example
```ts title="src/panels/settings.ts"
import { definePanel } from '../shared/vsceasy';
import type { SettingsApi } from '../shared/api';
export default definePanel({
title: 'Settings',
rpc: (vscode) => ({
// add RPC handlers here
}),
});
```
# publish init
> Prepare the project for the marketplace.
Marketplace preflight: ensure a README, CHANGELOG, icon placeholder, and the
required `package.json` fields, then run a dry-run `vsce ls`.
```bash
vsceasy publish init
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--skipDryPack` | boolean | Skip the `vsce ls` dry run. |
## What it checks / adds
- `README.md` and `CHANGELOG.md` (created if missing).
- An icon placeholder + `icon` field in `package.json`.
- Required publish fields (`publisher`, `repository`, `categories`, …).
- A dry-run package listing so you see exactly what would ship.
## Then package
```bash
bun run package # builds prod + vsce package --no-dependencies
```
This produces a `.vsix` you can upload to the marketplace or install locally with
**Extensions: Install from VSIX…**.
# rpc add
> Add a typed RPC method to a panel.
Extend a panel's RPC contract: appends a method to its interface in
`src/shared/api.ts` and adds a handler stub in the panel.
```bash
vsceasy rpc add --panel dashboard --method getStats --returns "Promise"
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--panel` | panel id | **Required.** Which panel to extend. |
| `--method` | text | **Required.** Method name. |
| `--params` | text | Param signature, e.g. `id: string, q?: string`. |
| `--returns` | text | Return type. Default `void`. |
## Examples
```bash
# no args, returns a value
vsceasy rpc add --panel dashboard --method getStats --returns "{ total: number }"
# with params
vsceasy rpc add --panel users --method find --params "q: string" --returns "User[]"
```
This adds to both sides:
```ts title="src/shared/api.ts"
export interface DashboardApi {
getStats(): Promise<{ total: number }>; // ← added
}
```
```ts title="src/panels/dashboard.ts"
rpc: (vscode) => ({
async getStats() {
// TODO: implement
return { total: 0 };
},
}),
```
Call it from the webview with full typing — see [Typed RPC](/guides/rpc/).
# statusBar add
> Add a status bar item bound to a command, panel, or popup menu.
Add a status bar item that runs a command, opens a panel, creates a new command,
or opens a popup menu.
```bash
vsceasy statusBar add --name sync --text "$(sync) Sync" --bindTo command --command doSync
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Item id. |
| `--text` | text | **Required.** Label (supports `$(codicon)` syntax). |
| `--alignment` | `left` \| `right` | Side of the status bar. |
| `--priority` | number | Higher = further left within its side. |
| `--bindTo` | `command` \| `panel` \| `create new command` \| `menu` | What the item does on click. |
| `--command` | command id | When `--bindTo command`. |
| `--panel` | panel id | When `--bindTo panel`. |
| `--newCommandTitle` | text | When `--bindTo "create new command"`. |
| `--menu` | menu id | When `--bindTo menu`. |
| `--label` / `--kind` | — | Menu item details when binding to a popup menu. |
## Examples
```bash
# open a panel
vsceasy statusBar add --name dash --text "$(dashboard) Dashboard" --bindTo panel --panel dashboard
# run an existing command
vsceasy statusBar add --name fmt --text "$(wand) Format" --bindTo command --command format
# create the command at the same time
vsceasy statusBar add --name greet --text "Hello" --bindTo "create new command" --newCommandTitle "Say Hello"
```
```ts title="src/statusBars/sync.ts"
import { defineStatusBar } from '../shared/vsceasy';
export default defineStatusBar({
text: '$(sync) Sync',
alignment: 'left',
command: 'doSync',
});
```
# store add
> Add a reactive store — an observable value a webview can track.
Scaffold a reactive store under `src/stores/`. A store holds one observable value;
mutate it and anything watching reacts. See the [Reactivity guide](/guides/reactivity/)
for the full picture.
```bash
vsceasy store add --name badgeCount --type number --initial 0
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Store id (camelCase, e.g. `badgeCount`). |
| `--type` | `number` \| `string` \| `boolean` \| `json` | Value type. Default `number`. |
| `--initial` | text | Initial value expression. Defaults per type: `0` / `''` / `false` / `null`. |
## What it generates
```ts title="src/stores/badgeCount.ts"
import { defineStore } from '../shared/vsceasy';
export const badgeCount = defineStore(0);
```
`defineStore` gives `get()`, `set(v)`, `update(fn)`, and `subscribe(cb)`.
## Using it
Mutate the store anywhere on the host:
```ts
import { badgeCount } from '../stores/badgeCount';
badgeCount.set(3);
badgeCount.update((n) => n + 1);
```
Push changes to a webview — `watch` the store in a panel's `rpc()` and `emit`:
```ts
import { watch } from '../shared/vsceasy';
import { badgeCount } from '../stores/badgeCount';
rpc: (vscode, ctx, emit) => {
watch(badgeCount, () => emit('badgeCount:changed', badgeCount.get()));
return { /* … */ };
}
```
React in the webview:
```ts
import { listen } from '../shared/vsceasy/client';
listen(api, 'badgeCount:changed', (v) => render(v));
```
## Examples
```bash
# a boolean flag, starts false
vsceasy store add --name sidebarOpen --type boolean
# a string with a custom initial value
vsceasy store add --name filter --type string --initial "'all'"
# arbitrary JSON state
vsceasy store add --name selection --type json --initial "{ ids: [] }"
```
See the [Reactivity guide](/guides/reactivity/) for ORM-entity reactivity
(`watchEntity`) and the host↔webview flow.
# subpanel add
> Add an inline sidebar webview section inside a menu container.
A subpanel is a webview view rendered inline inside a menu's activity-bar
container (unlike a panel, which opens in the editor area).
```bash
vsceasy subpanel add --name history --menu settings --title "History"
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Subpanel id. |
| `--menu` | menu id | **Required.** Container to render inside. |
| `--title` | text | View title. |
| `--withApi` | `yes` \| `no` | Generate a typed RPC interface. |
## What it generates
- `src/subpanels/.ts` — the subpanel definition.
- `src/webview/subpanels//{App.tsx,main.tsx}` — the React UI.
- Appends `ViewApi` to `src/shared/api.ts` when the API is on.
```ts title="src/subpanels/history.ts"
import { defineSubpanel } from '../shared/vsceasy';
export default defineSubpanel({
title: 'History',
menu: 'settings',
order: 2, // position inside the container
titleActions: [{ command: 'clearHistory' }], // buttons on the title row
});
```
Subpanels and panels share the same RPC machinery — see [Typed RPC](/guides/rpc/).
Subpanels and tree views share **one** ordering inside a container, so a tree
view with `order: 1` sits above a subpanel with `order: 2`. See
[Sidebar views](/guides/sidebar-views/).
# test setup
> Add Vitest config and a sample test to the project.
Scaffold a test harness: Vitest config, a sample test, and vscode/RPC mock
helpers.
```bash
vsceasy test setup
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--force` | boolean | Overwrite existing test files. |
## What it generates
- A Vitest config.
- A sample test under `src/__tests__/`.
- Mock helpers for the `vscode` API and the RPC bridge, so you can unit-test
panel handlers and services without a running extension host.
```bash
bun run test # vitest run
bun run test:watch # vitest
```
# treeview add
> Add a data-driven tree view to a menu.
A tree view renders hierarchical data inside a menu's container, driven by
`getChildren` / `getTreeItem`.
```bash
vsceasy treeview add --name files --menu settings --title "Files"
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--name` | text | **Required.** Tree view id. |
| `--menu` | menu id | **Required.** Container to render inside. |
| `--title` | text | View title. |
## What it generates
`src/treeViews/.ts` with a `getChildren` you fill in with real data.
```ts title="src/treeViews/files.ts"
import { defineTreeView, TreeNode } from '../shared/vsceasy';
export default defineTreeView({
title: 'Files',
menu: 'settings',
order: 1, // position inside the container
titleActions: [{ command: 'refreshFiles' }], // buttons on the title row
getChildren: async (parent, vscode, ctx) => {
if (!parent) {
return [
{ label: 'Item 1', icon: 'file', tooltip: 'Replace with real data' },
{ label: 'Group', icon: 'folder', expandable: true }, // children load on expand
] as TreeNode[];
}
// Lazy children — return based on parent.id / parent.contextValue.
return [];
},
});
```
A `TreeNode` may carry a `panel`, `command` or `run` to fire on click (the node
is forwarded to the command), plus `icon`, `tooltip`, `description`, `collapsed`,
and `contextValue` for `when`-clause targeting.
:::note[`expandable` marks lazy nodes]
A node is collapsible when it has non-empty `children` **or** sets
`expandable: true`. Without it, a node with no `children` is a leaf — that's what
keeps every leaf from getting an expand arrow that opens nothing.
:::
`order` and `titleActions` are covered in [Sidebar views](/guides/sidebar-views/),
along with why a title-bar command needs an `icon`.
# upgrade
> Sync framework-owned files from the bundled templates.
Sync the framework runtime (`src/shared/vsceasy/*`, `scripts/gen.ts`, and similar)
from the bundled templates. Dry-run by default.
```bash
vsceasy upgrade
```
## Flags
| Flag | Type | Notes |
| ---- | ---- | ----- |
| `--apply` | boolean | Apply the changes. Without it, upgrade is a dry run. |
| `--ui` | text | UI variant subfolder. Default `react`. |
## What it does
Compares your framework-owned files against the version shipped with the CLI and
reports each as `in-sync`, `would-create`, or `would-update`. With `--apply` it
writes the updates and runs `gen` if anything changed.
```bash
# preview what would change
vsceasy upgrade
# apply the updates
vsceasy upgrade --apply
```
:::caution
Upgrade only touches **framework-owned** files (`src/shared/vsceasy/*`,
`scripts/gen.ts`). Your panels, commands, models, and webviews are never
modified.
:::
# wizard
> Interactive guided flow — create a project or add features step by step.
The wizard detects whether you're inside a vsceasy project and guides you
accordingly. It takes no flags — it asks.
```bash
vsceasy wizard
```
## What it does
- **Outside a project** → walks you through [`create`](/commands/create/): name,
display name, publisher, preset.
- **Inside a project** → menus the common generators: panel, command, database,
model, helper, components. Anything not wired into the wizard is listed as the
exact command to run.
It reuses the same library functions the individual commands call, so the result
is identical to running the commands directly.
## Example session
```text
vsceasy — interactive wizard
Project: .
? What do you want to add?
❯ Panel webview + typed RPC
Command palette command
Database init the mini-ORM
Model entity + repo
Helper secrets/config/state/…
Components themed React UI library
Something else… show the full command list
```
Picking **Panel** asks for an id, title, a starter UI
(`blank` / `form` / `list` / `dashboard`), and whether to generate the RPC API.
See the [wizard guide](/guides/wizard/) for the full flow.
# Concepts
> The mental model — file-based registry, the gen step, RPC, and bootstrap.
Four ideas explain almost everything vsceasy does.
## 1. Convention directories
Each feature type has a directory. A file in it *is* the feature.
| Directory | API | Becomes… |
| --------------------- | ------------------------ | ---------------------------------------------- |
| `panels/` | `definePanel` | webview panel + auto `.open` cmd |
| `commands/` | `defineCommand` | palette command + keybindings |
| `menus/` | `defineMenu` | activity-bar container + tree view |
| `treeViews/` | `defineTreeView` | data-driven view inside a menu container |
| `subpanels/` | `defineSubpanel` | inline webview section inside a menu |
| `statusBars/` | `defineStatusBar` | status bar item |
| `jobs/` | `defineJob` | scheduled / event-triggered task |
| `completions/` | `defineCompletion` | IntelliSense provider |
| `inlineCompletions/` | `defineInlineCompletion` | ghost text at the cursor |
| `hovers/` | `defineHover` | hover panel (markdown) |
| `typingGuards/` | `defineTypingGuard` | keystroke / paste / delete interception |
| `decorations/` | `defineDecoration` | editor overlays |
| `terminals/` | `defineTerminal` | captured `exec` + visible terminal |
The last seven act **on the editor itself** — see
[Editor surface](/guides/editor-surface/).
```ts title="src/panels/dashboard.ts"
import { definePanel } from '../shared/vsceasy';
export default definePanel({
title: 'Dashboard',
});
```
## 2. The gen step
`scripts/gen.ts` scans those directories and writes two things:
- `src/extension/_registry.ts` — a typed registry of everything on disk.
- `package.json#contributes` — commands, keybindings, viewsContainers, views, all
kept in sync with the files.
Run it with `bun run gen`. Generators run it for you after writing files.
`gen` owns `commands`, `keybindings`, `viewsContainers` and `views`. Anything
else VS Code contributes — languages, grammars, snippets, themes, iconThemes,
`configuration` — goes in an optional **`contributes.extra.json`** at the project
root, which `gen` deep-merges in on every run. See
[`contributes.extra.json`](/guides/language-extensions/#contributesextrajson).
```mermaid
flowchart LR
DIRS["convention dirs"] --> GEN["gen.ts"]
GEN --> REG["_registry.ts"]
GEN --> CONTRIB["contributes"]
```
## 3. Typed RPC
The webview talks to the extension through one typed interface.
```ts title="src/shared/api.ts"
export interface DashboardApi {
getStats(): Promise<{ total: number }>;
}
```
```ts title="src/panels/dashboard.ts"
export default definePanel({
title: 'Dashboard',
rpc: (vscode) => ({
async getStats() {
return { total: 42 };
},
}),
});
```
```tsx title="src/webview/panels/dashboard/App.tsx"
import { connectWebview } from '../../../shared/vsceasy/client';
import type { DashboardApi } from '../../../shared/api';
const api = connectWebview();
const stats = await api.getStats(); // typed, no postMessage
```
See [Typed RPC](/guides/rpc/) for the full story.
## 4. Bootstrap
`extension.ts` is a one-liner. `bootstrap(registry)` registers everything from
the generated registry on activate, so you rarely touch activation events.
```ts title="src/extension/extension.ts"
import { bootstrap } from '../shared/vsceasy';
import { registry } from './_registry';
export const activate = bootstrap(registry, { onActivate: [/* initDb, … */] });
```
`onActivate` hooks run once on activate — wire `initDb(context)`,
`initSecrets(context)`, and similar there.
# Glossary
> Plain-language definitions of the terms used across the vsceasy docs.
Quick reference for the vocabulary used throughout these docs. Terms link to the
guide that covers them in full.
## RPC (Remote Procedure Call)
A way for the webview (React UI) to call functions that run in the extension
host as if they were local async functions — no manual `postMessage` plumbing.
In vsceasy the contract is a single **typed** TypeScript interface shared by both
sides, so calls are checked at compile time. See [Typed RPC](/guides/rpc/).
```ts
const api = connectWebview();
const stats = await api.getStats(); // typed call across the bridge
```
## Webview
A sandboxed browser frame VS Code renders inside the editor or sidebar. vsceasy
runs your React UI inside it. The webview cannot touch the VS Code API or the
filesystem directly — it reaches the extension host through [RPC](#rpc-remote-procedure-call).
## Extension host
The Node.js process where your extension code runs. It has full access to the
VS Code API, the filesystem, and secrets. The counterpart to the
[webview](#webview).
## Panel
A webview that opens in the editor area. Defined by a file in `panels/` with
`definePanel`. Each panel auto-registers an `.open` command. See
[Concepts](/concepts/).
## Subpanel
A webview section rendered **inline** inside a menu's activity-bar container,
instead of opening in the editor area like a panel. Defined in `subpanels/`.
## Command
A palette action (the `Cmd/Ctrl+Shift+P` list). A file in `commands/` with
`defineCommand` registers the command plus any keybindings.
## Menu
An activity-bar container (the icons down the left side) that holds tree views
and subpanels. Defined in `menus/` with `defineMenu`.
## Tree view
A data-driven list/tree rendered inside a menu container. Defined in
`treeViews/` with `defineTreeView`.
## Status bar item
A widget in the bottom status bar. Defined in `statusBars/` with
`defineStatusBar`.
## Convention directory
A directory whose name maps to a feature type (`panels/`, `commands/`,
`menus/`, …). Dropping a file into it **is** how you declare that feature — no
central registration list to edit. See [Concepts](/concepts/).
## The gen step
`scripts/gen.ts` — scans the convention directories and writes the generated
[registry](#registry) and the `package.json#contributes` block, keeping both in
sync with the files on disk. Run with `bun run gen`; generators run it for you.
## Registry
`src/extension/_registry.ts` — a generated, typed map of every feature found on
disk. Produced by [the gen step](#the-gen-step) and consumed by
[bootstrap](#bootstrap). You don't edit it by hand.
## contributes
The `contributes` block in `package.json` — VS Code's manifest of commands,
keybindings, view containers, and views. vsceasy generates and maintains it from
your files rather than asking you to hand-edit it.
## contributes.extra.json
An optional file at the project root for contributions [the gen step](#the-gen-step)
doesn't own — languages, grammars, snippets, themes, iconThemes, `configuration`.
`gen` deep-merges it into `package.json#contributes` on every run; the keys it
owns (`commands`, `keybindings`, `viewsContainers`, `views`) always win. See
[Language extensions](/guides/language-extensions/#contributesextrajson).
## Extension type
The shape `create` scaffolds: `ui` (React webview + RPC), `language` (grammar,
snippets, file icon, scoped colors), or `empty` (bare activate/deactivate).
Chosen with `--type`. See [create](/commands/create/).
## Bootstrap
`bootstrap(registry)` — the one-liner in `extension.ts` that registers
everything from the generated [registry](#registry) on activate. `onActivate`
hooks (e.g. `initDb`, `initSecrets`) run once at activation.
## Codegen
Code generation. vsceasy is codegen, not a runtime you ship — it writes plain
TypeScript + React into your project, and your built extension has **no runtime
dependency** on vsceasy. See [Introduction](/introduction/).
## Mini-ORM
The small, typed, filesystem-backed data store vsceasy ships. The bundled
provider writes each entity to a JSON file under the extension's storage dir;
swapping the provider doesn't change your entity definitions or call sites. See
[The mini-ORM](/guides/orm/).
## Model / Entity
A typed record definition (e.g. `User`) created with `vsceasy model add`. Lives
in `models/` and is persisted through the [mini-ORM](#mini-orm).
## CRUD
Create, Read, Update, Delete — the four basic operations on a record. `vsceasy
crud add` scaffolds a panel + RPC that perform them against a
[model](#model--entity). See [CRUD scaffolding](/guides/crud/).
## Helper
A generated typed wrapper for a common runtime concern — `secrets`, `config`,
`state`, `notifications`, `cache`, or `colorize` — written into `src/helpers/`.
Added with `vsceasy helper add`.
## Typing guard
A file in `typingGuards/` that sits between the keyboard and the document: it can
let a keystroke through, swallow it, or substitute something else, and it also
sees paste and deletions. See [Editor surface](/guides/editor-surface/#typing-guards).
## Ghost text
The dimmed inline suggestion at the cursor, produced by a file in
`inlineCompletions/`. The natural place to put an [LLM](/guides/llm/), which is
why `delayMs` and `cacheMs` exist.
## Title action
A command pinned to a view's title row via `titleActions`. `group: 'navigation'`
renders it as an icon button; anything else drops it into the `…` overflow menu.
The command needs an `icon` or it renders as text. See
[Sidebar views](/guides/sidebar-views/#title-bar-buttons).
## Scoped token colors
TextMate rules written to the user's `editor.tokenColorCustomizations` for your
language's scopes only, so other languages keep the active theme. Generated by
`--type language` or `helper add --kind colorize`. See
[Language extensions](/guides/language-extensions/#scoped-token-colors).
## Job
A unit of work that runs on a schedule (`--every 30s`) or in response to an
event. Added with `vsceasy job add`.
## Provider
A swappable backend behind an abstraction. The [mini-ORM](#mini-orm) has a
storage provider; the database can target per-workspace `storage` or shared
`global` storage.
# Webview components
> A themed React component library + ready-made panel UI templates.
import Showcase from '../../../components/vsceasy/Showcase.tsx';
import { ButtonDemo, InputDemo, FieldDemo, CardDemo, ListDemo } from '../../../components/vsceasy/demos.tsx';
Webviews start blank, but you don't have to. vsceasy ships a themed component
library and panel UI templates that use it.
Every preview below is the **real** component, synced verbatim from the generator
templates and rendered with representative VS Code theme tokens. Toggle
Dark/Light to see it adapt the way it does inside the editor.
## The component library
```bash
vsceasy components add
```
Writes `Button`, `Input`, `Field`, `Card`, `List` (+ `components.css`) into
`src/webview/components/`, all styled with `var(--vscode-*)` tokens so they match
the user's theme in light and dark mode.
```tsx
import { Button, Input, Field, Card, List } from '../../components';
import '../../components/components.css';
```
## Components
### Button
A theme-aware button with `primary` (default) and `secondary` variants. Passes
through all native `` props.
```tsx
Save
Cancel
Saving…
```
| Prop | Type | Notes |
| ---- | ---- | ----- |
| `variant` | `'primary' \| 'secondary'` | Default `primary`. |
| …rest | `ButtonHTMLAttributes` | `onClick`, `disabled`, `type`, … |
### Input
A theme-aware text input. Forwards its ref and all native ` ` props.
```tsx
setName(e.target.value)} placeholder="Jane Doe" />
```
| Prop | Type | Notes |
| ---- | ---- | ----- |
| …rest | `InputHTMLAttributes` | `value`, `onChange`, `type`, `placeholder`, … |
### Field
A labeled wrapper for a control, with optional `hint` or `error` text below it.
```tsx
setEmail(e.target.value)} />
```
| Prop | Type | Notes |
| ---- | ---- | ----- |
| `label` | string | Required. |
| `htmlFor` | string | Ties the label to the control. |
| `hint` | string | Shown when there's no error. |
| `error` | string | Replaces the hint, styled as an error. |
### Card
A bordered surface for grouping content, with an optional title and an actions
row in the header.
```tsx
Edit }>
…content…
```
| Prop | Type | Notes |
| ---- | ---- | ----- |
| `title` | string | Optional header title. |
| `actions` | ReactNode | Optional header actions (right-aligned). |
| `children` | ReactNode | Card body. |
### List
A selectable list. Rows highlight on hover and call `onSelect` when clicked.
Renders an empty state when there are no items.
```tsx
u.id}
onSelect={(u) => setSelected(u.id)}
renderItem={(u) => u.name}
empty="No users yet."
/>
```
| Prop | Type | Notes |
| ---- | ---- | ----- |
| `items` | `T[]` | The rows. |
| `getKey` | `(item, i) => string \| number` | Stable React key. |
| `renderItem` | `(item, i) => ReactNode` | Row content. |
| `onSelect` | `(item, i) => void` | Optional; makes rows clickable. |
| `empty` | ReactNode | Shown when `items` is empty. |
## All together
Composed into a small CRUD-style screen — the shape a generated panel takes.
## Panel templates
`panel add --template` starts a panel from a working screen built on these
components, with the matching RPC method already wired.
```bash
vsceasy panel add --name signup --template form
vsceasy panel add --name items --template list
vsceasy panel add --name stats --template dashboard
```
| Template | UI | RPC added |
| -------- | -- | --------- |
| `form` | inputs + Save | `save(input)` |
| `list` | list + Refresh | `list()` |
| `dashboard` | stat cards | `stats()` |
Non-blank templates auto-generate the component library on first use and force
the typed API on. Fill in the handler in the panel and you have a working screen.
# CRUD scaffolding
> Generate a full list + form UI over a model, end to end.
`crud add` is the fastest path from a model to a working UI. It generates a
service, a list panel, a form panel, the RPC contracts, and an optional menu wire.
## Walkthrough
```bash
# 1. database + model
vsceasy db init
vsceasy model add --name user \
--fields "id:string!,name:string,email?:string@,role:\"admin\"|\"user\",active:boolean"
# 2. full CRUD, wired into a new menu
vsceasy crud add --model user --menu new:admin
```
Reload the window and open the **admin** menu. You get:
- A **list** panel: table of rows, Refresh, + New, Edit, Delete.
- A **form** panel: typed inputs (text, number, checkbox, select for unions).
- A **service** (`UserService`) sitting between the RPC handlers and the repo.
## What's generated
```
src/services/UserService.ts business logic over UsersRepo()
src/services/userFormNav.ts list → form edit hand-off
src/panels/usersList.ts list panel definition + RPC
src/panels/userForm.ts form panel definition + RPC
src/webview/panels/usersList/ list React UI
src/webview/panels/userForm/ form React UI
src/shared/api.ts UsersListApi + UserFormApi appended
```
## Behavior built in
The scaffold handles the webview gotchas for you:
- **Live list.** The list reloads on focus/visibility and after a save, plus a
manual **Refresh** button — because webviews keep state when hidden.
- **Host-side delete.** Delete confirms with a native modal, since browser
`confirm()` is disabled in webviews.
- **Edit pre-fill.** Edit stashes the row id; the form pulls it on mount and
pre-fills via `get(id)`. Creating a new row clears the form afterward.
## Customizing
A generated `crud.config.ts` lets you hide fields or relabel columns:
```ts title="crud.config.ts"
export default {
fields: {
createdAt: { hideInForm: true },
email: { label: 'Email address' },
},
};
```
Re-run `crud add` after editing the config, or tweak the generated panels
directly — they're yours.
# Editor surface
> Completions, ghost text, typing guards, decorations and terminals — the primitives that act on the editor itself.
Panels and menus put UI *next to* the editor. The primitives on this page act
**on the editor itself**: what appears as you type, what is allowed to be typed,
what is painted over the text, and what runs in a terminal.
Each one is a convention directory scanned by `bun run gen`, exactly like
`panels/` and `commands/`.
| Directory | API | Registers |
|----------------------|---------------------------|------------------------------------|
| `completions/` | `defineCompletion` | `CompletionItemProvider` |
| `inlineCompletions/` | `defineInlineCompletion` | `InlineCompletionItemProvider` |
| `hovers/` | `defineHover` | `HoverProvider` |
| `typingGuards/` | `defineTypingGuard` | `type` / paste / delete overrides |
| `decorations/` | `defineDecoration` | `TextEditorDecorationType` |
| `terminals/` | `defineTerminal` | headless `exec` + visible terminal |
## Completions
A completion provider with two extras VS Code doesn't give you: a **delay** that
measures real keyboard silence, and a **gate** that can veto a request outright.
```ts
// src/completions/hints.ts
import { defineCompletion } from '../shared/vsceasy';
export default defineCompletion({
selector: 'typescript',
triggerCharacters: ['.'],
// Nothing appears until the user has been still for 700ms.
delayMs: 700,
gate: (ctx) => ctx.prefix.length >= 2,
provide: (ctx) => [
{ label: 'toSorted', kind: 'method', detail: 'non-mutating sort' },
],
});
```
`delayMs` is what makes a provider *non-invasive*: while you type fluently the
list never opens, because the debounce is measured against the last keystroke in
the document, not against the request. If the user types again while the
provider is waiting, the request is dropped — a newer one is already coming.
`selector` accepts a language id (`'python'`), a glob (`'**/practice/*.ts'`), a
full selector object, or an array of any of those.
## Inline completions (ghost text)
Same shape, but the result is grey text at the cursor. This is where an LLM
belongs — `delayMs` and `cacheMs` exist to keep you from hammering it.
```ts
// src/inlineCompletions/predict.ts
import { defineInlineCompletion, useLlm } from '../shared/vsceasy';
export default defineInlineCompletion({
selector: 'typescript',
delayMs: 900,
cacheMs: 15_000, // don't re-ask while the user reads the same suggestion
provide: async (ctx) => {
const text = await useLlm().complete(`Continue:\n${ctx.linePrefix}`, { maxTokens: 64 });
// Always re-check: the user has usually typed on while the model thought.
if (ctx.token.isCancellationRequested) return null;
return { text, onAccept: () => console.log('accepted') };
},
});
```
`onAccept` is the hook VS Code's own API lacks — use it for telemetry or scoring.
## Hovers
A hover provider returns **markdown** for the symbol under the pointer. Return
`null` or `''` to show nothing and let other providers answer.
```ts
// src/hovers/explain.ts
import { defineHover } from '../shared/vsceasy';
export default defineHover({
selector: 'typescript',
provide: async (ctx) => {
const doc = lookup(ctx.word);
if (!doc) return null;
return `**${ctx.word}** — ${doc.summary}\n\n[Practise it](command:myExt.practice)`;
},
});
```
The context carries `word`, the full `line`, `lineNumber`, `document`,
`position` and a `token`. Command links (`[text](command:ext.foo)`) are enabled,
so the panel can be interactive — a hover is a decent place to put an action the
user shouldn't have to hunt for in the palette.
## Typing guards
A guard sits between the keyboard and the document. It can let a keystroke
through, swallow it, or substitute something else.
```ts
// src/typingGuards/practice.ts
import { defineTypingGuard } from '../shared/vsceasy';
export default defineTypingGuard({
selector: '**/practice/**',
enabled: () => sessionIsRunning(),
onType: (e) => {
if (e.text !== expectedChar()) return { block: true, message: 'Wrong key' };
return true;
},
onPaste: () => ({ block: true, message: 'Paste disabled — type it out.' }),
onDelete: (e) => (e.hasSelection ? { block: true } : true),
onChange: (e) => countKeystrokes(e), // observe only, cannot block
});
```
:::danger[Deletions do not arrive through `onType`]
VS Code routes backspace, delete, the word-wise variants and cut as their own
commands. A guard that implements only `onType` will let the user delete
anything — and if the guard tracks an offset, one backspace desyncs it from the
buffer permanently. Implement `onDelete` whenever you implement `onType`.
:::
`onDelete` receives which command fired (`kind`), the text that would be
removed, and whether a selection was involved:
```ts
onDelete: (e) => {
if (e.kind === 'cut') return { block: true, message: 'No cutting.' };
if (e.hasSelection) return { block: true, message: 'Delete one character at a time.' };
return true; // allow, then resync your own state
},
```
Rather than counting deleted characters — which breaks on selections,
word-deletes and multi-cursors — allow the deletion and re-derive your state
from the buffer afterwards. That's the only approach that survives every
deletion path, including undo.
Return values:
| Return | Effect |
|-------------------------------|---------------------------------|
| `true` / `undefined` | let the keystroke through |
| `false` | swallow it silently |
| `{ block: true, message? }` | swallow it and warn |
| `{ insert: '…' }` | insert something else instead |
:::caution[`type` is exclusive]
VS Code lets only **one** extension override the `type` command. The runtime
registers a single override and fans it out to every guard in registration
order, so multiple guards coexist — but another extension that overrides `type`
will conflict with yours. Keep `enabled` tight so the guard is transparent
whenever it isn't needed.
:::
### Who owns paste, and when
`type` has a `default:type` twin to delegate back to. **Paste and the delete
commands don't** — once you override `editor.action.clipboardPasteAction` you own
it for the *whole window*, webview inputs and the terminal included, with nothing
to hand it back to.
So the runtime registers the paste override **only while a guard actually
applies** to the active document, and disposes it the moment none does. It
re-evaluates when the active editor changes and when a document opens.
That leaves one case it can't see: a guard whose `enabled` flips on state of your
own — a practice session starting or finishing — with no editor event to hang
off. Tell the runtime:
```ts
import { refreshTypingGuards } from '../shared/vsceasy';
session.start();
refreshTypingGuards(); // re-evaluate paste ownership now
```
Skip it and the override can stay registered after the guard goes inactive,
which breaks Cmd +V in webviews and the terminal.
Deletions are the same story: there is no `default:deleteLeft`, so when every
guard allows a deletion the runtime **performs it itself** through the edit API —
backspace/delete at line boundaries, the word-wise variants, cut, and every
selection in a multi-cursor. You get the normal editing behaviour back, but it is
the runtime doing it, not VS Code.
## Decorations
Paint over the editor without touching the buffer — ghost text, highlights,
gutter icons.
```ts
// src/decorations/target.ts
import { defineDecoration } from '../shared/vsceasy';
export default defineDecoration({
style: { after: { color: '#6a737d', fontStyle: 'italic' } },
on: ['changeActiveEditor', 'changeDocument', 'changeSelection'],
watch: (refresh) => session.subscribe(refresh), // same shape as treeViews
compute: (editor) => [
{ line: editor.selection.active.line, style: { after: { contentText: ' ← type this' } } },
],
});
```
Spans may carry their own `style`, merged over the base. Decoration types for
those variants are created lazily and cached, so a redraw doesn't leak one per
frame.
For a decoration that only ever updates on demand, use `on: ['manual']` and call
`refreshDecoration('')`.
## Terminals
Two modes: **captured** (`exec`, for scoring and parsing) and **visible**
(`send`, for output the user should watch).
```ts
// src/terminals/runner.ts
import { defineTerminal } from '../shared/vsceasy';
export default defineTerminal({
title: 'Test Runner',
timeoutMs: 45_000,
env: { NO_COLOR: '1' },
});
```
```ts
import { useTerminal } from '../shared/vsceasy';
const t = useTerminal('runner')!;
const run = await t.exec('bun test', { cwd: '/path/to/dir' });
if (run.code !== 0) console.log(run.stdout, run.stderr);
t.send('bun test --watch'); // visible terminal, output not captured
```
`exec` never throws on a non-zero exit — inspect `code`. A run killed by
`timeoutMs` comes back with `timedOut: true` and `code: null`.
## The LLM client
`createLlm` speaks to Ollama and any OpenAI-compatible endpoint over `fetch` —
no SDK dependency.
```ts
import { createLlm } from '../shared/vsceasy';
const llm = createLlm({ provider: 'ollama', model: 'qwen2.5-coder:7b' });
const text = await llm.chat([{ role: 'user', content: 'hi' }]);
const data = await llm.json<{ items: string[] }>([{ role: 'user', content: 'list 3 fruits as JSON' }]);
await llm.chat(messages, { onToken: (t) => append(t) }); // streaming
```
Streaming, JSON mode, model auto-resolution, `ping()`, reasoning models and the
settings-driven shared client (`initLlm` / `useLlm`) all have their own page:
**[The LLM client](/guides/llm/)**.
## Reactive status bar
`defineStatusBar` also takes `render` + `watch`, so an item can track live state
using the same pattern as tree views and decorations:
```ts
export default defineStatusBar({
text: 'Idle',
icon: 'dashboard',
watch: (refresh) => session.subscribe(refresh),
render: () => {
const s = session.get();
return s
? { text: `${s.wpm} wpm`, backgroundColor: s.wpm < 30 ? 'statusBarItem.warningBackground' : undefined }
: { text: 'Idle' };
},
});
```
Anything `render` omits falls back to the static fields, and returning
`{ visible: false }` hides the item.
# Language extensions
> Scaffold syntax highlighting, snippets, a file icon and scoped token colors with `create --type language`.
Not every extension is a webview. `create` takes a `--type` that decides the
**shape** of the project:
| `--type` | You get |
| -------- | ------- |
| `ui` (default) | React webview + typed RPC bridge + Vite build. |
| `language` | Grammar, language configuration, snippets, file icon theme, scoped colors. No React. |
| `empty` | Bare `activate` / `deactivate`. No React, no Vite. |
```bash
vsceasy create my-lang --type language
```
Run without `--type` in a terminal and it asks. `language` and `empty` both drop
React, Vite and the sample panel from the template, including the `dev:ui` /
`build:ui` scripts and the React dependencies — the extension build (esbuild),
`gen`, and every convention directory stay exactly as they are, so you can add a
panel later with [`panel add`](/commands/panel-add/).
## What `--type language` generates
```
my-lang/
├── contributes.extra.json # languages, grammars, snippets, iconThemes, configuration
├── language-configuration.json # brackets, comments, auto-closing pairs
├── syntaxes/.tmLanguage.json # the TextMate grammar
├── snippets/.json
├── fileicons/-icon-theme.json
├── icons/.svg
└── src/
├── colorize.ts # SCOPE + RULES for this language — edit these
├── helpers/colorize.ts # apply/removeTokenColors (the generic helper)
├── commands/applyColors.ts # : Apply Colors
├── commands/removeColors.ts # : Remove Colors
└── extension/extension.ts # bootstrap + auto-colorize on activate
```
The language id and TextMate scope are derived from the package name
(`my-lang` → `mylang`, `source.mylang`) and substituted into every file name and
file body. Rename them freely afterwards — they're plain files you own.
`activationEvents` is set to `onLanguage:` so the extension wakes up when
a matching file is opened.
```bash
cd my-lang
bun install
bun run gen # merges contributes.extra.json into package.json#contributes
bun run launch # opens the dev host — open a .mylang file
```
## `contributes.extra.json`
`gen` owns `commands`, `keybindings`, `viewsContainers` and `views` — it
rewrites them from the files on disk on every run. Everything else VS Code
contributes (languages, grammars, snippets, themes, iconThemes, configuration,
walkthroughs, …) goes in an optional **`contributes.extra.json`** at the project
root, which `gen` deep-merges into `package.json#contributes`.
Merge rules:
- Keys `gen` owns are **ignored** if present in extra — the generator stays
authoritative for those.
- Plain objects merge recursively; arrays and primitives from extra **replace**.
- Invalid JSON is skipped with a warning rather than failing the build.
This is where LLM settings, feature flags, and any other
`contributes.configuration` block belong too — see [The LLM client](/guides/llm/).
:::tip
Never hand-edit `package.json#contributes`. `gen` rewrites it. Put it in
`contributes.extra.json` and re-run `bun run gen`.
:::
## Scoped token colors
A theme decides what your language looks like, and most themes have never heard
of it. The generated `colorize` helper writes TextMate rules into the user's
`editor.tokenColorCustomizations` so your constructs are legible in any theme —
and only yours:
```ts title="src/colorize.ts"
export const SCOPE = 'source.mylang';
export const RULES: TokenColorRule[] = [
{ scope: 'comment.line.number-sign.mylang', settings: { foreground: '#6b7a6e', fontStyle: 'italic' } },
{ scope: 'string.quoted.double.basic.mylang', settings: { foreground: '#98c379' } },
{ scope: 'constant.numeric.mylang', settings: { foreground: '#d19a66' } },
];
```
Scope names must match your grammar. Because each rule carries the language
suffix baked into its TextMate scope, other languages keep the user's theme
untouched.
The generated `extension.ts` applies the rules on activate when the user has
opted in (default), and reacts to the toggle at runtime:
```ts title="src/extension/extension.ts"
export const activate = bootstrap(registry, {
onActivate: [
async (context, vscode) => {
if (colorizeEnabled(vscode)) await applyColors(vscode);
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (!e.affectsConfiguration('mylang.colorize')) return;
if (colorizeEnabled(vscode)) await applyColors(vscode);
else await removeColors(vscode);
}),
);
},
],
});
```
The opt-out setting `.colorize` is declared for you in
`contributes.extra.json`, and the two commands (**Apply Colors** / **Remove
Colors**) are ordinary files in `src/commands/`.
:::note[Rules go at the root]
`applyTokenColors` writes to the **root** `textMateRules`, not under a
`[]` key. `editor.tokenColorCustomizations` only supports `[ThemeName]`
keys, not `[language]` ([microsoft/vscode#66729](https://github.com/microsoft/vscode/issues/66729)).
Language targeting comes from the scope suffix on each rule.
:::
Rules this extension writes are tagged with a marker, so `removeColors` strips
exactly those and leaves rules the user wrote by hand intact. Re-applying is
idempotent.
### In a project that isn't `--type language`
The same helper is one command away:
```bash
vsceasy helper add --kind colorize
```
## Checking your work
[`doctor`](/commands/doctor/) verifies that every file referenced by your
`languages`, `grammars`, `snippets` and `iconThemes` contributions actually
exists — reading both `contributes.extra.json` and the merged `package.json`.
It stays silent on projects that declare none, so it costs `ui` projects nothing.
```bash
vsceasy doctor
```
# The LLM client
> Talk to Ollama or any OpenAI-compatible endpoint from your extension — streaming, JSON mode, model resolution, and user-configurable settings.
`createLlm` is a dependency-free client for **Ollama** and any
**OpenAI-compatible** endpoint. Everything goes through `fetch`, which the
extension host has had natively since VS Code 1.82 (Node 18) — nothing is added
to your `package.json`.
```ts
import { createLlm } from '../shared/vsceasy';
const llm = createLlm({ provider: 'ollama', model: 'qwen2.5-coder:7b' });
const text = await llm.chat([{ role: 'user', content: 'hi' }]);
```
## Options
| Option | Default | Notes |
| ------ | ------- | ----- |
| `provider` | `'ollama'` | `'ollama'` or `'openai'` (any OpenAI-compatible server). |
| `baseUrl` | `http://localhost:11434` / `https://api.openai.com/v1` | Per provider. |
| `model` | — | e.g. `qwen2.5-coder:7b`, `gpt-4o-mini`. Empty string means **auto** (see below). |
| `apiKey` | — | Sent as `Authorization: Bearer …`. Ignored by a plain Ollama. |
| `temperature` | provider default | |
| `maxTokens` | provider default | `num_predict` on Ollama, `max_tokens` on OpenAI. |
| `timeoutMs` | `60_000` | Aborts the request. |
## What the client can do
```ts
// full chat turn → assistant content
await llm.chat([{ role: 'system', content: 'Be terse.' }, { role: 'user', content: 'why?' }]);
// single-prompt shorthand
await llm.complete('Explain closures', { system: 'You are a tutor.', maxTokens: 200 });
// strict JSON, parsed and typed
const plan = await llm.json<{ steps: string[] }>([{ role: 'user', content: 'plan as JSON' }]);
// streaming — passing onToken switches the request to stream mode
await llm.chat(messages, { onToken: (chunk) => append(chunk) });
// what's installed on the endpoint
const models = await llm.models(); // [{ name, size? }]
// reachability probe, never throws
const status = await llm.ping(); // { ok, model?, warning?, error? }
```
`json()` tolerates the ```` ```json ```` fences and stray prose small local
models still emit even in JSON mode: it retries the raw text, the fenced block,
and finally the outermost `{…}` / `[…]` before throwing.
Cancel a call with an `AbortSignal` — useful in an inline-completion provider,
where the user has usually typed on already:
```ts
const ctl = new AbortController();
const p = llm.complete(prompt, { signal: ctl.signal, timeoutMs: 8_000 });
ctl.abort(); // rejects with "Request aborted"
```
## Model resolution (Ollama)
Ollama addresses models by their **full `name:tag`**. A configured
`qwen2.5-coder` does *not* match an installed `qwen2.5-coder:0.5b` — the request
404s. The client resolves the configured name against what is actually installed:
1. Exact match wins.
2. A name without a tag takes the first installed tag of that model.
3. An empty `model` means **auto** — pick something usable.
4. Nothing close → fall back to an installed model rather than 404ing.
The fallback prefers a coding model, then any general chat model, and never
picks an embedding model (it can't chat) or a `:cloud` alias (it needs
credentials). Resolution happens once per client and is cached.
```ts
await llm.resolveModel(); // the name requests will really use
await llm.ping();
// → { ok: true, model: 'qwen2.5-coder:0.5b',
// warning: '"qwen2.5-coder" is not installed — using "qwen2.5-coder:0.5b".' }
```
`ping()` deliberately checks the *model*, not just the server: reaching the
endpoint isn't enough if every call is about to 404.
## Reasoning models: `think`
```ts
await llm.chat(messages, { think: true, maxTokens: 2048 });
```
`think` is **off by default**. Ollama counts hidden reasoning against
`num_predict`, so a thinking model given a modest budget burns all of it and
returns **empty content**. When that happens the client throws a specific error
instead of silently returning `''`:
> The model used its entire token budget on internal reasoning and produced no
> answer. Raise maxTokens … or choose a non-reasoning model.
Turn `think` on only for tasks where the deliberation is worth the tokens, and
raise `maxTokens` with it.
## User-configurable: `initLlm` + `useLlm`
Hard-coding the host and model is fine for a prototype. To let the user choose,
build the **shared** client from settings on activate:
```ts title="src/extension/extension.ts"
import { bootstrap, initLlm } from '../shared/vsceasy';
import { registry } from './_registry';
export const activate = bootstrap(registry, {
// Pass the settings prefix explicitly when you know it.
onActivate: [(ctx) => initLlm(ctx, undefined, 'myExt')],
});
```
Then anywhere else:
```ts
import { useLlm } from '../shared/vsceasy';
const text = await useLlm().complete('…');
```
`initLlm` reads `.llm.*` and **rebuilds the client whenever those
settings change**, so switching model in the Settings UI takes effect without a
reload. `useLlm()` throws if called before activate.
### Settings to declare
Put them in [`contributes.extra.json`](/guides/language-extensions/#contributesextrajson)
— `gen` merges that file into `package.json#contributes`:
```json title="contributes.extra.json"
{
"configuration": {
"title": "My Extension",
"properties": {
"myExt.llm.provider": { "type": "string", "enum": ["ollama", "openai"], "default": "ollama" },
"myExt.llm.baseUrl": { "type": "string", "default": "http://localhost:11434" },
"myExt.llm.model": { "type": "string", "default": "", "markdownDescription": "Empty = auto-select an installed model." },
"myExt.llm.apiKey": { "type": "string", "default": "" },
"myExt.llm.temperature": { "type": "number" },
"myExt.llm.timeoutMs": { "type": "number", "default": 60000 }
}
}
}
```
:::caution[Pass the section, or declare the settings]
Without an explicit `section`, `initLlm` infers it by scanning installed
extensions for a `contributes.configuration` property matching
`.llm.(model|provider|baseUrl)`. That works once the settings above are
declared — but if they aren't, it falls back to `vsceasy.llm.*` and changing the
model appears to do nothing. Passing `'myExt'` removes the guesswork.
:::
## Using it for ghost text
The natural home for an LLM is an inline completion provider, where `delayMs`
and `cacheMs` keep you from hammering it:
```ts title="src/inlineCompletions/predict.ts"
import { defineInlineCompletion, useLlm } from '../shared/vsceasy';
export default defineInlineCompletion({
selector: 'typescript',
delayMs: 900,
cacheMs: 15_000,
provide: async (ctx) => {
const text = await useLlm().complete(`Continue:\n${ctx.linePrefix}`, { maxTokens: 64 });
if (ctx.token.isCancellationRequested) return null; // the user typed on
return { text };
},
});
```
See [Editor surface](/guides/editor-surface/) for the rest of the providers, and
[Code Trainer](/showcase/) for a full extension built on this client.
# The mini-ORM
> A typed, filesystem-backed store for extension data.
vsceasy ships a small ORM with a pluggable provider. The bundled provider writes
each entity to a JSON file under the extension's storage dir. Entity definitions
and call sites don't change if you later swap the provider.
## Setup
```bash
vsceasy db init
vsceasy model add --name user --fields "id:string!,name:string,email?:string@"
```
Wire `initDb` on activate:
```ts title="src/extension/extension.ts"
export const activate = bootstrap(registry, { onActivate: [initDb] });
```
## Defining entities
```ts title="src/models/User.ts"
import { defineEntity, db } from '../helpers/db';
export interface User {
id: string;
name: string;
email?: string;
}
export const Users = defineEntity('users', {
primaryKey: 'id',
indexes: ['email'],
});
export const UsersRepo = () => db()(Users);
```
## Repository API
```ts
const repo = UsersRepo();
await repo.insert({ id: 'u1', name: 'Jane' }); // throws on duplicate id
await repo.upsert({ id: 'u1', name: 'Janet' }); // insert or replace
await repo.update('u1', { name: 'J' }); // patch; null if absent
await repo.delete('u1'); // boolean
await repo.deleteMany({ active: false }); // count removed
await repo.clear(); // empty the entity
await repo.findById('u1'); // T | null
await repo.findOne({ email: 'j@x.io' }); // T | null
await repo.findMany({ // T[]
where: { active: true },
orderBy: 'name:asc',
limit: 20,
offset: 0,
});
await repo.count({ where: { active: true } }); // number
```
### Where operators
```ts
await repo.findMany({ where: { role: { in: ['admin', 'mod'] } } });
await repo.findMany({ where: { status: { neq: 'archived' } } });
```
## Transactions
`db.transaction` commits on success and rolls back on throw. Nested transactions
are rejected.
```ts
await db().transaction(async (tx) => {
await tx(Users).insert({ id: 'a', name: 'A' });
await tx(Accounts).insert({ id: 'a', userId: 'a' });
// if either throws, neither is committed
});
```
## Storage providers
- **`storage`** (default) — per-workspace storage dir. Falls back to global
storage when no folder is open, so activation never fails.
- **`global`** — shared across workspaces.
```ts
const orm = createDb(context, { provider: 'global', subdir: 'db' });
```
The provider interface (`load` / `save` / `transaction`) is the seam for future
backends like SQLite — your models and call sites stay the same.
# Publishing
> Prepare and package your extension for the marketplace.
## Preflight
```bash
vsceasy publish init
```
This ensures a README, CHANGELOG, an icon placeholder, the required
`package.json` fields, and runs a dry-run `vsce ls` so you see exactly what would
ship. See [`publish init`](/commands/publish-init/).
## Package
```bash
bun run package
```
Builds a production bundle (`build:prod`) and runs `vsce package
--no-dependencies`, producing a `.vsix`.
## Install locally
Test the packaged extension before publishing:
- In VS Code: **Extensions: Install from VSIX…** and pick the generated file.
- Or `code --install-extension your-extension-x.y.z.vsix`.
## Publish
Use the [vsce](https://github.com/microsoft/vscode-vsce) CLI with a publisher
token:
```bash
npx vsce publish
```
:::caution
Make sure `publisher` in `package.json` matches a publisher you control on the
marketplace, and bump the `version` before each publish.
:::
## Checklist
- [ ] `publisher`, `version`, `repository`, `categories` set
- [ ] icon present and referenced
- [ ] README + CHANGELOG accurate
- [ ] `vsceasy doctor` is clean
- [ ] tested the `.vsix` locally
# Reactivity
> Keep a visual element in sync with data — watch a source on the host, listen in the webview.
By default a webview reads data once (on mount) and on focus. **Reactivity** lets
a visual element track a value and update the moment it changes — no manual
refresh.
The model is two explicit, symmetric halves:
- **On the host you `watch` a source** and push a change event.
- **In the webview you `listen` for that event** and react.
```mermaid
flowchart LR
A["data changes (ORM entity or store)"] --> B["watch() / watchEntity() · host ·"]
B --> C["emit(topic) RPC event channel"]
C --> D["listen(api, topic) · webview ·"]
D --> E["re-read + re-render"]
```
There are two kinds of source.
## 1. ORM entities
Every mutation on an entity (`insert`, `upsert`, `update`, `delete`,
`deleteMany`, `clear`) fires a change. Subscribe with `watchEntity` from your
generated `db.ts`.
```ts title="src/subpanels/todoStats.ts" {2,3,7}
import { defineSubpanel } from '../shared/vsceasy';
import { Todos, TodosRepo } from '../models/Todo';
import { watchEntity } from '../helpers/db';
export default defineSubpanel({
title: 'Stats',
menu: 'todos',
rpc: (vscode, ctx, emit) => {
// SUBSCRIBE: any Todo change pushes an event to this webview.
watchEntity(Todos, () => emit('todos:changed'));
return {
async stats() {
const todos = await TodosRepo().findMany();
return { total: todos.length, done: todos.filter((t) => t.done).length };
},
};
},
});
```
The `rpc` factory receives a third argument, `emit` — that's how a handler pushes
an event to its own webview.
## 2. Stores
A store is a single observable value for arbitrary, non-ORM state (a counter, a
flag, a selection). Create one with `vsceasy store add`, or by hand:
```ts title="src/stores/badgeCount.ts"
import { defineStore } from '../shared/vsceasy';
export const badgeCount = defineStore(0);
```
Mutate it anywhere on the host and `watch` it the same way:
```ts
import { watch } from '../shared/vsceasy';
import { badgeCount } from '../stores/badgeCount';
// in a panel/subpanel rpc():
rpc: (vscode, ctx, emit) => {
watch(badgeCount, () => emit('badge:changed', badgeCount.get()));
return { /* … */ };
}
// then anywhere a command runs:
badgeCount.set(3);
badgeCount.update((n) => n + 1);
```
`defineStore` gives you `get()`, `set(v)`, `update(fn)`, and `subscribe(cb)`.
`set` is a no-op when the value is unchanged (`Object.is`), and `subscribe`
returns an unsubscribe function.
## The webview side — `listen`
Wherever your element should react, call `listen(api, topic, cb)`. It's a thin,
named wrapper over the RPC event channel so the place you listen reads clearly.
```tsx title="src/webview/subpanels/todoStats/App.tsx" {2,11}
import { connectWebview, listen } from '../../../shared/vsceasy/client';
const api = connectWebview();
export function App() {
const [s, setS] = useState(null);
useEffect(() => {
const refresh = () => void api.stats().then(setS);
refresh();
// LISTEN: re-read whenever the host says todos changed.
return listen(api, 'todos:changed', refresh);
}, []);
// …
}
```
`listen` returns an unsubscribe function — return it from `useEffect` (or call it
on teardown) so the subscription is cleaned up.
:::note[Framework-agnostic]
`listen` just runs your callback — it has no opinion about React. In plain JS
the body is `el.textContent = …`; in React it's `setState`. The reactivity layer
is the same either way.
:::
## Cleanup
`watchEntity`, `watch`, and `store.subscribe` all return an unsubscribe function.
On the host, the panel's RPC server is disposed when the webview closes, which
tears down the event channel; for long-lived subscriptions push the unsubscribe
onto `ctx.subscriptions` wrapped in `{ dispose }`. In the webview, return the
`listen` result from your effect.
## When to reach for it
- A summary/stat view that must track a list it doesn't own (the canonical case).
- A badge or status indicator bound to a store.
- Any element that would otherwise need a manual "Refresh" button to stay correct.
For a list that already reloads on focus (like the generated CRUD list),
reactivity is optional polish; for a sibling panel that must mirror another, it's
the clean fix.
# Relations
> Link models with ref(Model) and get a populated dropdown in the CRUD form — Symfony-style.
A field can point at another model. You declare it once, and `crud add` turns it
into a **dropdown populated from the related model's rows** — no hand-wiring. It's
modeled after Symfony's `make:entity` relation flow.
## Declare the relation
Use `ref(Model)` as the field type. The related model must already exist.
```bash
# the model you'll point at
vsceasy model add --name category --fields "id:string!,name:string"
# the field that references it
vsceasy model add --name todo \
--fields "id:string!,title:string,category:ref(Category)"
```
In the interactive loop the prompt lists the models you can relate to, and an
invalid `ref(X)` errors with the model to create first.
## What the model stores
`category:ref(Category)` becomes a `categoryId` foreign key plus a metadata block:
```ts title="src/models/Todo.ts"
export interface Todo {
id: string;
title: string;
categoryId: string; // → Category
}
export const TodoRelations = {
categoryId: { model: 'Category' },
} as const;
```
The FK stores the related row's id. `TodoRelations` is what `crud add` reads to
build the dropdown — you don't touch it by hand.
The dropdown label defaults to the related model's first string field. Override
it with `label=`:
```bash
--fields "...,category:ref(Category, label=name)"
```
## What CRUD generates
Run `crud add` on the model with the relation:
```bash
vsceasy crud add --model todo --menu new:todos
```
Three things get wired automatically:
1. **The form API gains `options()`:**
```ts title="src/shared/api.ts"
export interface TodoFormApi {
// …
options(): Promise>;
}
```
2. **The form panel implements it** — loading the related rows over the repo:
```ts title="src/panels/todoForm.ts"
import { CategoriesRepo } from '../models/Category';
// …
async options() {
return {
categoryId: (await CategoriesRepo().findMany())
.map((x) => ({ value: String(x.id), label: String(x.name) })),
};
}
```
3. **The form webview renders a populated ``** — loading options on mount
and storing the chosen id:
```tsx title="src/webview/panels/todoForm/App.tsx"
const [relOptions, setRelOptions] = useState({});
useEffect(() => { void api.options().then(setRelOptions); }, []);
// …
{(relOptions['categoryId'] ?? []).map((o) => (
{o.label}
))}
```
The result — the Category field is a dropdown of the actual Category rows:

## Scope
Relations are **ManyToOne**: a foreign key on one model pointing at another. The
FK holds the related id — there's no join table, cascade, or eager join. To show
a related label in the **list** (not just the form), join in your service's
`list()` method, or store a denormalized label.
:::tip[Plurals]
`model add` defaults the repo handle to `s`, so `Category` →
`Categorys`. Pass `--plural Categories` when creating the model for a nicer
generated `CategoriesRepo`.
:::
# Typed RPC
> How the webview talks to the extension — one interface, both sides typed.
vsceasy panels and subpanels talk to the extension over a typed RPC bridge. You
define one interface; both sides are typed from it. No manual `postMessage`.
## The contract
```ts title="src/shared/api.ts"
import type { User } from '../models/User';
export interface UsersApi {
list(): Promise;
get(id: string): Promise;
save(row: User): Promise;
}
```
## The handlers (extension side)
```ts title="src/panels/users.ts"
import { definePanel } from '../shared/vsceasy';
import type { UsersApi } from '../shared/api';
import { UserService } from '../services/UserService';
export default definePanel({
title: 'Users',
rpc: (vscode, context) => ({
async list() {
return UserService.list();
},
async get(id) {
return UserService.get(id);
},
async save(row) {
const saved = await UserService.save(row);
void vscode.window.showInformationMessage(`Saved ${saved.id}`);
return saved;
},
}),
});
```
## The client (webview side)
```tsx title="src/webview/panels/users/App.tsx"
import { connectWebview } from '../../../shared/vsceasy/client';
import type { UsersApi } from '../../../shared/api';
const api = connectWebview();
const rows = await api.list(); // User[]
const saved = await api.save(row); // User
```
Add methods incrementally with [`rpc add`](/commands/rpc-add/).
## How it works
Transport is `webview.postMessage` + `acquireVsCodeApi`. The protocol:
```text
{ id, kind: 'call', method, args }
{ id, kind: 'result', ok: true, value }
{ id, kind: 'result', ok: false, error: { message, stack? } }
```
```mermaid
sequenceDiagram
participant UI as React UI
participant C as rpc client
participant E as extension host
participant H as handler
UI->>C: api.save(row)
C->>E: postMessage { id, kind:'call', method, args }
E->>H: dispatch by method
H-->>E: value (or throw)
E-->>C: postMessage { id, kind:'result', ok, value|error }
C-->>UI: resolve / reject
```
## Webview gotchas
- `confirm()` / `alert()` are **disabled** in webviews. Confirm in the host via
`showWarningMessage({ modal: true }, …)` instead.
- Webviews keep state when hidden (`retainContextWhenHidden`). To refresh on
reveal, listen for `focus` / `visibilitychange` and re-fetch.
Both gotchas are already handled in the [CRUD](/guides/crud/) scaffold.
# Sidebar views
> Ordering tree views and subpanels inside a container, title-bar buttons, and lazily loaded tree nodes.
A [menu](/commands/menu/) is an activity-bar container. Inside it live the
container's own tree, plus any number of **tree views** (`defineTreeView`) and
**subpanels** (`defineSubpanel`). This page covers what you control about how
they're arranged and what they can do.
## Ordering
Subpanels and tree views share **one** ordering inside a container, so the two
kinds can be interleaved deliberately:
```ts title="src/treeViews/catalog.ts"
export default defineTreeView({ title: 'Catalog', menu: 'trainer', order: 1, getChildren });
```
```ts title="src/subpanels/ask.ts"
export default defineSubpanel({ title: 'Ask', menu: 'trainer', order: 2 });
```
Low `order` first. Views without an `order` keep their discovery order, **after**
every ordered one. `gen` writes the resulting sequence into
`package.json#contributes.views`, so re-run it after changing an `order`.
## Title-bar buttons
`titleActions` pins commands to a view's title row:
```ts title="src/treeViews/catalog.ts"
export default defineTreeView({
title: 'Catalog',
menu: 'trainer',
titleActions: [
{ command: 'refreshCatalog' }, // icon button
{ command: 'exportCatalog', group: 'overflow' }, // … menu
{ command: 'addProblem', when: 'config.myExt.editing' },
],
getChildren,
});
```
| Field | Default | Meaning |
| ----- | ------- | ------- |
| `command` | — | The id you gave `defineCommand` — **without** the extension prefix. |
| `group` | `'navigation'` | `navigation` renders an inline icon button; anything else drops into the `…` overflow menu. |
| `when` | — | ANDed with the view match rather than replacing it. |
`gen` writes these to `contributes.menus['view/title']` as
`view == && `.
:::caution[Give the command an icon]
A `view/title` entry with no icon renders as its plain **title text**, which
looks broken next to real buttons. Set `icon` on the command:
```ts title="src/commands/refreshCatalog.ts"
export default defineCommand({
id: 'refreshCatalog',
title: 'Refresh Catalog',
icon: 'refresh', // codicon name; '$(refresh)' also accepted
run: () => reload(),
});
```
`gen` writes it to `contributes.commands[].icon`. Codicon names autocomplete.
:::
`titleActions` works the same on subpanels.
## Tree nodes: leaves vs. lazy children
A node is collapsible when it **has** children, or when it says it will load
them later:
```ts
getChildren: async (parent) => {
if (!parent) {
return [
{ label: 'Arrays & Hashing', icon: 'symbol-array', expandable: true }, // lazy
{ label: 'README.md', icon: 'file' }, // leaf
{ label: 'Group', collapsed: 'expanded', children: [{ label: 'child' }] },
];
}
return loadProblems(parent.id ?? parent.label); // called on expand
},
```
| Node shape | Renders as |
| ---------- | ---------- |
| `children: [...]` (non-empty) | collapsible, children already in hand |
| `expandable: true` | collapsible, `getChildren(node)` runs on expand |
| neither | leaf — no expand arrow |
`expandable` exists because an omitted `children` cannot mean both "leaf" and
"load later": most leaves never set the field, so treating `undefined` as lazy
would put a useless expand arrow on every one of them.
`collapsed: 'expanded' | 'collapsed'` sets the initial state (default
`collapsed`), and `showCollapseAll` on the view toggles the *Collapse All*
button (default `true`).
## Clicks
A node can carry `panel`, `command`, or `run`. When it carries a `command`, the
clicked **node is forwarded** to the handler — a data-driven tree's command is
almost always about *which* item was clicked:
```ts
run: (vscode, ctx, node) => openProblem((node as TreeNode).id),
```
Command references from tree nodes, menu items and status-bar items resolve by
**either** the registry key (the filename) **or** the `id` declared on the def.
So a file `src/commands/refresh.ts` exporting `defineCommand({ id: 'refreshCatalog' })`
can be referenced as either name without a runtime "unknown command" error.
## Keeping a view live
`watch` receives a `refresh` callback and returns an unsubscribe — the same
shape used by status bar items and decorations:
```ts
watch: (refresh) => watchEntity(Problems, refresh),
```
See [Reactivity](/guides/reactivity/) for the store and `watchEntity` side of it.
# The wizard
> A guided, context-aware flow for creating projects and adding features.
`vsceasy wizard` is the fastest way to get going. It detects whether you're inside
a vsceasy project and adapts.
```bash
vsceasy wizard
```
## Outside a project
It offers to scaffold one, prompting for name, display name, publisher, and
preset — the same result as [`create`](/commands/create/).
## Inside a project
It menus the common generators:
```text
? What do you want to add?
❯ Panel webview + typed RPC
Command palette command
Database init the mini-ORM
Model entity + repo
Helper secrets/config/state/…
Components themed React UI library
Something else… show the full command list
```
- **Panel** → id, title, starter UI (`blank` / `form` / `list` / `dashboard`), RPC
on/off.
- **Database** → provider (`storage` / `global`).
- **Model** → name + a field spec (`id:string!,name:string,…`).
- **Helper** → kind.
- **Components** → generates the component library.
- **Something else…** → prints the exact commands for everything not wired in.
Every choice delegates to the same library functions the standalone commands use,
so the wizard and the CLI produce identical output.
:::tip
Arrow keys move, type to filter long lists, Enter selects, Esc cancels.
:::
# Introduction
> What vsceasy is, what it generates, and when to reach for it.
vsceasy is a CLI that scaffolds and grows VS Code extensions. It is **codegen**,
not a runtime you ship — your extension has no dependency on vsceasy at run time.
The CLI writes plain TypeScript + React into your project; you own and edit it.
:::tip[How to say it]
**vsceasy** is a blend of `VSC` (VS Code) and `easy`.
- **English:** "vee-see-easy"
- **Español:** "visici"
That's the whole pitch in the name: VS Code, made easy.
:::
## What you get
- **File-based routing.** One file per panel, command, menu, tree view,
subpanel, or status bar item. A `gen` step scans the convention directories and
writes `src/extension/_registry.ts` plus `package.json#contributes`.
- **Typed RPC.** A single interface in `src/shared/api.ts` types both the
extension handlers and the webview client. Call `api.method(...)` — no manual
message plumbing.
- **React webviews.** Panels and subpanels render React, themed with VS Code CSS
variables. Optional UI templates (`form`, `list`, `dashboard`) start you from a
working screen.
- **A mini-ORM.** `db init` + `model add` give you typed entities with a
filesystem-backed store. `crud add` scaffolds a full list + form UI over a model.
- **Editor-surface primitives.** Completions, ghost text, hovers, typing guards
(intercept keystrokes, paste and deletions), decorations, and terminals — the
same one-file-per-feature convention. See
[Editor surface](/guides/editor-surface/).
- **A built-in LLM client.** Ollama or any OpenAI-compatible endpoint over
`fetch`, with streaming, JSON mode, model auto-resolution and settings-driven
configuration. No SDK dependency. See [The LLM client](/guides/llm/).
- **Operational helpers.** Jobs (interval / daily / event / file watch), runtime
helpers (secrets, config, state, notifications, cache, colorize), a test
harness, and publish tooling.
## When to use it
Reach for vsceasy when you're building a webview-heavy extension and want to skip
the boilerplate: panel registration, the RPC bridge, the build pipeline, and the
`contributes` bookkeeping. You stay in plain VS Code APIs everywhere it matters —
vsceasy just removes the repetitive wiring.
It isn't only for webviews. `create --type language` scaffolds a full
[language extension](/guides/language-extensions/) (grammar, snippets, file icon,
scoped colors) with no React at all, and `--type empty` gives you a bare
extension with the same file-based routing. See the
[showcase](/showcase/) for one of each.
## How it fits together
```mermaid
flowchart LR
CLI["vsceasy CLI"] -->|scaffolds| PROJ["your extension"]
PROJ --> GEN["bun run gen"]
GEN --> REG["_registry.ts"]
GEN --> CONTRIB["package.json#contributes"]
REG --> BOOT["bootstrap(registry)"]
BOOT --> VSCODE["VS Code on activate"]
```
Next: [Quick start](/quick-start/) to scaffold a project, or
[Concepts](/concepts/) for the mental model.
# Project layout
> What a generated vsceasy project looks like on disk.
A freshly scaffolded project:
```
my-extension/
├── src/
│ ├── extension/
│ │ ├── extension.ts # bootstrap(registry) — wires VS Code on activate
│ │ └── _registry.ts # AUTO-GENERATED by `bun run gen`
│ ├── panels/.ts # one file = one webview panel (definePanel)
│ ├── commands/.ts # one file = one palette command (defineCommand)
│ ├── menus/.ts # activity-bar container + items (defineMenu)
│ ├── treeViews/.ts # data-driven tree view (defineTreeView)
│ ├── subpanels/.ts # inline webview section inside a menu
│ ├── statusBars/.ts # status bar item (defineStatusBar)
│ ├── jobs/.ts # scheduled / event-triggered task (defineJob)
│ ├── completions/.ts # IntelliSense provider (defineCompletion)
│ ├── inlineCompletions/.ts # ghost text (defineInlineCompletion)
│ ├── hovers/.ts # hover panel (defineHover)
│ ├── typingGuards/.ts # keystroke / paste / delete guard
│ ├── decorations/.ts # editor overlays (defineDecoration)
│ ├── terminals/.ts # exec + visible terminal (defineTerminal)
│ ├── webview/
│ │ ├── panels// # React UI per panel (App.tsx, main.tsx)
│ │ └── components/ # shared themed components (after `components add`)
│ ├── services/ # business logic (e.g. Service.ts)
│ ├── models/ # typed entities (after `model add`)
│ ├── helpers/ # db.ts, secrets.ts, … (after `db init` / `helper add`)
│ └── shared/
│ ├── api.ts # RPC contracts (interface per panel)
│ └── vsceasy/ # framework runtime — synced via `vsceasy upgrade`
├── scripts/gen.ts # registry + contributes generator
├── contributes.extra.json # optional — contributions gen doesn't own
├── .vscode/launch.json # Extension Development Host launch
├── vite.config.ts # webview build
└── package.json # esbuild for extension, vite for UI
```
Every convention directory is optional — `gen` only writes what it finds. A
project scaffolded with `--type language` or `--type empty` has no `webview/`,
no `vite.config.ts` and no React dependencies; a language project adds
`syntaxes/`, `snippets/`, `fileicons/` and `language-configuration.json` at the
root instead. See [Language extensions](/guides/language-extensions/).
## Owned vs generated
- **You own** everything under `panels/`, `commands/`, `webview/`, `services/`,
`models/`, `helpers/`, and `shared/api.ts`. Edit freely.
- **Generated** — `src/extension/_registry.ts` and `package.json#contributes` are
rewritten by `gen`. Don't hand-edit them.
- **Framework runtime** — `src/shared/vsceasy/*` and `scripts/gen.ts` are owned by
vsceasy. Keep them current with [`vsceasy upgrade`](/commands/upgrade/); don't
edit them.
## Build pipeline
- Extension code → **esbuild** → `dist/extension.js` (CJS, node target).
- Each panel/subpanel UI → **vite** → `dist/webview///`.
- `bun run dev` runs both in watch; **F5** launches the dev host.
- `bun run package` → `.vsix` via `@vscode/vsce`.
## When do I need to run `gen`?
`gen` rewrites two things: `src/extension/_registry.ts` (what panels / commands /
menus / jobs / etc. exist) and `package.json#contributes` (how they're declared to
VS Code). So:
**Run `gen`** when a hand edit changes *what exists* or *how it's contributed*:
- Add, delete, or rename a file in any convention directory — `src/panels/`,
`src/commands/`, `src/menus/`, `src/statusBars/`, `src/subpanels/`,
`src/treeViews/`, `src/jobs/`, `src/completions/`, `src/inlineCompletions/`,
`src/hovers/`, `src/typingGuards/`, `src/decorations/`, `src/terminals/`.
- Change a panel/command/menu's `id`, `title`, `command`, `menu`, `icon`,
`keybinding`, `when`, `order`, or `titleActions`.
- Edit `contributes.extra.json` — `gen` merges it into
`package.json#contributes` on every run.
**You don't need `gen`** when a hand edit only touches *logic*:
- The body of an `rpc`, `run`, or `getChildren` handler.
- A webview `App.tsx` (vite recompiles that, not `gen`).
- A model, service, store, or helper — those aren't in the registry.
The `vsceasy` generators run `gen` for you. And `bun run dev`, `build`, and
`launch` all run it first — so if you use those, you rarely run `gen` by hand.
# Quick start
> Scaffold a vsceasy extension, run it, and add your first feature.
## Prerequisites
- **Node.js** ≥ 18 (the project targets node 18 for the extension bundle).
- **bun** or **npm**. Examples use bun; npm works everywhere too.
- **VS Code** to launch the Extension Development Host.
## Install (optional)
You can run the CLI without installing it via `bunx @vsceasy/cli …` (or
`npx @vsceasy/cli …`). To get the shorter `vsceasy` command everywhere, install
the binary globally:
```bash
bun add -g @vsceasy/cli # or: npm i -g @vsceasy/cli
# use globally
vsceasy --version
```
The rest of this guide uses the global `vsceasy` form. Without a global install,
prefix any `vsceasy ` with `bunx @vsceasy/cli `.
## 1. Scaffold
```bash
bunx @vsceasy/cli create my-extension
# or, if installed globally: vsceasy create my-extension
cd my-extension
```
After scaffolding, `create` offers to **initialize a git repository** and
**install dependencies** (defaults to yes on both). Accept the install prompt and
you can skip `bun install` yourself.
Or fully scripted — `--git` / `--install` skip the prompts:
```bash
bunx @vsceasy/cli create \
--name my-extension \
--displayName "My Extension" \
--description "Does cool things" \
--publisher my-publisher \
--ui react \
--preset full \
--git \
--install
```
`--preset full` includes a sample panel + RPC. `--preset minimal` gives an empty
extension.
## 2. Run it
```bash
bun run dev
```
This runs `gen` then builds the extension (esbuild) and webviews (vite) in watch
mode. Press **F5** in VS Code to launch the Extension Development Host with the
bundled launch config.
:::tip
First launch with no folder open is fine — the mini-ORM falls back to global
storage so activation never fails.
:::
## 3. Add a feature
```bash
# a webview panel with a ready-made form UI + RPC
vsceasy panel add --name signup --template form
# a palette command
vsceasy command add --name sayHello --title "Say Hello"
```
After a generator runs it executes `bun run gen` to wire the registry and
`contributes`. If that didn't run automatically, run it yourself:
```bash
bun run gen
```
## 4. Try the data stack
```bash
vsceasy db init
vsceasy model add --name user --fields "id:string!,name:string,email?:string@,active:boolean"
vsceasy crud add --model user --menu new:admin
```
You now have a list panel, a form panel, a service, and an activity-bar menu —
all typed end to end. Reload the window and open the **admin** menu.
## Prefer to be guided?
```bash
vsceasy wizard
```
The [wizard](/guides/wizard/) detects whether you're inside a project and walks
you through creating one or adding features.
# Roadmap
> Where vsceasy is headed, what's shipped, and how to contribute to the next release.
This page is the public view of where **vsceasy** is going. It is intentionally
high-level — the source of truth for shipped work is [CHANGELOG.md](https://github.com/jairoFernandez/vsceasy/blob/main/CHANGELOG.md),
and for in-flight work it is the [issue tracker](https://github.com/jairoFernandez/vsceasy/issues).
:::note[Status]
**v0.1 — stable but pre-1.0.** The generated code and the runtime API may still
change between minor versions. Pin a version in CI and read the changelog before
upgrading. We aim to keep `vsceasy upgrade` able to migrate generated projects
across breaking changes.
:::
## Guiding principles
These don't change release to release — they're the lens we use to accept or
reject every feature.
- **Codegen, not a framework you ship.** vsceasy writes plain TypeScript + React
into your project. Your extension never depends on vsceasy at run time. If a
feature would force a runtime dependency, it has to justify itself hard.
- **You own the output.** Generated code is readable, editable, and yours. No
hidden magic, no lock-in. Re-running a generator should never clobber your edits.
- **Stay close to the VS Code API.** We remove boilerplate (registration, the RPC
bridge, the build pipeline, `contributes` bookkeeping) — not the platform. You
keep using real `vscode.*` APIs everywhere it matters.
- **One file = one feature.** File-based routing stays the core mental model.
- **Every generator is tested.** A feature without generator tests doesn't ship.
## Shipped (v0.1)
For the full list see the [changelog](https://github.com/jairoFernandez/vsceasy/blob/main/CHANGELOG.md).
The headlines:
- **Scaffolding** — `create` (with post-scaffold git init + dep install), three
extension types (`ui` / [`language`](/guides/language-extensions/) / `empty`),
presets, and the interactive [wizard](/guides/wizard/).
- **File-based routing** — panels, commands, menus, tree views, subpanels, status
bar items, RPC handlers; a `gen` step writes the registry + `contributes`, and
deep-merges `contributes.extra.json` for everything it doesn't own.
- **[Editor surface](/guides/editor-surface/)** — completions, inline completions
(ghost text), hovers, typing guards (keystroke / paste / delete), decorations,
and terminals.
- **[LLM client](/guides/llm/)** — Ollama + OpenAI-compatible endpoints over
`fetch`, with streaming, JSON mode, model auto-resolution, `ping`, and a
settings-driven shared client.
- **[Sidebar views](/guides/sidebar-views/)** — view ordering, title-bar buttons
(`titleActions`), and lazily expanded tree nodes.
- **Typed RPC** — one shared interface types both sides of the bridge. See the
[RPC guide](/guides/rpc/).
- **React webviews** + a [component library](/guides/components/) themed with VS
Code tokens.
- **Mini-ORM** — [`db init`](/commands/db-init/), [`model add`](/commands/model-add/),
[`crud add`](/guides/crud/), with [relations](/guides/relations/) (`ref(Model)`)
and [reactivity](/guides/reactivity/) (`watch` / `listen` / stores).
- **Operational helpers** — [jobs](/commands/job-add/) (interval / daily / event /
file watch), runtime [helpers](/commands/helper-add/) (secrets, config, state,
notifications, cache), a [test harness](/commands/test-setup/), and
[publish tooling](/guides/publishing/).
- **Maintenance** — [`doctor`](/commands/doctor/) checks and
[`upgrade`](/commands/upgrade/) migrations.
## In progress / next
Targeted for upcoming **0.1.x** and **0.2** releases. Order is rough, not a
commitment. Each item links to its tracking issue once one exists — if you want
one, open it.
### Data layer
- **SQLite provider for the ORM.** The provider interface was designed to host a
real database next to the filesystem JSON store. SQLite is the first target.
- **Richer relations.** Today `ref(Model)` is ManyToOne only (FK on this model,
no join table, no cascade). OneToMany / ManyToMany and cascade options are the
natural next step.
- **Migrations.** A story for evolving entity shapes once a row store exists.
### UI
- **More webview UI options.** v0.1 ships React only (Svelte/Vue/Vanilla were
intentionally dropped to focus the first release). Re-introducing additional
UI targets is on the table once the React surface is stable.
- **More component primitives** in the generated component library.
### DX & tooling
- **More `doctor` checks** as common misconfigurations surface from real use.
- **Tighter `upgrade` migrations** so generated projects can cross breaking
changes without hand-editing.
- **Better error messages** across generators — keep quoting the exact target
path and naming what to create.
:::tip[Want something not listed?]
The roadmap is shaped by what people build. Open an
[issue](https://github.com/jairoFernandez/vsceasy/issues) describing the
extension you're trying to ship and where vsceasy got in your way. Concrete
use cases beat abstract feature requests.
:::
## How to contribute
vsceasy is MIT-licensed and contributions are welcome. The full setup lives in
[CONTRIBUTING.md](https://github.com/jairoFernandez/vsceasy/blob/main/CONTRIBUTING.md);
here's the shape of it.
### Get the repo running
```bash
git clone https://github.com//vscode-extension-framework
cd vscode-extension-framework
bun install
bun test
bun run build
bun run start # run the CLI from source
```
### Where things live
```
src/
├── commands// # CLI command definitions (param parsing, UX)
├── lib// # generators (pure functions; no CLI deps)
└── tests/ # bun test suites mirroring lib/ and commands/
packages/
└── vsceasy-runtime/ # standalone runtime — the source of truth, copied into the template
templates/
├── react/ # the project template `create` copies
└── _generators/ # snippet templates for ` add` commands
```
The runtime template under `templates/react/src/shared/vsceasy/` is
**regenerated** by `bun run sync:runtime`. Edit the canonical copy under
`packages/vsceasy-runtime/src/` — never the mirror.
### Adding a generator
The repeatable pattern for almost every feature:
1. Snippet template → `templates/_generators//`.
2. Pure generator → `src/lib//add.ts`
— signature `(opts, projectRoot, templatesRoot) => { created: string[] }`.
3. CLI command → `src/commands//add.ts` (param defs + a thin call into
the generator).
4. Wire it into its group → `src/commands/groups.ts`.
5. Test the generator → `src/tests/lib/.test.ts` (use temp dirs, never
the real cwd).
6. Update the [changelog](https://github.com/jairoFernandez/vsceasy/blob/main/CHANGELOG.md)
(under `[Unreleased]`) and the docs.
### Ground rules
- **Conventional Commits** (`feat:`, `fix:`, `refactor:`, `docs:`, `test:`).
- **One logical change per PR**, with tests. PRs must keep `bun test` green.
- **No new runtime dependencies without discussion** — we rely on
`@ideascol/cli-maker` only.
- **TypeScript strict mode**; run `bun run lint` and `bun run format` first.
### Good first contributions
- A new [`doctor`](/commands/doctor/) check for a misconfiguration you hit.
- A new component for the [component library](/guides/components/).
- Docs fixes — wrong path, stale flag, a guide that didn't match what the CLI did.
- A generator test covering an edge case (duplicate names, odd field specs).
These touch one area, have a clear test pattern to copy, and get you through the
build/test loop without needing the whole architecture in your head.
# Showcase
> Real extensions built with vsceasy — filter by the framework feature you care about.
import ProjectShowcase from '../../components/vsceasy/ProjectShowcase.tsx';
Extensions shipped on top of vsceasy. Filter by a feature to see who uses it and
how; the mock panels are interactive — type out a solution, try to paste, toggle
scoped colors.
:::note[The mocks are illustrations]
They're rendered with the same VS Code theme tokens the
[component library](/guides/components/) uses, not screenshots. Follow the
GitHub links for the real thing.
:::
## Worth stealing
**Code Trainer — generated exercises are verified by running them.** Every
generated or imported problem is materialised to a scratch directory and its
tests run twice: against the reference solution (must pass) and against the
starter (must fail). Anything else is rejected and regenerated. A model will
happily emit tests that don't compile or that pass against an empty function —
no static check catches that.
**Code Trainer — retries are repairs, not redos.** On failure the model is shown
its own output plus the exact complaint, rather than being asked the same
question again. Re-asking reproduces the same mistake.
**TOML — the file icon theme is opt-in.** An icon theme is global: activating one
replaces *all* workbench file icons, not just `.toml`. The extension ships one
and lets the user pick it, rather than hijacking the workbench on install.
**TOML — recolor your language, not the editor.** Bundling a full color theme
replaces the user's theme entirely. [Scoped token
colors](/guides/language-extensions/#scoped-token-colors) touch only your
language's TextMate scopes, so everything else keeps looking the way the user
chose.
## Built something?
Open a [PR](https://github.com/jairoFernandez/vsceasy) or an
[issue](https://github.com/jairoFernandez/vsceasy/issues) with the repo link and
which vsceasy features it leans on — it goes on this page.
# 1. Scaffold the project
> Create the Todo extension project and understand every generated file.
We start from an empty extension and grow it. The `minimal` preset gives us a
clean slate — no sample panel to delete later.
## Run it
```bash
vsceasy create \
--name todo-demo \
--displayName "Todo Demo" \
--description "A todo list built with vsceasy" \
--publisher demo \
--ui react \
--preset minimal
```
Output:
```text
✓ Created todo-demo at todo-demo
Next steps:
cd todo-demo
bun install
bun run launch # builds + opens Extension Development Host
```
`create` then asks whether to **init git** and **install dependencies**. Say yes
to both (or pass `--git --install` to skip the prompts). Then:
```bash
cd todo-demo
```
## What got generated
```text
todo-demo/
├─ .vscode/launch.json F5 launches the Extension Development Host
├─ package.json extension manifest + build scripts
├─ vsceasy.config.ts per-project defaults (publisher, ui, …)
├─ scripts/gen.ts the code generator (`bun run gen`)
├─ vite.config.ts builds the React webviews
└─ src/
├─ extension/extension.ts the activate() entry point
├─ commands/hello.ts one sample command
├─ webview/styles.css shared webview styling
└─ shared/
├─ api.ts RPC contracts (empty for now)
└─ vsceasy/ the runtime (bootstrap, rpc, define, client)
```
The pieces worth knowing up front:
| File | What it does |
| ---- | ------------ |
| `src/extension/extension.ts` | Calls `bootstrap(registry)` — the single activate hook. You rarely edit it. |
| `src/shared/vsceasy/` | The vendored runtime: `definePanel`, `defineJob`, the typed RPC bridge, the webview client. Generators import from here. |
| `src/shared/api.ts` | One TypeScript interface per panel. This is the **contract** shared by the extension host and the React UI — the source of end-to-end type safety. |
| `scripts/gen.ts` | Scans `src/panels`, `src/commands`, `src/menus`, `src/jobs`, builds a registry, and writes the `contributes` block of `package.json`. You run it via `bun run gen`. |
Nothing here is locked away — every generated file lands in your `src/` and is
yours to edit.
## The build scripts
`package.json` ships a handful of scripts you'll use:
| Script | Purpose |
| ------ | ------- |
| `bun run gen` | Regenerate the registry + `package.json` contributions. Run after adding panels/commands/jobs. |
| `bun run dev` | Watch-build the extension and webviews. Press **F5** in VS Code for the dev host. |
| `bun run launch` | One-shot build + open the Extension Development Host in a new window. |
:::note
`create` requires `--name`; it has **no** positional argument
(`vsceasy create todo-demo` errors). All other fields fall back to sensible
defaults or prompts.
:::
Next: [add the database and the Todo model →](/tutorial/02-model/)
# 2. The database and the Todo model
> Initialize the mini-ORM and define a typed, persisted Todo entity.
The CRUD UI in the next step is generated *from a model*, so we define the data
first.
## Initialize the database
```bash
vsceasy db init
```
```text
✓ Database initialized (provider: storage).
+ src/helpers/db.ts
~ wired initDb(context) into src/extension/extension.ts
```
This drops the [mini-ORM](/guides/orm/) at `src/helpers/db.ts` and wires
`initDb(context)` into your activate hook so the database is ready before any
panel queries it. The default `storage` provider writes one JSON file per entity
under the extension's storage directory.
`src/extension/extension.ts` now reads:
```ts title="src/extension/extension.ts"
export const activate = bootstrap(registry, { onActivate: [initDb] });
```
## Add the Todo model
We give the model five fields, each picked to show a different input later:
```bash
vsceasy model add --name todo \
--fields 'id:string!,title:string,done:boolean,priority:"low"|"medium"|"high",dueDate?:Date'
```
```text
✓ Model created (primaryKey: id).
+ src/models/Todo.ts
```
### Reading the field spec
| Spec piece | Meaning |
| ---------- | ------- |
| `id:string!` | `string` field, `!` marks it the **primary key** |
| `title:string` | required text |
| `done:boolean` | a boolean → renders as a **checkbox** |
| `priority:"low"\|"medium"\|"high"` | a **literal union** → renders as a **dropdown** |
| `dueDate?:Date` | the `?` makes it **optional**; `Date` → renders as a **date picker** |
## What got generated
```ts title="src/models/Todo.ts"
import { defineEntity, db } from '../helpers/db';
export interface Todo {
id: string;
title: string;
done: boolean;
priority: "low"|"medium"|"high";
dueDate?: Date;
}
export const Todos = defineEntity('todos', {
primaryKey: 'id',
});
/** Typed repo accessor. Lazy — assumes `initDb(context)` ran on activate. */
export const TodosRepo = () => db()(Todos);
```
Three things to notice:
- **`interface Todo`** is the single source of truth for the shape. The form and
list UIs, the service, and the RPC contracts all derive from it.
- **`Todos`** is the entity definition (name + primary key).
- **`TodosRepo()`** is your typed data access — `findMany`, `findById`,
`insert`, `upsert`, `delete`, all returning `Todo`. You'll use it from services
and jobs.
No UI yet — just typed, persisted data. The next command turns this model into a
working interface.
Next: [generate the CRUD UI →](/tutorial/03-crud/)
# 3. Generate the CRUD UI
> Turn the Todo model into a full list + form UI with typed RPC, in one command.
This is where the model becomes an app. One command scaffolds a service, a list
panel, a form panel, the RPC contracts, and a menu to reach them.
## Run it
```bash
vsceasy crud add --model todo --menu new:todos
```
`--menu new:todos` creates a new **activity-bar container** called *Todos* and
wires the list + form into it. (Use `--menu none` to skip the menu, or
`--menu existing:` to add to one you already have.)
```text
✓ CRUD scaffolded for todo.
+ src/services/TodoService.ts
+ src/services/todoFormNav.ts
+ src/panels/todosList.ts
+ src/panels/todoForm.ts
+ src/webview/panels/todosList/App.tsx
+ src/webview/panels/todosList/main.tsx
+ src/webview/panels/todoForm/App.tsx
+ src/webview/panels/todoForm/main.tsx
~ src/shared/api.ts
+ menu "todos" wired with List + New entries
Registry + package.json updated. Reload extension to try it.
```
`crud add` ran `bun run gen` for you, so `package.json` already contributes the
container, the views, and the commands.
## What got generated
```text
src/services/TodoService.ts business logic over TodosRepo()
src/services/todoFormNav.ts list → form "edit this row" hand-off
src/panels/todosList.ts list panel: RPC handlers (host side)
src/panels/todoForm.ts form panel: RPC handlers (host side)
src/webview/panels/todosList/ list React UI (runs in the webview)
src/webview/panels/todoForm/ form React UI (runs in the webview)
src/menus/todos.ts the activity-bar menu
src/shared/api.ts TodosListApi + TodoFormApi appended
```
### The contract (`src/shared/api.ts`)
Everything hangs off these two interfaces. The host *implements* them; the
webview *calls* them — both type-checked against the same source.
```ts title="src/shared/api.ts"
export interface TodosListApi {
list(): Promise;
delete(id: Todo['id']): Promise;
openForm(id?: Todo['id']): Promise;
}
export interface TodoFormApi {
pendingId(): Promise;
get(id: Todo['id'] | null): Promise;
save(row: Todo): Promise;
cancel(): Promise;
}
```
### The service (`src/services/TodoService.ts`)
Plain functions over the repo — the place to add validation or derived fields.
```ts title="src/services/TodoService.ts"
export const TodoService = {
list: () => TodosRepo().findMany({ orderBy: 'id:desc' }),
get: (id) => TodosRepo().findById(id),
save: (row) => TodosRepo().upsert(row),
delete: (id) => TodosRepo().delete(id),
};
```
### The list panel (`src/panels/todosList.ts`)
The panel's `rpc` block implements `TodosListApi` on the host side. Note the
**delete confirmation runs in the host** — `confirm()` is disabled in webviews:
```ts title="src/panels/todosList.ts"
export default definePanel({
title: 'Todos',
command: { title: 'Todos: List' },
rpc: (vscode) => ({
async list() { return TodoService.list(); },
async delete(id) {
const pick = await vscode.window.showWarningMessage(
`Delete Todos "${String(id)}"?`, { modal: true }, 'Delete',
);
if (pick !== 'Delete') return false;
return TodoService.delete(id);
},
async openForm(id) {
setPendingTodoId(id ?? null);
await vscode.commands.executeCommand('tododemo.openTodoForm', id ?? null);
},
}),
});
```
### The React UIs
The webview side just calls the typed client — no message-passing boilerplate:
```ts
const api = connectWebview();
// ...
setRows(await api.list()); // typed as Todo[]
await api.delete(r.id); // typed argument
api.openForm(r.id); // jump to the form, editing this row
```
The generated form maps each model field to the right input automatically:
| Model field | Generated input |
| ----------- | --------------- |
| `title: string` | text box |
| `done: boolean` | checkbox |
| `priority: "low"\|"medium"\|"high"` | `` with the three options |
| `dueDate?: Date` | native date picker |
## See it run
After a reload (or `bun run launch`) the **Todos** view shows the list:

Clicking **+ New** (or **Edit** on a row) opens the form beside it. Each field
rendered the input its type called for:

The **Priority** dropdown's options come straight from the union in the model —
`low`, `medium`, `high`, nothing hand-written:

## Behavior you get for free
- **Live list.** The list reloads on focus/visibility and after a save, plus a
manual **Refresh** — because webviews keep state when hidden.
- **Host-side delete.** Confirmation is a native modal, since browser `confirm()`
is disabled in webviews.
- **Edit pre-fill.** Edit stashes the row id; the form pulls it on mount and
pre-fills via `get(id)`. Creating a new row clears the form afterward.
:::tip[Relating models]
A field can point at another model with `ref(Model)` — the form then renders a
dropdown populated from that model's rows. See the
[Relations guide](/guides/relations/).
:::
Next: [add a reminder job and run it →](/tutorial/04-job-and-run/)
# 4. A reminder job, then run it
> Add a background job that warns about overdue todos, then launch the extension.
A todo list should nag you. We'll add a background **job** that checks for
overdue todos once a day and shows a warning.
## Add the job
```bash
vsceasy job add --name dueReminder --title "Due Todo Reminder" --dailyAt "09:00"
```
```text
✓ Job "dueReminder" added.
+ src/jobs/dueReminder.ts
```
`job add` supports several schedule shapes — pick one:
| Flag | Fires |
| ---- | ----- |
| `--every "30s"` | on an interval (`30s`, `5m`, `2h`, `1d`) |
| `--dailyAt "09:00"` | once a day at local time |
| `--on startup` | on a VS Code event (`startup`, `saveDocument`, …) |
| `--onFile "**/*.md"` | when matching files change |
The runtime registers the timer on activate and cleans it up on deactivate — you
only write the work.
## Fill in the work
The generated job is a stub. Replace its `run` to query overdue todos with the
same `TodosRepo()` the panels use, and warn if any are past due:
```ts title="src/jobs/dueReminder.ts"
import { defineJob } from '../shared/vsceasy';
import { TodosRepo } from '../models/Todo';
export default defineJob({
title: 'Due Todo Reminder',
schedule: { dailyAt: '09:00' },
run: async (vscode) => {
const now = Date.now();
const todos = await TodosRepo().findMany();
const overdue = todos.filter(
(t) => !t.done && t.dueDate && new Date(t.dueDate).getTime() < now,
);
if (overdue.length === 0) return;
const titles = overdue.map((t) => t.title).join(', ');
vscode.window.showWarningMessage(
`${overdue.length} overdue todo${overdue.length > 1 ? 's' : ''}: ${titles}`,
);
},
});
```
Because the job and the panels share `TodosRepo()` and the `Todo` type, this is
fully type-checked — `t.done`, `t.dueDate`, `t.title` are all known fields.
:::tip[Testing a daily job]
A `dailyAt` job won't fire on demand. To see the notification while developing,
temporarily switch the schedule to `{ every: '10s' }`, reload, and add a todo
with a past `dueDate`. Switch it back when you're done.
:::
## Build and launch
Everything is wired. Build and open the Extension Development Host:
```bash
bun run launch
```
This builds the extension + webviews and opens a new VS Code window titled
**[Extension Development Host]**. Open the **Todos** view from the activity bar
to use the list and form.
Inside VS Code you can instead press **F5** (after `bun run dev`) for a
watch-mode loop: edit a panel, reload the host, see the change.
## What you built
Starting from an empty project, four commands produced:
- a typed, persisted `Todo` model (`db init` + `model add`),
- a full list + form UI with typed RPC and an activity-bar menu (`crud add`),
- a daily overdue-reminder notification (`job add`),
and `bun run gen` kept `package.json` and the registry in sync the whole way.
The CRUD step already gave us an activity-bar menu. The next steps go deeper into
how that menu works and what else can dock inside it.
Next: [menus — the navigation model →](/tutorial/05-menus/)
## Reference
- [The mini-ORM](/guides/orm/) — queries, indexes, and providers behind `TodosRepo()`.
- [Typed RPC](/guides/rpc/) — how the host ↔ webview bridge stays type-safe.
- [CRUD scaffolding](/guides/crud/) — customizing generated fields via `crud.config.ts`.
- [Publishing](/guides/publishing/) — ship your extension to the Marketplace.
# 5. Menus — the navigation model
> How menus tie panels, commands, subpanels and tree views together in the activity bar.
So far `crud add` created a menu for us (`--menu new:todos`). Now we'll build one
by hand and learn the model behind it — because **the menu is how everything in a
vsceasy extension connects**.
## The mental model
A **menu** is one icon in VS Code's activity bar (the left strip) and the
container that opens when you click it. Everything the user reaches lives under a
menu:
```mermaid
flowchart TD
M["Menu (activity-bar container)"]
M --> T["Tree of items"]
T --> P["panel → opens a webview tab"]
T --> C["command → runs a command"]
T --> U["url → opens a link"]
T --> G["group → collapsible folder"]
M --> SP["Subpanel → inline webview view"]
M --> TV["Tree view → data-driven tree"]
```
A menu holds two distinct things:
1. **A tree of navigation items** — each item *points at* something by id: a
`panel`, a `command`, a `url`, or a `group` of nested items. The menu never
contains the panel or command; it only references it. That indirection is the
whole idea — panels and commands don't know about menus, and you can wire the
same panel into several menus.
2. **Attached views** — subpanels (inline webviews) and tree views render
*inside* the same container, below the item tree. (Covered in the next step.)
## Create a menu
```bash
vsceasy menu add --name tools --title "Tools" --icon tools
```
```text
✓ Menu "tools" added.
Created:
+ src/menus/tools.ts
Registry + package.json updated.
```
The generated file is a skeleton with two empty groups:
```ts title="src/menus/tools.ts"
import { defineMenu } from '../shared/vsceasy';
export default defineMenu({
title: 'Tools',
icon: 'tools',
items: [
{ label: 'Panels', children: [ /* … */ ] },
{ label: 'Actions', children: [ /* … */ ] },
],
});
```
## Wire items into it
`menu edit` adds one item at a time. The `--kind` flag picks what the item points
at:
```bash
# a panel link — opens the Todos list webview
vsceasy menu edit --name tools --kind panel \
--panel todosList --label "All Todos" --icon list-unordered --group Panels
# a command — runs the hello command
vsceasy menu edit --name tools --kind command \
--command hello --label "Say Hello" --icon play --group Actions
# a url — opens an external link
vsceasy menu edit --name tools --kind url \
--url "https://vsceasy.dev" --label "Docs" --icon book --group Actions
```
The file now shows the three connection types side by side:
```ts title="src/menus/tools.ts"
export default defineMenu({
title: 'Tools',
icon: 'tools',
items: [
{
label: 'Panels',
children: [
{ label: 'All Todos', icon: 'list-unordered', panel: 'todosList' },
],
},
{
label: 'Actions',
children: [
{ label: 'Say Hello', icon: 'play', command: 'hello' },
{ label: 'Docs', icon: 'book', url: 'https://vsceasy.dev' },
],
},
],
});
```
### The item kinds
| Field on the item | What clicking it does |
| ----------------- | --------------------- |
| `panel: 'id'` | Opens that panel (a webview tab in the editor area). |
| `command: 'id'` | Runs that command's `run()` handler. |
| `url: 'https://…'` | Opens the link in the browser. |
| `children: [ … ]` | A group — collapses/expands; holds nested items. |
| `run: (vscode, ctx) => …` | Inline handler, for one-off logic without a separate command. |
`icon`, `description`, and `collapsed` are optional on any item.
## What gen writes
`bun run gen` turns every `src/menus/*.ts` into VS Code's `contributes`. For our
two menus (`tools` and the CRUD-generated `todos`) it produced:
```json title="package.json (excerpt)"
"viewsContainers": {
"activitybar": [
{ "id": "tododemo-tools", "title": "Tools", "icon": "$(tools)" },
{ "id": "tododemo-todos", "title": "Todos", "icon": "$(symbol-misc)" }
]
},
"views": {
"tododemo-tools": [ { "id": "tododemo-tools", "name": "Tools" } ],
"tododemo-todos": [ { "id": "tododemo-todos", "name": "Todos" } ]
}
```
- Each menu becomes one **activity-bar container** with id `-`.
- The icon string becomes `$(codicon)` form.
- The item tree itself is rendered at runtime by a `TreeDataProvider`, not baked
into `package.json` — so you change items by editing the `.ts` file and
re-running `gen`, never by hand-editing JSON.
## See it run
The `todos` menu (built by `crud add`) shows its item tree in the activity bar —
the `Todos` and `New Todo` items point at the `todosList` and `todoForm` panels:

## Why the indirection matters
Because items reference targets by id:
- The same `todosList` panel is reachable from the `todos` menu **and** the
`tools` menu — define once, link anywhere.
- Renaming or restyling a menu never touches the panels.
- A command can be triggered from the command palette, a menu item, a status bar
item, or a tree node — all pointing at the same `command: 'id'`.
This is the backbone for the next two steps: the **status bar** item and the
**sidebar views** both plug into this same id-reference model.
Next: [add a status bar item →](/tutorial/06-statusbar/)
# 6. A status bar item
> Add a status bar entry that opens the Todos panel, bound by id.
The status bar is the strip at the bottom of VS Code. A status bar item is a tiny
button there — it has no UI of its own; it **binds to** a command, a panel, or a
popup menu, reusing the same id-reference model from the previous step.
## Add it
We'll add a `Todos` button that opens the list panel:
```bash
vsceasy statusBar add --name todoCount \
--text '$(checklist) Todos' \
--bindTo panel --panel todosList \
--alignment left --priority 100 \
--tooltip "Open the todo list"
```
```text
✓ Status bar "todoCount" added.
Created:
+ src/statusBars/todoCount.ts
```
The `$(checklist)` in `--text` is a codicon — status bar text supports inline
`$(icon)` syntax.
## What got generated
```ts title="src/statusBars/todoCount.ts"
import { defineStatusBar } from '../shared/vsceasy';
export default defineStatusBar({
text: '$(checklist) Todos',
tooltip: 'Open the todo list',
alignment: 'left',
priority: 100,
panel: 'todosList',
});
```
## The binding model
A status bar item picks **one** click target. When more than one is set, this is
the precedence:
| Field | Click behavior | Precedence |
| ----- | -------------- | ---------- |
| `menu: [ … ]` | Opens a QuickPick popup of items | highest |
| `panel: 'id'` | Opens that panel | middle |
| `command: 'id'` | Runs that command | lowest |
`--bindTo` chooses which one the CLI writes:
```bash
# run a command
vsceasy statusBar add --name sync --text '$(sync) Sync' \
--bindTo command --command doSync
# open a popup menu of choices
vsceasy statusBar add --name todoMenu --text '$(list-unordered) Todo' \
--bindTo menu
# (then it prompts for the menu items: label + kind + target)
# create a brand-new command and bind to it in one shot
vsceasy statusBar add --name refresh --text '$(refresh)' \
--bindTo "create new command" --newCommandTitle "Refresh Todos"
```
`alignment` (`left`/`right`) and `priority` (higher = further toward the center)
position it. No `package.json` contribution is needed — status bar items are pure
runtime, registered by `bootstrap` from the registry.
## See it run
After a reload the item sits at the bottom-left. Clicking it opens the Todos list
— the same `todosList` panel the menu links to:

Next: [add sidebar views — a subpanel and a tree view →](/tutorial/07-sidebar-views/)
# 7. Sidebar views — subpanel & tree view
> Render an inline webview and a data-driven tree inside a menu's container.
A menu's container can hold more than the item tree. Two kinds of **views** dock
inside it:
- a **subpanel** — an inline React webview (like a panel, but in the sidebar),
- a **tree view** — a data-driven tree you fill from code.
Both attach to a menu by id (`menu: 'todos'`) and stack under that menu's item
tree in the same activity-bar container.
| | Subpanel | Tree view |
| --- | -------- | --------- |
| Renders | A React webview (your HTML/JSX) | Native VS Code tree nodes |
| You write | UI + optional typed RPC | `getChildren()` returning nodes |
| Best for | Custom layouts, charts, forms | Hierarchical lists, navigation |
## Add a subpanel: live stats
```bash
vsceasy subpanel add --name todoStats --menu todos --title "Stats" --withApi yes
```
```text
✓ Webview view "todoStats" added.
Created:
+ src/subpanels/todoStats.ts
+ src/webview/subpanels/todoStats/App.tsx
+ src/webview/subpanels/todoStats/main.tsx
Modified:
~ src/shared/api.ts
```
`--withApi yes` adds a typed RPC interface so the webview can ask the host for
data. We define a `stats()` call and implement it over the same `TodosRepo()`:
```ts title="src/shared/api.ts"
export interface TodoStatsViewApi {
stats(): Promise<{ total: number; done: number; overdue: number }>;
}
```
```ts title="src/subpanels/todoStats.ts"
export default defineSubpanel({
title: 'Stats',
menu: 'todos',
rpc: () => ({
async stats() {
const now = Date.now();
const todos = await TodosRepo().findMany();
return {
total: todos.length,
done: todos.filter((t) => t.done).length,
overdue: todos.filter(
(t) => !t.done && t.dueDate && new Date(t.dueDate).getTime() < now,
).length,
};
},
}),
});
```
The webview calls it through the typed client — no message plumbing:
```tsx title="src/webview/subpanels/todoStats/App.tsx"
const api = connectWebview();
// …
useEffect(() => { void api.stats().then(setS); }, []);
```
## Add a tree view: todos by priority
```bash
vsceasy treeview add --name byPriority --menu todos --title "By Priority"
```
```text
✓ Tree view "byPriority" added under menu "todos".
+ src/treeViews/byPriority.ts
```
The generated stub has a `getChildren` you fill with real data. Ours returns one
group per priority, and lazy-loads the todos in each group on expand. A node can
carry a `panel` so clicking it opens the form:
```ts title="src/treeViews/byPriority.ts"
export default defineTreeView({
title: 'By Priority',
menu: 'todos',
getChildren: async (parent) => {
const todos = await TodosRepo().findMany();
if (!parent) {
// Root: one collapsible group per priority, with a count.
return ['high', 'medium', 'low'].map((p) => ({
id: p,
label: p,
description: String(todos.filter((t) => t.priority === p).length),
collapsed: 'collapsed',
}));
}
// Children: the todos in that priority — click opens the form.
return todos
.filter((t) => t.priority === parent.id)
.map((t) => ({ label: t.title, panel: 'todoForm' }));
},
});
```
A `TreeNode` can carry `icon`, `tooltip`, `description`, `collapsed`,
`contextValue`, and a click target (`panel`, `command`, or a `run` handler) —
the same id-reference model as menu items.
## What gen writes
Both views are added to the **menu's container** in `package.json`. The `todos`
container now lists three views — the menu's own tree, the webview subpanel, and
the tree view:
```json title="package.json (excerpt)"
"views": {
"tododemo-todos": [
{ "id": "tododemo-todos", "name": "Todos" },
{ "id": "tododemo-todos-todoStats", "name": "Stats", "type": "webview" },
{ "id": "tododemo-todos-byPriority", "name": "By Priority" }
]
}
```
View ids follow `--`, so a view always knows which menu
container it belongs to.
## See it run
Open the **Todos** container. Under the menu tree you get the live **Stats**
webview (totals pulled from the repo) and the **By Priority** tree:

Expanding a priority group lazy-loads its todos; clicking one opens the form:

## You've now seen the whole model
Everything connects through the menu container and id references:
- **panels / commands / urls** — reached from menu items, status bar, or tree nodes
- **subpanels** — inline webviews docked in a menu container
- **tree views** — data-driven trees docked in a menu container
- **status bar** — a shortcut bound to any of the above
That's the full surface area of a vsceasy UI.
The Stats view still reads its numbers once, though — save a todo and they go
stale. The last step makes it live.
Next: [make the Stats view live →](/tutorial/08-reactivity/)
# 8. Make the Stats view live
> Use watch + listen so the Stats subpanel updates the moment a todo changes.
The Stats subpanel from step 7 reads its numbers once. Save a todo and they go
stale until you reopen the view. Let's make it **react** — update the instant a
todo changes — with the reactivity layer.
The idea: the host **watches** the Todo entity and pushes an event; the webview
**listens** and re-reads. Two small edits.
## Host: watch the entity
The `rpc` factory gets a third argument, `emit`, for pushing events to its own
webview. Subscribe to Todo changes with `watchEntity` (from your generated
`db.ts`) and emit:
```ts title="src/subpanels/todoStats.ts" {3,9,10}
import { defineSubpanel } from '../shared/vsceasy';
import { Todos, TodosRepo } from '../models/Todo';
import { watchEntity } from '../helpers/db';
export default defineSubpanel({
title: 'Stats',
menu: 'todos',
rpc: (_vscode, _ctx, emit) => {
// SUBSCRIBE: any Todo change pushes an event to this webview.
watchEntity(Todos, () => emit('todos:changed'));
return {
async stats() {
const now = Date.now();
const todos = await TodosRepo().findMany();
return {
total: todos.length,
done: todos.filter((t) => t.done).length,
overdue: todos.filter(
(t) => !t.done && t.dueDate && new Date(t.dueDate).getTime() < now,
).length,
};
},
};
},
});
```
Every ORM mutation (`insert`/`upsert`/`update`/`delete`/…) fires the entity's
watchers — so saving or deleting a todo anywhere triggers this.
## Webview: listen and re-read
In the Stats UI, call `listen` and re-fetch when the event arrives:
```tsx title="src/webview/subpanels/todoStats/App.tsx" {1,9,12}
import { connectWebview, listen } from '../../../shared/vsceasy/client';
const api = connectWebview();
export function App() {
const [s, setS] = useState<{ total: number; done: number; overdue: number } | null>(null);
useEffect(() => {
const refresh = () => void api.stats().then(setS);
refresh();
// LISTEN: re-read whenever the host says todos changed.
return listen(api, 'todos:changed', refresh);
}, []);
// …render total / done / overdue…
}
```
`listen` returns an unsubscribe function — returning it from `useEffect` cleans up
on unmount.
## See it react
Reload, open the Todos container, and edit a todo — untick **Done** on the
overdue one and save. The Stats numbers update on their own:

No Refresh button, no focus trick — the view tracks the data.
## Two kinds of source
You watched an **ORM entity** here. The other source is a **store** — an
observable value for non-ORM state:
```bash
vsceasy store add --name badgeCount --type number
```
```ts
import { watch } from '../shared/vsceasy';
import { badgeCount } from '../stores/badgeCount';
// host, in rpc():
watch(badgeCount, () => emit('badge:changed', badgeCount.get()));
// anywhere:
badgeCount.update((n) => n + 1); // every watcher fires
```
Same `watch` → `emit` → `listen` flow, different source.
## You've finished the tutorial
You built a complete extension — data, CRUD UI, a job, menus, status bar, sidebar
views, and a live-updating view — entirely from `vsceasy` commands.
- [Reactivity guide](/guides/reactivity/) — the full reference for `watch`,
`watchEntity`, `defineStore`, and `listen`.
- [Command reference](/commands/) — every command and flag.
# Tutorial: Build a Todo extension
> Build a complete VS Code Todo-list extension with vsceasy, one command at a time.
This tutorial builds a working **Todo list** VS Code extension from nothing — a
database, a typed model, a full CRUD UI, and a background reminder — using only
`vsceasy` commands. After each command you'll see exactly what it generated and
why.
It mirrors the shape of Angular's *Tour of Heroes*: small steps, each one runnable,
each one explained.
## What you'll build
A VS Code extension that:
- Stores todos in the built-in [mini-ORM](/guides/orm/).
- Shows a **list panel** (table with Refresh / New / Edit / Delete).
- Shows a **form panel** with the right input per field — text, checkbox for the
boolean, a **dropdown** for the priority union, a **date picker** for the due date.
- Fires a **daily reminder** notification for overdue todos.

## What you'll learn
- How `create` scaffolds a project, and what each generated file is for.
- How `db init` + `model add` define typed, persisted data.
- How `crud add` turns a model into a full list + form UI with typed RPC.
- How `job add` registers background work.
- How `bun run gen` wires everything into `package.json` and the registry.
## Prerequisites
- [Bun](https://bun.sh) installed (`bun --version`).
- VS Code with the `code` CLI on your `PATH` (Command Palette →
*Shell Command: Install 'code' command in PATH*).
## The steps
1. [Scaffold the project](/tutorial/01-scaffold/) — `vsceasy create`.
2. [Add the database and the Todo model](/tutorial/02-model/) — `db init` + `model add`.
3. [Generate the CRUD UI](/tutorial/03-crud/) — `crud add`.
4. [Add a reminder job and run it](/tutorial/04-job-and-run/) — `job add`, then launch.
:::tip[Flags vs. prompts]
Every command below is shown in its non-interactive **flag** form so you can copy
it verbatim. Run any command with no flags to get the interactive prompts instead.
Note that `create` requires `--name` — it has no positional argument.
:::