diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore
new file mode 100644
index 00000000000..77f1925a04a
--- /dev/null
+++ b/apps/vscode/.vscodeignore
@@ -0,0 +1,6 @@
+.vscode/**
+node_modules/**
+src/**
+tsconfig.json
+vite.config.*
+*.test.*
diff --git a/apps/vscode/README.md b/apps/vscode/README.md
new file mode 100644
index 00000000000..8386c738385
--- /dev/null
+++ b/apps/vscode/README.md
@@ -0,0 +1,47 @@
+# T3 Code for VS Code
+
+
+
+This package exposes T3 Code as a dedicated VS Code secondary-sidebar chat tab, alongside the
+Claude Code, Chat, and Codex tabs. It connects directly to the same T3 Code server used by the web,
+desktop, and mobile clients, so projects, threads, messages, turn state, and assistant streaming
+remain synchronized. It also provides an optional native `@t3` participant inside VS Code Chat.
+
+## Development
+
+1. Start T3 Code (`pnpm dev`), which listens at `http://127.0.0.1:3773` by default.
+2. Build the extension with `pnpm --filter t3-code build`.
+3. Open this repository in VS Code, choose **Run Extension** from the Run and Debug view, and point
+ the extension-development host at `apps/vscode` if prompted.
+4. Select the **T3 Code** tab in the secondary sidebar. Use **T3 Code: Open Chat** from the Command
+ Palette if the secondary sidebar is hidden.
+
+The extension first uses the backend advertised by the T3 Desktop runtime beside its extension
+host. This works independently in local, SSH, and other remote windows and avoids treating a
+synced `127.0.0.1` setting as the same machine. For a fallback backend, set `t3Code.serverUrl`.
+Remote servers can use **T3 Code: Set Server Bearer Token**; the token is stored in VS Code secret
+storage and exchanged for a short-lived WebSocket ticket.
+
+## Dedicated chat workflow
+
+The T3 Code tab contains a worktree-scoped thread picker, synchronized transcript, context control,
+and prompt composer. Select a thread to continue it on any T3 client, or use **+** to create one for
+the open worktree. Enter sends; Shift+Enter inserts a newline.
+
+The same operations are also available through the optional native Chat participant:
+
+- `@t3 /threads` selects an existing thread whose worktree matches the open workspace folder.
+- `@t3 /new` creates a synchronized thread (and a project when the folder is not registered yet).
+- A normal `@t3` prompt continues the last selected thread, or the most recently updated matching
+ thread. If none exists, it creates one.
+- `@t3 /history`, `/status`, and `/stop` inspect or control the selected server thread.
+
+Active editor context is included by default and can be toggled from the composer, with
+`@t3 /context`, or **T3 Code: Toggle Automatic Editor Context**. This preference is kept in VS Code's
+extension state and never written to workspace settings. A non-empty
+selection includes the exact character range; an empty selection includes the cursor line and
+column. Explicit Chat references such as `#file` and attached selections are always included.
+
+The **T3 Code: Ask About Selection** editor action opens Chat with `@t3` prefilled. Context is sent
+as structured-looking provider context using workspace-relative paths and language-aware Markdown
+fences. T3 Code clients present that envelope as a context reference rather than authored text.
diff --git a/apps/vscode/media/screenshot.jpg b/apps/vscode/media/screenshot.jpg
new file mode 100644
index 00000000000..5d7df918794
Binary files /dev/null and b/apps/vscode/media/screenshot.jpg differ
diff --git a/apps/vscode/media/t3-code.svg b/apps/vscode/media/t3-code.svg
new file mode 100644
index 00000000000..fd6dba4e5e5
--- /dev/null
+++ b/apps/vscode/media/t3-code.svg
@@ -0,0 +1,3 @@
+
diff --git a/apps/vscode/package.json b/apps/vscode/package.json
new file mode 100644
index 00000000000..d89b3d49803
--- /dev/null
+++ b/apps/vscode/package.json
@@ -0,0 +1,203 @@
+{
+ "name": "t3-code",
+ "displayName": "T3 Code",
+ "version": "0.0.37",
+ "private": true,
+ "description": "A dedicated VS Code chat client for synchronized T3 Code threads.",
+ "categories": [
+ "AI",
+ "Chat",
+ "Other"
+ ],
+ "publisher": "t3code",
+ "type": "module",
+ "main": "./dist/extension.cjs",
+ "scripts": {
+ "build": "esbuild src/extension.ts --bundle --platform=node --format=cjs --target=node20 --external:vscode --outfile=dist/extension.cjs && esbuild src/webview.ts --bundle --platform=browser --format=iife --target=es2022 --outfile=dist/webview.js",
+ "typecheck": "tsgo --noEmit",
+ "test": "vp test run"
+ },
+ "dependencies": {
+ "@t3tools/client-runtime": "workspace:*",
+ "@t3tools/contracts": "workspace:*",
+ "@t3tools/shared": "workspace:*",
+ "dompurify": "^3.2.6",
+ "effect": "catalog:",
+ "marked": "^15.0.12"
+ },
+ "devDependencies": {
+ "@types/vscode": "1.95.0",
+ "esbuild": "^0.28.0",
+ "vite-plus": "catalog:"
+ },
+ "contributes": {
+ "viewsContainers": {
+ "activitybar": [
+ {
+ "id": "t3CodeViewContainer",
+ "title": "T3 Code",
+ "icon": "media/t3-code.svg",
+ "when": "t3Code.useActivityBar"
+ }
+ ],
+ "secondarySidebar": [
+ {
+ "id": "t3CodeSecondaryViewContainer",
+ "title": "T3 Code",
+ "icon": "media/t3-code.svg",
+ "when": "!t3Code.useActivityBar"
+ }
+ ]
+ },
+ "views": {
+ "t3CodeViewContainer": [
+ {
+ "id": "t3Code.chatView",
+ "type": "webview",
+ "name": "T3 Code",
+ "when": "t3Code.useActivityBar"
+ }
+ ],
+ "t3CodeSecondaryViewContainer": [
+ {
+ "id": "t3Code.chatViewSecondary",
+ "type": "webview",
+ "name": "T3 Code",
+ "when": "!t3Code.useActivityBar"
+ }
+ ]
+ },
+ "chatParticipants": [
+ {
+ "id": "t3-code.chat",
+ "name": "t3",
+ "fullName": "T3 Code",
+ "description": "Work with synchronized Codex, Claude, Cursor, Grok, and OpenCode threads.",
+ "isSticky": true,
+ "commands": [
+ {
+ "name": "new",
+ "description": "Create a thread for this worktree"
+ },
+ {
+ "name": "threads",
+ "description": "Choose a thread for this worktree"
+ },
+ {
+ "name": "history",
+ "description": "Show the synchronized thread history"
+ },
+ {
+ "name": "context",
+ "description": "Toggle automatic editor context"
+ },
+ {
+ "name": "stop",
+ "description": "Interrupt the active turn"
+ },
+ {
+ "name": "status",
+ "description": "Show connection and thread status"
+ }
+ ]
+ }
+ ],
+ "commands": [
+ {
+ "command": "t3Code.newThread",
+ "title": "T3 Code: New Thread for Worktree",
+ "category": "T3 Code"
+ },
+ {
+ "command": "t3Code.selectThread",
+ "title": "T3 Code: Select Worktree Thread",
+ "category": "T3 Code"
+ },
+ {
+ "command": "t3Code.askSelection",
+ "title": "T3 Code: Ask About Selection",
+ "category": "T3 Code",
+ "icon": "$(comment-discussion)"
+ },
+ {
+ "command": "t3Code.toggleEditorContext",
+ "title": "T3 Code: Toggle Automatic Editor Context",
+ "category": "T3 Code"
+ },
+ {
+ "command": "t3Code.showDiagnostics",
+ "title": "T3 Code: Show Diagnostics",
+ "category": "T3 Code"
+ },
+ {
+ "command": "t3Code.setBearerToken",
+ "title": "T3 Code: Set Server Bearer Token",
+ "category": "T3 Code"
+ },
+ {
+ "command": "t3Code.clearBearerToken",
+ "title": "T3 Code: Clear Server Bearer Token",
+ "category": "T3 Code"
+ },
+ {
+ "command": "t3Code.openChat",
+ "title": "T3 Code: Open Chat",
+ "category": "T3 Code"
+ }
+ ],
+ "menus": {
+ "editor/context": [
+ {
+ "command": "t3Code.askSelection",
+ "group": "navigation@90",
+ "when": "editorHasSelection"
+ }
+ ]
+ },
+ "configuration": {
+ "title": "T3 Code",
+ "properties": {
+ "t3Code.serverUrl": {
+ "type": "string",
+ "scope": "machine",
+ "default": "http://127.0.0.1:3773",
+ "description": "Fallback T3 Code server HTTP URL when no local T3 Desktop runtime is advertised. WebSocket and environment endpoints are derived from it."
+ },
+ "t3Code.defaultRuntimeMode": {
+ "type": "string",
+ "enum": [
+ "approval-required",
+ "auto-accept-edits",
+ "full-access"
+ ],
+ "default": "full-access",
+ "description": "Runtime mode for new T3 Code threads."
+ },
+ "t3Code.desktopClientSettingsPath": {
+ "type": "string",
+ "default": "",
+ "description": "Optional path to T3 Desktop's client-settings.json for sharing provider and model favorites. The default auto-detects ~/.t3/userdata or ~/.t3/dev."
+ }
+ }
+ }
+ },
+ "activationEvents": [
+ "onChatParticipant:t3-code.chat",
+ "onCommand:t3Code.askSelection",
+ "onCommand:t3Code.clearBearerToken",
+ "onCommand:t3Code.newThread",
+ "onCommand:t3Code.openChat",
+ "onCommand:t3Code.selectThread",
+ "onCommand:t3Code.setBearerToken",
+ "onCommand:t3Code.showDiagnostics",
+ "onCommand:t3Code.toggleEditorContext",
+ "onView:t3Code.chatView",
+ "onView:t3Code.chatViewSecondary"
+ ],
+ "extensionKind": [
+ "workspace"
+ ],
+ "engines": {
+ "vscode": "^1.95.0"
+ }
+}
diff --git a/apps/vscode/src/chatViewProvider.ts b/apps/vscode/src/chatViewProvider.ts
new file mode 100644
index 00000000000..09f6ccd7f78
--- /dev/null
+++ b/apps/vscode/src/chatViewProvider.ts
@@ -0,0 +1,732 @@
+/* oxlint-disable unicorn/require-post-message-target-origin -- VS Code Webview.postMessage is not Window.postMessage. */
+import type {
+ ModelSelection,
+ OrchestrationThread,
+ OrchestrationThreadShell,
+ RuntimeMode,
+ ThreadId,
+ UploadChatAttachment,
+} from "@t3tools/contracts";
+import { ApprovalRequestId, ProviderInstanceId } from "@t3tools/contracts";
+import * as vscode from "vscode";
+
+import { composePrompt, type TextContext } from "./editorContext.ts";
+import { derivePendingInteractions } from "./pendingInteractions.ts";
+import type { T3Client } from "./t3Client.ts";
+import { resolveThreadDisplayStatus } from "./threadStatus.ts";
+import { presentTasks } from "./taskPresentation.ts";
+import { presentToolCalls } from "./toolPresentation.ts";
+import { presentResolvedUserInputs } from "./userInputPresentation.ts";
+
+interface ChatViewActions {
+ readonly worktreePath: () => string;
+ readonly ensureConnected: () => Promise;
+ readonly restoreThread: () => Promise;
+ readonly createThread: (title?: string, modelSelection?: ModelSelection) => Promise;
+ readonly selectThread: (threadId: string) => Promise;
+ readonly toggleContext: () => Promise;
+ readonly contextEnabled: () => boolean;
+ readonly runtimeMode: () => RuntimeMode;
+ readonly editorContext: () => TextContext | null;
+ readonly favoriteProviderIds: () => ReadonlyArray;
+ readonly favoriteModelKeys: () => ReadonlyArray;
+ readonly toggleProviderFavorite: (instanceId: string) => Promise;
+ readonly toggleModelFavorite: (modelKey: string) => Promise;
+ readonly onFavoritesChanged: vscode.Event;
+}
+
+type WebviewRequest =
+ | { readonly type: "ready" | "refresh" | "stop" | "toggleContext" }
+ | {
+ readonly type: "sendNewThread";
+ readonly text: string;
+ readonly instanceId: string;
+ readonly model: string;
+ readonly options: ModelSelection["options"];
+ readonly images: ReadonlyArray;
+ }
+ | { readonly type: "selectThread"; readonly threadId: string }
+ | {
+ readonly type: "selectModel";
+ readonly instanceId: string;
+ readonly model: string;
+ readonly options: ModelSelection["options"];
+ }
+ | { readonly type: "openLink"; readonly href: string }
+ | { readonly type: "openEditorContext"; readonly path: string; readonly detail: string }
+ | { readonly type: "copyText"; readonly text: string }
+ | {
+ readonly type: "approvalResponse";
+ readonly requestId: string;
+ readonly decision: "accept" | "acceptForSession" | "decline";
+ }
+ | {
+ readonly type: "userInputResponse";
+ readonly requestId: string;
+ readonly answers: Readonly>>;
+ }
+ | { readonly type: "toggleProviderFavorite"; readonly instanceId: string }
+ | { readonly type: "toggleModelFavorite"; readonly modelKey: string }
+ | {
+ readonly type: "send";
+ readonly text: string;
+ readonly images: ReadonlyArray;
+ };
+
+function hasImageArray(value: object): boolean {
+ return (
+ "images" in value &&
+ Array.isArray(value.images) &&
+ value.images.every(
+ (image) =>
+ typeof image === "object" &&
+ image !== null &&
+ "type" in image &&
+ image.type === "image" &&
+ "name" in image &&
+ typeof image.name === "string" &&
+ "mimeType" in image &&
+ typeof image.mimeType === "string" &&
+ "sizeBytes" in image &&
+ typeof image.sizeBytes === "number" &&
+ "dataUrl" in image &&
+ typeof image.dataUrl === "string",
+ )
+ );
+}
+
+function nonce(): string {
+ return Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16)), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("");
+}
+
+function isRequest(value: unknown): value is WebviewRequest {
+ if (typeof value !== "object" || value === null || !("type" in value)) return false;
+ const type = (value as { readonly type?: unknown }).type;
+ if (type === "ready" || type === "refresh" || type === "stop" || type === "toggleContext") {
+ return true;
+ }
+ if (type === "sendNewThread") {
+ return (
+ "text" in value &&
+ typeof value.text === "string" &&
+ "instanceId" in value &&
+ typeof value.instanceId === "string" &&
+ "model" in value &&
+ typeof value.model === "string" &&
+ "options" in value &&
+ (value.options === undefined || Array.isArray(value.options)) &&
+ hasImageArray(value)
+ );
+ }
+ if (type === "selectThread") {
+ return "threadId" in value && typeof value.threadId === "string";
+ }
+ if (type === "selectModel") {
+ return (
+ "instanceId" in value &&
+ typeof value.instanceId === "string" &&
+ "model" in value &&
+ typeof value.model === "string" &&
+ "options" in value &&
+ (value.options === undefined || Array.isArray(value.options))
+ );
+ }
+ if (type === "openLink") return "href" in value && typeof value.href === "string";
+ if (type === "approvalResponse") {
+ return (
+ "requestId" in value &&
+ typeof value.requestId === "string" &&
+ "decision" in value &&
+ (value.decision === "accept" ||
+ value.decision === "acceptForSession" ||
+ value.decision === "decline")
+ );
+ }
+ if (type === "userInputResponse") {
+ return (
+ "requestId" in value &&
+ typeof value.requestId === "string" &&
+ "answers" in value &&
+ typeof value.answers === "object" &&
+ value.answers !== null &&
+ Object.values(value.answers).every(
+ (answer) =>
+ typeof answer === "string" ||
+ (Array.isArray(answer) && answer.every((entry) => typeof entry === "string")),
+ )
+ );
+ }
+ if (type === "openEditorContext") {
+ return (
+ "path" in value &&
+ typeof value.path === "string" &&
+ "detail" in value &&
+ typeof value.detail === "string"
+ );
+ }
+ if (type === "copyText") return "text" in value && typeof value.text === "string";
+ if (type === "toggleProviderFavorite") {
+ return "instanceId" in value && typeof value.instanceId === "string";
+ }
+ if (type === "toggleModelFavorite") {
+ return "modelKey" in value && typeof value.modelKey === "string";
+ }
+ return (
+ type === "send" && "text" in value && typeof value.text === "string" && hasImageArray(value)
+ );
+}
+
+export class T3ChatViewProvider implements vscode.WebviewViewProvider, vscode.Disposable {
+ static readonly primaryViewType = "t3Code.chatView";
+ static readonly secondaryViewType = "t3Code.chatViewSecondary";
+
+ #view: vscode.WebviewView | null = null;
+ #busy = false;
+ #error: string | null = null;
+ readonly client: T3Client;
+ readonly actions: ChatViewActions;
+ readonly extensionUri: vscode.Uri;
+ readonly #disposables: vscode.Disposable[];
+ readonly #attachmentUrls = new Map();
+ readonly #attachmentLoads = new Set();
+
+ constructor(client: T3Client, actions: ChatViewActions, extensionUri: vscode.Uri) {
+ this.client = client;
+ this.actions = actions;
+ this.extensionUri = extensionUri;
+ this.#disposables = [
+ client.onShellChanged(() => this.#publish()),
+ client.onThreadChanged(() => this.#publish()),
+ client.onConnectionChanged((connected) => {
+ if (!connected) this.#error = "Connection lost. Refresh to reconnect to T3 Code.";
+ this.#publish();
+ }),
+ vscode.window.onDidChangeActiveTextEditor(() => this.#publish()),
+ vscode.window.onDidChangeTextEditorSelection(() => this.#publish()),
+ vscode.workspace.onDidChangeConfiguration((event) => {
+ if (event.affectsConfiguration("t3Code")) void this.#refresh();
+ }),
+ actions.onFavoritesChanged(() => this.#publish()),
+ ];
+ }
+
+ resolveWebviewView(view: vscode.WebviewView): void {
+ this.#view = view;
+ view.webview.options = {
+ enableScripts: true,
+ localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "dist")],
+ };
+ view.webview.html = this.#html(view.webview);
+ this.#disposables.push(
+ view.webview.onDidReceiveMessage((message: unknown) => {
+ if (isRequest(message)) void this.#handle(message);
+ }),
+ view.onDidDispose(() => {
+ if (this.#view === view) this.#view = null;
+ }),
+ view.onDidChangeVisibility(() => {
+ if (view.visible && this.client.serverConfig === null) void this.#refresh();
+ }),
+ );
+ }
+
+ dispose(): void {
+ for (const disposable of this.#disposables) disposable.dispose();
+ this.#disposables.length = 0;
+ }
+
+ async reveal(): Promise {
+ try {
+ await vscode.commands.executeCommand(`${T3ChatViewProvider.secondaryViewType}.focus`);
+ } catch {
+ await vscode.commands.executeCommand(`${T3ChatViewProvider.primaryViewType}.focus`);
+ }
+ await this.#view?.webview.postMessage({ type: "focusComposer" });
+ }
+
+ async #handle(message: WebviewRequest): Promise {
+ try {
+ switch (message.type) {
+ case "ready":
+ case "refresh":
+ await this.#refresh();
+ return;
+ case "sendNewThread":
+ await this.#run(async () => {
+ const authored = message.text.trim();
+ if (authored === "" && message.images.length === 0) return;
+ const modelSelection: ModelSelection = {
+ instanceId: ProviderInstanceId.make(message.instanceId),
+ model: message.model,
+ ...(message.options === undefined ? {} : { options: message.options }),
+ };
+ const threadId = await this.actions.createThread(
+ authored.slice(0, 80) || "Image",
+ modelSelection,
+ );
+ const editorContext = this.actions.contextEnabled()
+ ? this.actions.editorContext()
+ : null;
+ await this.client.sendPrompt({
+ threadId,
+ prompt: composePrompt(authored, editorContext === null ? [] : [editorContext]),
+ runtimeMode: this.actions.runtimeMode(),
+ modelSelection,
+ attachments: message.images,
+ });
+ // The shell/title update can arrive before the detail stream. Hydrate
+ // the authoritative first message before the webview leaves draft mode.
+ await this.client.selectThread(threadId);
+ await this.client.waitForActiveThread();
+ await this.#view?.webview.postMessage({ type: "sentNewThread" });
+ });
+ return;
+ case "selectThread":
+ await this.#run(async () => {
+ await this.actions.selectThread(message.threadId);
+ await this.client.waitForActiveThread();
+ });
+ return;
+ case "selectModel":
+ await this.#run(() =>
+ this.client.setModelSelection({
+ instanceId: ProviderInstanceId.make(message.instanceId),
+ model: message.model,
+ ...(message.options === undefined ? {} : { options: message.options }),
+ }),
+ );
+ return;
+ case "openLink":
+ await this.#openLink(message.href);
+ return;
+ case "openEditorContext": {
+ if (message.path.split("/").includes("..")) return;
+ const uri = vscode.Uri.joinPath(
+ vscode.Uri.file(this.actions.worktreePath()),
+ ...message.path.split("/"),
+ );
+ const document = await vscode.workspace.openTextDocument(uri);
+ const editor = await vscode.window.showTextDocument(document);
+ const line = /(?:line|lines)\s+(\d+)/u.exec(message.detail)?.[1];
+ if (line !== undefined) {
+ const position = new vscode.Position(Math.max(0, Number(line) - 1), 0);
+ editor.selection = new vscode.Selection(position, position);
+ editor.revealRange(new vscode.Range(position, position));
+ }
+ return;
+ }
+ case "copyText":
+ await vscode.env.clipboard.writeText(message.text);
+ return;
+ case "approvalResponse":
+ await this.#run(() =>
+ this.client.respondToApproval(
+ ApprovalRequestId.make(message.requestId),
+ message.decision,
+ ),
+ );
+ return;
+ case "userInputResponse":
+ await this.#run(() =>
+ this.client.respondToUserInput(ApprovalRequestId.make(message.requestId), {
+ ...message.answers,
+ }),
+ );
+ return;
+ case "toggleProviderFavorite":
+ await this.actions.toggleProviderFavorite(message.instanceId);
+ this.#publish();
+ return;
+ case "toggleModelFavorite":
+ await this.actions.toggleModelFavorite(message.modelKey);
+ this.#publish();
+ return;
+ case "stop":
+ await this.#run(() => this.client.interrupt());
+ return;
+ case "toggleContext":
+ await this.actions.toggleContext();
+ this.#publish();
+ return;
+ case "send": {
+ const authored = message.text.trim();
+ if (authored === "" && message.images.length === 0) return;
+ await this.#run(async () => {
+ await this.actions.ensureConnected();
+ // Resolve/restore thread for the *current* worktree (multi-root safe) before reusing activeThread.
+ await this.actions.restoreThread();
+ const threadId =
+ this.client.activeThread?.id ??
+ (await this.actions.createThread(authored.slice(0, 80)));
+ const context = this.actions.contextEnabled() ? this.actions.editorContext() : null;
+ await this.client.sendPrompt({
+ threadId,
+ prompt: composePrompt(authored, context === null ? [] : [context]),
+ runtimeMode: this.actions.runtimeMode(),
+ attachments: message.images,
+ });
+ await this.#view?.webview.postMessage({ type: "sent" });
+ });
+ return;
+ }
+ }
+ } catch (cause) {
+ this.#error = cause instanceof Error ? cause.message : String(cause);
+ this.#publish();
+ }
+ }
+
+ async #refresh(): Promise {
+ await this.#run(async () => {
+ await this.actions.ensureConnected();
+ await this.actions.restoreThread();
+ });
+ }
+
+ async #run(action: () => Promise): Promise {
+ if (this.#busy) return;
+ this.#busy = true;
+ this.#error = null;
+ this.#publish();
+ try {
+ await action();
+ } catch (cause) {
+ this.#error = cause instanceof Error ? cause.message : String(cause);
+ } finally {
+ this.#busy = false;
+ this.#publish();
+ }
+ }
+
+ #publish(): void {
+ const view = this.#view;
+ if (view === null) return;
+ let threads: ReadonlyArray = [];
+ try {
+ threads = this.client.threadsForWorktree(this.actions.worktreePath());
+ } catch {
+ // The empty-workspace message below is more useful than surfacing this as a connection error.
+ }
+ const editorContext = this.actions.editorContext();
+ this.#loadAttachmentUrls(this.client.activeThread);
+ void view.webview.postMessage({
+ type: "state",
+ state: {
+ busy: this.#busy,
+ error: this.#error,
+ connected: this.client.serverConfig !== null,
+ environmentLabel: this.client.serverConfig?.environment.label ?? "T3 Code",
+ threads: threads.map((thread) => ({
+ id: thread.id,
+ title: thread.title,
+ model: thread.modelSelection.model,
+ status: resolveThreadDisplayStatus(thread),
+ updatedAt: thread.updatedAt,
+ })),
+ activeThread: this.#serializeThread(this.client.activeThread),
+ models:
+ this.client.serverConfig?.providers
+ .filter((provider) => provider.enabled && provider.installed)
+ .flatMap((provider) =>
+ provider.models.map((model) => ({
+ instanceId: provider.instanceId,
+ model: model.slug,
+ driver: provider.driver,
+ providerLabel: provider.displayName ?? provider.driver,
+ modelLabel: model.name,
+ optionDescriptors: model.capabilities?.optionDescriptors ?? [],
+ })),
+ ) ?? [],
+ favoriteProviderIds: this.actions.favoriteProviderIds(),
+ favoriteModelKeys: this.actions.favoriteModelKeys(),
+ contextEnabled: this.actions.contextEnabled(),
+ editorContext:
+ editorContext === null
+ ? null
+ : {
+ path: editorContext.relativePath,
+ startLine: editorContext.startLine,
+ endLine: editorContext.endLine,
+ kind: editorContext.kind,
+ },
+ },
+ });
+ }
+
+ #serializeThread(thread: OrchestrationThread | null) {
+ if (thread === null) return null;
+ return {
+ id: thread.id,
+ title: thread.title,
+ model: thread.modelSelection.model,
+ instanceId: thread.modelSelection.instanceId,
+ runtimeMode: thread.runtimeMode,
+ status: resolveThreadDisplayStatus({
+ latestTurn: thread.latestTurn,
+ session: thread.session,
+ }),
+ turnStartedAt: thread.latestTurn?.startedAt ?? thread.latestTurn?.requestedAt ?? null,
+ pendingInteractions: derivePendingInteractions(thread.activities),
+ tasks: presentTasks(thread.activities, thread.latestTurn?.turnId ?? null),
+ toolCalls: presentToolCalls(thread.activities, {
+ latestTurn: thread.latestTurn,
+ session: thread.session,
+ }),
+ resolvedUserInputs: presentResolvedUserInputs(thread.activities),
+ messages: thread.messages.map((message) => ({
+ id: message.id,
+ role: message.role,
+ text: message.text,
+ streaming: message.streaming,
+ createdAt: message.createdAt,
+ attachments: (message.attachments ?? []).map((attachment) => ({
+ ...attachment,
+ previewUrl: this.#attachmentUrls.get(attachment.id) ?? null,
+ })),
+ })),
+ };
+ }
+
+ #loadAttachmentUrls(thread: OrchestrationThread | null): void {
+ for (const message of thread?.messages ?? []) {
+ for (const attachment of message.attachments ?? []) {
+ if (this.#attachmentUrls.has(attachment.id) || this.#attachmentLoads.has(attachment.id)) {
+ continue;
+ }
+ this.#attachmentLoads.add(attachment.id);
+ void this.client
+ .createAttachmentUrl(attachment.id)
+ .then((url) => vscode.env.asExternalUri(vscode.Uri.parse(url)))
+ .then((uri) => this.#attachmentUrls.set(attachment.id, uri.toString(true)))
+ .catch(() => undefined)
+ .finally(() => {
+ this.#attachmentLoads.delete(attachment.id);
+ this.#publish();
+ });
+ }
+ }
+ }
+
+ async #openLink(rawHref: string): Promise {
+ const href = rawHref.trim();
+ if (href === "" || href.startsWith("#")) return;
+ if (/^https?:\/\//iu.test(href)) {
+ await vscode.env.openExternal(vscode.Uri.parse(href));
+ return;
+ }
+ if (/^[a-z][a-z0-9+.-]*:/iu.test(href) && !href.startsWith("file:")) return;
+ const [pathPart = "", fragment = ""] = href.replace(/^file:\/\//iu, "").split("#", 2);
+ const worktree = this.actions.worktreePath();
+ const uri = pathPart.startsWith("/")
+ ? vscode.Uri.file(pathPart)
+ : vscode.Uri.joinPath(vscode.Uri.file(worktree), pathPart);
+ // Defense-in-depth: only allow files inside the current worktree (consistent with editor context).
+ const normalizedWorktree = worktree.replace(/\\/gu, "/");
+ const normalizedPath = uri.fsPath.replace(/\\/gu, "/");
+ if (
+ !normalizedPath.startsWith(normalizedWorktree + "/") &&
+ normalizedPath !== normalizedWorktree
+ ) {
+ void vscode.window.showWarningMessage("Cannot open file outside the current worktree.");
+ return;
+ }
+ const document = await vscode.workspace.openTextDocument(uri);
+ const lineMatch = /^(?:L)?(\d+)/u.exec(fragment);
+ const line = Math.max(0, Number(lineMatch?.[1] ?? 1) - 1);
+ await vscode.window.showTextDocument(document, {
+ selection: new vscode.Range(line, 0, line, 0),
+ preview: true,
+ });
+ }
+
+ #html(webview: vscode.Webview): string {
+ const scriptNonce = nonce();
+ const scriptUri = webview.asWebviewUri(
+ vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js"),
+ );
+ return `
+
+
+
+
+
+
+
+
+
+
+
Connecting to T3 Code…
+
+
+
+
+
+
+
+
+
+
+
+`;
+ }
+}
diff --git a/apps/vscode/src/desktopFavorites.ts b/apps/vscode/src/desktopFavorites.ts
new file mode 100644
index 00000000000..8d0d676d5c1
--- /dev/null
+++ b/apps/vscode/src/desktopFavorites.ts
@@ -0,0 +1,196 @@
+// @effect-diagnostics nodeBuiltinImport:off - VS Code extension host persistence uses Node's filesystem directly.
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import * as vscode from "vscode";
+
+interface FavoriteEntry {
+ readonly provider: string;
+ readonly model: string;
+}
+
+interface DesktopServerRuntime {
+ readonly origin?: unknown;
+}
+
+export async function readDesktopServerUrl(): Promise {
+ for (const candidate of [
+ NodePath.join(NodeOS.homedir(), ".t3", "userdata", "server-runtime.json"),
+ NodePath.join(NodeOS.homedir(), ".t3", "dev", "server-runtime.json"),
+ ]) {
+ try {
+ const runtime = JSON.parse(
+ await NodeFS.promises.readFile(candidate, "utf8"),
+ ) as DesktopServerRuntime;
+ if (typeof runtime.origin === "string" && runtime.origin.trim() !== "") {
+ return new URL(runtime.origin).toString();
+ }
+ } catch (cause) {
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") continue;
+ }
+ }
+ return null;
+}
+
+export async function readDesktopBootstrapCredential(): Promise {
+ for (const candidate of [
+ NodePath.join(NodeOS.homedir(), ".t3", "userdata", "local-bootstrap-credential"),
+ NodePath.join(NodeOS.homedir(), ".t3", "dev", "local-bootstrap-credential"),
+ ]) {
+ try {
+ const credential = (await NodeFS.promises.readFile(candidate, "utf8")).trim();
+ if (credential !== "") return credential;
+ } catch (cause) {
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") continue;
+ }
+ }
+ return null;
+}
+
+function isFavoriteEntry(value: unknown): value is FavoriteEntry {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "provider" in value &&
+ typeof value.provider === "string" &&
+ "model" in value &&
+ typeof value.model === "string"
+ );
+}
+
+function parseDocument(text: string): Record {
+ const value: unknown = JSON.parse(text);
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new Error("T3 desktop client settings are not a JSON object.");
+ }
+ return value as Record;
+}
+
+export class DesktopFavoritesStore implements vscode.Disposable {
+ readonly #changed = new vscode.EventEmitter();
+ #watcher: vscode.FileSystemWatcher | null = null;
+ #settingsPath = "";
+ #favorites: ReadonlyArray = [];
+ #providerFavorites: ReadonlyArray = [];
+
+ readonly onDidChange = this.#changed.event;
+
+ get providerIds(): ReadonlyArray {
+ return this.#providerFavorites;
+ }
+
+ get modelKeys(): ReadonlyArray {
+ return this.#favorites.map((favorite) => `${favorite.provider}:${favorite.model}`);
+ }
+
+ async initialize(): Promise {
+ this.#settingsPath = await this.#resolveSettingsPath();
+ await this.#reload();
+ this.#watcher?.dispose();
+ this.#watcher = vscode.workspace.createFileSystemWatcher(
+ new vscode.RelativePattern(
+ NodePath.dirname(this.#settingsPath),
+ NodePath.basename(this.#settingsPath),
+ ),
+ );
+ this.#watcher.onDidChange(() => void this.#reload());
+ this.#watcher.onDidCreate(() => void this.#reload());
+ this.#watcher.onDidDelete(() => {
+ this.#favorites = [];
+ this.#providerFavorites = [];
+ this.#changed.fire();
+ });
+ }
+
+ async toggleProvider(instanceId: string): Promise {
+ await this.#update((document) => {
+ const favorites = Array.isArray(document.providerFavorites)
+ ? document.providerFavorites.filter((value): value is string => typeof value === "string")
+ : [];
+ const index = favorites.indexOf(instanceId);
+ if (index >= 0) favorites.splice(index, 1);
+ else favorites.push(instanceId);
+ document.providerFavorites = favorites;
+ });
+ }
+
+ async toggleModel(modelKey: string): Promise {
+ const separator = modelKey.indexOf(":");
+ if (separator <= 0 || separator === modelKey.length - 1) {
+ throw new Error("Invalid model favorite.");
+ }
+ await this.#toggle(modelKey.slice(0, separator), modelKey.slice(separator + 1));
+ }
+
+ dispose(): void {
+ this.#watcher?.dispose();
+ this.#changed.dispose();
+ }
+
+ async #resolveSettingsPath(): Promise {
+ const configured = vscode.workspace
+ .getConfiguration("t3Code")
+ .get("desktopClientSettingsPath", "")
+ .trim();
+ if (configured !== "") return configured.replace(/^~/u, NodeOS.homedir());
+ const candidates = [
+ NodePath.join(NodeOS.homedir(), ".t3", "userdata", "client-settings.json"),
+ NodePath.join(NodeOS.homedir(), ".t3", "dev", "client-settings.json"),
+ ];
+ for (const candidate of candidates) {
+ try {
+ await NodeFS.promises.access(candidate);
+ return candidate;
+ } catch {
+ // Try the next desktop channel.
+ }
+ }
+ return candidates[0]!;
+ }
+
+ async #reload(): Promise {
+ try {
+ const document = parseDocument(await NodeFS.promises.readFile(this.#settingsPath, "utf8"));
+ this.#favorites = Array.isArray(document.favorites)
+ ? document.favorites.filter(isFavoriteEntry)
+ : [];
+ this.#providerFavorites = Array.isArray(document.providerFavorites)
+ ? document.providerFavorites.filter((value): value is string => typeof value === "string")
+ : [];
+ } catch (cause) {
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
+ this.#favorites = [];
+ this.#providerFavorites = [];
+ }
+ this.#changed.fire();
+ }
+
+ async #toggle(provider: string, model: string): Promise {
+ await this.#update((document) => {
+ const favorites = Array.isArray(document.favorites)
+ ? document.favorites.filter(isFavoriteEntry)
+ : [];
+ const index = favorites.findIndex(
+ (favorite) => favorite.provider === provider && favorite.model === model,
+ );
+ if (index >= 0) favorites.splice(index, 1);
+ else favorites.push({ provider, model });
+ document.favorites = favorites;
+ });
+ }
+
+ async #update(mutate: (document: Record) => void): Promise {
+ let document: Record = {};
+ try {
+ document = parseDocument(await NodeFS.promises.readFile(this.#settingsPath, "utf8"));
+ } catch (cause) {
+ if ((cause as NodeJS.ErrnoException).code !== "ENOENT") throw cause;
+ }
+ mutate(document);
+ await NodeFS.promises.mkdir(NodePath.dirname(this.#settingsPath), { recursive: true });
+ const temporaryPath = `${this.#settingsPath}.${process.pid}.tmp`;
+ await NodeFS.promises.writeFile(temporaryPath, `${JSON.stringify(document)}\n`, "utf8");
+ await NodeFS.promises.rename(temporaryPath, this.#settingsPath);
+ await this.#reload();
+ }
+}
diff --git a/apps/vscode/src/editorContext.test.ts b/apps/vscode/src/editorContext.test.ts
new file mode 100644
index 00000000000..608590d4dda
--- /dev/null
+++ b/apps/vscode/src/editorContext.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { composePrompt, renderTextContext, splitEditorContext } from "./editorContext.ts";
+
+describe("editor context", () => {
+ it("describes precise selections", () => {
+ expect(
+ renderTextContext({
+ relativePath: "src/main.ts",
+ languageId: "typescript",
+ text: "const answer = 42;",
+ startLine: 8,
+ endLine: 8,
+ kind: "selection",
+ }),
+ ).toContain("src/main.ts (line 8)\n```typescript\nconst answer = 42;\n```");
+ });
+
+ it("keeps context separate from the authored prompt", () => {
+ const rendered = composePrompt("Explain this", [
+ {
+ relativePath: "readme.md",
+ languageId: "markdown",
+ text: "hello",
+ startLine: 1,
+ endLine: 1,
+ cursorColumn: 3,
+ kind: "cursor-line",
+ },
+ ]);
+ expect(rendered).toMatch(/^Explain this\n\n/);
+ expect(rendered).toContain("line 1, column 3");
+ expect(splitEditorContext(rendered)).toEqual({
+ text: "Explain this",
+ references: [{ path: "readme.md", detail: "line 1, column 3" }],
+ });
+ });
+});
diff --git a/apps/vscode/src/editorContext.ts b/apps/vscode/src/editorContext.ts
new file mode 100644
index 00000000000..e7254520e4e
--- /dev/null
+++ b/apps/vscode/src/editorContext.ts
@@ -0,0 +1,55 @@
+export interface TextContext {
+ readonly relativePath: string;
+ readonly languageId: string;
+ readonly text: string;
+ readonly startLine: number;
+ readonly endLine: number;
+ readonly cursorColumn?: number;
+ readonly kind: "selection" | "cursor-line" | "reference";
+}
+
+export interface ParsedEditorContext {
+ readonly text: string;
+ readonly references: ReadonlyArray<{ readonly path: string; readonly detail: string }>;
+}
+
+const MAX_CONTEXT_CHARS = 48_000;
+
+function fenceFor(text: string): string {
+ let fence = "```";
+ while (text.includes(fence)) fence += "`";
+ return fence;
+}
+
+export function renderTextContext(context: TextContext): string {
+ const clipped =
+ context.text.length > MAX_CONTEXT_CHARS
+ ? `${context.text.slice(0, MAX_CONTEXT_CHARS)}\n… (context truncated)`
+ : context.text;
+ const range =
+ context.startLine === context.endLine
+ ? `line ${context.startLine}`
+ : `lines ${context.startLine}-${context.endLine}`;
+ const cursor = context.cursorColumn === undefined ? "" : `, column ${context.cursorColumn}`;
+ const fence = fenceFor(clipped);
+ return `### ${context.relativePath} (${range}${cursor})\n${fence}${context.languageId}\n${clipped}\n${fence}`;
+}
+
+export function composePrompt(prompt: string, contexts: ReadonlyArray): string {
+ if (contexts.length === 0) return prompt;
+ return `${prompt}\n\n\n${contexts.map(renderTextContext).join("\n\n")}\n`;
+}
+
+export function splitEditorContext(prompt: string): ParsedEditorContext {
+ const references: Array<{ path: string; detail: string }> = [];
+ const text = prompt.replace(
+ /\n*\s*([\s\S]*?)\s*<\/editor_context>\s*/gu,
+ (_match, body: string) => {
+ for (const heading of body.matchAll(/^###\s+(.+?)\s+\(([^\n)]+)\)\s*$/gmu)) {
+ references.push({ path: heading[1]!, detail: heading[2]! });
+ }
+ return "";
+ },
+ );
+ return { text: text.trim(), references };
+}
diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts
new file mode 100644
index 00000000000..ba741a0b20c
--- /dev/null
+++ b/apps/vscode/src/extension.ts
@@ -0,0 +1,462 @@
+// @effect-diagnostics globalDate:off
+import type {
+ ModelSelection,
+ OrchestrationMessage,
+ OrchestrationThread,
+ OrchestrationThreadShell,
+ RuntimeMode,
+ ThreadId,
+} from "@t3tools/contracts";
+import * as vscode from "vscode";
+
+import { composePrompt, type TextContext } from "./editorContext.ts";
+import {
+ DesktopFavoritesStore,
+ readDesktopBootstrapCredential,
+ readDesktopServerUrl,
+} from "./desktopFavorites.ts";
+import { serverCandidates } from "./serverResolution.ts";
+import { T3ChatViewProvider } from "./chatViewProvider.ts";
+import { T3Client } from "./t3Client.ts";
+
+const ACTIVE_THREAD_KEY_PREFIX = "t3Code.activeThread";
+const BEARER_TOKEN_SECRET = "t3Code.serverBearerToken";
+
+function workspaceFolder(): vscode.WorkspaceFolder | undefined {
+ const activeUri = vscode.window.activeTextEditor?.document.uri;
+ return (
+ (activeUri === undefined ? undefined : vscode.workspace.getWorkspaceFolder(activeUri)) ??
+ vscode.workspace.workspaceFolders?.[0]
+ );
+}
+
+function worktreePath(): string {
+ const folder = workspaceFolder();
+ if (folder === undefined) throw new Error("Open a workspace folder before using T3 Code.");
+ return folder.uri.fsPath;
+}
+
+function activeThreadStorageKey(): string {
+ return `${ACTIVE_THREAD_KEY_PREFIX}:${workspaceFolder()?.uri.toString() ?? "none"}`;
+}
+
+function relativePath(uri: vscode.Uri): string {
+ const folder = vscode.workspace.getWorkspaceFolder(uri);
+ return folder === undefined ? uri.fsPath : vscode.workspace.asRelativePath(uri, false);
+}
+
+function languageFor(uri: vscode.Uri, fallback = "text"): string {
+ return (
+ vscode.workspace.textDocuments.find((document) => document.uri.toString() === uri.toString())
+ ?.languageId ?? fallback
+ );
+}
+
+function activeEditorContext(): TextContext | null {
+ const editor = vscode.window.activeTextEditor;
+ if (editor === undefined || editor.document.uri.scheme !== "file") return null;
+ const selection = editor.selection;
+ if (!selection.isEmpty) {
+ return {
+ relativePath: relativePath(editor.document.uri),
+ languageId: editor.document.languageId,
+ text: editor.document.getText(selection),
+ startLine: selection.start.line + 1,
+ endLine: selection.end.line + 1,
+ kind: "selection",
+ };
+ }
+ const line = editor.document.lineAt(selection.active.line);
+ return {
+ relativePath: relativePath(editor.document.uri),
+ languageId: editor.document.languageId,
+ text: line.text,
+ startLine: line.lineNumber + 1,
+ endLine: line.lineNumber + 1,
+ cursorColumn: selection.active.character + 1,
+ kind: "cursor-line",
+ };
+}
+
+async function referenceContexts(
+ references: ReadonlyArray,
+): Promise> {
+ const contexts: TextContext[] = [];
+ for (const reference of references.toReversed()) {
+ const value = reference.value;
+ if (value instanceof vscode.Location) {
+ const document = await vscode.workspace.openTextDocument(value.uri);
+ contexts.push({
+ relativePath: relativePath(value.uri),
+ languageId: document.languageId,
+ text: document.getText(value.range),
+ startLine: value.range.start.line + 1,
+ endLine: value.range.end.line + 1,
+ kind: "reference",
+ });
+ } else if (value instanceof vscode.Uri && value.scheme === "file") {
+ const document = await vscode.workspace.openTextDocument(value);
+ contexts.push({
+ relativePath: relativePath(value),
+ languageId: languageFor(value, document.languageId),
+ text: document.getText(),
+ startLine: 1,
+ endLine: document.lineCount,
+ kind: "reference",
+ });
+ } else if (typeof value === "string" && value.trim() !== "") {
+ contexts.push({
+ relativePath: reference.modelDescription ?? reference.id,
+ languageId: "text",
+ text: value,
+ startLine: 1,
+ endLine: 1,
+ kind: "reference",
+ });
+ }
+ }
+ return contexts;
+}
+
+function configuration(): {
+ readonly serverUrl: string;
+ readonly runtimeMode: RuntimeMode;
+} {
+ const config = vscode.workspace.getConfiguration("t3Code");
+ return {
+ serverUrl: config.get("serverUrl", "http://127.0.0.1:3773"),
+ runtimeMode: config.get("defaultRuntimeMode", "full-access"),
+ };
+}
+
+function threadLabel(
+ thread: OrchestrationThreadShell,
+): vscode.QuickPickItem & { threadId: ThreadId } {
+ const state = thread.latestTurn?.state ?? thread.session?.status ?? "idle";
+ return {
+ label: thread.title,
+ description: state,
+ detail: `${thread.modelSelection.model} · ${new Date(thread.updatedAt).toLocaleString()}`,
+ threadId: thread.id,
+ };
+}
+
+function renderHistory(thread: OrchestrationThread): string {
+ if (thread.messages.length === 0) return "_No messages yet._";
+ return thread.messages
+ .map(
+ (message) =>
+ `**${message.role === "assistant" ? "T3 Code" : message.role}**\n\n${message.text}`,
+ )
+ .join("\n\n---\n\n");
+}
+
+function newestAssistantMessage(thread: OrchestrationThread): OrchestrationMessage | null {
+ return thread.messages.findLast((message) => message.role === "assistant") ?? null;
+}
+
+export function activate(context: vscode.ExtensionContext): void {
+ const output = vscode.window.createOutputChannel(
+ vscode.env.remoteName === undefined ? "T3 Code" : "T3 Code (Remote)",
+ );
+ context.subscriptions.push(output);
+ const log = (message: string): void => {
+ output.appendLine(`${new Date().toISOString()} ${message}`);
+ };
+ log(
+ `activate version=${String(context.extension.packageJSON.version)} remote=${vscode.env.remoteName ?? "local"} workspace=${workspaceFolder()?.uri.fsPath ?? "none"}`,
+ );
+ const client = new T3Client(log);
+ const desktopFavorites = new DesktopFavoritesStore();
+ context.subscriptions.push(desktopFavorites);
+ void desktopFavorites.initialize().catch((error: unknown) => {
+ void vscode.window.showWarningMessage(
+ `T3 Code could not load desktop favorites: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ });
+ const [major = 1, minor = 0] = vscode.version.split(".").map(Number);
+ const supportsSecondarySidebar = major > 1 || (major === 1 && minor >= 106);
+ void vscode.commands.executeCommand(
+ "setContext",
+ "t3Code.useActivityBar",
+ !supportsSecondarySidebar,
+ );
+
+ const ensureConnected = async (): Promise => {
+ const config = configuration();
+ const bearerToken = await context.secrets.get(BEARER_TOKEN_SECRET);
+ const bootstrapCredential = await readDesktopBootstrapCredential();
+ const connect = async (serverUrl: string): Promise => {
+ if (bearerToken !== undefined && bearerToken !== "") {
+ try {
+ await client.connect(serverUrl, bearerToken);
+ return;
+ } catch (bearerCause) {
+ if (bootstrapCredential === null) throw bearerCause;
+ }
+ }
+ if (bootstrapCredential !== null) {
+ await client.connectWithBootstrap(serverUrl, bootstrapCredential);
+ } else {
+ await client.connect(serverUrl);
+ }
+ };
+ const desktopServerUrl = await readDesktopServerUrl();
+ const candidates = serverCandidates(desktopServerUrl, config.serverUrl);
+ let lastCause: unknown = new Error("No T3 Code server endpoint is available.");
+ for (const candidate of candidates) {
+ try {
+ log(`connection candidate source=${candidate.source} endpoint=${candidate.url}`);
+ await connect(candidate.url);
+ await client.waitForShell();
+ return;
+ } catch (cause) {
+ lastCause = cause;
+ log(
+ `connection candidate failed source=${candidate.source} endpoint=${candidate.url} error=${cause instanceof Error ? cause.message : String(cause)}`,
+ );
+ }
+ }
+ throw lastCause;
+ };
+
+ const rememberThread = async (threadId: ThreadId): Promise => {
+ await context.workspaceState.update(activeThreadStorageKey(), threadId);
+ };
+
+ const selectThread = async (): Promise => {
+ await ensureConnected();
+ const threads = client.threadsForWorktree(worktreePath());
+ if (threads.length === 0) {
+ void vscode.window.showInformationMessage("No T3 Code threads exist for this worktree yet.");
+ return undefined;
+ }
+ const picked = await vscode.window.showQuickPick(threads.map(threadLabel), {
+ title: "T3 Code threads for this worktree",
+ placeHolder: "Choose a synchronized thread",
+ });
+ if (picked === undefined) return undefined;
+ await client.selectThread(picked.threadId);
+ await client.waitForActiveThread();
+ await rememberThread(picked.threadId);
+ return picked.threadId;
+ };
+
+ const createThread = async (
+ title = "New thread",
+ modelSelection?: ModelSelection,
+ ): Promise => {
+ await ensureConnected();
+ const threadId = await client.createThreadForWorktree({
+ worktreePath: worktreePath(),
+ title,
+ runtimeMode: configuration().runtimeMode,
+ ...(modelSelection === undefined ? {} : { modelSelection }),
+ });
+ await rememberThread(threadId);
+ return threadId;
+ };
+
+ const restoreThread = async (): Promise => {
+ await ensureConnected();
+ const stored = context.workspaceState.get(activeThreadStorageKey());
+ const threads = client.threadsForWorktree(worktreePath());
+ const thread = threads.find((candidate) => candidate.id === stored) ?? threads[0];
+ if (thread === undefined) return undefined;
+ await client.selectThread(thread.id);
+ await client.waitForActiveThread();
+ await rememberThread(thread.id);
+ return thread.id;
+ };
+
+ const editorContextStateKey = "t3Code.includeEditorContext";
+ const contextEnabled = () => context.globalState.get(editorContextStateKey, true);
+ const toggleContext = async (): Promise => {
+ const next = !contextEnabled();
+ await context.globalState.update(editorContextStateKey, next);
+ return next;
+ };
+
+ const selectThreadById = async (threadId: string): Promise => {
+ await ensureConnected();
+ const thread = client.shell?.threads.find((candidate) => candidate.id === threadId);
+ if (thread === undefined) throw new Error("The selected T3 Code thread no longer exists.");
+ await client.selectThread(thread.id);
+ await client.waitForActiveThread();
+ await rememberThread(thread.id);
+ };
+
+ const chatView = new T3ChatViewProvider(
+ client,
+ {
+ worktreePath,
+ ensureConnected,
+ restoreThread,
+ createThread,
+ selectThread: selectThreadById,
+ toggleContext,
+ contextEnabled,
+ runtimeMode: () => configuration().runtimeMode,
+ editorContext: activeEditorContext,
+ favoriteProviderIds: () => desktopFavorites.providerIds,
+ favoriteModelKeys: () => desktopFavorites.modelKeys,
+ toggleProviderFavorite: (instanceId) => desktopFavorites.toggleProvider(instanceId),
+ toggleModelFavorite: (modelKey) => desktopFavorites.toggleModel(modelKey),
+ onFavoritesChanged: desktopFavorites.onDidChange,
+ },
+ context.extensionUri,
+ );
+
+ const handler: vscode.ChatRequestHandler = async (request, _chatContext, response, token) => {
+ try {
+ if (request.command === "context") {
+ const enabled = await toggleContext();
+ response.markdown(
+ `Automatic active-editor context is now **${enabled ? "on" : "off"}**. Explicit \`#file\` and selection references are always included.`,
+ );
+ return;
+ }
+ if (request.command === "threads") {
+ const selected = await selectThread();
+ if (selected !== undefined)
+ response.markdown(`Selected synchronized thread \`${selected}\`.`);
+ return;
+ }
+ if (request.command === "new") {
+ const title = request.prompt.trim() || "New thread";
+ const threadId = await createThread(title.slice(0, 80));
+ response.markdown(
+ `Created synchronized thread **${title.slice(0, 80)}** (\`${threadId}\`).`,
+ );
+ return;
+ }
+ const threadId =
+ (await restoreThread()) ??
+ (await createThread(request.prompt.trim().slice(0, 80) || "New thread"));
+ if (request.command === "history") {
+ response.markdown(renderHistory(await client.waitForActiveThread()));
+ return;
+ }
+ if (request.command === "status") {
+ const thread = await client.waitForActiveThread();
+ response.markdown(
+ `Connected to **${client.serverConfig?.environment.label ?? "T3 Code"}**.\n\nThread: **${thread.title}** \nModel: \`${thread.modelSelection.model}\` \nRuntime: \`${thread.runtimeMode}\` \nState: \`${thread.latestTurn?.state ?? thread.session?.status ?? "idle"}\``,
+ );
+ return;
+ }
+ if (request.command === "stop") {
+ await client.interrupt();
+ response.markdown("Interrupt requested.");
+ return;
+ }
+
+ const explicit = await referenceContexts(request.references);
+ const automatic = contextEnabled() ? activeEditorContext() : null;
+ const contexts = automatic === null ? explicit : [automatic, ...explicit];
+ const prompt = composePrompt(request.prompt, contexts);
+ for (const reference of request.references) {
+ if (reference.value instanceof vscode.Uri) response.reference(reference.value);
+ else if (reference.value instanceof vscode.Location) response.reference(reference.value);
+ }
+
+ const initial = client.activeThread;
+ const initialAssistant = initial === null ? null : newestAssistantMessage(initial);
+ const initialTurnId = initial?.latestTurn?.turnId ?? null;
+ let emitted = "";
+ let turnObserved = false;
+ let finished = false;
+ let disposeThreadChange = (): void => {};
+ response.progress(`Sending to ${initial?.modelSelection.model ?? "T3 Code"}…`);
+
+ const completion = new Promise((resolve, reject) => {
+ const disposable = client.onThreadChanged((thread) => {
+ if (thread === null || finished) return;
+ const turn = thread.latestTurn;
+ if (turn !== null && (turn.state === "running" || turn.turnId !== initialTurnId)) {
+ turnObserved = true;
+ }
+ const assistant = newestAssistantMessage(thread);
+ if (assistant !== null && assistant.id !== initialAssistant?.id) {
+ const next = assistant.text;
+ if (next.startsWith(emitted)) response.markdown(next.slice(emitted.length));
+ else if (next !== emitted) response.markdown(`\n\n${next}`);
+ emitted = next;
+ }
+ if (turnObserved && turn !== null && turn.state !== "running") {
+ finished = true;
+ disposable.dispose();
+ if (turn.state === "error")
+ reject(new Error(thread.session?.lastError ?? "The T3 Code turn failed."));
+ else resolve();
+ }
+ });
+ token.onCancellationRequested(() => {
+ if (finished) return;
+ finished = true;
+ disposable.dispose();
+ void client.interrupt().finally(resolve);
+ });
+ disposeThreadChange = () => disposable.dispose();
+ });
+ try {
+ await client.sendPrompt({ threadId, prompt, runtimeMode: configuration().runtimeMode });
+ await completion;
+ } finally {
+ disposeThreadChange();
+ }
+ if (emitted === "") response.markdown("_Turn completed without an assistant message._");
+ return { metadata: { threadId } };
+ } catch (cause) {
+ const message = cause instanceof Error ? cause.message : String(cause);
+ response.markdown(`$(error) ${message}`);
+ return { errorDetails: { message } };
+ }
+ };
+
+ const participant = vscode.chat.createChatParticipant("t3-code.chat", handler);
+ participant.iconPath = new vscode.ThemeIcon("comment-discussion");
+ context.subscriptions.push(
+ vscode.commands.registerCommand("t3Code.showDiagnostics", () => output.show(true)),
+ chatView,
+ vscode.window.registerWebviewViewProvider(T3ChatViewProvider.primaryViewType, chatView, {
+ webviewOptions: { retainContextWhenHidden: true },
+ }),
+ vscode.window.registerWebviewViewProvider(T3ChatViewProvider.secondaryViewType, chatView, {
+ webviewOptions: { retainContextWhenHidden: true },
+ }),
+ participant,
+ vscode.commands.registerCommand("t3Code.newThread", async () => {
+ const title = await vscode.window.showInputBox({
+ prompt: "Thread title",
+ value: "New thread",
+ });
+ if (title !== undefined) await createThread(title.trim() || "New thread");
+ }),
+ vscode.commands.registerCommand("t3Code.selectThread", selectThread),
+ vscode.commands.registerCommand("t3Code.toggleEditorContext", toggleContext),
+ vscode.commands.registerCommand("t3Code.askSelection", async () => {
+ await chatView.reveal();
+ }),
+ vscode.commands.registerCommand("t3Code.openChat", () => chatView.reveal()),
+ vscode.commands.registerCommand("t3Code.setBearerToken", async () => {
+ const token = await vscode.window.showInputBox({
+ prompt: "T3 Code server bearer token",
+ password: true,
+ ignoreFocusOut: true,
+ });
+ if (token !== undefined && token.trim() !== "") {
+ await context.secrets.store(BEARER_TOKEN_SECRET, token.trim());
+ void vscode.window.showInformationMessage(
+ "T3 Code bearer token stored in VS Code secret storage.",
+ );
+ }
+ }),
+ vscode.commands.registerCommand("t3Code.clearBearerToken", async () => {
+ await context.secrets.delete(BEARER_TOKEN_SECRET);
+ void vscode.window.showInformationMessage("T3 Code bearer token cleared.");
+ }),
+ { dispose: () => void client.dispose() },
+ );
+}
+
+export function deactivate(): void {}
diff --git a/apps/vscode/src/ids.ts b/apps/vscode/src/ids.ts
new file mode 100644
index 00000000000..e2e74dbb064
--- /dev/null
+++ b/apps/vscode/src/ids.ts
@@ -0,0 +1,14 @@
+import { CommandId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
+
+function randomUuid(): string {
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
+
+export const newCommandId = (): CommandId => CommandId.make(randomUuid());
+export const newMessageId = (): MessageId => MessageId.make(randomUuid());
+export const newProjectId = (): ProjectId => ProjectId.make(randomUuid());
+export const newThreadId = (): ThreadId => ThreadId.make(randomUuid());
diff --git a/apps/vscode/src/pendingInteractions.test.ts b/apps/vscode/src/pendingInteractions.test.ts
new file mode 100644
index 00000000000..f8326dcec33
--- /dev/null
+++ b/apps/vscode/src/pendingInteractions.test.ts
@@ -0,0 +1,79 @@
+import type { OrchestrationThreadActivity } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { derivePendingInteractions } from "./pendingInteractions.ts";
+
+const activity = (kind: string, payload: unknown, sequence: number): OrchestrationThreadActivity =>
+ ({
+ id: `activity-${sequence}`,
+ kind,
+ payload,
+ sequence,
+ tone: "approval",
+ summary: kind,
+ turnId: null,
+ createdAt: `2026-07-10T00:00:0${sequence}.000Z`,
+ }) as OrchestrationThreadActivity;
+
+describe("derivePendingInteractions", () => {
+ it("keeps unresolved approvals and removes resolved ones", () => {
+ expect(
+ derivePendingInteractions([
+ activity("approval.requested", { requestId: "one", requestKind: "command" }, 1),
+ activity("approval.requested", { requestId: "two", requestType: "file_read_approval" }, 2),
+ activity("approval.resolved", { requestId: "one" }, 3),
+ ]),
+ ).toEqual([
+ expect.objectContaining({ requestId: "two", requestKind: "file-read", kind: "approval" }),
+ ]);
+ });
+
+ it("preserves structured user-input questions", () => {
+ const [pending] = derivePendingInteractions([
+ activity(
+ "user-input.requested",
+ {
+ requestId: "question",
+ questions: [
+ {
+ id: "choice",
+ header: "Choice",
+ question: "Which one?",
+ options: [{ label: "First", description: "Use the first option." }],
+ multiSelect: false,
+ },
+ ],
+ },
+ 1,
+ ),
+ ]);
+ expect(pending).toMatchObject({ kind: "user-input", requestId: "question" });
+ });
+
+ it("removes requests that the provider reports as stale", () => {
+ expect(
+ derivePendingInteractions([
+ activity(
+ "user-input.requested",
+ {
+ requestId: "stale",
+ questions: [
+ {
+ id: "choice",
+ header: "Choice",
+ question: "Which?",
+ options: [{ label: "One", description: "First." }],
+ },
+ ],
+ },
+ 1,
+ ),
+ activity(
+ "provider.user-input.respond.failed",
+ { requestId: "stale", detail: "Stale pending user-input request: stale" },
+ 2,
+ ),
+ ]),
+ ).toEqual([]);
+ });
+});
diff --git a/apps/vscode/src/pendingInteractions.ts b/apps/vscode/src/pendingInteractions.ts
new file mode 100644
index 00000000000..75c2fc4965e
--- /dev/null
+++ b/apps/vscode/src/pendingInteractions.ts
@@ -0,0 +1,113 @@
+import type { OrchestrationThreadActivity, UserInputQuestion } from "@t3tools/contracts";
+
+export interface PendingApproval {
+ readonly kind: "approval";
+ readonly requestId: string;
+ readonly requestKind: "command" | "file-read" | "file-change";
+ readonly detail: string | null;
+ readonly createdAt: string;
+}
+
+export interface PendingUserInput {
+ readonly kind: "user-input";
+ readonly requestId: string;
+ readonly questions: ReadonlyArray;
+ readonly createdAt: string;
+}
+
+export type PendingInteraction = PendingApproval | PendingUserInput;
+
+function record(value: unknown): Record | null {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function requestKind(value: unknown): PendingApproval["requestKind"] | null {
+ if (value === "command" || value === "file-read" || value === "file-change") return value;
+ if (
+ value === "command_execution_approval" ||
+ value === "exec_command_approval" ||
+ value === "dynamic_tool_call"
+ )
+ return "command";
+ if (value === "file_read_approval") return "file-read";
+ if (value === "file_change_approval" || value === "apply_patch_approval") return "file-change";
+ return null;
+}
+
+function questions(value: unknown): ReadonlyArray | null {
+ if (!Array.isArray(value)) return null;
+ const parsed = value.filter((entry): entry is UserInputQuestion => {
+ const question = record(entry);
+ return (
+ typeof question?.id === "string" &&
+ typeof question.header === "string" &&
+ typeof question.question === "string" &&
+ Array.isArray(question.options) &&
+ question.options.every((option) => {
+ const value = record(option);
+ return typeof value?.label === "string" && typeof value.description === "string";
+ })
+ );
+ });
+ return parsed.length === value.length && parsed.length > 0 ? parsed : null;
+}
+
+function isStaleFailure(payload: Record): boolean {
+ const detail = typeof payload.detail === "string" ? payload.detail.toLowerCase() : "";
+ return (
+ detail.includes("stale pending approval request") ||
+ detail.includes("stale pending user-input request") ||
+ detail.includes("unknown pending approval request") ||
+ detail.includes("unknown pending user-input request")
+ );
+}
+
+export function derivePendingInteractions(
+ activities: ReadonlyArray,
+): ReadonlyArray {
+ const pending = new Map();
+ for (const activity of [...activities].toSorted((left, right) => {
+ if (left.sequence !== undefined && right.sequence !== undefined)
+ return left.sequence - right.sequence;
+ return left.createdAt.localeCompare(right.createdAt);
+ })) {
+ const payload = record(activity.payload);
+ if (payload === null || typeof payload.requestId !== "string") continue;
+ const requestId = payload.requestId;
+ if (activity.kind === "approval.requested") {
+ const kind = requestKind(payload.requestKind ?? payload.requestType);
+ if (kind !== null) {
+ pending.set(requestId, {
+ kind: "approval",
+ requestId,
+ requestKind: kind,
+ detail: typeof payload.detail === "string" ? payload.detail : null,
+ createdAt: activity.createdAt,
+ });
+ }
+ } else if (activity.kind === "user-input.requested") {
+ const parsed = questions(payload.questions);
+ if (parsed !== null) {
+ pending.set(requestId, {
+ kind: "user-input",
+ requestId,
+ questions: parsed,
+ createdAt: activity.createdAt,
+ });
+ }
+ } else if (activity.kind === "approval.resolved" || activity.kind === "user-input.resolved") {
+ pending.delete(requestId);
+ } else if (
+ (activity.kind === "provider.approval.respond.failed" ||
+ activity.kind === "provider.user-input.respond.failed") &&
+ isStaleFailure(payload)
+ ) {
+ pending.delete(requestId);
+ }
+ }
+ return [...pending.values()].toSorted((left, right) =>
+ left.createdAt.localeCompare(right.createdAt),
+ );
+}
diff --git a/apps/vscode/src/providerIcon.ts b/apps/vscode/src/providerIcon.ts
new file mode 100644
index 00000000000..99f527dc1fe
--- /dev/null
+++ b/apps/vscode/src/providerIcon.ts
@@ -0,0 +1,58 @@
+interface ProviderIconSpec {
+ readonly viewBox: string;
+ readonly color?: string;
+ readonly paths: ReadonlyArray;
+}
+
+const ICONS: Readonly> = {
+ codex: {
+ viewBox: "0 0 256 260",
+ paths: [
+ "M239.184 106.203a64.716 64.716 0 0 0-5.576-53.103C219.452 28.459 191 15.784 163.213 21.74A65.586 65.586 0 0 0 52.096 45.22a64.716 64.716 0 0 0-43.23 31.36c-14.31 24.602-11.061 55.634 8.033 76.74a64.665 64.665 0 0 0 5.525 53.102c14.174 24.65 42.644 37.324 70.446 31.36a64.72 64.72 0 0 0 48.754 21.744c28.481.025 53.714-18.361 62.414-45.481a64.767 64.767 0 0 0 43.229-31.36c14.137-24.558 10.875-55.423-8.083-76.483Zm-97.56 136.338a48.397 48.397 0 0 1-31.105-11.255l1.535-.87 51.67-29.825a8.595 8.595 0 0 0 4.247-7.367v-72.85l21.845 12.636c.218.111.37.32.409.563v60.367c-.056 26.818-21.783 48.545-48.601 48.601Zm-104.466-44.61a48.345 48.345 0 0 1-5.781-32.589l1.534.921 51.722 29.826a8.339 8.339 0 0 0 8.441 0l63.181-36.425v25.221a.87.87 0 0 1-.358.665l-52.335 30.184c-23.257 13.398-52.97 5.431-66.404-17.803ZM23.549 85.38a48.499 48.499 0 0 1 25.58-21.333v61.39a8.288 8.288 0 0 0 4.195 7.316l62.874 36.272-21.845 12.636a.819.819 0 0 1-.767 0L41.353 151.53c-23.211-13.454-31.171-43.144-17.804-66.405v.256Zm179.466 41.695-63.08-36.63L161.73 77.86a.819.819 0 0 1 .768 0l52.233 30.184a48.6 48.6 0 0 1-7.316 87.635v-61.391a8.544 8.544 0 0 0-4.4-7.213Zm21.742-32.69-1.535-.922-51.619-30.081a8.39 8.39 0 0 0-8.492 0L99.98 99.808V74.587a.716.716 0 0 1 .307-.665l52.233-30.133a48.652 48.652 0 0 1 72.236 50.391v.205ZM88.061 139.097l-21.845-12.585a.87.87 0 0 1-.41-.614V65.685a48.652 48.652 0 0 1 79.757-37.346l-1.535.87-51.67 29.825a8.595 8.595 0 0 0-4.246 7.367l-.051 72.697Zm11.868-25.58 28.138-16.217 28.188 16.218v32.434l-28.086 16.218-28.188-16.218-.052-32.434Z",
+ ],
+ },
+ claudeAgent: {
+ viewBox: "0 0 24 24",
+ color: "#d97757",
+ paths: [
+ "M12 1.5c.7 0 1.2.5 1.2 1.2v5l3.5-3.5a1.2 1.2 0 1 1 1.7 1.7l-3.5 3.5h5a1.2 1.2 0 1 1 0 2.4h-5l3.5 3.5a1.2 1.2 0 1 1-1.7 1.7l-3.5-3.5v5a1.2 1.2 0 1 1-2.4 0v-5L7.3 17a1.2 1.2 0 1 1-1.7-1.7l3.5-3.5h-5a1.2 1.2 0 1 1 0-2.4h5L5.6 5.9a1.2 1.2 0 1 1 1.7-1.7l3.5 3.5v-5c0-.7.5-1.2 1.2-1.2Z",
+ ],
+ },
+ cursor: {
+ viewBox: "0 0 466.73 532.09",
+ paths: [
+ "M457.43 125.94 244.42 2.96c-6.84-3.95-15.28-3.95-22.12 0L9.3 125.94C3.55 129.26 0 135.4 0 142.05v247.99c0 6.65 3.55 12.79 9.3 16.11l213.01 122.98c6.84 3.95 15.28 3.95 22.12 0l213.01-122.98c5.75-3.32 9.3-9.46 9.3-16.11V142.05c0-6.65-3.55-12.79-9.3-16.11ZM444.05 151.99 238.42 508.15c-1.39 2.4-5.06 1.42-5.06-1.36V273.58c0-4.66-2.49-8.97-6.53-11.31L24.87 145.67c-2.4-1.39-1.42-5.06 1.36-5.06h411.26c5.84 0 9.49 6.33 6.57 11.39Z",
+ ],
+ },
+ grok: {
+ viewBox: "0 0 24 24",
+ paths: [
+ "M9.269 15.284 17.248 9.361c.391-.291.95-.177 1.137.274.98 2.378.542 5.237-1.41 7.2-1.951 1.963-4.667 2.393-7.149 1.413l-2.711 1.262c3.889 2.673 8.612 2.012 11.563-.958 2.34-2.354 3.065-5.563 2.387-8.456-.983-4.245.242-5.944 2.757-9.668l-3.123 3.32L9.269 15.284Zm-1.647 1.44c-2.791-2.682-2.31-6.832.072-9.225 1.761-1.771 4.647-2.494 7.166-1.431l2.705-1.257c-.487-.354-1.112-.735-1.828-1.003-3.24-1.34-7.119-.673-9.753 1.973-2.533 2.548-3.33 6.465-1.962 9.808 1.384 3.383-2.2 5.816-4.022 7.983l7.622-6.848Z",
+ ],
+ },
+ opencode: {
+ viewBox: "0 0 32 40",
+ paths: ["M24 32H8V16h16v16Z", "M24 8H8v24h16V8Zm8 32H0V0h32v40Z"],
+ },
+};
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+
+export function renderProviderIcon(container: HTMLElement, driver: string, label: string): void {
+ container.replaceChildren();
+ const spec = ICONS[driver];
+ if (spec === undefined) {
+ container.textContent = label.slice(0, 2).toUpperCase();
+ return;
+ }
+ const svg = document.createElementNS(SVG_NS, "svg");
+ svg.setAttribute("viewBox", spec.viewBox);
+ svg.setAttribute("aria-hidden", "true");
+ svg.setAttribute("fill", spec.color ?? "currentColor");
+ for (const pathData of spec.paths) {
+ const path = document.createElementNS(SVG_NS, "path");
+ path.setAttribute("d", pathData);
+ svg.append(path);
+ }
+ container.append(svg);
+}
diff --git a/apps/vscode/src/serverResolution.test.ts b/apps/vscode/src/serverResolution.test.ts
new file mode 100644
index 00000000000..010aa425eaf
--- /dev/null
+++ b/apps/vscode/src/serverResolution.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { serverCandidates } from "./serverResolution.ts";
+
+describe("serverCandidates", () => {
+ it("prefers the backend advertised by the local desktop runtime", () => {
+ expect(serverCandidates("http://127.0.0.1:3773", "http://127.0.0.1:8080")).toEqual([
+ { source: "desktop", url: "http://127.0.0.1:3773/" },
+ { source: "configured", url: "http://127.0.0.1:8080/" },
+ ]);
+ });
+
+ it("deduplicates equivalent desktop and configured URLs", () => {
+ expect(serverCandidates("http://127.0.0.1:3773", "http://127.0.0.1:3773/")).toEqual([
+ { source: "desktop", url: "http://127.0.0.1:3773/" },
+ ]);
+ });
+
+ it("uses the configured URL when no desktop runtime is advertised", () => {
+ expect(serverCandidates(null, "http://remote.example:8080")).toEqual([
+ { source: "configured", url: "http://remote.example:8080/" },
+ ]);
+ });
+});
diff --git a/apps/vscode/src/serverResolution.ts b/apps/vscode/src/serverResolution.ts
new file mode 100644
index 00000000000..8ada338919e
--- /dev/null
+++ b/apps/vscode/src/serverResolution.ts
@@ -0,0 +1,28 @@
+export interface ServerCandidate {
+ readonly source: "desktop" | "configured";
+ readonly url: string;
+}
+
+function normalizeServerUrl(value: string | null): string | null {
+ if (value === null || value.trim() === "") return null;
+ return new URL(value).toString();
+}
+
+/**
+ * Prefer the backend advertised by the T3 process running beside this
+ * extension host. A configured localhost URL may be synced between machines
+ * or refer to a VS Code-forwarded port, so it is only a fallback.
+ */
+export function serverCandidates(
+ desktopServerUrl: string | null,
+ configuredServerUrl: string,
+): ReadonlyArray {
+ const desktop = normalizeServerUrl(desktopServerUrl);
+ const configured = normalizeServerUrl(configuredServerUrl);
+ const candidates: Array = [];
+ if (desktop !== null) candidates.push({ source: "desktop", url: desktop });
+ if (configured !== null && configured !== desktop) {
+ candidates.push({ source: "configured", url: configured });
+ }
+ return candidates;
+}
diff --git a/apps/vscode/src/t3Client.ts b/apps/vscode/src/t3Client.ts
new file mode 100644
index 00000000000..1b42d72b9d5
--- /dev/null
+++ b/apps/vscode/src/t3Client.ts
@@ -0,0 +1,670 @@
+// @effect-diagnostics globalDate:off globalFetch:off
+import {
+ DEFAULT_MODEL,
+ DEFAULT_RUNTIME_MODE,
+ EnvironmentId,
+ ORCHESTRATION_WS_METHODS,
+ PRIMARY_LOCAL_ENVIRONMENT_ID,
+ ProviderInstanceId,
+ type ClientOrchestrationCommand,
+ type ModelSelection,
+ type OrchestrationProjectShell,
+ OrchestrationShellSnapshot,
+ OrchestrationThreadDetailSnapshot,
+ type OrchestrationThread,
+ type OrchestrationThreadShell,
+ type RuntimeMode,
+ type ApprovalRequestId,
+ type ProviderApprovalDecision,
+ type ProviderUserInputAnswers,
+ type ServerConfig,
+ type ThreadId,
+ type UploadChatAttachment,
+ WS_METHODS,
+} from "@t3tools/contracts";
+import {
+ PrimaryConnectionTarget,
+ type PreparedConnection,
+} from "@t3tools/client-runtime/connection";
+import {
+ remoteHttpClientLayer,
+ type RpcSession,
+ makeWsRpcProtocolClient,
+} from "@t3tools/client-runtime/rpc";
+import {
+ bootstrapRemoteBearerSession,
+ resolveRemoteWebSocketConnectionUrl,
+} from "@t3tools/client-runtime/authorization";
+import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment";
+import { applyShellStreamEvent } from "@t3tools/client-runtime/state/shell";
+import { applyThreadDetailEvent } from "@t3tools/client-runtime/state/threads";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
+import * as Fiber from "effect/Fiber";
+import * as Layer from "effect/Layer";
+import * as ManagedRuntime from "effect/ManagedRuntime";
+import * as Scope from "effect/Scope";
+import * as Schema from "effect/Schema";
+import * as Stream from "effect/Stream";
+import * as Schedule from "effect/Schedule";
+import * as Socket from "effect/unstable/socket/Socket";
+import * as RpcClient from "effect/unstable/rpc/RpcClient";
+import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization";
+
+import { newCommandId, newMessageId, newProjectId, newThreadId } from "./ids.ts";
+
+type ThreadListener = (thread: OrchestrationThread | null) => void;
+type ShellListener = (shell: OrchestrationShellSnapshot) => void;
+type ConnectionListener = (connected: boolean) => void;
+
+function wsBaseUrl(httpBaseUrl: string): string {
+ const url = new URL(httpBaseUrl);
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
+ return url.toString();
+}
+
+function localSocketUrl(httpBaseUrl: string): string {
+ const url = new URL(wsBaseUrl(httpBaseUrl));
+ url.pathname = "/ws";
+ url.search = "";
+ url.hash = "";
+ return url.toString();
+}
+
+function normalizedPath(path: string): string {
+ return path.replaceAll("\\", "/").replace(/\/$/u, "").toLocaleLowerCase();
+}
+
+function messageFromCause(cause: unknown): string {
+ if (cause instanceof Error && cause.message.trim() !== "") return cause.message;
+ return String(cause);
+}
+
+export class T3Client {
+ readonly #log: (message: string) => void;
+ readonly #runtime = ManagedRuntime.make(
+ Layer.merge(
+ Layer.succeed(
+ Socket.WebSocketConstructor,
+ (url: string, protocols?: string | string[]) => new globalThis.WebSocket(url, protocols),
+ ),
+ remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)),
+ ),
+ );
+ #scope: Scope.Closeable | null = null;
+ #session: RpcSession | null = null;
+ #shellFiber: Fiber.Fiber | null = null;
+ #threadFiber: Fiber.Fiber | null = null;
+ #closedFiber: Fiber.Fiber | null = null;
+ #shell: OrchestrationShellSnapshot | null = null;
+ #activeThread: OrchestrationThread | null = null;
+ #activeThreadSequence: number | null = null;
+ #activeThreadId: ThreadId | null = null;
+ #serverConfig: ServerConfig | null = null;
+ #httpBaseUrl: string | null = null;
+ #bearerToken: string | null = null;
+ #connectionKey = "";
+ #listeners = new Set();
+ #shellListeners = new Set();
+ #connectionListeners = new Set();
+ #shellWaiters = new Set<(snapshot: OrchestrationShellSnapshot) => void>();
+ #threadWaiters = new Set<(thread: OrchestrationThread) => void>();
+
+ constructor(log: (message: string) => void = () => {}) {
+ this.#log = log;
+ }
+
+ get shell(): OrchestrationShellSnapshot | null {
+ return this.#shell;
+ }
+
+ get activeThread(): OrchestrationThread | null {
+ return this.#activeThread;
+ }
+
+ get serverConfig(): ServerConfig | null {
+ return this.#serverConfig;
+ }
+
+ onThreadChanged(listener: ThreadListener): { dispose(): void } {
+ this.#listeners.add(listener);
+ return { dispose: () => this.#listeners.delete(listener) };
+ }
+
+ onShellChanged(listener: ShellListener): { dispose(): void } {
+ this.#shellListeners.add(listener);
+ return { dispose: () => this.#shellListeners.delete(listener) };
+ }
+
+ onConnectionChanged(listener: ConnectionListener): { dispose(): void } {
+ this.#connectionListeners.add(listener);
+ return { dispose: () => this.#connectionListeners.delete(listener) };
+ }
+
+ async waitForShell(): Promise {
+ if (this.#shell !== null) return this.#shell;
+ return new Promise((resolve) => this.#shellWaiters.add(resolve));
+ }
+
+ async waitForActiveThread(): Promise {
+ if (this.#activeThread !== null) return this.#activeThread;
+ return new Promise((resolve) => this.#threadWaiters.add(resolve));
+ }
+
+ async connect(httpBaseUrl: string, bearerToken?: string): Promise {
+ const startedAt = Date.now();
+ const normalizedBaseUrl = new URL(httpBaseUrl).toString();
+ const key = `${normalizedBaseUrl}|${bearerToken ?? ""}`;
+ if (this.#session !== null && this.#connectionKey === key) return;
+ this.#log(`connect start endpoint=${normalizedBaseUrl}`);
+ await this.#closeConnection();
+
+ let environmentId = EnvironmentId.make(PRIMARY_LOCAL_ENVIRONMENT_ID);
+ let socketUrl = localSocketUrl(normalizedBaseUrl);
+ let label = "T3 Code";
+ if (bearerToken !== undefined && bearerToken !== "") {
+ const descriptor = await this.#runtime.runPromise(
+ fetchRemoteEnvironmentDescriptor({ httpBaseUrl: normalizedBaseUrl }),
+ );
+ socketUrl = await this.#runtime.runPromise(
+ resolveRemoteWebSocketConnectionUrl({
+ wsBaseUrl: wsBaseUrl(normalizedBaseUrl),
+ httpBaseUrl: normalizedBaseUrl,
+ bearerToken,
+ }),
+ );
+ label = descriptor.label;
+ environmentId = descriptor.environmentId;
+ }
+ const target = new PrimaryConnectionTarget({
+ environmentId,
+ label,
+ httpBaseUrl: normalizedBaseUrl,
+ wsBaseUrl: wsBaseUrl(normalizedBaseUrl),
+ });
+ const prepared: PreparedConnection = {
+ environmentId,
+ label,
+ httpBaseUrl: normalizedBaseUrl,
+ socketUrl,
+ httpAuthorization:
+ bearerToken === undefined || bearerToken === ""
+ ? null
+ : { _tag: "Bearer", token: bearerToken },
+ target,
+ };
+ const scope = await this.#runtime.runPromise(Scope.make());
+ try {
+ const session = (await this.#runtime.runPromise(
+ this.#buildRpcSession(prepared).pipe(Scope.provide(scope)) as any,
+ )) as RpcSession;
+ this.#scope = scope;
+ this.#session = session;
+ this.#connectionKey = key;
+ this.#serverConfig = await this.#runtime.runPromise((session as any).initialConfig);
+ this.#log(`rpc ready in ${Date.now() - startedAt}ms endpoint=${normalizedBaseUrl}`);
+ this.#httpBaseUrl = normalizedBaseUrl;
+ this.#bearerToken = bearerToken ?? null;
+ await this.#loadShellSnapshot(normalizedBaseUrl, bearerToken);
+ this.#emitConnection(true);
+ this.#startShellSubscription(session as any);
+ if (this.#activeThreadId !== null)
+ this.#startThreadSubscription(session as any, this.#activeThreadId);
+ this.#closedFiber = this.#runtime.runFork(
+ Effect.exit((session as any).closed).pipe(
+ Effect.tap(() =>
+ Effect.sync(() => {
+ if (this.#session === session) {
+ this.#session = null;
+ this.#serverConfig = null;
+ this.#connectionKey = "";
+ this.#emitConnection(false);
+ }
+ }),
+ ),
+ Effect.asVoid,
+ ) as any,
+ );
+ this.#log(`connect complete in ${Date.now() - startedAt}ms endpoint=${normalizedBaseUrl}`);
+ } catch (cause) {
+ await this.#runtime.runPromise(Scope.close(scope, Exit.void));
+ throw new Error(`Could not connect to T3 Code: ${messageFromCause(cause)}`, { cause });
+ }
+ }
+
+ async connectWithBootstrap(httpBaseUrl: string, credential: string): Promise {
+ const baseUrl = new URL(httpBaseUrl);
+ if (this.#session !== null && this.#httpBaseUrl === baseUrl.toString()) return;
+ const startedAt = Date.now();
+ this.#log(`bootstrap start endpoint=${baseUrl}`);
+ const session = await this.#runtime.runPromise(
+ bootstrapRemoteBearerSession({
+ httpBaseUrl,
+ credential,
+ clientMetadata: { label: "T3 Code for VS Code", deviceType: "desktop" },
+ }),
+ );
+ this.#log(`bootstrap complete in ${Date.now() - startedAt}ms endpoint=${baseUrl}`);
+ await this.connect(httpBaseUrl, session.access_token);
+ }
+
+ projectsForWorktree(worktreePath: string): ReadonlyArray {
+ const shell = this.#shell;
+ if (shell === null) return [];
+ const target = normalizedPath(worktreePath);
+ const projectIds = new Set(
+ shell.threads
+ .filter((thread) => {
+ const project = shell.projects.find((candidate) => candidate.id === thread.projectId);
+ return normalizedPath(thread.worktreePath ?? project?.workspaceRoot ?? "") === target;
+ })
+ .map((thread) => thread.projectId),
+ );
+ return shell.projects.filter(
+ (project) => normalizedPath(project.workspaceRoot) === target || projectIds.has(project.id),
+ );
+ }
+
+ threadsForWorktree(worktreePath: string): ReadonlyArray {
+ const shell = this.#shell;
+ if (shell === null) return [];
+ const target = normalizedPath(worktreePath);
+ return shell.threads
+ .filter((thread) => {
+ const project = shell.projects.find((candidate) => candidate.id === thread.projectId);
+ return normalizedPath(thread.worktreePath ?? project?.workspaceRoot ?? "") === target;
+ })
+ .toSorted((left, right) => right.updatedAt.localeCompare(left.updatedAt));
+ }
+
+ async selectThread(threadId: ThreadId): Promise {
+ const session = this.#requireSession();
+ this.#activeThreadId = threadId;
+ this.#activeThread = null;
+ this.#activeThreadSequence = null;
+ this.#emitThread();
+ await this.#stopThreadSubscription();
+ await this.#loadThreadSnapshot(threadId);
+ this.#startThreadSubscription(session, threadId);
+ }
+
+ async createThreadForWorktree(input: {
+ readonly worktreePath: string;
+ readonly title: string;
+ readonly runtimeMode?: RuntimeMode;
+ readonly modelSelection?: ModelSelection;
+ }): Promise {
+ const session = this.#requireSession();
+ const createdAt = new Date().toISOString();
+ let project = this.projectsForWorktree(input.worktreePath)[0];
+ if (project === undefined) {
+ const projectId = newProjectId();
+ const modelSelection = input.modelSelection ?? this.#defaultModelSelection();
+ await this.#dispatch({
+ type: "project.create",
+ commandId: newCommandId(),
+ projectId,
+ title: input.worktreePath.split(/[\\/]/u).at(-1) ?? "Workspace",
+ workspaceRoot: input.worktreePath,
+ defaultModelSelection: modelSelection,
+ createdAt,
+ });
+ project = {
+ id: projectId,
+ title: input.worktreePath.split(/[\\/]/u).at(-1) ?? "Workspace",
+ workspaceRoot: input.worktreePath,
+ defaultModelSelection: modelSelection,
+ scripts: [],
+ createdAt,
+ updatedAt: createdAt,
+ };
+ }
+ const threadId = newThreadId();
+ const modelSelection =
+ input.modelSelection ?? project.defaultModelSelection ?? this.#defaultModelSelection();
+ const isProjectRoot =
+ normalizedPath(project.workspaceRoot) === normalizedPath(input.worktreePath);
+ await this.#dispatch({
+ type: "thread.create",
+ commandId: newCommandId(),
+ threadId,
+ projectId: project.id,
+ title: input.title,
+ modelSelection,
+ runtimeMode: input.runtimeMode ?? DEFAULT_RUNTIME_MODE,
+ interactionMode: "default",
+ branch: null,
+ worktreePath: isProjectRoot ? null : input.worktreePath,
+ createdAt,
+ });
+ this.#activeThreadId = threadId;
+ this.#activeThread = null;
+ this.#activeThreadSequence = null;
+ await this.#stopThreadSubscription();
+ this.#startThreadSubscription(session, threadId);
+ return threadId;
+ }
+
+ async sendPrompt(input: {
+ readonly threadId: ThreadId;
+ readonly prompt: string;
+ readonly runtimeMode?: RuntimeMode;
+ readonly modelSelection?: ModelSelection;
+ readonly attachments?: ReadonlyArray;
+ }): Promise {
+ const thread = this.#activeThread;
+ const modelSelection =
+ input.modelSelection ?? thread?.modelSelection ?? this.#defaultModelSelection();
+ await this.#dispatch({
+ type: "thread.turn.start",
+ commandId: newCommandId(),
+ threadId: input.threadId,
+ message: {
+ messageId: newMessageId(),
+ role: "user",
+ text: input.prompt,
+ attachments: input.attachments ?? [],
+ },
+ modelSelection,
+ titleSeed: input.prompt.trim().slice(0, 80) || "New thread",
+ runtimeMode: input.runtimeMode ?? thread?.runtimeMode ?? DEFAULT_RUNTIME_MODE,
+ interactionMode: thread?.interactionMode ?? "default",
+ createdAt: new Date().toISOString(),
+ });
+ }
+
+ async setModelSelection(modelSelection: ModelSelection): Promise {
+ const thread = this.#activeThread;
+ if (thread === null) throw new Error("Select a T3 Code thread before changing models.");
+ await this.#dispatch({
+ type: "thread.meta.update",
+ commandId: newCommandId(),
+ threadId: thread.id,
+ modelSelection,
+ });
+ }
+
+ async createAttachmentUrl(attachmentId: string): Promise {
+ const session = this.#requireSession();
+ const httpBaseUrl = this.#httpBaseUrl;
+ if (httpBaseUrl === null) throw new Error("T3 Code is not connected.");
+ const result = await this.#runtime.runPromise(
+ session.client[WS_METHODS.assetsCreateUrl]({
+ resource: { _tag: "attachment", attachmentId },
+ }),
+ );
+ return new URL(result.relativeUrl, httpBaseUrl).toString();
+ }
+
+ async interrupt(): Promise {
+ const thread = this.#activeThread;
+ if (thread === null) return;
+ await this.#dispatch({
+ type: "thread.turn.interrupt",
+ commandId: newCommandId(),
+ threadId: thread.id,
+ ...(thread.latestTurn?.turnId === undefined ? {} : { turnId: thread.latestTurn.turnId }),
+ createdAt: new Date().toISOString(),
+ });
+ }
+
+ async respondToApproval(
+ requestId: ApprovalRequestId,
+ decision: ProviderApprovalDecision,
+ ): Promise {
+ const thread = this.#activeThread;
+ if (thread === null) throw new Error("Select a T3 Code thread before responding.");
+ await this.#dispatch({
+ type: "thread.approval.respond",
+ commandId: newCommandId(),
+ threadId: thread.id,
+ requestId,
+ decision,
+ createdAt: new Date().toISOString(),
+ });
+ }
+
+ async respondToUserInput(
+ requestId: ApprovalRequestId,
+ answers: ProviderUserInputAnswers,
+ ): Promise {
+ const thread = this.#activeThread;
+ if (thread === null) throw new Error("Select a T3 Code thread before responding.");
+ await this.#dispatch({
+ type: "thread.user-input.respond",
+ commandId: newCommandId(),
+ threadId: thread.id,
+ requestId,
+ answers,
+ createdAt: new Date().toISOString(),
+ });
+ }
+
+ async dispose(): Promise {
+ await this.#closeConnection();
+ await this.#runtime.dispose();
+ }
+
+ #requireSession(): RpcSession {
+ if (this.#session === null) throw new Error("T3 Code is not connected.");
+ return this.#session;
+ }
+
+ #defaultModelSelection(): ModelSelection {
+ const provider = this.#serverConfig?.providers.find(
+ (candidate) => candidate.enabled && candidate.installed && candidate.models.length > 0,
+ );
+ return {
+ instanceId: provider?.instanceId ?? ProviderInstanceId.make("codex"),
+ model:
+ provider?.models.find((model) => !model.isCustom)?.slug ??
+ provider?.models[0]?.slug ??
+ DEFAULT_MODEL,
+ };
+ }
+
+ async #dispatch(command: ClientOrchestrationCommand): Promise {
+ const session = this.#requireSession();
+ await this.#runtime.runPromise(
+ session.client[ORCHESTRATION_WS_METHODS.dispatchCommand](command),
+ );
+ }
+
+ #startShellSubscription(session: RpcSession): void {
+ const stream = session.client[ORCHESTRATION_WS_METHODS.subscribeShell](
+ this.#shell === null ? {} : { afterSequence: this.#shell.snapshotSequence },
+ ).pipe(
+ Stream.runForEach((item) =>
+ Effect.sync(() => {
+ if (item.kind === "snapshot") this.#shell = item.snapshot;
+ else if (this.#shell !== null) this.#shell = applyShellStreamEvent(this.#shell, item);
+ if (this.#shell !== null) {
+ for (const resolve of this.#shellWaiters) resolve(this.#shell);
+ this.#shellWaiters.clear();
+ for (const listener of this.#shellListeners) listener(this.#shell);
+ }
+ }),
+ ),
+ Effect.tapCause((cause) => Effect.sync(() => this.#log(`shell stream failed ${cause}`))),
+ );
+ this.#shellFiber = this.#runtime.runFork(stream);
+ }
+
+ #buildRpcSession(prepared: PreparedConnection): Effect.Effect {
+ // Simplified session construction for dumbed-down upstream vscode contribution.
+ // Matches the shape expected by the rest of T3Client.
+ return Effect.gen(function* () {
+ const connected = yield* Deferred.make() as any;
+ const disconnected = yield* Deferred.make() as any;
+
+ const hooks = (RpcClient.ConnectionHooks as any).of({
+ onConnect: Effect.asVoid(Deferred.succeed(connected, undefined)),
+ onDisconnect: Effect.flatMap((Deferred as any).isDone(connected), (was: boolean) =>
+ (Deferred as any).fail(disconnected, new Error(was ? "disconnected" : "connect failed")),
+ ),
+ });
+
+ const socketLayer = (Socket as any).layerWebSocket(prepared.socketUrl, {
+ openTimeout: "15 seconds",
+ });
+ const protocolLayer = Layer.effect(
+ (RpcClient as any).Protocol,
+ (RpcClient as any).makeProtocolSocket({
+ retryTransientErrors: false,
+ retryPolicy: Schedule.recurs(0),
+ }),
+ ).pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ socketLayer,
+ (RpcSerialization as any).layerJson,
+ Layer.succeed((RpcClient as any).ConnectionHooks, hooks),
+ ),
+ ),
+ );
+
+ const protocolContext = yield* Layer.build(protocolLayer) as any;
+ const client = yield* (makeWsRpcProtocolClient as any).pipe(
+ Effect.provide(protocolContext),
+ ) as any;
+
+ const initialConfig = yield* (client as any)
+ [WS_METHODS.serverGetConfig]({})
+ .pipe(Effect.cached) as any;
+
+ const ready = Effect.raceFirst(
+ Effect.andThen(Deferred.await(connected), initialConfig as any).pipe(Effect.asVoid),
+ (Deferred as any).await(disconnected) as any,
+ );
+
+ return {
+ client,
+ initialConfig,
+ ready,
+ probe: Effect.asVoid((client as any)[WS_METHODS.serverGetConfig]({})),
+ closed: (Deferred as any).await(disconnected),
+ } as unknown as RpcSession;
+ }) as any;
+ }
+
+ async #loadShellSnapshot(httpBaseUrl: string, bearerToken?: string): Promise {
+ const startedAt = Date.now();
+ const url = new URL("/api/orchestration/shell", httpBaseUrl);
+ const headers =
+ bearerToken === undefined || bearerToken === ""
+ ? {}
+ : { authorization: `Bearer ${bearerToken}` };
+ const response = await globalThis.fetch(url, { headers });
+ if (!response.ok) {
+ throw new Error(`Could not load T3 Code threads (HTTP ${response.status}).`);
+ }
+ const decode = Schema.decodeUnknownSync(Schema.fromJsonString(OrchestrationShellSnapshot));
+ this.#shell = decode(await response.text());
+ this.#log(
+ `shell HTTP complete in ${Date.now() - startedAt}ms projects=${this.#shell.projects.length} threads=${this.#shell.threads.length} sequence=${this.#shell.snapshotSequence}`,
+ );
+ for (const resolve of this.#shellWaiters) resolve(this.#shell);
+ this.#shellWaiters.clear();
+ for (const listener of this.#shellListeners) listener(this.#shell);
+ }
+
+ #startThreadSubscription(session: RpcSession, threadId: ThreadId): void {
+ const stream = session.client[ORCHESTRATION_WS_METHODS.subscribeThread]({
+ threadId,
+ ...(this.#activeThreadSequence === null ? {} : { afterSequence: this.#activeThreadSequence }),
+ }).pipe(
+ Stream.runForEach((item) =>
+ Effect.sync(() => {
+ if (item.kind === "snapshot") {
+ this.#activeThread = item.snapshot.thread;
+ this.#activeThreadSequence = item.snapshot.snapshotSequence;
+ } else if (this.#activeThread !== null) {
+ const result = applyThreadDetailEvent(this.#activeThread, item.event);
+ this.#activeThread =
+ result.kind === "updated"
+ ? result.thread
+ : result.kind === "deleted"
+ ? null
+ : this.#activeThread;
+ this.#activeThreadSequence = item.event.sequence;
+ }
+ if (this.#activeThread !== null) {
+ for (const resolve of this.#threadWaiters) resolve(this.#activeThread);
+ this.#threadWaiters.clear();
+ }
+ this.#emitThread();
+ }),
+ ),
+ Effect.tapCause((cause) =>
+ Effect.sync(() => this.#log(`thread stream failed id=${threadId} ${cause}`)),
+ ),
+ );
+ this.#threadFiber = this.#runtime.runFork(stream);
+ }
+
+ async #loadThreadSnapshot(threadId: ThreadId): Promise {
+ const startedAt = Date.now();
+ if (this.#httpBaseUrl === null) throw new Error("T3 Code is not connected.");
+ const url = new URL(
+ `/api/orchestration/threads/${encodeURIComponent(threadId)}`,
+ this.#httpBaseUrl,
+ );
+ const headers =
+ this.#bearerToken === null ? {} : { authorization: `Bearer ${this.#bearerToken}` };
+ const response = await globalThis.fetch(url, { headers });
+ if (!response.ok) {
+ throw new Error(`Could not load the T3 Code thread (HTTP ${response.status}).`);
+ }
+ const decode = Schema.decodeUnknownSync(
+ Schema.fromJsonString(OrchestrationThreadDetailSnapshot),
+ );
+ const snapshot = decode(await response.text());
+ this.#activeThread = snapshot.thread;
+ this.#activeThreadSequence = snapshot.snapshotSequence;
+ this.#log(
+ `thread HTTP complete in ${Date.now() - startedAt}ms id=${threadId} messages=${snapshot.thread.messages.length} sequence=${snapshot.snapshotSequence}`,
+ );
+ for (const resolve of this.#threadWaiters) resolve(this.#activeThread);
+ this.#threadWaiters.clear();
+ this.#emitThread();
+ }
+
+ #emitThread(): void {
+ for (const listener of this.#listeners) listener(this.#activeThread);
+ }
+
+ #emitConnection(connected: boolean): void {
+ for (const listener of this.#connectionListeners) listener(connected);
+ }
+
+ async #stopThreadSubscription(): Promise {
+ if (this.#threadFiber === null) return;
+ await this.#runtime.runPromise(Fiber.interrupt(this.#threadFiber));
+ this.#threadFiber = null;
+ }
+
+ async #closeConnection(): Promise {
+ await this.#stopThreadSubscription();
+ if (this.#closedFiber !== null) {
+ await this.#runtime.runPromise(Fiber.interrupt(this.#closedFiber));
+ this.#closedFiber = null;
+ }
+ if (this.#shellFiber !== null) {
+ await this.#runtime.runPromise(Fiber.interrupt(this.#shellFiber));
+ this.#shellFiber = null;
+ }
+ if (this.#scope !== null) {
+ await this.#runtime.runPromise(Scope.close(this.#scope, Exit.void));
+ this.#scope = null;
+ }
+ this.#session = null;
+ this.#activeThread = null;
+ this.#activeThreadSequence = null;
+ this.#shell = null;
+ this.#serverConfig = null;
+ this.#httpBaseUrl = null;
+ this.#bearerToken = null;
+ this.#connectionKey = "";
+ }
+}
diff --git a/apps/vscode/src/taskPresentation.test.ts b/apps/vscode/src/taskPresentation.test.ts
new file mode 100644
index 00000000000..b1928700199
--- /dev/null
+++ b/apps/vscode/src/taskPresentation.test.ts
@@ -0,0 +1,54 @@
+import { TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { presentTasks } from "./taskPresentation.ts";
+
+function plan(
+ id: string,
+ turnId: string,
+ createdAt: string,
+ steps: ReadonlyArray>,
+): OrchestrationThreadActivity {
+ return {
+ id,
+ kind: "turn.plan.updated",
+ tone: "info",
+ summary: "Plan updated",
+ payload: { plan: steps },
+ turnId,
+ createdAt,
+ } as OrchestrationThreadActivity;
+}
+
+describe("presentTasks", () => {
+ it("prefers the latest plan for the active turn", () => {
+ expect(
+ presentTasks(
+ [
+ plan("old", "turn-1", "2026-07-11T00:00:00.000Z", [
+ { step: "Old task", status: "pending" },
+ ]),
+ plan("current", "turn-2", "2026-07-11T00:00:01.000Z", [
+ { step: "Ship tasks", status: "inProgress" },
+ { step: "Verify", status: "completed" },
+ ]),
+ ],
+ TurnId.make("turn-2"),
+ ),
+ ).toMatchObject({
+ tasks: [
+ { step: "Ship tasks", status: "inProgress" },
+ { step: "Verify", status: "completed" },
+ ],
+ });
+ });
+
+ it("falls back to the most recent plan across previous turns", () => {
+ expect(
+ presentTasks(
+ [plan("old", "turn-1", "2026-07-11T00:00:00.000Z", [{ step: "Persist" }])],
+ TurnId.make("turn-2"),
+ )?.tasks,
+ ).toEqual([{ step: "Persist", status: "pending" }]);
+ });
+});
diff --git a/apps/vscode/src/taskPresentation.ts b/apps/vscode/src/taskPresentation.ts
new file mode 100644
index 00000000000..bc27dcc0ab4
--- /dev/null
+++ b/apps/vscode/src/taskPresentation.ts
@@ -0,0 +1,55 @@
+import type { OrchestrationThreadActivity, TurnId } from "@t3tools/contracts";
+
+export interface PresentedTask {
+ readonly step: string;
+ readonly status: "pending" | "inProgress" | "completed";
+}
+
+export interface PresentedTasks {
+ readonly explanation: string | null;
+ readonly createdAt: string;
+ readonly tasks: ReadonlyArray;
+}
+
+function planFromActivity(activity: OrchestrationThreadActivity): PresentedTasks | null {
+ if (activity.kind !== "turn.plan.updated") return null;
+ const payload =
+ typeof activity.payload === "object" && activity.payload !== null
+ ? (activity.payload as Record)
+ : null;
+ if (!Array.isArray(payload?.plan)) return null;
+ const tasks: PresentedTask[] = [];
+ for (const entry of payload.plan) {
+ if (typeof entry !== "object" || entry === null) continue;
+ const task = entry as Record;
+ if (typeof task.step !== "string" || task.step.trim() === "") continue;
+ tasks.push({
+ step: task.step.trim(),
+ status: task.status === "completed" || task.status === "inProgress" ? task.status : "pending",
+ });
+ }
+ if (tasks.length === 0) return null;
+ return {
+ explanation: typeof payload.explanation === "string" ? payload.explanation : null,
+ createdAt: activity.createdAt,
+ tasks,
+ };
+}
+
+export function presentTasks(
+ activities: ReadonlyArray,
+ latestTurnId: TurnId | null,
+): PresentedTasks | null {
+ const plans = activities
+ .filter((activity) => activity.kind === "turn.plan.updated")
+ .toSorted(
+ (left, right) =>
+ (left.sequence ?? 0) - (right.sequence ?? 0) ||
+ left.createdAt.localeCompare(right.createdAt),
+ );
+ const preferred =
+ (latestTurnId === null
+ ? undefined
+ : plans.findLast((activity) => activity.turnId === latestTurnId)) ?? plans.at(-1);
+ return preferred === undefined ? null : planFromActivity(preferred);
+}
diff --git a/apps/vscode/src/threadStatus.test.ts b/apps/vscode/src/threadStatus.test.ts
new file mode 100644
index 00000000000..ec963249b2f
--- /dev/null
+++ b/apps/vscode/src/threadStatus.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveThreadDisplayStatus } from "./threadStatus.ts";
+
+describe("resolveThreadDisplayStatus", () => {
+ it("shows a live running thread as working", () => {
+ expect(
+ resolveThreadDisplayStatus({
+ latestTurn: { state: "running" },
+ session: { status: "running" },
+ }),
+ ).toEqual({ kind: "working", label: "Working" });
+ });
+
+ it("prioritizes an interrupted session over its stale running turn", () => {
+ expect(
+ resolveThreadDisplayStatus({
+ latestTurn: { state: "running" },
+ session: { status: "interrupted" },
+ }),
+ ).toEqual({ kind: "needs-wake-up", label: "Needs wake up" });
+ });
+
+ it("shows a settled turn as completed", () => {
+ expect(
+ resolveThreadDisplayStatus({
+ latestTurn: { state: "completed" },
+ session: { status: "ready" },
+ }),
+ ).toEqual({ kind: "completed", label: "Completed" });
+ });
+});
diff --git a/apps/vscode/src/threadStatus.ts b/apps/vscode/src/threadStatus.ts
new file mode 100644
index 00000000000..842c781cf49
--- /dev/null
+++ b/apps/vscode/src/threadStatus.ts
@@ -0,0 +1,44 @@
+import type { OrchestrationLatestTurnState, OrchestrationSessionStatus } from "@t3tools/contracts";
+
+export type ThreadDisplayStatusKind =
+ | "working"
+ | "completed"
+ | "needs-wake-up"
+ | "connecting"
+ | "needs-attention"
+ | "error"
+ | "ready";
+
+export interface ThreadDisplayStatus {
+ readonly kind: ThreadDisplayStatusKind;
+ readonly label: string;
+}
+
+export interface ThreadStatusSource {
+ readonly latestTurn: null | { readonly state: OrchestrationLatestTurnState };
+ readonly session: null | { readonly status: OrchestrationSessionStatus };
+ readonly hasPendingApprovals?: boolean;
+ readonly hasPendingUserInput?: boolean;
+}
+
+export function resolveThreadDisplayStatus(source: ThreadStatusSource): ThreadDisplayStatus {
+ if (source.hasPendingApprovals || source.hasPendingUserInput) {
+ return { kind: "needs-attention", label: "Needs attention" };
+ }
+ if (source.session?.status === "interrupted" || source.latestTurn?.state === "interrupted") {
+ return { kind: "needs-wake-up", label: "Needs wake up" };
+ }
+ if (source.session?.status === "starting") {
+ return { kind: "connecting", label: "Connecting" };
+ }
+ if (source.session?.status === "running" || source.latestTurn?.state === "running") {
+ return { kind: "working", label: "Working" };
+ }
+ if (source.session?.status === "error" || source.latestTurn?.state === "error") {
+ return { kind: "error", label: "Error" };
+ }
+ if (source.latestTurn?.state === "completed") {
+ return { kind: "completed", label: "Completed" };
+ }
+ return { kind: "ready", label: "Ready" };
+}
diff --git a/apps/vscode/src/toolPresentation.test.ts b/apps/vscode/src/toolPresentation.test.ts
new file mode 100644
index 00000000000..64003ce93ea
--- /dev/null
+++ b/apps/vscode/src/toolPresentation.test.ts
@@ -0,0 +1,116 @@
+import { type OrchestrationThreadActivity, TurnId } from "@t3tools/contracts";
+import { describe, expect, it } from "vite-plus/test";
+
+import { presentToolCalls } from "./toolPresentation.ts";
+
+function activity(
+ input: Omit, "id" | "kind"> & {
+ readonly id: string;
+ readonly kind: string;
+ },
+): OrchestrationThreadActivity {
+ return {
+ tone: "tool",
+ summary: "Tool call",
+ payload: {},
+ turnId: null,
+ createdAt: "2026-07-10T00:00:00.000Z",
+ ...input,
+ } as OrchestrationThreadActivity;
+}
+
+describe("presentToolCalls", () => {
+ it("collapses lifecycle updates and retains completed command details", () => {
+ expect(
+ presentToolCalls([
+ activity({
+ id: "start",
+ kind: "tool.updated",
+ payload: { itemType: "command_execution", data: { toolCallId: "call-1" } },
+ }),
+ activity({
+ id: "done",
+ kind: "tool.completed",
+ payload: {
+ itemType: "command_execution",
+ title: "Ran command",
+ data: { toolCallId: "call-1", item: { command: ["vp", "check"] } },
+ },
+ }),
+ ]),
+ ).toEqual([
+ expect.objectContaining({
+ id: "done",
+ title: "Ran command",
+ status: "completed",
+ preview: "vp check",
+ detail: "vp check",
+ }),
+ ]);
+ });
+
+ it("preserves MCP input and result JSON for expansion", () => {
+ const [tool] = presentToolCalls([
+ activity({
+ id: "mcp",
+ kind: "tool.completed",
+ summary: "t3-code · preview_status",
+ payload: {
+ itemType: "mcp_tool_call",
+ data: { item: { tool: "preview_status", arguments: {}, result: "attached" } },
+ },
+ }),
+ ]);
+ expect(tool?.detail).toContain('"tool": "preview_status"');
+ expect(tool?.status).toBe("completed");
+ });
+
+ it("omits non-tool activities", () => {
+ expect(presentToolCalls([activity({ id: "message", kind: "message.created" })])).toEqual([]);
+ });
+
+ it("collects and deduplicates nested changed file paths", () => {
+ const [tool] = presentToolCalls([
+ activity({
+ id: "files",
+ kind: "tool.completed",
+ summary: "File change",
+ payload: {
+ itemType: "file_change",
+ data: {
+ item: {
+ changes: [
+ { path: "src/one.ts" },
+ { filename: "src/two.ts" },
+ { newPath: "src/one.ts" },
+ ],
+ },
+ },
+ },
+ }),
+ ]);
+ expect(tool?.changedFiles).toEqual(["src/one.ts", "src/two.ts"]);
+ expect(tool?.preview).toBe("2 changed files");
+ });
+
+ it("settles orphan tool updates when their turn has completed", () => {
+ const orphan = activity({
+ id: "orphan-progress",
+ kind: "tool.updated",
+ turnId: TurnId.make("old-turn"),
+ summary: "Tool updated",
+ });
+ expect(
+ presentToolCalls([orphan], {
+ latestTurn: { turnId: "newer-turn", completedAt: null },
+ session: { status: "running", activeTurnId: "newer-turn" },
+ })[0]?.status,
+ ).toBe("completed");
+ expect(
+ presentToolCalls([orphan], {
+ latestTurn: { turnId: "old-turn", completedAt: null },
+ session: { status: "running", activeTurnId: "old-turn" },
+ })[0]?.status,
+ ).toBe("running");
+ });
+});
diff --git a/apps/vscode/src/toolPresentation.ts b/apps/vscode/src/toolPresentation.ts
new file mode 100644
index 00000000000..e8ba1b6884e
--- /dev/null
+++ b/apps/vscode/src/toolPresentation.ts
@@ -0,0 +1,198 @@
+import type { OrchestrationThreadActivity } from "@t3tools/contracts";
+
+export interface PresentedToolCall {
+ readonly id: string;
+ readonly createdAt: string;
+ readonly title: string;
+ readonly itemType: string | null;
+ readonly status: "running" | "completed" | "failed" | "stopped";
+ readonly preview: string | null;
+ readonly detail: string | null;
+ readonly changedFiles: ReadonlyArray;
+}
+
+export interface ToolPresentationContext {
+ readonly latestTurn: null | {
+ readonly turnId: string;
+ readonly completedAt: string | null;
+ };
+ readonly session: null | {
+ readonly status: string;
+ readonly activeTurnId: string | null;
+ };
+}
+
+function record(value: unknown): Record | null {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function text(value: unknown): string | null {
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
+}
+
+function commandText(value: unknown): string | null {
+ if (typeof value === "string") return text(value);
+ if (!Array.isArray(value)) return null;
+ const parts = value.filter((part): part is string => typeof part === "string");
+ return parts.length === value.length && parts.length > 0 ? parts.join(" ") : null;
+}
+
+function toolCallId(payload: Record): string | null {
+ return text(record(payload.data)?.toolCallId) ?? text(payload.toolCallId);
+}
+
+function toolCommand(payload: Record): string | null {
+ const data = record(payload.data);
+ const item = record(data?.item);
+ const input = record(item?.input);
+ const result = record(item?.result);
+ return (
+ commandText(item?.command) ??
+ commandText(input?.command) ??
+ commandText(result?.command) ??
+ commandText(data?.command)
+ );
+}
+
+function rawOutputDetail(payload: Record): string | null {
+ const rawOutput = record(record(payload.data)?.rawOutput);
+ if (rawOutput === null) return null;
+ const totalFiles = typeof rawOutput.totalFiles === "number" ? rawOutput.totalFiles : null;
+ if (totalFiles !== null) {
+ return `${totalFiles} file${totalFiles === 1 ? "" : "s"}${rawOutput.truncated === true ? "+" : ""}`;
+ }
+ return text(rawOutput.content) ?? text(rawOutput.stdout);
+}
+
+function collectChangedFiles(value: unknown, target: string[], seen: Set, depth = 0): void {
+ if (depth > 4 || target.length >= 12) return;
+ if (Array.isArray(value)) {
+ for (const entry of value) collectChangedFiles(entry, target, seen, depth + 1);
+ return;
+ }
+ const data = record(value);
+ if (data === null) return;
+ for (const key of ["path", "filePath", "relativePath", "filename", "newPath", "oldPath"]) {
+ const path = text(data[key]);
+ if (path !== null && !seen.has(path)) {
+ seen.add(path);
+ target.push(path);
+ }
+ }
+ for (const key of [
+ "item",
+ "result",
+ "input",
+ "data",
+ "changes",
+ "files",
+ "edits",
+ "patch",
+ "patches",
+ "operations",
+ ]) {
+ if (key in data) collectChangedFiles(data[key], target, seen, depth + 1);
+ }
+}
+
+function changedFiles(payload: Record): ReadonlyArray {
+ const files: string[] = [];
+ collectChangedFiles(payload.data, files, new Set());
+ return files;
+}
+
+function expandedDetail(payload: Record, command: string | null): string | null {
+ const data = record(payload.data);
+ const item = record(data?.item);
+ const blocks = [command, text(payload.detail), rawOutputDetail(payload)];
+ if (text(payload.itemType) === "mcp_tool_call" && item !== null) {
+ blocks.push(JSON.stringify(item, null, 2));
+ }
+ const unique = [...new Set(blocks.filter((block): block is string => block !== null))];
+ return unique.length > 0 ? unique.join("\n\n") : null;
+}
+
+function lifecycleStatus(
+ activity: OrchestrationThreadActivity,
+ payload: Record,
+): PresentedToolCall["status"] {
+ const status = text(payload.status)?.toLowerCase();
+ if (status === "failed" || status === "declined") return "failed";
+ if (status === "stopped") return "stopped";
+ if (activity.kind === "tool.completed" || status === "completed") return "completed";
+ return "running";
+}
+
+function present(
+ activity: OrchestrationThreadActivity,
+ context?: ToolPresentationContext,
+): PresentedToolCall | null {
+ if (activity.kind !== "tool.updated" && activity.kind !== "tool.completed") return null;
+ const payload = record(activity.payload) ?? {};
+ const command = toolCommand(payload);
+ const title = text(payload.title) ?? activity.summary.replace(/\s+complete(?:d)?$/iu, "").trim();
+ const detail = expandedDetail(payload, command);
+ const files = changedFiles(payload);
+ const rawPreview =
+ command ??
+ text(payload.detail) ??
+ rawOutputDetail(payload) ??
+ (files.length > 0 ? `${files.length} changed file${files.length === 1 ? "" : "s"}` : null);
+ const preview = rawPreview === title ? null : rawPreview;
+ const lifecycle = lifecycleStatus(activity, payload);
+ const turnIsSettled =
+ lifecycle === "running" &&
+ activity.turnId !== null &&
+ context?.latestTurn !== null &&
+ context?.latestTurn !== undefined &&
+ (activity.turnId !== context.latestTurn.turnId ||
+ (context.latestTurn.completedAt !== null &&
+ !(
+ context.session?.status === "running" && context.session.activeTurnId === activity.turnId
+ )));
+ return {
+ id: activity.id,
+ createdAt: activity.createdAt,
+ title,
+ itemType: text(payload.itemType),
+ status: turnIsSettled ? "completed" : lifecycle,
+ preview,
+ detail,
+ changedFiles: files,
+ };
+}
+
+export function presentToolCalls(
+ activities: ReadonlyArray,
+ context?: ToolPresentationContext,
+): ReadonlyArray {
+ const presented: Array = [];
+ for (const activity of [...activities].toSorted((left, right) => {
+ if (left.sequence !== undefined && right.sequence !== undefined) {
+ return left.sequence - right.sequence;
+ }
+ return left.createdAt.localeCompare(right.createdAt);
+ })) {
+ const tool = present(activity, context);
+ if (tool === null) continue;
+ const payload = record(activity.payload) ?? {};
+ const id = toolCallId(payload);
+ const collapseKey = id === null ? `${tool.itemType ?? ""}\u001f${tool.title}` : `id:${id}`;
+ const previous = presented.at(-1);
+ if (previous?.collapseKey === collapseKey && previous.status === "running") {
+ presented[presented.length - 1] = {
+ ...previous,
+ ...tool,
+ preview: tool.preview ?? previous.preview,
+ detail: tool.detail ?? previous.detail,
+ changedFiles: [...new Set([...previous.changedFiles, ...tool.changedFiles])],
+ collapseKey,
+ };
+ } else {
+ presented.push({ ...tool, collapseKey });
+ }
+ }
+ return presented.map(({ collapseKey: _collapseKey, ...tool }) => tool);
+}
diff --git a/apps/vscode/src/userInputPresentation.ts b/apps/vscode/src/userInputPresentation.ts
new file mode 100644
index 00000000000..17f03a984e8
--- /dev/null
+++ b/apps/vscode/src/userInputPresentation.ts
@@ -0,0 +1,19 @@
+import type { OrchestrationThreadActivity } from "@t3tools/contracts";
+
+export interface PresentedResolvedUserInput {
+ readonly activityId: string;
+ readonly createdAt: string;
+ readonly answers: ReadonlyArray<{
+ readonly header: string;
+ readonly question: string;
+ readonly answer: string;
+ }>;
+}
+
+export function presentResolvedUserInputs(
+ _activities: ReadonlyArray,
+): ReadonlyArray {
+ // Stub for dumbed-down upstream contribution; advanced user-input transcripts
+ // are a newer feature not required for the initial VS Code extension.
+ return [];
+}
diff --git a/apps/vscode/src/webview.ts b/apps/vscode/src/webview.ts
new file mode 100644
index 00000000000..ee57683e7bf
--- /dev/null
+++ b/apps/vscode/src/webview.ts
@@ -0,0 +1,1310 @@
+/* oxlint-disable no-unsanitized/property, unicorn/require-post-message-target-origin -- Markdown is sanitized; VS Code's postMessage API has no targetOrigin argument. */
+// @effect-diagnostics globalDate:off globalTimers:off
+import DOMPurify from "dompurify";
+import { marked } from "marked";
+
+import { splitEditorContext } from "./editorContext.ts";
+import { renderProviderIcon } from "./providerIcon.ts";
+
+interface VsCodeApi {
+ readonly postMessage: (message: unknown) => void;
+}
+
+declare function acquireVsCodeApi(): VsCodeApi;
+
+interface ViewThread {
+ readonly id: string;
+ readonly title: string;
+ readonly model: string;
+ readonly status: ThreadDisplayStatus;
+}
+
+interface ThreadDisplayStatus {
+ readonly kind:
+ | "working"
+ | "completed"
+ | "needs-wake-up"
+ | "connecting"
+ | "needs-attention"
+ | "error"
+ | "ready";
+ readonly label: string;
+}
+
+interface ViewMessage {
+ readonly id: string;
+ readonly role: "user" | "assistant" | "system";
+ readonly text: string;
+ readonly streaming: boolean;
+ readonly createdAt: string;
+ readonly attachments: ReadonlyArray<{
+ readonly id: string;
+ readonly name: string;
+ readonly mimeType: string;
+ readonly previewUrl: string | null;
+ }>;
+}
+
+interface ViewToolCall {
+ readonly id: string;
+ readonly createdAt: string;
+ readonly title: string;
+ readonly itemType: string | null;
+ readonly status: "running" | "completed" | "failed" | "stopped";
+ readonly preview: string | null;
+ readonly detail: string | null;
+ readonly changedFiles: ReadonlyArray;
+}
+
+interface ViewResolvedUserInput {
+ readonly activityId: string;
+ readonly createdAt: string;
+ readonly answers: ReadonlyArray<{
+ readonly header: string;
+ readonly question: string;
+ readonly answer: string;
+ }>;
+}
+
+interface ViewPendingApproval {
+ readonly kind: "approval";
+ readonly requestId: string;
+ readonly requestKind: "command" | "file-read" | "file-change";
+ readonly detail: string | null;
+}
+
+interface ViewPendingUserInput {
+ readonly kind: "user-input";
+ readonly requestId: string;
+ readonly questions: ReadonlyArray<{
+ readonly id: string;
+ readonly header: string;
+ readonly question: string;
+ readonly multiSelect?: boolean;
+ readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }>;
+ }>;
+}
+
+interface ViewState {
+ readonly busy: boolean;
+ readonly error: string | null;
+ readonly environmentLabel: string;
+ readonly threads: ReadonlyArray;
+ readonly activeThread: null | {
+ readonly id: string;
+ readonly instanceId: string;
+ readonly model: string;
+ readonly options?: ReadonlyArray<{ readonly id: string; readonly value: string | boolean }>;
+ readonly status: ThreadDisplayStatus;
+ readonly turnStartedAt: string | null;
+ readonly messages: ReadonlyArray;
+ readonly toolCalls: ReadonlyArray;
+ readonly resolvedUserInputs: ReadonlyArray;
+ readonly pendingInteractions: ReadonlyArray;
+ readonly tasks: null | {
+ readonly explanation: string | null;
+ readonly createdAt: string;
+ readonly tasks: ReadonlyArray<{
+ readonly step: string;
+ readonly status: "pending" | "inProgress" | "completed";
+ }>;
+ };
+ };
+ readonly models: ReadonlyArray<{
+ readonly instanceId: string;
+ readonly model: string;
+ readonly driver: string;
+ readonly providerLabel: string;
+ readonly modelLabel: string;
+ readonly optionDescriptors: ReadonlyArray<
+ | {
+ readonly id: string;
+ readonly label: string;
+ readonly type: "select";
+ readonly currentValue?: string;
+ readonly options: ReadonlyArray<{
+ readonly id: string;
+ readonly label: string;
+ readonly isDefault?: boolean;
+ }>;
+ }
+ | {
+ readonly id: string;
+ readonly label: string;
+ readonly type: "boolean";
+ readonly currentValue?: boolean;
+ }
+ >;
+ }>;
+ readonly favoriteProviderIds: ReadonlyArray;
+ readonly favoriteModelKeys: ReadonlyArray;
+ readonly contextEnabled: boolean;
+ readonly editorContext: null | {
+ readonly path: string;
+ readonly startLine: number;
+ readonly endLine: number;
+ readonly kind: "selection" | "cursor-line" | "reference";
+ };
+}
+
+const vscode = acquireVsCodeApi();
+const threads = requiredElement("threads");
+const messages = requiredElement("messages");
+const status = requiredElement("status");
+const prompt = requiredElement("prompt");
+const slashCommands = requiredElement("slash-commands");
+const send = requiredElement("send");
+const pendingAttachments = requiredElement("pending-attachments");
+const pendingInteractions = requiredElement("pending-interactions");
+const contextButton = requiredElement("context");
+const contextLabel = requiredElement("context-label");
+const provider = requiredElement("provider");
+const providerIcon = requiredElement("provider-icon");
+const favoriteProvider = requiredElement("favorite-provider");
+const model = requiredElement("model");
+const favoriteModel = requiredElement("favorite-model");
+const modelOptions = requiredElement("model-options");
+const tasksDetails = requiredElement("tasks-details");
+const tasksLabel = requiredElement("tasks-label");
+let currentState: ViewState | null = null;
+let draftSelection: null | {
+ instanceId: string;
+ model: string;
+ options: Array<{ id: string; value: string | boolean }>;
+} = null;
+interface PendingImage {
+ readonly key: string;
+ readonly type: "image";
+ readonly name: string;
+ readonly mimeType: string;
+ readonly sizeBytes: number;
+ readonly dataUrl: string;
+}
+let pendingImages: PendingImage[] = [];
+let tasksExpanded = false;
+let selectedSlashCommand = 0;
+const pendingAnswers = new Map>();
+const submittedAnswers = new Map<
+ string,
+ Readonly>>
+>();
+const submittedApprovals = new Map();
+
+const COMMANDS = [
+ { name: "/new", description: "Start a new synchronized thread" },
+ { name: "/threads", description: "Choose a worktree thread" },
+ { name: "/model", description: "Choose the active model" },
+ { name: "/context", description: "Include or exclude editor context" },
+ { name: "/stop", description: "Stop the active turn" },
+] as const;
+
+marked.setOptions({
+ gfm: true,
+ breaks: false,
+});
+
+function requiredElement(id: string): A {
+ const element = document.getElementById(id);
+ if (element === null) throw new Error(`Missing webview element #${id}.`);
+ return element as A;
+}
+
+function post(message: unknown): void {
+ vscode.postMessage(message);
+}
+
+function emptyMessage(text: string): HTMLElement {
+ const element = document.createElement("div");
+ element.className = "empty";
+ element.textContent = text;
+ return element;
+}
+
+function modelFavoriteKey(instanceId: string, modelSlug: string): string {
+ return `${instanceId}:${modelSlug}`;
+}
+
+function favoritesFirst(items: ReadonlyArray, isFavorite: (item: A) => boolean): A[] {
+ return items
+ .map((item, index) => ({ item, index, favorite: isFavorite(item) }))
+ .toSorted(
+ (left, right) => Number(right.favorite) - Number(left.favorite) || left.index - right.index,
+ )
+ .map(({ item }) => item);
+}
+
+function formatElapsed(startedAt: string, nowMs: number): string | null {
+ const startMs = Date.parse(startedAt);
+ if (!Number.isFinite(startMs)) return null;
+ const totalSeconds = Math.max(0, Math.floor((nowMs - startMs) / 1_000));
+ const hours = Math.floor(totalSeconds / 3_600);
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
+ const seconds = totalSeconds % 60;
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
+ return `${seconds}s`;
+}
+
+function renderMarkdown(text: string): HTMLElement {
+ const content = document.createElement("div");
+ content.className = "content markdown-body";
+ const parsed = marked.parse(text, { async: false });
+ content.innerHTML = DOMPurify.sanitize(parsed, {
+ USE_PROFILES: { html: true },
+ ADD_ATTR: ["target", "rel"],
+ });
+
+ for (const anchor of content.querySelectorAll("a[href]")) {
+ anchor.rel = "noreferrer noopener";
+ anchor.addEventListener("click", (event) => {
+ event.preventDefault();
+ post({ type: "openLink", href: anchor.getAttribute("href") ?? "" });
+ });
+ }
+
+ for (const code of content.querySelectorAll("code:not(pre code)")) {
+ const href = code.textContent?.trim() ?? "";
+ if (!/^https?:\/\/\S+$/iu.test(href)) continue;
+ const anchor = document.createElement("a");
+ anchor.href = href;
+ anchor.rel = "noreferrer noopener";
+ anchor.className = "inline-code-link";
+ anchor.addEventListener("click", (event) => {
+ event.preventDefault();
+ post({ type: "openLink", href });
+ });
+ code.before(anchor);
+ anchor.append(code);
+ }
+
+ for (const pre of content.querySelectorAll("pre")) {
+ const code = pre.querySelector("code");
+ if (code === null) continue;
+ const wrapper = document.createElement("div");
+ wrapper.className = "code-block";
+ const header = document.createElement("div");
+ header.className = "code-header";
+ const language = [...code.classList]
+ .find((name) => name.startsWith("language-"))
+ ?.slice("language-".length);
+ const label = document.createElement("span");
+ label.textContent = language ?? "code";
+ const copy = document.createElement("button");
+ copy.className = "copy-code";
+ copy.textContent = "Copy";
+ copy.title = "Copy code";
+ copy.addEventListener("click", () => {
+ post({ type: "copyText", text: code.textContent ?? "" });
+ copy.textContent = "Copied";
+ globalThis.setTimeout(() => {
+ copy.textContent = "Copy";
+ }, 1_200);
+ });
+ header.append(label, copy);
+ pre.before(wrapper);
+ wrapper.append(header, pre);
+ }
+
+ for (const table of content.querySelectorAll("table")) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "table-scroll";
+ table.before(wrapper);
+ wrapper.append(table);
+ }
+ return content;
+}
+
+function renderMessage(message: ViewMessage): HTMLElement {
+ const wrapper = document.createElement("article");
+ wrapper.className = `message ${message.role}`;
+ wrapper.setAttribute("aria-label", `${message.role} message`);
+ const parsedContext = splitEditorContext(message.text);
+ const attachmentOnlyText = parsedContext.text.startsWith(
+ "[User attached one or more images without additional text.",
+ );
+ const content = renderMarkdown(
+ attachmentOnlyText && message.attachments.length > 0 ? "" : parsedContext.text,
+ );
+ if (message.streaming) content.classList.add("streaming");
+ wrapper.append(content);
+ if (parsedContext.references.length > 0) {
+ const references = document.createElement("div");
+ references.className = "context-references";
+ for (const reference of parsedContext.references) {
+ const chip = document.createElement("button");
+ chip.className = "context-reference";
+ chip.textContent = `▱ ${reference.path} · ${reference.detail}`;
+ chip.title = `Open ${reference.path}`;
+ chip.addEventListener("click", () =>
+ post({ type: "openEditorContext", path: reference.path, detail: reference.detail }),
+ );
+ references.append(chip);
+ }
+ wrapper.append(references);
+ }
+ if (message.attachments.length > 0) {
+ const attachments = document.createElement("div");
+ attachments.className = "attachments";
+ for (const attachment of message.attachments) {
+ const link = document.createElement("a");
+ link.className = "attachment";
+ link.title = attachment.name;
+ if (attachment.previewUrl !== null) {
+ link.href = attachment.previewUrl;
+ link.addEventListener("click", (event) => {
+ event.preventDefault();
+ post({ type: "openLink", href: attachment.previewUrl });
+ });
+ const image = document.createElement("img");
+ image.src = attachment.previewUrl;
+ image.alt = attachment.name;
+ image.loading = "lazy";
+ link.append(image);
+ }
+ const name = document.createElement("span");
+ name.className = "attachment-name";
+ name.textContent =
+ attachment.previewUrl === null ? `Loading ${attachment.name}…` : attachment.name;
+ link.append(name);
+ attachments.append(link);
+ }
+ wrapper.append(attachments);
+ }
+ return wrapper;
+}
+
+function toolIcon(itemType: string | null): string {
+ if (itemType === "command_execution") return ">_";
+ if (itemType === "file_change") return "±";
+ if (itemType === "web_search") return "⌕";
+ if (itemType === "image_view") return "◉";
+ return "⌘";
+}
+
+function renderToolCall(tool: ViewToolCall): HTMLElement {
+ const wrapper = document.createElement("details");
+ wrapper.className = `tool-call ${tool.status}`;
+ wrapper.open = false;
+ const summary = document.createElement("summary");
+ const icon = document.createElement("span");
+ icon.className = "tool-call-icon";
+ icon.textContent = toolIcon(tool.itemType);
+ const title = document.createElement("span");
+ title.className = "tool-call-title";
+ title.textContent = tool.title;
+ const preview = document.createElement("span");
+ preview.className = "tool-call-preview";
+ preview.textContent = tool.preview === null ? "" : ` · ${tool.preview.replace(/\s+/gu, " ")}`;
+ const state = document.createElement("span");
+ state.className = "tool-call-state";
+ state.textContent =
+ tool.status === "completed"
+ ? "✓"
+ : tool.status === "running"
+ ? "●"
+ : tool.status === "stopped"
+ ? "■"
+ : "×";
+ summary.append(icon, title, preview, state);
+ wrapper.append(summary);
+ if (tool.detail !== null) {
+ const detail = document.createElement("pre");
+ detail.className = "tool-call-detail";
+ detail.textContent = tool.detail;
+ wrapper.append(detail);
+ }
+ if (tool.changedFiles.length > 0) {
+ const files = document.createElement("div");
+ files.className = "tool-changed-files";
+ for (const path of tool.changedFiles) {
+ const button = document.createElement("button");
+ button.className = "tool-changed-file";
+ button.title = `Open ${path}`;
+ button.textContent = path;
+ button.addEventListener("click", () =>
+ post({ type: "openEditorContext", path, detail: "changed file" }),
+ );
+ files.append(button);
+ }
+ wrapper.append(files);
+ }
+ if (tool.detail === null && tool.changedFiles.length === 0) {
+ wrapper.classList.add("not-expandable");
+ summary.addEventListener("click", (event) => event.preventDefault());
+ }
+ return wrapper;
+}
+
+function renderToolCallGroup(tools: ReadonlyArray): HTMLElement {
+ if (tools.length === 1) return renderToolCall(tools[0]!);
+ const group = document.createElement("details");
+ group.className = "tool-call-group";
+ // Tool output is intentionally opt-in. Live status updates rebuild the
+ // timeline, so never infer expansion from running state or viewport entry.
+ group.open = false;
+ const summary = document.createElement("summary");
+ const running = tools.filter((tool) => tool.status === "running").length;
+ const failed = tools.filter((tool) => tool.status === "failed").length;
+ const state = running > 0 ? `${running} running` : failed > 0 ? `${failed} failed` : "Completed";
+ summary.textContent = `${tools.length} tool calls · ${state}`;
+ const body = document.createElement("div");
+ body.className = "tool-call-group-body";
+ body.append(...tools.map(renderToolCall));
+ group.append(summary, body);
+ return group;
+}
+
+function renderResolvedUserInput(input: ViewResolvedUserInput): HTMLElement {
+ const wrapper = document.createElement("section");
+ wrapper.className = "message user user-input-response";
+ wrapper.setAttribute("aria-label", "Your answers");
+
+ for (const entry of input.answers) {
+ const answer = document.createElement("div");
+ answer.className = "user-input-response-item";
+ const question = document.createElement("div");
+ question.className = "user-input-response-question";
+ question.textContent = entry.question;
+ const value = document.createElement("div");
+ value.className = "user-input-response-answer";
+ value.textContent = entry.answer;
+ answer.append(question, value);
+ wrapper.append(answer);
+ }
+ return wrapper;
+}
+
+function approvalLabel(kind: ViewPendingApproval["requestKind"]): string {
+ if (kind === "command") return "Command approval";
+ if (kind === "file-read") return "File-read approval";
+ return "File-change approval";
+}
+
+function renderApproval(interaction: ViewPendingApproval): HTMLElement {
+ const card = document.createElement("section");
+ card.className = "interaction-card approval-card";
+ const heading = document.createElement("div");
+ heading.className = "interaction-heading";
+ heading.textContent = approvalLabel(interaction.requestKind);
+ card.append(heading);
+ const submitted = submittedApprovals.get(interaction.requestId);
+ if (submitted !== undefined) {
+ heading.textContent = "Approval response submitted";
+ const detail = document.createElement("div");
+ detail.className = "interaction-detail";
+ detail.textContent = submitted;
+ card.append(detail);
+ return card;
+ }
+ if (interaction.detail !== null) {
+ const detail = document.createElement("div");
+ detail.className = "interaction-detail";
+ detail.textContent = interaction.detail;
+ card.append(detail);
+ }
+ const actions = document.createElement("div");
+ actions.className = "interaction-actions";
+ for (const [label, decision, className] of [
+ ["Deny", "decline", ""],
+ ["Allow for session", "acceptForSession", ""],
+ ["Allow", "accept", "allow"],
+ ] as const) {
+ const button = document.createElement("button");
+ button.textContent = label;
+ button.className = className;
+ button.addEventListener("click", () => {
+ submittedApprovals.set(interaction.requestId, label);
+ if (currentState !== null) renderPendingInteractions(currentState);
+ post({ type: "approvalResponse", requestId: interaction.requestId, decision });
+ });
+ actions.append(button);
+ }
+ card.append(actions);
+ return card;
+}
+
+function renderUserInput(interaction: ViewPendingUserInput): HTMLElement {
+ const card = document.createElement("section");
+ card.className = "interaction-card user-input-card";
+ const heading = document.createElement("div");
+ heading.className = "interaction-heading";
+ heading.textContent = "Input requested";
+ card.append(heading);
+ const submitted = submittedAnswers.get(interaction.requestId);
+ if (submitted !== undefined) {
+ heading.textContent = "Input submitted";
+ for (const question of interaction.questions) {
+ const answer = submitted[question.id];
+ const row = document.createElement("div");
+ row.className = "interaction-detail";
+ row.textContent = `${question.header}: ${Array.isArray(answer) ? answer.join(", ") : String(answer ?? "")}`;
+ card.append(row);
+ }
+ return card;
+ }
+ const answers = pendingAnswers.get(interaction.requestId) ?? {};
+ pendingAnswers.set(interaction.requestId, answers);
+ for (const question of interaction.questions) {
+ const group = document.createElement("fieldset");
+ group.className = "interaction-question-group";
+ const legend = document.createElement("legend");
+ legend.textContent = question.header;
+ const questionText = document.createElement("div");
+ questionText.className = "interaction-question";
+ questionText.textContent = question.question;
+ group.append(legend, questionText);
+ for (const option of question.options) {
+ const label = document.createElement("label");
+ label.className = "interaction-option";
+ const input = document.createElement("input");
+ input.type = question.multiSelect === true ? "checkbox" : "radio";
+ input.name = `${interaction.requestId}:${question.id}`;
+ const current = answers[question.id];
+ input.checked = Array.isArray(current)
+ ? current.includes(option.label)
+ : current === option.label;
+ input.addEventListener("change", () => {
+ if (question.multiSelect === true) {
+ const selected = Array.isArray(answers[question.id])
+ ? [...(answers[question.id] as string[])]
+ : [];
+ answers[question.id] = input.checked
+ ? [...new Set([...selected, option.label])]
+ : selected.filter((value) => value !== option.label);
+ } else if (input.checked) {
+ answers[question.id] = option.label;
+ }
+ });
+ const copy = document.createElement("span");
+ copy.textContent = option.label;
+ const description = document.createElement("small");
+ description.textContent = option.description;
+ copy.append(description);
+ label.append(input, copy);
+ group.append(label);
+ }
+ const custom = document.createElement("input");
+ custom.className = "interaction-custom";
+ custom.placeholder = "Other…";
+ custom.value =
+ typeof answers[question.id] === "string" &&
+ !question.options.some((option) => option.label === answers[question.id])
+ ? (answers[question.id] as string)
+ : "";
+ custom.addEventListener("input", () => {
+ if (custom.value.trim() !== "") answers[question.id] = custom.value.trim();
+ else delete answers[question.id];
+ });
+ group.append(custom);
+ card.append(group);
+ }
+ const actions = document.createElement("div");
+ actions.className = "interaction-actions";
+ const submit = document.createElement("button");
+ submit.className = "allow";
+ submit.textContent = "Submit";
+ submit.addEventListener("click", () => {
+ const complete = interaction.questions.every((question) => {
+ const answer = answers[question.id];
+ return typeof answer === "string" ? answer.trim() !== "" : (answer?.length ?? 0) > 0;
+ });
+ if (!complete) return;
+ submittedAnswers.set(interaction.requestId, { ...answers });
+ if (currentState !== null) renderPendingInteractions(currentState);
+ post({ type: "userInputResponse", requestId: interaction.requestId, answers });
+ });
+ actions.append(submit);
+ card.append(actions);
+ return card;
+}
+
+function renderPendingInteractions(state: ViewState): void {
+ const activeIds = new Set(
+ (state.activeThread?.pendingInteractions ?? []).map((interaction) => interaction.requestId),
+ );
+ for (const requestId of submittedAnswers.keys()) {
+ if (!activeIds.has(requestId)) submittedAnswers.delete(requestId);
+ }
+ for (const requestId of submittedApprovals.keys()) {
+ if (!activeIds.has(requestId)) submittedApprovals.delete(requestId);
+ }
+ pendingInteractions.replaceChildren(
+ ...(state.activeThread?.pendingInteractions ?? []).map((interaction) =>
+ interaction.kind === "approval" ? renderApproval(interaction) : renderUserInput(interaction),
+ ),
+ );
+}
+
+function renderTasks(state: ViewState): void {
+ const presented = state.activeThread?.tasks ?? null;
+ tasksDetails.replaceChildren();
+ if (presented === null) {
+ tasksLabel.textContent = "Tasks";
+ const empty = document.createElement("div");
+ empty.className = "tasks-empty";
+ empty.textContent = "No task list for this thread yet.";
+ tasksDetails.append(empty);
+ return;
+ }
+ const completed = presented.tasks.filter((task) => task.status === "completed").length;
+ tasksLabel.textContent = `Tasks ${completed}/${presented.tasks.length}`;
+ if (presented.explanation !== null) {
+ const explanation = document.createElement("div");
+ explanation.className = "tasks-explanation";
+ explanation.textContent = presented.explanation;
+ tasksDetails.append(explanation);
+ }
+ const list = document.createElement("ol");
+ list.className = "tasks-list";
+ for (const task of presented.tasks) {
+ const item = document.createElement("li");
+ item.className = `task-item ${task.status}`;
+ const icon = document.createElement("span");
+ icon.className = "task-icon";
+ icon.textContent = task.status === "completed" ? "✓" : task.status === "inProgress" ? "◔" : "·";
+ const text = document.createElement("span");
+ text.textContent = task.step;
+ item.append(icon, text);
+ list.append(item);
+ }
+ tasksDetails.append(list);
+}
+
+function render(next: ViewState): void {
+ currentState = next;
+ const wasNearBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 80;
+ threads.replaceChildren();
+ if (draftSelection !== null) {
+ const draft = document.createElement("option");
+ draft.textContent = "New thread";
+ draft.value = "__draft__";
+ draft.selected = true;
+ threads.append(draft);
+ for (const thread of next.threads) {
+ const option = document.createElement("option");
+ option.value = thread.id;
+ option.textContent = `${thread.title} · ${thread.status.label}`;
+ threads.append(option);
+ }
+ } else if (next.threads.length === 0) {
+ const option = document.createElement("option");
+ option.textContent = "No threads for this worktree";
+ option.value = "";
+ threads.append(option);
+ } else {
+ for (const thread of next.threads) {
+ const option = document.createElement("option");
+ option.value = thread.id;
+ option.textContent = `${thread.title} · ${thread.status.label}`;
+ option.selected = thread.id === next.activeThread?.id;
+ threads.append(option);
+ }
+ }
+ threads.disabled = next.busy;
+ renderActiveStatus(next);
+ renderPendingInteractions(next);
+ renderTasks(next);
+
+ messages.replaceChildren();
+ if (draftSelection !== null) {
+ messages.append(emptyMessage("Choose a provider and model, then send your first message."));
+ } else if (
+ next.activeThread === null ||
+ (next.activeThread.messages.length === 0 &&
+ next.activeThread.toolCalls.length === 0 &&
+ next.activeThread.resolvedUserInputs.length === 0)
+ ) {
+ messages.append(
+ emptyMessage(
+ next.activeThread === null
+ ? "Create or select a thread for this worktree."
+ : "Start a conversation in this synchronized thread.",
+ ),
+ );
+ } else {
+ const timeline = [
+ ...next.activeThread.messages.map((message) => ({
+ kind: "message" as const,
+ createdAt: message.createdAt,
+ order: 0,
+ element: renderMessage(message),
+ })),
+ ...next.activeThread.toolCalls.map((tool) => ({
+ kind: "tool" as const,
+ createdAt: tool.createdAt,
+ order: 1,
+ tool,
+ })),
+ ...next.activeThread.resolvedUserInputs.map((input) => ({
+ kind: "input" as const,
+ createdAt: input.createdAt,
+ order: 2,
+ element: renderResolvedUserInput(input),
+ })),
+ ].toSorted(
+ (left, right) => left.createdAt.localeCompare(right.createdAt) || left.order - right.order,
+ );
+ const elements: HTMLElement[] = [];
+ for (let index = 0; index < timeline.length; index += 1) {
+ const item = timeline[index]!;
+ if (item.kind !== "tool") {
+ elements.push(item.element);
+ continue;
+ }
+ const tools = [item.tool];
+ while (timeline[index + 1]?.kind === "tool") {
+ index += 1;
+ tools.push((timeline[index] as Extract<(typeof timeline)[number], { kind: "tool" }>).tool);
+ }
+ elements.push(renderToolCallGroup(tools));
+ }
+ messages.append(...elements);
+ if (wasNearBottom) messages.scrollTop = messages.scrollHeight;
+ }
+
+ const editorContext = next.editorContext;
+ contextButton.classList.toggle("excluded", !next.contextEnabled);
+ contextButton.title = next.contextEnabled
+ ? "Exclude active editor context"
+ : "Include active editor context";
+ contextButton.setAttribute("aria-pressed", String(next.contextEnabled));
+ const contextDescription =
+ editorContext === null
+ ? "No active editor"
+ : editorContext.kind === "selection"
+ ? `${editorContext.endLine - editorContext.startLine + 1} lines selected`
+ : `${editorContext.path}:${editorContext.startLine}`;
+ contextLabel.textContent = next.contextEnabled
+ ? contextDescription
+ : `Excluded · ${contextDescription}`;
+ const selection = currentSelection(next);
+ provider.replaceChildren();
+ model.replaceChildren();
+ if (selection === null) {
+ const providerOption = document.createElement("option");
+ providerOption.textContent = next.environmentLabel;
+ providerOption.value = "";
+ provider.append(providerOption);
+ const modelOption = document.createElement("option");
+ modelOption.textContent = "Select a thread";
+ modelOption.value = "";
+ model.append(modelOption);
+ } else {
+ const providers = new Map();
+ for (const candidate of next.models) {
+ if (!providers.has(candidate.instanceId)) providers.set(candidate.instanceId, candidate);
+ }
+ const favoriteProviderIds = new Set(next.favoriteProviderIds);
+ for (const [instanceId, candidate] of favoritesFirst([...providers.entries()], ([instanceId]) =>
+ favoriteProviderIds.has(instanceId),
+ )) {
+ const option = document.createElement("option");
+ option.value = instanceId;
+ option.textContent = candidate.providerLabel;
+ option.selected = instanceId === selection.instanceId;
+ provider.append(option);
+ }
+ const favoriteModelKeys = new Set(next.favoriteModelKeys);
+ const selectedProviderModels = favoritesFirst(
+ next.models.filter((candidate) => candidate.instanceId === selection.instanceId),
+ (candidate) => favoriteModelKeys.has(modelFavoriteKey(candidate.instanceId, candidate.model)),
+ );
+ for (const candidate of selectedProviderModels) {
+ const option = document.createElement("option");
+ option.value = candidate.model;
+ option.textContent = candidate.modelLabel;
+ option.selected = candidate.model === selection.model;
+ model.append(option);
+ }
+ }
+ renderModelOptions(next);
+ renderProviderIdentity(next);
+ renderFavoriteControls(next);
+ provider.disabled = selection === null || draftSelection === null || next.busy;
+ model.disabled = selection === null || next.busy;
+ send.disabled = next.busy;
+ prompt.disabled = next.busy;
+ renderComposerAction();
+}
+
+function renderFavoriteControls(state: ViewState): void {
+ const selection = currentSelection(state);
+ favoriteProvider.disabled = selection === null;
+ favoriteModel.disabled = selection === null;
+ const providerActive =
+ selection !== null && state.favoriteProviderIds.includes(selection.instanceId);
+ const modelActive =
+ selection !== null &&
+ state.favoriteModelKeys.includes(modelFavoriteKey(selection.instanceId, selection.model));
+ for (const [button, active, noun] of [
+ [favoriteProvider, providerActive, "provider"],
+ [favoriteModel, modelActive, "model"],
+ ] as const) {
+ button.textContent = active ? "★" : "☆";
+ button.classList.toggle("active", active);
+ button.title = `${active ? "Remove" : "Add"} ${noun} ${active ? "from" : "to"} favorites`;
+ button.setAttribute("aria-label", button.title);
+ }
+}
+
+function renderActiveStatus(state: ViewState): void {
+ const activeStatus = draftSelection === null ? state.activeThread?.status : undefined;
+ status.className = state.error === null ? (activeStatus?.kind ?? "") : "error";
+ if (state.error !== null) {
+ status.textContent = state.error;
+ return;
+ }
+ if (state.busy) {
+ status.textContent = "Synchronizing…";
+ return;
+ }
+ if (activeStatus === undefined) {
+ status.textContent = "";
+ return;
+ }
+ if (activeStatus.kind === "working") {
+ const elapsed = state.activeThread?.turnStartedAt
+ ? formatElapsed(state.activeThread.turnStartedAt, Date.now())
+ : null;
+ status.textContent = elapsed === null ? "Working…" : `Working for ${elapsed}`;
+ return;
+ }
+ status.textContent = activeStatus.kind === "connecting" ? "Connecting…" : activeStatus.label;
+}
+
+function renderProviderIdentity(state: ViewState): void {
+ const candidate = selectedModelCandidate(state);
+ if (candidate === undefined) {
+ providerIcon.replaceChildren();
+ return;
+ }
+ renderProviderIcon(providerIcon, candidate.driver, candidate.providerLabel);
+ providerIcon.title = candidate.providerLabel;
+}
+
+function isRunning(): boolean {
+ return draftSelection === null && currentState?.activeThread?.status.kind === "working";
+}
+
+function hasComposerInput(): boolean {
+ return prompt.value.trim() !== "" || pendingImages.length > 0;
+}
+
+function renderComposerAction(): void {
+ const stopping = isRunning() && !hasComposerInput();
+ send.textContent = stopping ? "Stop" : "Send";
+ send.title = stopping ? "Stop active turn" : "Send message";
+ send.classList.toggle("stop-action", stopping);
+}
+
+function uploadImages(): Array<{
+ type: "image";
+ name: string;
+ mimeType: string;
+ sizeBytes: number;
+ dataUrl: string;
+}> {
+ return pendingImages.map(({ type, name, mimeType, sizeBytes, dataUrl }) => ({
+ type,
+ name,
+ mimeType,
+ sizeBytes,
+ dataUrl,
+ }));
+}
+
+function clearPendingImages(): void {
+ pendingImages = [];
+ renderPendingImages();
+}
+
+function renderPendingImages(): void {
+ pendingAttachments.replaceChildren();
+ for (const image of pendingImages) {
+ const chip = document.createElement("div");
+ chip.className = "pending-attachment";
+ const thumbnail = document.createElement("img");
+ thumbnail.src = image.dataUrl;
+ thumbnail.alt = image.name;
+ const label = document.createElement("span");
+ label.textContent = image.name;
+ const remove = document.createElement("button");
+ remove.textContent = "×";
+ remove.title = `Remove ${image.name}`;
+ remove.addEventListener("click", () => {
+ pendingImages = pendingImages.filter((candidate) => candidate.key !== image.key);
+ renderPendingImages();
+ });
+ chip.append(thumbnail, label, remove);
+ pendingAttachments.append(chip);
+ }
+ renderComposerAction();
+}
+
+function fileDataUrl(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener("load", () =>
+ typeof reader.result === "string"
+ ? resolve(reader.result)
+ : reject(new Error("Invalid image")),
+ );
+ reader.addEventListener("error", () =>
+ reject(reader.error ?? new Error("Could not read image")),
+ );
+ reader.readAsDataURL(file);
+ });
+}
+
+async function addClipboardImages(files: ReadonlyArray): Promise {
+ for (const [index, file] of files.entries()) {
+ if (!file.type.startsWith("image/")) continue;
+ pendingImages.push({
+ key: `${file.name}:${file.size}:${globalThis.performance.now()}:${index}`,
+ type: "image",
+ name: file.name || `pasted-image-${pendingImages.length + 1}.png`,
+ mimeType: file.type || "image/png",
+ sizeBytes: file.size,
+ dataUrl: await fileDataUrl(file),
+ });
+ }
+ renderPendingImages();
+}
+
+function currentSelection(state: ViewState) {
+ if (draftSelection !== null) return draftSelection;
+ const thread = state.activeThread;
+ if (thread === null) return null;
+ return {
+ instanceId: thread.instanceId,
+ model: thread.model,
+ options: [...(thread.options ?? [])],
+ };
+}
+
+function selectedModelCandidate(state: ViewState) {
+ const selection = currentSelection(state);
+ if (selection === null) return undefined;
+ return state.models.find(
+ (candidate) =>
+ candidate.instanceId === selection.instanceId && candidate.model === selection.model,
+ );
+}
+
+function selectedOptions(state: ViewState): Array<{ id: string; value: string | boolean }> {
+ return [...(currentSelection(state)?.options ?? [])];
+}
+
+function sendModelSelection(
+ state: ViewState,
+ options: Array<{ id: string; value: string | boolean }>,
+): void {
+ const selection = currentSelection(state);
+ if (selection === null) return;
+ if (draftSelection !== null) {
+ draftSelection = { ...draftSelection, options };
+ renderModelOptions(state);
+ return;
+ }
+ post({
+ type: "selectModel",
+ instanceId: selection.instanceId,
+ model: selection.model,
+ options,
+ });
+}
+
+function renderModelOptions(state: ViewState): void {
+ modelOptions.replaceChildren();
+ const candidate = selectedModelCandidate(state);
+ if (candidate === undefined || candidate.optionDescriptors.length === 0) return;
+ const values = new Map(selectedOptions(state).map((option) => [option.id, option.value]));
+ for (const descriptor of candidate.optionDescriptors) {
+ const label = document.createElement("label");
+ label.className = "model-option";
+ const title = document.createElement("span");
+ const optionIdentity = `${descriptor.id} ${descriptor.label}`.toLowerCase();
+ const compactIcon =
+ optionIdentity.includes("service") || optionIdentity.includes("tier") ? "ϟ" : null;
+ const omitVisibleLabel = optionIdentity.includes("reason");
+ title.textContent = compactIcon ?? (omitVisibleLabel ? "" : descriptor.label);
+ title.hidden = omitVisibleLabel;
+ title.className = compactIcon === null ? "" : "model-option-icon";
+ title.title = descriptor.label;
+ label.append(title);
+ if (descriptor.type === "select") {
+ const select = document.createElement("select");
+ select.setAttribute("aria-label", descriptor.label);
+ select.title = descriptor.label;
+ for (const choice of descriptor.options) {
+ const option = document.createElement("option");
+ option.value = choice.id;
+ option.textContent = choice.label;
+ option.selected =
+ choice.id ===
+ (values.get(descriptor.id) ??
+ descriptor.currentValue ??
+ descriptor.options.find((entry) => entry.isDefault)?.id);
+ select.append(option);
+ }
+ select.addEventListener("change", () => {
+ const options = selectedOptions(state).filter((option) => option.id !== descriptor.id);
+ options.push({ id: descriptor.id, value: select.value });
+ sendModelSelection(state, options);
+ });
+ label.append(select);
+ } else {
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.setAttribute("aria-label", descriptor.label);
+ checkbox.checked = Boolean(values.get(descriptor.id) ?? descriptor.currentValue ?? false);
+ checkbox.addEventListener("change", () => {
+ const options = selectedOptions(state).filter((option) => option.id !== descriptor.id);
+ options.push({ id: descriptor.id, value: checkbox.checked });
+ sendModelSelection(state, options);
+ });
+ label.prepend(checkbox);
+ }
+ modelOptions.append(label);
+ }
+}
+
+function submit(): void {
+ const slash = prompt.value.trim();
+ if (slash.startsWith("/") && executeSlashCommand(slash)) return;
+ if (!hasComposerInput()) return;
+ const images = uploadImages();
+ if (draftSelection !== null) {
+ post({
+ type: "sendNewThread",
+ text: prompt.value,
+ instanceId: draftSelection.instanceId,
+ model: draftSelection.model,
+ options: draftSelection.options,
+ images,
+ });
+ return;
+ }
+ post({ type: "send", text: prompt.value, images });
+}
+
+function beginNewThread(): void {
+ if (currentState === null) return;
+ const active = currentSelection(currentState);
+ const fallback = currentState.models[0];
+ if (active === null && fallback === undefined) return;
+ draftSelection = active ?? {
+ instanceId: fallback!.instanceId,
+ model: fallback!.model,
+ options: [],
+ };
+ prompt.value = "";
+ slashCommands.hidden = true;
+ render(currentState);
+ prompt.focus();
+}
+
+function executeSlashCommand(value: string): boolean {
+ const command = COMMANDS.find((candidate) => candidate.name === value.toLowerCase());
+ if (command === undefined) return false;
+ prompt.value = "";
+ slashCommands.hidden = true;
+ switch (command.name) {
+ case "/new":
+ beginNewThread();
+ break;
+ case "/threads":
+ threads.focus();
+ threads.click();
+ break;
+ case "/model":
+ model.focus();
+ model.click();
+ break;
+ case "/context":
+ post({ type: "toggleContext" });
+ break;
+ case "/stop":
+ post({ type: "stop" });
+ break;
+ }
+ renderComposerAction();
+ return true;
+}
+
+function matchingSlashCommands(): ReadonlyArray<(typeof COMMANDS)[number]> {
+ const query = prompt.value.trim().toLowerCase();
+ if (!query.startsWith("/") || query.includes(" ")) return [];
+ return COMMANDS.filter((command) => command.name.startsWith(query));
+}
+
+function renderSlashCommands(): void {
+ const matching = matchingSlashCommands();
+ selectedSlashCommand = Math.min(selectedSlashCommand, Math.max(0, matching.length - 1));
+ slashCommands.replaceChildren();
+ slashCommands.hidden = matching.length === 0;
+ for (const [index, command] of matching.entries()) {
+ const button = document.createElement("button");
+ button.className = `slash-command${index === selectedSlashCommand ? " selected" : ""}`;
+ button.type = "button";
+ const name = document.createElement("span");
+ name.className = "slash-command-name";
+ name.textContent = command.name;
+ const description = document.createElement("span");
+ description.className = "slash-command-description";
+ description.textContent = command.description;
+ button.append(name, description);
+ button.addEventListener("mousedown", (event) => event.preventDefault());
+ button.addEventListener("click", () => executeSlashCommand(command.name));
+ slashCommands.append(button);
+ }
+}
+
+function positionTasksDetails(): void {
+ const toggle = requiredElement("tasks-toggle");
+ const viewportPadding = 8;
+ const width = Math.min(320, Math.max(0, globalThis.innerWidth - viewportPadding * 2));
+ const toggleBounds = toggle.getBoundingClientRect();
+ const left = Math.min(
+ Math.max(viewportPadding, toggleBounds.right - width),
+ Math.max(viewportPadding, globalThis.innerWidth - width - viewportPadding),
+ );
+ tasksDetails.style.width = `${width}px`;
+ tasksDetails.style.left = `${left}px`;
+ tasksDetails.style.bottom = `${Math.max(viewportPadding, globalThis.innerHeight - toggleBounds.top + 7)}px`;
+}
+
+window.addEventListener("message", (event: MessageEvent) => {
+ if (typeof event.data !== "object" || event.data === null || !("type" in event.data)) return;
+ if (event.data.type === "state" && "state" in event.data) render(event.data.state as ViewState);
+ if (event.data.type === "sent") {
+ prompt.value = "";
+ clearPendingImages();
+ prompt.focus();
+ }
+ if (event.data.type === "sentNewThread") {
+ draftSelection = null;
+ prompt.value = "";
+ clearPendingImages();
+ prompt.focus();
+ }
+ if (event.data.type === "focusComposer") prompt.focus();
+});
+threads.addEventListener("change", () => {
+ if (threads.value !== "" && threads.value !== "__draft__") {
+ draftSelection = null;
+ post({ type: "selectThread", threadId: threads.value });
+ }
+});
+provider.addEventListener("change", () => {
+ if (draftSelection === null || currentState === null || provider.value === "") return;
+ const firstModel = currentState.models.find(
+ (candidate) => candidate.instanceId === provider.value,
+ );
+ if (firstModel === undefined) return;
+ draftSelection = { instanceId: firstModel.instanceId, model: firstModel.model, options: [] };
+ render(currentState);
+});
+model.addEventListener("change", () => {
+ if (model.value === "" || currentState === null) return;
+ const selection = currentSelection(currentState);
+ if (selection === null) return;
+ if (draftSelection !== null) {
+ draftSelection = { instanceId: selection.instanceId, model: model.value, options: [] };
+ render(currentState);
+ return;
+ }
+ post({ type: "selectModel", instanceId: selection.instanceId, model: model.value, options: [] });
+});
+favoriteProvider.addEventListener("click", () => {
+ if (currentState === null) return;
+ const selection = currentSelection(currentState);
+ if (selection !== null)
+ post({ type: "toggleProviderFavorite", instanceId: selection.instanceId });
+});
+favoriteModel.addEventListener("click", () => {
+ if (currentState === null) return;
+ const selection = currentSelection(currentState);
+ if (selection !== null) {
+ post({
+ type: "toggleModelFavorite",
+ modelKey: modelFavoriteKey(selection.instanceId, selection.model),
+ });
+ }
+});
+requiredElement("tasks-toggle").addEventListener("click", () => {
+ positionTasksDetails();
+ tasksExpanded = !tasksExpanded;
+ requiredElement("tasks-control").classList.toggle("pinned", tasksExpanded);
+});
+requiredElement("tasks-control").addEventListener("pointerenter", positionTasksDetails);
+function closeComposerPopovers(): void {
+ tasksExpanded = false;
+ const control = requiredElement("tasks-control");
+ control.classList.remove("pinned");
+ if (control.contains(document.activeElement)) (document.activeElement as HTMLElement).blur();
+}
+document.addEventListener("pointerdown", (event) => {
+ const target = event.target;
+ if (!(target instanceof Node)) return;
+ if (requiredElement("tasks-control").contains(target)) return;
+ closeComposerPopovers();
+});
+document.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && tasksExpanded) {
+ closeComposerPopovers();
+ event.preventDefault();
+ }
+});
+globalThis.addEventListener("resize", () => {
+ positionTasksDetails();
+});
+requiredElement("new").addEventListener("click", beginNewThread);
+requiredElement("refresh").addEventListener("click", () => post({ type: "refresh" }));
+contextButton.addEventListener("click", () => post({ type: "toggleContext" }));
+send.addEventListener("click", () => {
+ if (isRunning() && !hasComposerInput()) post({ type: "stop" });
+ else submit();
+});
+prompt.addEventListener("input", () => {
+ selectedSlashCommand = 0;
+ renderSlashCommands();
+ renderComposerAction();
+});
+prompt.addEventListener("paste", (event) => {
+ const images = [...(event.clipboardData?.files ?? [])].filter((file) =>
+ file.type.startsWith("image/"),
+ );
+ if (images.length === 0) return;
+ event.preventDefault();
+ void addClipboardImages(images);
+});
+prompt.addEventListener("keydown", (event) => {
+ const matching = matchingSlashCommands();
+ if (matching.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
+ event.preventDefault();
+ selectedSlashCommand =
+ (selectedSlashCommand + (event.key === "ArrowDown" ? 1 : -1) + matching.length) %
+ matching.length;
+ renderSlashCommands();
+ return;
+ }
+ if (event.key === "Escape" && !slashCommands.hidden) {
+ event.preventDefault();
+ slashCommands.hidden = true;
+ return;
+ }
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ if (matching.length > 0) {
+ executeSlashCommand(matching[selectedSlashCommand]!.name);
+ return;
+ }
+ submit();
+ }
+});
+post({ type: "ready" });
+
+globalThis.setInterval(() => {
+ if (currentState?.activeThread?.status.kind === "working") renderActiveStatus(currentState);
+}, 1_000);
diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json
new file mode 100644
index 00000000000..b4e09f80a0f
--- /dev/null
+++ b/apps/vscode/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "types": ["vscode", "node"]
+ },
+ "include": ["src"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ee737f9e750..5d9a8fc35da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -495,6 +495,37 @@ importers:
specifier: 'catalog:'
version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ apps/vscode:
+ dependencies:
+ '@t3tools/client-runtime':
+ specifier: workspace:*
+ version: link:../../packages/client-runtime
+ '@t3tools/contracts':
+ specifier: workspace:*
+ version: link:../../packages/contracts
+ '@t3tools/shared':
+ specifier: workspace:*
+ version: link:../../packages/shared
+ dompurify:
+ specifier: ^3.2.6
+ version: 3.4.12
+ effect:
+ specifier: 4.0.0-beta.78
+ version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
+ marked:
+ specifier: ^15.0.12
+ version: 15.0.12
+ devDependencies:
+ '@types/vscode':
+ specifier: 1.95.0
+ version: 1.95.0
+ esbuild:
+ specifier: ^0.28.0
+ version: 0.28.1
+ vite-plus:
+ specifier: 'catalog:'
+ version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+
apps/web:
dependencies:
'@base-ui/react':
@@ -4916,12 +4947,18 @@ packages:
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+ '@types/vscode@1.95.0':
+ resolution: {integrity: sha512-0LBD8TEiNbet3NvWsmn59zLzOFu/txSlGxnv5yAFHCrhG9WvAnR3IvfHzMOs2aeWqgvNjq9pO99IUw8d3n+unw==}
+
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -6168,6 +6205,9 @@ packages:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
+ dompurify@3.4.12:
+ resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==}
+
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -7854,6 +7894,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+ marked@15.0.12:
+ resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
+ engines: {node: '>= 18'}
+ hasBin: true
+
marky@1.3.0:
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
@@ -14958,10 +15003,15 @@ snapshots:
'@types/statuses@2.0.6': {}
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
+ '@types/vscode@1.95.0': {}
+
'@types/ws@8.18.1':
dependencies:
'@types/node': 24.12.4
@@ -16337,6 +16387,10 @@ snapshots:
dependencies:
domelementtype: 2.3.0
+ dompurify@3.4.12:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -18322,6 +18376,8 @@ snapshots:
markdown-table@3.0.4: {}
+ marked@15.0.12: {}
+
marky@1.3.0: {}
matcher@3.0.0: