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)} /> ``` 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 `` 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 ` ))} ``` The result — the Category field is a dropdown of the actual Category rows: ![A CRUD form with a Category field rendered as a dropdown of Work, Home, Errands](/tutorial/relation-form.svg) ## 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"` | `