diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6958aa0b..fa0f4dbb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -131,6 +131,32 @@ jobs:
working-directory: packages/vercel-sdk
run: npm test
+ vscode-moss-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+ cache-dependency-path: packages/vscode-moss/package-lock.json
+ - name: Install dependencies
+ working-directory: packages/vscode-moss
+ run: npm ci
+ - name: Typecheck
+ working-directory: packages/vscode-moss
+ run: npm run check
+ - name: Unit tests
+ working-directory: packages/vscode-moss
+ run: npm test
+ - name: Compile extension bundle
+ working-directory: packages/vscode-moss
+ run: npm run compile
+ - name: Package VSIX (publishability check)
+ working-directory: packages/vscode-moss
+ run: npm run package:ci
+
vitepress-plugin-test:
runs-on: ubuntu-latest
steps:
diff --git a/.gitignore b/.gitignore
index 6b5eed5c..eb746b63 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,4 +21,7 @@ venv/
*CLAUDE.md
*.egg-info
-.moss-cache
\ No newline at end of file
+.moss-cache
+
+.docs/
+.vscode/settings.json
diff --git a/README.md b/README.md
index 2dc5ba0f..bba547f0 100644
--- a/README.md
+++ b/README.md
@@ -242,6 +242,7 @@ const results = await client.query(name, "your query", { topK: 5 });
| [LiveKit](https://github.com/livekit/livekit) | Available | [`apps/livekit-moss-vercel/`](apps/livekit-moss-vercel/) |
| [Next.js](https://nextjs.org) | Available | [`apps/next-js/`](apps/next-js/) |
| [VitePress](https://vitepress.dev) | Available | [`packages/vitepress-plugin-moss/`](packages/vitepress-plugin-moss/) |
+| VS Code / Cursor | Available | [`packages/vscode-moss/`](packages/vscode-moss/) (semantic search sidebar; install from VSIX) |
| [Vercel AI SDK](https://sdk.vercel.ai) | Available | [`packages/vercel-sdk/`](packages/vercel-sdk/) |
| [CrewAI](https://github.com/crewAIInc/crewAI) | Coming soon | — |
diff --git a/ROADMAP.md b/ROADMAP.md
index e744b43b..8c8161d4 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -61,7 +61,7 @@ These are well-scoped and ready for contributors. Each one has (or will have) a
### Developer Tools
- [ ] **Moss CLI** — manage indexes, run queries, import data, and inspect results from the terminal (`moss index create`, `moss query`, `moss import`)
-- [ ] **VS Code extension** — semantic search over your codebase directly from the editor sidebar
+- [x] **VS Code extension** — semantic search over your codebase from the editor sidebar ([`packages/vscode-moss/`](packages/vscode-moss/); VSIX install, no Marketplace listing)
### Search Quality
diff --git a/packages/vscode-moss/.gitignore b/packages/vscode-moss/.gitignore
new file mode 100644
index 00000000..d3e15b1e
--- /dev/null
+++ b/packages/vscode-moss/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+out/
+*.vsix
diff --git a/packages/vscode-moss/.vscode/launch.json b/packages/vscode-moss/.vscode/launch.json
new file mode 100644
index 00000000..908b1f00
--- /dev/null
+++ b/packages/vscode-moss/.vscode/launch.json
@@ -0,0 +1,17 @@
+{
+ "version": "0.2.0",
+ "configurations": [
+ {
+ "name": "Run Extension (open this folder as workspace)",
+ "type": "extensionHost",
+ "request": "launch",
+ "args": ["--extensionDevelopmentPath=${workspaceFolder}"],
+ "outFiles": ["${workspaceFolder}/out/**/*.js"],
+ "preLaunchTask": "npm: compile",
+ "env": {
+ "MOSS_PROJECT_ID": "${env:MOSS_PROJECT_ID}",
+ "MOSS_PROJECT_KEY": "${env:MOSS_PROJECT_KEY}"
+ }
+ }
+ ]
+}
diff --git a/packages/vscode-moss/.vscode/tasks.json b/packages/vscode-moss/.vscode/tasks.json
new file mode 100644
index 00000000..ab3ba025
--- /dev/null
+++ b/packages/vscode-moss/.vscode/tasks.json
@@ -0,0 +1,31 @@
+{
+ "version": "2.0.0",
+ "tasks": [
+ {
+ "type": "npm",
+ "script": "compile",
+ "problemMatcher": [],
+ "group": {
+ "kind": "build",
+ "isDefault": true
+ },
+ "label": "npm: compile"
+ },
+ {
+ "type": "npm",
+ "script": "watch",
+ "problemMatcher": [],
+ "isBackground": true,
+ "presentation": { "reveal": "silent" },
+ "group": "build",
+ "label": "npm: watch"
+ },
+ {
+ "type": "npm",
+ "script": "check",
+ "problemMatcher": "$tsc",
+ "group": "build",
+ "label": "npm: check"
+ }
+ ]
+}
diff --git a/packages/vscode-moss/.vscodeignore b/packages/vscode-moss/.vscodeignore
new file mode 100644
index 00000000..f5de64da
--- /dev/null
+++ b/packages/vscode-moss/.vscodeignore
@@ -0,0 +1,10 @@
+.vscode/**
+.vscode-test/**
+src/**
+**/*.map
+**/*.ts
+esbuild.config.mjs
+!out/**/*.js
+tsconfig.json
+.gitignore
+**/.env*
diff --git a/packages/vscode-moss/INDEXING.md b/packages/vscode-moss/INDEXING.md
new file mode 100644
index 00000000..c8c9739e
--- /dev/null
+++ b/packages/vscode-moss/INDEXING.md
@@ -0,0 +1,120 @@
+# Indexing flow in vscode-moss
+
+This document describes **how workspace indexing works** inside the Moss VS Code extension: entry point, file discovery, chunking, upload, and what happens afterward. For a shorter checklist and code map, see [`WORKFLOW.md`](./WORKFLOW.md). For a Markdown-documentation–oriented pipeline (render → HTML → heading-aware chunks), see the repo’s [**moss-md-indexer** workflow](https://github.com/usemoss/moss/blob/main/packages/moss-md-indexer/INDEXER-WORKFLOW.md).
+
+## Entry point
+
+Indexing runs when the user triggers **`Moss: Index Workspace`** (command `moss.indexWorkspace`), including from the status bar item registered in `extension.ts`. The implementation lives in **`runIndexWorkspace`** in [`src/indexWorkspace.ts`](./src/indexWorkspace.ts).
+
+ Preconditions:
+
+- At least one **workspace folder** must be open; otherwise the command shows an error and returns.
+- **Credentials** must resolve via `resolveCredentials` / `resolveCredentialsForWorkspace`: **per-workspace credentials blob** (Secret Storage, keyed by workspace folder URI), then environment pair (`MOSS_PROJECT_ID` + `MOSS_PROJECT_KEY`), then migration from the legacy global key + `MOSS_PROJECT_ID`, then env project ID + legacy/`MOSS_PROJECT_KEY`. If missing, the user sees an error and indexing does not start.
+
+The work runs inside **`vscode.window.withProgress`** (notification area, **cancellable**).
+
+## Configuration resolution
+
+Before any I/O, the extension loads **`getMossConfig`** for the **first workspace folder** (`workspaceFolders[0]`). That yields:
+
+- **`indexName`** — from `moss.indexName` or auto-generated from the workspace name when empty.
+- **`includeGlobs` / `excludeGlobs`** — merged with extra safe excludes (`EXTRA_SAFE_EXCLUDES`, e.g. `**/.svn/**`) so dangerous paths are always filtered.
+- **`respectGitignore`** — when true (default), apply each folder’s root `.gitignore` after the glob scan (see Step 1 below).
+- **`maxFileSizeBytes`**, **`chunkMaxLines`**, **`chunkOverlapLines`**, **`modelId`**, etc.
+
+Multi-root workspaces: **all roots are scanned**, but **settings** (globs, index name, chunk options) come from the **primary** folder only, consistent with the extension README.
+
+## Step 1 — Discover files
+
+**`findWorkspaceFiles`** uses `vscode.workspace.findFiles` with:
+
+- **Includes** — from config (default effectively `**/*` if nothing is set).
+- **Excludes** — brace-combined when possible, or multiple scans per include pattern.
+- **Cap** — at most **`MAX_FILE_SCAN` (80,000)** URIs; if the scan hits the cap, indexing continues with a **warning** so users can narrow `moss.includeGlob` / `moss.excludeGlob`.
+
+Files are **deduped** and **sorted** by `fsPath` for stable ordering.
+
+When **`moss.respectGitignore`** is true (default), **`filterUrisByRootGitignore`** drops URIs that match each workspace folder’s **root** `.gitignore` (via the [`ignore`](https://www.npmjs.com/package/ignore) package, same semantics as Git for that file). **Nested** `.gitignore` files are not loaded. Set **`moss.respectGitignore`** to false to index ignored paths (for example build output).
+
+## Step 2 — Read, filter, and chunk per file
+
+For each URI (with cancellation checks between files):
+
+1. **Workspace membership** — Skip if the file is not under any `WorkspaceFolder`.
+2. **Binary extension** — Skip paths whose extension is in a fixed denylist (archives, images, binaries, fonts, etc.).
+3. **Size** — Skip if `stat.size > maxFileSizeBytes`.
+4. **Text** — Read bytes and decode as **UTF-8** with `fatal: true`; skip if decode fails or a `NUL` byte appears (treated as non-text).
+5. **Relative path** — `asRelativePath` must be non-empty.
+6. **Chunking** — **`chunkFileContent`** ([`chunking.ts`](./src/chunking.ts)) with:
+ - `languageId` from the file extension where supported (Markdown, JS/TS, Python, Rust, Go, Java, Ruby, PHP, C/C++, C#, etc.).
+ - **Structure-aware** chunks when the language is wired for Tree-sitter in [`structureChunking.ts`](./src/structureChunking.ts).
+ - **Line-window fallback** in [`chunkCore.ts`](./src/chunkCore.ts) when structure-aware splitting is not used.
+
+Each chunk becomes a Moss **`DocumentInfo`**: stable **`id`**, **`text`**, and string **`metadata`** (e.g. `path`, `startLine`, `endLine`; in multi-root, `workspaceFolderIndex` / `workspaceFolderName`).
+
+**Chunk budget** — The in-memory list **`allDocs`** is capped at **`MAX_MOSS_DOCUMENTS` (60,000)**. When the limit is reached, remaining files are skipped and a **warning** is shown.
+
+If no documents are produced (everything skipped or empty), indexing stops with a warning and **no** API upload.
+
+## Step 3 — Upload to Moss
+
+Progress shows **“Uploading index to Moss…”**.
+
+1. Construct **`MossClient(projectId, projectKey)`**.
+2. **`deleteIndex(indexName)`** — Wrapped in **`tolerateDeleteIndex`**: “not found” style errors are treated as OK; other failures are logged as warnings but do not necessarily abort (see implementation for exact behavior).
+3. **`createIndex(indexName, allDocs, { modelId })`** — Full replace of the remote index content for that name.
+
+On **success**:
+
+- **`notifySearchIndexStale()`** — Tells sidebar search to **`resetSearchSession()`** so stale `loadIndex` / client state is cleared after a full reindex.
+- **`workspaceState`** is updated under **`MOSS_LAST_INDEXED_KEY`** with index name, chunk count, file count, and timestamp (drives the status bar “indexed Xm ago” text).
+- **`notifyMossIndexed()`** refreshes the status bar immediately.
+
+On **`createIndex` failure**, the user sees an error message; workspace last-indexed state is **not** updated for this run.
+
+## Step 4 — Local search cache warm-up (optional)
+
+After upload, progress shows **“Preparing local search cache…”** and the code **`await sleep(POST_CREATE_SETTLE_MS)`** (**2.5s**) to let the service settle before downloading.
+
+Then **`ensureLocalIndexLoaded(client, cfg.indexName, localState)`** runs on the **same** `MossClient` used for upload. The `localState` object is **fresh** for this call only (not shared with the sidebar session).
+
+- If **`loadIndex`** succeeds, verbose logs note that the local query cache is warmed.
+- If it **fails**, indexing still **succeeded**; search falls back to **cloud** `query` until a later successful `loadIndex` (for example from the sidebar). A non-cancellation cancel after upload may skip warm-up and show an informational message.
+
+Finally, an information message summarizes files indexed and chunk count.
+
+## Cancellation
+
+The user can cancel from the progress notification. The implementation checks **`token.isCancellationRequested`** after the scan, during the per-file loop, before upload, before `createIndex`, and before / after the settle delay. Partial work is not uploaded unless `createIndex` already completed.
+
+## Code reference summary
+
+| Concern | Location |
+|--------|-----------|
+| Command registration | `src/extension.ts` |
+| Orchestration, scan, upload, warm-up | `src/indexWorkspace.ts` (`runIndexWorkspace`, `findWorkspaceFiles`, `tolerateDeleteIndex`) |
+| Credentials and `moss.*` resolution | `src/config.ts` |
+| Chunking | `src/chunking.ts`, `src/chunkCore.ts`, `src/structureChunking.ts` |
+| Last-indexed persistence (status bar) | `src/lastIndexed.ts`, `src/mossStatusBar.ts` |
+| Invalidate sidebar search after reindex | `src/mossQueryState.ts` (`notifySearchIndexStale`) |
+| Local index helper | `src/mossQueryState.ts` (`ensureLocalIndexLoaded`) |
+
+## Diagram (high level)
+
+```mermaid
+flowchart TD
+ A[Moss: Index Workspace] --> B{Open folder + credentials?}
+ B -->|no| Z[Show error]
+ B -->|yes| C[Resolve moss config for workspaceFolders0]
+ C --> D[findWorkspaceFiles capped at 80k]
+ D --> E[For each file: filter, UTF-8 read, chunkFileContent]
+ E --> F{Any documents?}
+ F -->|no| W[Warning and stop]
+ F -->|yes| G[deleteIndex tolerate missing]
+ G --> H[createIndex]
+ H -->|fail| E2[Error and stop]
+ H -->|ok| I[notifySearchIndexStale + save last indexed + status bar]
+ I --> J[Sleep 2.5s]
+ J --> K[ensureLocalIndexLoaded optional]
+ K --> L[Success message]
+```
diff --git a/packages/vscode-moss/LICENSE b/packages/vscode-moss/LICENSE
new file mode 100644
index 00000000..372ad0ad
--- /dev/null
+++ b/packages/vscode-moss/LICENSE
@@ -0,0 +1,25 @@
+BSD 2-Clause License
+
+Copyright (c) 2026, Moss Team
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/packages/vscode-moss/README.md b/packages/vscode-moss/README.md
new file mode 100644
index 00000000..74a7eafd
--- /dev/null
+++ b/packages/vscode-moss/README.md
@@ -0,0 +1,128 @@
+# vscode-moss
+
+VS Code extension for **semantic codebase search** with [Moss](https://moss.dev). This package is under active development.
+
+**Distribution:** install from a **`.vsix`** file only — this extension is **not** published on the VS Code Marketplace.
+
+## Install from VSIX
+
+1. Build the VSIX (or use one attached to a release):
+
+ ```bash
+ cd packages/vscode-moss
+ npm ci
+ npm run check && npm run compile
+ npm run package
+ ```
+
+ This produces **`vscode-moss-0.0.1.vsix`** (or whatever version is in `package.json`) in the current directory. Size depends on **`@moss-dev/moss`** and its **`@moss-dev/moss-core`** (N-API) dependency shipped in `node_modules`.
+
+2. In VS Code or Cursor: **Extensions** → **`…`** (Views and More Actions) → **Install from VSIX…** → choose the `.vsix` file.
+
+3. Reload the window if prompted, then configure Moss (**Moss: Configure credentials** or settings / env) and use **Moss: Index Workspace** as usual.
+
+## Search your workspace
+
+1. Set credentials: **Moss: Configure credentials** (prompts for project ID and project key — stored together per workspace). To remove stored credentials, run **Moss: Clear credentials**. Alternatively set **`MOSS_PROJECT_ID`** and **`MOSS_PROJECT_KEY`** in the environment (credentials are not Moss settings — they live in Secret Storage or env).
+2. Run **Moss: Index Workspace** (crawl + chunk + upload to Moss).
+3. Open the **Moss** icon in the activity bar → **Search**, or run **Moss: Search** from the Command Palette. Type a query (search runs as you pause typing, ~320ms debounce; **Enter** or **Search** runs immediately); click a result to jump to the file and line range.
+
+To change Moss options without opening Search, run **Moss: Open Settings** from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`).
+
+Indexing and search logs go to **View → Output** → channel **Moss**. Set **`moss.logVerbose`** to `true` for step-by-step indexing and search logs (default is a shorter summary).
+
+The **status bar** shows **Moss: not indexed** or **Moss: indexed … ago** (when a folder is open). Click it to run **Moss: Index Workspace**.
+
+### Privacy
+
+File paths and file contents you index are sent to **Moss** (cloud) for embedding and storage in your project’s index. Queries are also processed by Moss. Do not index secrets or data you are not allowed to send to a third-party service. See [moss.dev](https://moss.dev) for product terms and security expectations.
+
+### Settings (`moss.*`)
+
+| Setting | Purpose |
+|--------|---------|
+| Credentials | **Moss: Configure credentials** (per-workspace Secret Storage) or **`MOSS_PROJECT_ID`** / **`MOSS_PROJECT_KEY`** env — not user settings. |
+| `indexName` | Index name (default derived from workspace folder name). |
+| `modelId` | Embedding model for `createIndex`. |
+| `includeGlob` / `excludeGlob` | Which files to crawl when indexing. |
+| `respectGitignore` | When `true` (default), skip paths matched by each workspace folder’s **root** `.gitignore`. |
+| `maxFileSizeBytes` | Skip larger files. |
+| `topK` | Number of search hits. |
+| `alpha` | Hybrid search blend: `1.0` = semantic only, `0.0` = keyword only (default `0.8`). |
+| `chunkMaxLines` / `chunkOverlapLines` | Line-based chunking when indexing. |
+| `logVerbose` | Extra lines in **Output → Moss**. |
+
+### Multi-root workspaces
+
+**Indexing** walks every workspace folder, and chunk metadata records which root a file came from (`workspaceFolderIndex`). **`moss.*` settings used for indexing and sidebar search are read from the first folder only** (`workspaceFolders[0]`): `indexName`, `includeGlob`, `excludeGlob`, chunk options, `topK`, `alpha`, etc. Per-folder `moss.*` overrides on other roots are ignored. Put shared Moss settings in the workspace file or the first root’s `.vscode/settings.json`, or use a single-folder workspace if you need different indexes per root.
+
+Changing Moss settings that affect **credentials, index name, search, or indexing** (see `extension.ts` — all `moss.*` except **`logVerbose`**) resets the sidebar search session so the next search picks up new configuration.
+
+## Development
+
+Indexing pipeline details: see [**WORKFLOW.md**](./WORKFLOW.md) in this package.
+
+### Automated tests
+
+From `packages/vscode-moss`:
+
+```bash
+npm ci
+npm run check # TypeScript
+npm test # Vitest (chunking, paths, config, mossQueryState)
+npm run compile
+```
+
+### Manual QA (before release)
+
+Use the **Extension Development Host** (F5 — launch config lives under **`packages/vscode-moss/.vscode`**) with real Moss credentials.
+
+1. **Happy path:** Open a small test folder → configure credentials → **Moss: Index Workspace** → **Moss: Search** for text you know exists → click a result → editor jumps to the right file and range.
+2. **Cancel indexing:** Start **Moss: Index Workspace** on a larger tree → cancel from the notification → confirm no crash; **Output → Moss** notes cancellation.
+3. **Multi-root:** Open a workspace with two folders → index → search → open a hit from each root (paths resolve via `workspaceFolderIndex`).
+
+### Troubleshooting (`loadIndex` / local search)
+
+**Moss: Index Workspace** and sidebar search use **`@moss-dev/moss`** (`createIndex`, `deleteIndex`, `loadIndex`, `query`).
+
+- The extension always tries **`loadIndex`** first so queries run locally when possible.
+- If **`loadIndex`** fails, read the error in **Output → Moss** (Node / native addon constraints, WASM paths, or network/proxy blocking the download). Search still runs **`query`**; the SDK falls back to the **cloud** query API automatically.
+- For the rest of that sidebar session, **`loadIndex` is not retried** for the same index (no repeated “downloading index” UI). Run **Moss: Index Workspace** again, change Moss settings, or close and reopen the Moss Search view to reset and retry local load.
+
+This extension uses **`"type": "module"`** (ESM); **`out/extension.js`** is built as ESM (`NodeNext`).
+
+### Credentials (F5 / dev)
+
+Set **`MOSS_PROJECT_ID`** and **`MOSS_PROJECT_KEY`** in the Extension Development Host environment (recommended: `env` in **`packages/vscode-moss/.vscode/launch.json`**) or use your OS environment. Project credentials are not Moss settings keys.
+
+### Run the extension (monorepo)
+
+1. Open the **`moss` repository root** in VS Code (or open **`packages/vscode-moss`** only — see below).
+2. **Terminal:** `cd packages/vscode-moss && npm install && npm run compile` (or rely on the watch task).
+3. **Run and Debug** → **vscode-moss: Run Extension** (defined in **`packages/vscode-moss/.vscode/launch.json`**; `extensionDevelopmentPath` points at this package).
+4. In the Extension Development Host, use **Moss: Configure credentials**, **Moss: Index Workspace**, and **Moss: Search** as needed.
+5. Open **Output** → channel **Moss** for logs.
+
+### Run the extension (this folder only)
+
+Open **`packages/vscode-moss`** as the workspace folder and use **Run Extension (open this folder as workspace)**.
+
+### Build
+
+- **`npm run check`** — TypeScript (`tsc --noEmit`).
+- **`npm run compile`** — bundles `src/extension.ts` → `out/extension.js` with **esbuild** (Moss packages stay **external** and load from `node_modules`).
+- **`npm run watch`** — esbuild watch (no typecheck loop; run **`check`** in another terminal or before commit).
+
+```bash
+npm install
+npm run check && npm run compile
+npm run watch # optional during development
+```
+
+### Package VSIX (build only)
+
+Same as [Install from VSIX](#install-from-vsix) step 1. You can also run **`npm run package`** (`vsce package`), which triggers **`vscode:prepublish`** (check + compile) first.
+
+Bundling only shrinks **our** entry file; **`@moss-dev/moss`** and **`@moss-dev/moss-core`** remain **external** and ship inside the VSIX via `node_modules`.
+
+`package.json` still includes **`icon`** and **`galleryBanner`** for consistency if you ever list the extension elsewhere; they are not required for VSIX install.
diff --git a/packages/vscode-moss/WORKFLOW.md b/packages/vscode-moss/WORKFLOW.md
new file mode 100644
index 00000000..3c4e28e8
--- /dev/null
+++ b/packages/vscode-moss/WORKFLOW.md
@@ -0,0 +1,28 @@
+# vscode-moss indexing workflow
+
+This document describes how **vscode-moss** turns workspace files into a Moss index. For a deeper, Markdown-documentation–oriented pipeline (render → HTML → chunking with heading context), see the Moss repo’s [**moss-md-indexer** workflow](https://github.com/usemoss/moss/blob/main/packages/moss-md-indexer/INDEXER-WORKFLOW.md).
+
+## 1. High-level flow
+
+1. **Configure** — Resolve Moss credentials (per-workspace Secret Storage blob keyed by folder URI; then env pair; legacy migration). Credentials are **not** `moss.*` settings. **`moss.*` for indexing/search is resolved against the first workspace folder** (`workspaceFolders[0]`); multi-root workspaces still index all roots, but include/exclude, `indexName`, chunk options, and search `topK` / `alpha` come from that folder’s effective settings only (see README).
+2. **Discover** — Scan the workspace with `vscode.workspace.findFiles`, merging the primary folder’s `moss.includeGlob`, `moss.excludeGlob`, and extra safe excludes (e.g. `.git`, `node_modules`). Caps apply (`MAX_FILE_SCAN`, `MAX_MOSS_DOCUMENTS`).
+3. **Filter** — Skip binary-by-extension files, oversize files, and paths that fail UTF-8 decode.
+4. **Chunk** — For each file, read text and call `chunkFileContent` (`chunking.ts`):
+ - **Structure-aware** — For supported `languageId` values (Markdown, JS/TS, and other Tree-sitter grammars wired in `structureChunking.ts`), emit chunks aligned to structure when possible.
+ - **Fallback** — Otherwise use overlapping **line windows** (`chunkFileContentLineWindowsOnly` → `chunkLineWindowSegment` in `chunkCore.ts`), with small-file and max-character rules.
+5. **Metadata** — Each chunk is a Moss **`DocumentInfo`**: `id`, `text`, and string-keyed `metadata` (`path`, `startLine`, `endLine`, optional `workspaceFolderIndex` / `workspaceFolderName` for multi-root).
+6. **Upload** — `deleteIndex` (ignored if missing) then `createIndex` on **`MossClient`** with the chosen `modelId`.
+7. **Local warm-up** — After a short settle delay, call `loadIndex` on a fresh client so the downloaded index is ready for fast local `query` (or cloud fallback if load fails).
+
+## 2. Sidebar search (related)
+
+When the **Search** webview is created, the extension warms the same index with `loadIndex` using a **session-scoped** client and load state. If `loadIndex` fails, that index name is marked so **the session does not retry** `loadIndex` (queries use cloud fallback until the session resets). The session is cleared when the view is disposed, Moss **credentials** change, relevant **`moss.*`** settings change (index/search/indexing-related, not `logVerbose`), or a full re-index completes (`notifySearchIndexStale`).
+
+## 3. Code map
+
+| Stage | Primary files |
+|--------|----------------|
+| Entry / progress UI | `indexWorkspace.ts` |
+| File discovery | `indexWorkspace.ts` (`findWorkspaceFiles`) |
+| Chunking | `chunking.ts`, `chunkCore.ts`, `structureChunking.ts` |
+| Moss API | `@moss-dev/moss` (`MossClient`) |
diff --git a/packages/vscode-moss/esbuild.config.mjs b/packages/vscode-moss/esbuild.config.mjs
new file mode 100644
index 00000000..6860ee20
--- /dev/null
+++ b/packages/vscode-moss/esbuild.config.mjs
@@ -0,0 +1,28 @@
+import * as esbuild from "esbuild";
+
+const watch = process.argv.includes("--watch");
+
+const buildOptions = {
+ entryPoints: ["src/extension.ts"],
+ bundle: true,
+ outfile: "out/extension.js",
+ platform: "node",
+ format: "esm",
+ target: "es2022",
+ sourcemap: true,
+ logLevel: "info",
+ /** Provided by the extension host; Moss packages pull large native/WASM stacks — keep resolvable from node_modules. */
+ external: [
+ "vscode",
+ "@moss-dev/moss",
+ "@moss-dev/moss-core",
+ "web-tree-sitter",
+ ],
+};
+
+if (watch) {
+ const ctx = await esbuild.context(buildOptions);
+ await ctx.watch();
+} else {
+ await esbuild.build(buildOptions);
+}
diff --git a/packages/vscode-moss/media/icon.png b/packages/vscode-moss/media/icon.png
new file mode 100644
index 00000000..29d56138
Binary files /dev/null and b/packages/vscode-moss/media/icon.png differ
diff --git a/packages/vscode-moss/media/moss-icon.svg b/packages/vscode-moss/media/moss-icon.svg
new file mode 100644
index 00000000..94617ae4
--- /dev/null
+++ b/packages/vscode-moss/media/moss-icon.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/vscode-moss/media/searchView.js b/packages/vscode-moss/media/searchView.js
new file mode 100644
index 00000000..b6ed6c8d
--- /dev/null
+++ b/packages/vscode-moss/media/searchView.js
@@ -0,0 +1,391 @@
+/**
+ * Moss sidebar search webview script.
+ * Loaded via webview.asWebviewUri — kept out of TS template literals so regex escapes are not mangled.
+ */
+(function () {
+ const vscode = acquireVsCodeApi();
+ const input = document.getElementById("query");
+ const btn = document.getElementById("searchBtn");
+ const meta = document.getElementById("meta");
+ const indexPrep = document.getElementById("indexPrep");
+ const errorBanner = document.getElementById("errorBanner");
+ const emptyBlock = document.getElementById("emptyBlock");
+ const emptyState = document.getElementById("emptyState");
+ const resultList = document.getElementById("resultList");
+ const mossSettingsLink = document.getElementById("mossSettingsLink");
+
+ if (
+ !input ||
+ !btn ||
+ !meta ||
+ !errorBanner ||
+ !emptyBlock ||
+ !emptyState ||
+ !resultList ||
+ !mossSettingsLink
+ ) {
+ return;
+ }
+
+ const DEFAULT_EMPTY_HTML =
+ "Run Moss: Index Workspace to index your files, then search here.";
+
+ const prior = vscode.getState();
+ if (prior && typeof prior.query === "string") {
+ input.value = prior.query;
+ }
+
+ let selectedHitIndex = -1;
+ const SEARCH_DEBOUNCE_MS = 320;
+ let searchDebounceId = null;
+
+ function persistQuery() {
+ vscode.setState({ query: input.value });
+ }
+
+ function openSettingsClick(e) {
+ e.preventDefault();
+ vscode.postMessage({ type: "openMossSettings" });
+ }
+ mossSettingsLink.addEventListener("click", openSettingsClick);
+ mossSettingsLink.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" || e.key === " ") openSettingsClick(e);
+ });
+
+ function setLoading(loading) {
+ // Do not disable the query input while loading: disabling removes focus in the
+ // webview, so live search would force a click back into the field after each query.
+ btn.disabled = loading;
+ btn.textContent = loading ? "Searching…" : "Search";
+ }
+
+ function showError(message) {
+ errorBanner.textContent = message;
+ errorBanner.classList.add("visible");
+ }
+
+ function clearError() {
+ errorBanner.textContent = "";
+ errorBanner.classList.remove("visible");
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+ }
+
+ function escapeRegExp(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+
+ /** Split path into directory (with trailing slash) + basename for display. */
+ function splitPath(p) {
+ const norm = String(p).replace(/\\/g, "/");
+ const i = norm.lastIndexOf("/");
+ if (i <= 0) {
+ return { dir: "", base: norm || "" };
+ }
+ return { dir: norm.slice(0, i + 1), base: norm.slice(i + 1) };
+ }
+
+ /**
+ * Wrap query terms (length >= 2) in ; split on regex so we never inject HTML from the snippet.
+ */
+ function highlightSnippet(text, query) {
+ const raw = String(text);
+ const q = typeof query === "string" ? query : "";
+ const terms = [
+ ...new Set(
+ q
+ .trim()
+ .toLowerCase()
+ .split(/\s+/)
+ .filter((t) => t.length >= 2)
+ ),
+ ].sort((a, b) => b.length - a.length);
+ if (terms.length === 0) {
+ return escapeHtml(raw);
+ }
+ const pattern = terms.map((t) => escapeRegExp(t)).join("|");
+ if (!pattern) {
+ return escapeHtml(raw);
+ }
+ const re = new RegExp("(" + pattern + ")", "gi");
+ const parts = raw.split(re);
+ return parts
+ .map((part, i) => {
+ if (i % 2 === 1) {
+ return '' + escapeHtml(part) + "";
+ }
+ return escapeHtml(part);
+ })
+ .join("");
+ }
+
+ function hitRowDomId(hitIndex) {
+ return "moss-hit-" + hitIndex;
+ }
+
+ function getResultRows() {
+ return [...resultList.querySelectorAll(".result-row")];
+ }
+
+ function clearResultSelection() {
+ selectedHitIndex = -1;
+ resultList.removeAttribute("aria-activedescendant");
+ getResultRows().forEach((el) => {
+ el.classList.remove("result-row--selected");
+ el.setAttribute("aria-selected", "false");
+ el.tabIndex = -1;
+ });
+ }
+
+ function applyResultSelection(focusSelected) {
+ const focus = focusSelected !== false;
+ const rows = getResultRows();
+ rows.forEach((el, i) => {
+ const on = i === selectedHitIndex;
+ el.classList.toggle("result-row--selected", on);
+ el.setAttribute("aria-selected", on ? "true" : "false");
+ el.tabIndex = on ? 0 : -1;
+ if (on && focus) {
+ el.focus();
+ el.scrollIntoView({ block: "nearest" });
+ }
+ });
+ if (selectedHitIndex >= 0 && rows[selectedHitIndex]) {
+ const id = rows[selectedHitIndex].id;
+ if (id) resultList.setAttribute("aria-activedescendant", id);
+ } else {
+ resultList.removeAttribute("aria-activedescendant");
+ }
+ }
+
+ function openHitIndex(idx) {
+ if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0) return;
+ vscode.postMessage({ type: "openResult", hitIndex: idx });
+ }
+
+ function flushLiveQuery() {
+ if (searchDebounceId !== null) {
+ clearTimeout(searchDebounceId);
+ searchDebounceId = null;
+ }
+ const text = input.value.trim();
+ clearError();
+ persistQuery();
+ vscode.postMessage({ type: "query", text });
+ }
+
+ function scheduleLiveQuery() {
+ if (searchDebounceId !== null) clearTimeout(searchDebounceId);
+ searchDebounceId = setTimeout(() => {
+ searchDebounceId = null;
+ flushLiveQuery();
+ }, SEARCH_DEBOUNCE_MS);
+ }
+
+ if (prior && typeof prior.query === "string" && prior.query.trim() !== "") {
+ scheduleLiveQuery();
+ }
+
+ btn.addEventListener("click", () => flushLiveQuery());
+ input.addEventListener("input", () => {
+ clearResultSelection();
+ persistQuery();
+ scheduleLiveQuery();
+ });
+ input.addEventListener("focus", () => {
+ clearResultSelection();
+ });
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ flushLiveQuery();
+ return;
+ }
+ if (e.key === "ArrowDown" && resultList.style.display !== "none") {
+ const rows = getResultRows();
+ if (rows.length === 0) return;
+ e.preventDefault();
+ selectedHitIndex = 0;
+ applyResultSelection();
+ }
+ });
+
+ resultList.addEventListener("keydown", (e) => {
+ const rows = getResultRows();
+ if (rows.length === 0) return;
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ if (selectedHitIndex < rows.length - 1) {
+ selectedHitIndex += 1;
+ applyResultSelection();
+ }
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ if (selectedHitIndex > 0) {
+ selectedHitIndex -= 1;
+ applyResultSelection();
+ } else {
+ clearResultSelection();
+ input.focus();
+ }
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const idx = parseInt(
+ rows[selectedHitIndex]?.getAttribute("data-hit-index") || "",
+ 10
+ );
+ openHitIndex(idx);
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ clearResultSelection();
+ input.focus();
+ }
+ });
+
+ window.addEventListener("message", (event) => {
+ const msg = event.data;
+ if (!msg || typeof msg.type !== "string") return;
+
+ if (msg.type === "loading") {
+ setLoading(!!msg.loading);
+ if (msg.loading) {
+ clearResultSelection();
+ meta.textContent = "";
+ if (indexPrep) {
+ indexPrep.textContent = "";
+ indexPrep.classList.remove("visible");
+ }
+ resultList.innerHTML = "";
+ resultList.removeAttribute("aria-activedescendant");
+ resultList.style.display = "none";
+ emptyBlock.style.display = "none";
+ }
+ return;
+ }
+
+ if (msg.type === "localIndexLoading") {
+ if (!indexPrep) return;
+ const t = typeof msg.text === "string" ? msg.text : "";
+ if (t) {
+ indexPrep.textContent = t;
+ indexPrep.classList.add("visible");
+ } else {
+ indexPrep.textContent = "";
+ indexPrep.classList.remove("visible");
+ }
+ return;
+ }
+
+ if (msg.type === "clearError") {
+ clearError();
+ return;
+ }
+
+ if (msg.type === "clearResults") {
+ clearResultSelection();
+ clearError();
+ meta.textContent = "";
+ if (indexPrep) {
+ indexPrep.textContent = "";
+ indexPrep.classList.remove("visible");
+ }
+ resultList.innerHTML = "";
+ resultList.removeAttribute("aria-activedescendant");
+ resultList.style.display = "none";
+ emptyBlock.style.display = "block";
+ emptyState.innerHTML = DEFAULT_EMPTY_HTML;
+ return;
+ }
+
+ if (msg.type === "error") {
+ clearResultSelection();
+ showError(msg.message || "Search failed.");
+ resultList.style.display = "none";
+ resultList.innerHTML = "";
+ resultList.removeAttribute("aria-activedescendant");
+ emptyBlock.style.display = "block";
+ emptyState.innerHTML =
+ "Could not complete this search. Fix the issue above, then try again.";
+ return;
+ }
+
+ if (msg.type === "results") {
+ clearResultSelection();
+ const hits = Array.isArray(msg.hits) ? msg.hits : [];
+ const queryText = typeof msg.query === "string" ? msg.query : "";
+ if (hits.length === 0) {
+ emptyBlock.style.display = "block";
+ emptyState.innerHTML =
+ "No results. Try different wording or run Moss: Index Workspace.";
+ resultList.style.display = "none";
+ resultList.innerHTML = "";
+ resultList.removeAttribute("aria-activedescendant");
+ const t = typeof msg.timeMs === "number" ? msg.timeMs + " ms" : "";
+ meta.textContent = t ? "0 results · " + t : "0 results";
+ return;
+ }
+
+ emptyBlock.style.display = "none";
+ resultList.style.display = "flex";
+ resultList.innerHTML = hits
+ .map((h) => {
+ const rawPath = h.path || "";
+ const { dir, base } = splitPath(rawPath);
+ const pathHtml =
+ (dir
+ ? '' + escapeHtml(dir) + ""
+ : "") +
+ '' +
+ escapeHtml(base || rawPath) +
+ "";
+ const line = escapeHtml(String(h.lineLabel ?? ""));
+ const score =
+ typeof h.score === "number" ? h.score.toFixed(3) : "";
+ const snippet = highlightSnippet(h.snippet || "", queryText);
+ const domId = hitRowDomId(h.index);
+ return (
+ '