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 + +![T3 Code in VS Code](media/screenshot.jpg) + +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: