diff --git a/apps/site/src/App.vue b/apps/site/src/App.vue
index ec267581..a0b42f02 100644
--- a/apps/site/src/App.vue
+++ b/apps/site/src/App.vue
@@ -227,6 +227,20 @@ onUnmounted(() => {
Pythinker Code
Think first, then code.
An open-source AI engineering agent for your terminal. It reads your repo, edits files, runs commands, and iterates until the job is done.
+
+
+
@@ -630,7 +644,7 @@ onUnmounted(() => {
.hero-install {
max-width: 720px;
- margin: 36px auto 0;
+ margin: 10px auto 0;
}
.hero-download-milestone {
@@ -639,6 +653,25 @@ onUnmounted(() => {
justify-content: center;
}
+.hero-npm-badge {
+ display: inline-flex;
+ margin-top: 14px;
+ border-radius: var(--radius);
+ opacity: 0.85;
+ transition: opacity 0.2s ease;
+}
+
+.hero-npm-badge:hover {
+ opacity: 1;
+}
+
+/* ponytail: width:auto lets the badge grow as the download count does; the width attr only reserves space */
+.hero-npm-badge img {
+ display: block;
+ width: auto;
+ height: 20px;
+}
+
.hero-caption {
margin-top: 12px;
color: var(--ink-subtle);
diff --git a/apps/site/src/components/LegacyDownloadsPopup.vue b/apps/site/src/components/LegacyDownloadsPopup.vue
index 3e560d1a..a1740150 100644
--- a/apps/site/src/components/LegacyDownloadsPopup.vue
+++ b/apps/site/src/components/LegacyDownloadsPopup.vue
@@ -25,7 +25,7 @@ const visible = ref(true);
aria-label="View pythinker-code download statistics on Pepy"
>

diff --git a/apps/vscode/package.json b/apps/vscode/package.json
index f3ba029b..f8fb4180 100644
--- a/apps/vscode/package.json
+++ b/apps/vscode/package.json
@@ -3,7 +3,7 @@
"publisher": "pythoughts",
"displayName": "Pythinker Code",
"description": "Pythinker Code extension for VS Code",
- "version": "0.6.7",
+ "version": "0.8.3",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -53,7 +53,7 @@
"pythinker.yoloMode": {
"type": "boolean",
"default": false,
- "description": "Auto-approve regular tool calls; the agent may still ask questions"
+ "description": "Start new sessions in YOLO mode (auto-approve regular tool calls; the agent may still ask questions). Sessions keep the mode they were last left in; changing this applies to the open sessions too."
},
"pythinker.autosave": {
"type": "boolean",
@@ -249,6 +249,7 @@
"package:platform": "node scripts/vsix-package.mjs",
"package:verify": "node scripts/vsix-verify.mjs",
"publish:vsix": "node scripts/vsix-publish.mjs",
+ "release": "node scripts/release-extension.mjs",
"publish:ovsx": "node scripts/ovsx-publish.mjs"
},
"devDependencies": {
diff --git a/apps/vscode/resources/pythinker-icon-from-ico.png b/apps/vscode/resources/pythinker-icon-from-ico.png
deleted file mode 100644
index 865d7529..00000000
Binary files a/apps/vscode/resources/pythinker-icon-from-ico.png and /dev/null differ
diff --git a/apps/vscode/resources/pythinker-icon-storefront.png b/apps/vscode/resources/pythinker-icon-storefront.png
index 865d7529..a604484e 100644
Binary files a/apps/vscode/resources/pythinker-icon-storefront.png and b/apps/vscode/resources/pythinker-icon-storefront.png differ
diff --git a/apps/vscode/scripts/ovsx-publish.mjs b/apps/vscode/scripts/ovsx-publish.mjs
index 05809c6b..fcf476da 100644
--- a/apps/vscode/scripts/ovsx-publish.mjs
+++ b/apps/vscode/scripts/ovsx-publish.mjs
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { runLocalCli } from './local-cli.mjs';
import { parsePublishArguments, publishUsage } from './publish-args.mjs';
+import { messageOf, publishEachTarget } from './publish-retry.mjs';
import { extensionRoot, isMainModule } from './vsix-targets.mjs';
import { verifyVsix } from './vsix-verify.mjs';
@@ -15,22 +16,24 @@ async function main() {
if (!process.env.OVSX_PAT) throw new Error('OVSX_PAT is required to publish.');
await verifyInputs(options);
- for (const file of options.files) {
- console.log(`Publishing verified package ${file}...`);
- try {
- runLocalCli('ovsx', 'ovsx', ['publish', file], {
- cwd: extensionRoot,
- encoding: 'utf8',
- stdio: 'pipe',
- });
- } catch (error) {
- if (/already exists/i.test(error instanceof Error ? error.message : String(error))) {
- console.log(`Package already exists: ${file}`);
- continue;
+ await publishEachTarget({
+ targets: options.targets,
+ files: options.files,
+ registry: 'Open VSX',
+ publishOne: (file) => {
+ try {
+ runLocalCli('ovsx', 'ovsx', ['publish', file], {
+ cwd: extensionRoot,
+ encoding: 'utf8',
+ stdio: 'pipe',
+ });
+ return 'published';
+ } catch (error) {
+ if (/already exists/i.test(messageOf(error))) return 'skipped';
+ throw error;
}
- throw error;
- }
- }
+ },
+ });
}
async function verifyInputs(options) {
diff --git a/apps/vscode/scripts/publish-retry.mjs b/apps/vscode/scripts/publish-retry.mjs
new file mode 100644
index 00000000..944d9af1
--- /dev/null
+++ b/apps/vscode/scripts/publish-retry.mjs
@@ -0,0 +1,106 @@
+// Publishing to a registry fails in three different ways, and they need three
+// different responses: a flaky network call should be retried, a bad token
+// should stop everything immediately, and a rejected package should fail only
+// its own target so the remaining ones still ship.
+const TRANSIENT_PATTERN =
+ /request timeout|etimedout|econnreset|econnrefused|enotfound|eai_again|socket hang up|network|\b(?:429|500|502|503|504)\b|too many requests|service unavailable|gateway/i;
+const AUTH_PATTERN = /\b401\b|unauthorized|invalidaccess|access denied|not allowed|forbidden|\b403\b|authentication|invalid token|expired/i;
+
+export const DEFAULT_ATTEMPTS = 3;
+
+export function messageOf(error) {
+ return error instanceof Error ? error.message : String(error);
+}
+
+/**
+ * `auth` aborts the whole run — every remaining target would fail identically.
+ * `transient` is worth retrying. `fatal` fails one target and lets the rest go.
+ */
+export function classifyError(error) {
+ const message = messageOf(error);
+ if (AUTH_PATTERN.test(message)) return 'auth';
+ if (TRANSIENT_PATTERN.test(message)) return 'transient';
+ return 'fatal';
+}
+
+function delay(ms) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+}
+
+/**
+ * Runs `action`, retrying only transient failures with a widening backoff.
+ * Returns the action's value; rethrows the last error once attempts run out.
+ */
+export async function withRetry(action, options = {}) {
+ const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
+ const label = options.label ?? 'operation';
+ const backoffMs = options.backoffMs ?? [5000, 15000];
+
+ for (let attempt = 1; ; attempt += 1) {
+ try {
+ return await action();
+ } catch (error) {
+ const kind = classifyError(error);
+ if (kind !== 'transient' || attempt >= attempts) {
+ throw error;
+ }
+ const wait = backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
+ console.warn(`${label}: ${kind} failure on attempt ${attempt}/${attempts}, retrying in ${wait / 1000}s...`);
+ console.warn(` ${messageOf(error).split('\n')[0]}`);
+ await delay(wait);
+ }
+ }
+}
+
+/**
+ * Publishes every target, keeping going after a per-target failure so one flaky
+ * upload cannot strand the rest. Throws a summary naming exactly which targets
+ * are live and which still need a re-run, because a half-published version is
+ * the state that is hardest to reason about afterwards.
+ */
+export async function publishEachTarget({ targets, files, registry, publishOne }) {
+ const published = [];
+ const skipped = [];
+ const failures = [];
+ let abortReason = '';
+
+ for (let index = 0; index < targets.length; index += 1) {
+ const target = targets[index];
+ const file = files[index];
+ if (abortReason) {
+ failures.push({ target, message: `not attempted (${abortReason})` });
+ continue;
+ }
+ console.log(`Publishing verified package ${file}...`);
+ try {
+ const outcome = await withRetry(() => publishOne(file, target), { label: `${registry} ${target}` });
+ (outcome === 'skipped' ? skipped : published).push(target);
+ } catch (error) {
+ const kind = classifyError(error);
+ failures.push({ target, message: messageOf(error).split('\n')[0] });
+ if (kind === 'auth') {
+ abortReason = 'aborted after an authentication failure';
+ }
+ }
+ }
+
+ summarize({ registry, published, skipped, failures });
+ if (failures.length > 0) {
+ throw new Error(
+ `${registry}: ${failures.length} of ${targets.length} target(s) failed. ` +
+ `Published targets are live and will be skipped on a re-run — fix the cause and run the publish command again.`,
+ );
+ }
+ return { published, skipped };
+}
+
+function summarize({ registry, published, skipped, failures }) {
+ console.log(`\n${registry} summary:`);
+ if (published.length > 0) console.log(` published: ${published.join(', ')}`);
+ if (skipped.length > 0) console.log(` already published: ${skipped.join(', ')}`);
+ for (const failure of failures) {
+ console.log(` FAILED ${failure.target}: ${failure.message}`);
+ }
+}
diff --git a/apps/vscode/scripts/release-extension.mjs b/apps/vscode/scripts/release-extension.mjs
new file mode 100644
index 00000000..dfe04263
--- /dev/null
+++ b/apps/vscode/scripts/release-extension.mjs
@@ -0,0 +1,176 @@
+#!/usr/bin/env node
+// Tag and publish the VS Code extension in one pass:
+// preflight -> bump -> commit -> build -> package+verify -> publish -> tag -> push.
+//
+// pnpm --filter pythinker-code run release 0.8.3
+// pnpm --filter pythinker-code run release 0.8.3 --dry-run
+//
+// The Marketplace token is read from the macOS keychain, so it never reaches a
+// shell history or a file. Store it once with:
+// security add-generic-password -s pythinker-vsce-pat -a "$USER" -w
+import { execFileSync } from 'node:child_process';
+import { readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { extensionRoot, isMainModule } from './vsix-targets.mjs';
+
+const KEYCHAIN_SERVICE = 'pythinker-vsce-pat';
+const OVSX_KEYCHAIN_SERVICE = 'pythinker-ovsx-pat';
+const MANIFEST = join(extensionRoot, 'package.json');
+const repoRoot = join(extensionRoot, '..', '..');
+
+function run(command, args, options = {}) {
+ return execFileSync(command, args, { stdio: 'inherit', cwd: repoRoot, ...options });
+}
+
+function capture(command, args, options = {}) {
+ return execFileSync(command, args, { encoding: 'utf8', cwd: repoRoot, ...options }).trim();
+}
+
+/** A secret in argv or the environment leaks into `ps` and shell history; the keychain does not. */
+function keychainSecret(service) {
+ try {
+ return capture('security', ['find-generic-password', '-s', service, '-w'], { stdio: ['ignore', 'pipe', 'ignore'] });
+ } catch {
+ return '';
+ }
+}
+
+function parseArguments(argv) {
+ let version = '';
+ let dryRun = false;
+ for (const argument of argv) {
+ if (argument === '--dry-run') dryRun = true;
+ else if (argument === '--') continue;
+ else if (argument.startsWith('-')) throw new Error(`Unknown option: ${argument}`);
+ else if (version) throw new Error('Pass exactly one version.');
+ else version = argument;
+ }
+ if (!/^\d+\.\d+\.\d+$/.test(version)) {
+ throw new Error('Usage: release
[--dry-run]');
+ }
+ return { version, dryRun };
+}
+
+function readManifest() {
+ return JSON.parse(readFileSync(MANIFEST, 'utf8'));
+}
+
+function isNewer(next, current) {
+ const a = next.split('.').map(Number);
+ const b = current.split('.').map(Number);
+ for (let i = 0; i < 3; i += 1) {
+ if (a[i] !== b[i]) return a[i] > b[i];
+ }
+ return false;
+}
+
+/** The Marketplace refuses a duplicate version, so catch it before anything is built. */
+async function assertUnpublished(version) {
+ const response = await fetch('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json;api-version=7.1-preview.1' },
+ body: JSON.stringify({ filters: [{ criteria: [{ filterType: 7, value: 'pythoughts.pythinker-code' }] }], flags: 914 }),
+ });
+ if (!response.ok) {
+ console.warn(`Could not reach the Marketplace to check for ${version}; continuing.`);
+ return;
+ }
+ const body = await response.json();
+ const versions = body.results?.[0]?.extensions?.[0]?.versions ?? [];
+ if (versions.some((entry) => entry.version === version)) {
+ throw new Error(`${version} is already on the Marketplace. Versions cannot be reused — pick the next one.`);
+ }
+}
+
+function assertCleanTree() {
+ if (capture('git', ['status', '--porcelain'])) {
+ throw new Error('Working tree is dirty. Commit or stash first so the tag matches what ships.');
+ }
+}
+
+async function main() {
+ const { version, dryRun } = parseArguments(process.argv.slice(2));
+ const tag = `pythinker-code-vscode@${version}`;
+ const manifest = readManifest();
+
+ assertCleanTree();
+ if (!isNewer(version, manifest.version)) {
+ throw new Error(`${version} does not come after the current ${manifest.version}.`);
+ }
+ if (capture('git', ['tag', '--list', tag])) {
+ throw new Error(`Tag ${tag} already exists.`);
+ }
+ await assertUnpublished(version);
+
+ const vscePat = process.env.VSCE_PAT || keychainSecret(KEYCHAIN_SERVICE);
+ if (!vscePat && !dryRun) {
+ throw new Error(
+ `No Marketplace token. Store one with: security add-generic-password -s ${KEYCHAIN_SERVICE} -a "$USER" -w`,
+ );
+ }
+
+ console.log(`\n== ${manifest.version} -> ${version}${dryRun ? ' (dry run)' : ''} ==\n`);
+ const originalManifest = readFileSync(MANIFEST, 'utf8');
+ writeFileSync(MANIFEST, originalManifest.replace(`"version": "${manifest.version}"`, `"version": "${version}"`));
+
+ if (dryRun) {
+ // A dry run must leave the tree exactly as it found it, including after a
+ // failed build — otherwise the next real run trips its own clean-tree check.
+ try {
+ run('pnpm', ['build']);
+ run('pnpm', ['--filter', 'pythinker-code', 'run', 'package:platform']);
+ console.log(`\nDry run: built and verified ${version}. Nothing published, nothing tagged.`);
+ } finally {
+ writeFileSync(MANIFEST, originalManifest);
+ }
+ return;
+ }
+
+ run('pnpm', ['build']);
+ run('pnpm', ['--filter', 'pythinker-code', 'run', 'package:platform']);
+
+ // Commit before publishing so the shipped bits always correspond to a commit,
+ // and tag only once the Marketplace has actually accepted them.
+ run('git', ['add', 'apps/vscode/package.json']);
+ run('git', ['commit', '-m', `chore(vscode): release ${version}`]);
+
+ try {
+ run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:vsix'], { env: { ...process.env, VSCE_PAT: vscePat } });
+ } catch (error) {
+ // The version bump is already committed and some targets may already be
+ // live, so say exactly how to finish rather than leaving it to be worked out.
+ throw new Error(
+ `${error instanceof Error ? error.message : String(error)}\n\n` +
+ `${version} is partly published and NOT tagged. The publish summary above lists which\n` +
+ `targets are live; published ones are skipped on a re-run. Finish with:\n` +
+ ` pnpm --filter pythinker-code run publish:vsix\n` +
+ ` git tag -a ${tag} -m "Pythinker Code VS Code extension ${version}"\n` +
+ `Do not bump the version again — ${version} is already consumed.`,
+ );
+ }
+
+ const ovsxPat = process.env.OVSX_PAT || keychainSecret(OVSX_KEYCHAIN_SERVICE);
+ if (ovsxPat) {
+ // Open VSX serves Cursor / VSCodium / Windsurf, but it must never undo a
+ // successful Marketplace publish.
+ try {
+ run('pnpm', ['--filter', 'pythinker-code', 'run', 'publish:ovsx'], { env: { ...process.env, OVSX_PAT: ovsxPat } });
+ } catch (error) {
+ console.warn(`Open VSX publish failed, Marketplace is live: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ } else {
+ console.warn(`No Open VSX token (keychain service ${OVSX_KEYCHAIN_SERVICE}) — skipped.`);
+ }
+
+ run('git', ['tag', '-a', tag, '-m', `Pythinker Code VS Code extension ${version}`]);
+ console.log(`\nPublished ${version} and tagged ${tag}.`);
+ console.log(`Push it with: git push origin ${capture('git', ['rev-parse', '--abbrev-ref', 'HEAD'])} ${tag}`);
+}
+
+if (isMainModule(import.meta.url)) {
+ main().catch((error) => {
+ console.error(`\nRelease failed: ${error instanceof Error ? error.message : String(error)}`);
+ process.exitCode = 1;
+ });
+}
diff --git a/apps/vscode/scripts/vsix-publish.mjs b/apps/vscode/scripts/vsix-publish.mjs
index e7952478..9f51453f 100644
--- a/apps/vscode/scripts/vsix-publish.mjs
+++ b/apps/vscode/scripts/vsix-publish.mjs
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
import { runLocalCli } from './local-cli.mjs';
import { parsePublishArguments, publishUsage } from './publish-args.mjs';
+import { publishEachTarget } from './publish-retry.mjs';
import { extensionRoot, isMainModule } from './vsix-targets.mjs';
import { verifyVsix } from './vsix-verify.mjs';
@@ -12,18 +13,30 @@ async function main() {
console.log(publishUsage('Visual Studio Marketplace'));
return;
}
- if (!process.env.VSCE_PAT) throw new Error('VSCE_PAT is required to publish.');
+ // A token is the only option in CI, but a maintainer publishing by hand can
+ // authenticate as the Entra identity `az login` already established instead.
+ const azureCredential = process.env.VSCE_AZURE_CREDENTIAL === '1';
+ if (!azureCredential && !process.env.VSCE_PAT) {
+ throw new Error('Set VSCE_PAT, or VSCE_AZURE_CREDENTIAL=1 to publish as the signed-in Entra identity.');
+ }
await verifyInputs(options);
- for (const file of options.files) {
- console.log(`Publishing verified package ${file}...`);
- runLocalCli(
- '@vscode/vsce',
- 'vsce',
- ['publish', '--packagePath', file, '--skip-duplicate'],
- { cwd: extensionRoot },
- );
- }
+ await publishEachTarget({
+ targets: options.targets,
+ files: options.files,
+ registry: 'Marketplace',
+ publishOne: (file) => {
+ const result = runLocalCli(
+ '@vscode/vsce',
+ 'vsce',
+ ['publish', '--packagePath', file, '--skip-duplicate', ...(azureCredential ? ['--azure-credential'] : [])],
+ { cwd: extensionRoot, encoding: 'utf8', stdio: 'pipe' },
+ );
+ const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
+ process.stdout.write(output);
+ return /already published/i.test(output) ? 'skipped' : 'published';
+ },
+ });
}
async function verifyInputs(options) {
diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts
index 3eafc5b1..a8116ae7 100644
--- a/apps/vscode/shared/bridge.ts
+++ b/apps/vscode/shared/bridge.ts
@@ -25,6 +25,11 @@ export const Methods = {
OpenFolder: "openFolder",
GetModels: "getModels",
+ GetProviders: "getProviders",
+ GetProviderCatalog: "getProviderCatalog",
+ AddCatalogProvider: "addCatalogProvider",
+ RemoveProvider: "removeProvider",
+
GetMCPServers: "getMCPServers",
AddMCPServer: "addMCPServer",
UpdateMCPServer: "updateMCPServer",
@@ -90,6 +95,8 @@ export type RpcMessageValidation =
export const Events = {
ExtensionConfigChanged: "extensionConfigChanged",
MCPServersChanged: "mcpServersChanged",
+ ProvidersChanged: "providersChanged",
+ SlashCommandsChanged: "slashCommandsChanged",
StreamEvent: "streamEvent",
FocusInput: "focusInput",
InsertMention: "insertMention",
@@ -139,6 +146,8 @@ function validateParams(method: RpcMethod, params: unknown): boolean {
case Methods.OpenSettings:
case Methods.OpenFolder:
case Methods.GetModels:
+ case Methods.GetProviders:
+ case Methods.GetProviderCatalog:
case Methods.GetMCPServers:
case Methods.AbortChat:
case Methods.ResetSession:
@@ -167,6 +176,14 @@ function validateParams(method: RpcMethod, params: unknown): boolean {
&& isOptionalType(params["enableNewConversationShortcut"], "boolean")
&& isOptionalType(params["showThinkingContent"], "boolean")
&& isOptionalType(params["showThinkingExpanded"], "boolean");
+ case Methods.AddCatalogProvider:
+ return isPlainObject(params)
+ && isNonEmptyString(params["providerId"])
+ && isOptionalType(params["apiKey"], "string")
+ && isOptionalType(params["apiKeyEnvVar"], "string")
+ && isOptionalType(params["defaultModel"], "string");
+ case Methods.RemoveProvider:
+ return hasNonEmptyString(params, "providerId");
case Methods.AddMCPServer:
return isMcpServerConfig(params);
case Methods.UpdateMCPServer:
diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts
index d006c209..49e4b6e5 100644
--- a/apps/vscode/shared/legacy-sdk.ts
+++ b/apps/vscode/shared/legacy-sdk.ts
@@ -126,6 +126,22 @@ export interface QuestionResponse {
export interface SubagentEvent {
parent_tool_call_id: string;
event: LegacyWireEvent;
+ /** Emitting subagent. */
+ agent_id: string;
+ /** Display name for the lane, e.g. "explore". Absent for non-DynamicWorkflow subagents. */
+ agent_label?: string;
+ /** Position within the DynamicWorkflow batch, 1-based. Absent when the subagent has none. */
+ agent_index?: number;
+}
+
+export interface SubagentStatusPayload {
+ parent_tool_call_id: string;
+ agent_id: string;
+ agent_label?: string;
+ agent_index?: number;
+ status: 'spawned' | 'running' | 'done' | 'failed' | 'suspended';
+ error?: string;
+ result_summary?: string;
}
export type LegacyWireEvent =
@@ -142,6 +158,7 @@ export type LegacyWireEvent =
| { type: 'ToolResult'; payload: ToolResult }
| { type: 'SteerInput'; payload: { user_input: string | ContentPart[] } }
| { type: 'SubagentEvent'; payload: SubagentEvent }
+ | { type: 'SubagentStatus'; payload: SubagentStatusPayload }
| { type: string; payload: unknown };
export type StreamEvent =
diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts
index 1b2c5a3c..c9b86a30 100644
--- a/apps/vscode/shared/types.ts
+++ b/apps/vscode/shared/types.ts
@@ -69,3 +69,43 @@ export interface LoginStatus {
}
export type { QuestionRequest, QuestionItem, QuestionOption, QuestionResponse } from "./legacy-sdk";
+
+/** A provider as it exists in config.toml. The API key itself never crosses the bridge. */
+export interface ConfiguredProvider {
+ id: string;
+ type: string;
+ baseUrl?: string;
+ keySource: "config" | "env" | "oauth" | "none";
+ /** Host of the provider's base URL, shown so a managed provider is identifiable. */
+ host?: string;
+ apiKeyEnvVar?: string;
+ catalogUrl?: string;
+ models: string[];
+}
+
+export interface ProvidersView {
+ providers: ConfiguredProvider[];
+ defaultModel: string | null;
+}
+
+export interface CatalogModelSummary {
+ id: string;
+ name: string;
+ maxContextTokens?: number;
+ thinking: boolean;
+}
+
+export interface CatalogProviderSummary {
+ id: string;
+ name: string;
+ wire?: string;
+ apiKeyEnvVar?: string;
+ models: CatalogModelSummary[];
+}
+
+export interface AddCatalogProviderRequest {
+ providerId: string;
+ apiKey?: string;
+ apiKeyEnvVar?: string;
+ defaultModel?: string;
+}
diff --git a/apps/vscode/src/PythinkerWebviewProvider.ts b/apps/vscode/src/PythinkerWebviewProvider.ts
index d280333c..94090bf5 100644
--- a/apps/vscode/src/PythinkerWebviewProvider.ts
+++ b/apps/vscode/src/PythinkerWebviewProvider.ts
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
-import type { PythinkerHarness } from "@pythoughts/pythinker-code-sdk";
+import type { PermissionMode, PythinkerHarness } from "@pythoughts/pythinker-code-sdk";
import { Events } from "../shared/bridge";
import { BridgeHandler } from "./bridge-handler";
@@ -145,8 +145,8 @@ export class PythinkerWebviewProvider implements vscode.WebviewViewProvider {
return this.bridgeHandler.getBaselineContent(sessionId, filePath);
}
- async setYoloModeForActiveSessions(enabled: boolean): Promise {
- await this.bridgeHandler.runtime.setYoloModeForActiveSessions(enabled);
+ async setPermissionModeForActiveSessions(mode: PermissionMode): Promise {
+ await this.bridgeHandler.runtime.setPermissionModeForActiveSessions(mode);
}
private getHtml(webviewId: string, webview: vscode.Webview): string {
diff --git a/apps/vscode/src/bridge-handler.ts b/apps/vscode/src/bridge-handler.ts
index 8799bf06..7033981d 100644
--- a/apps/vscode/src/bridge-handler.ts
+++ b/apps/vscode/src/bridge-handler.ts
@@ -2,11 +2,13 @@ import * as path from "node:path";
import * as vscode from "vscode";
import {
+ Events,
validateRpcMessage,
type RpcMethod,
type RpcResult,
} from "../shared/bridge";
import { VSCodeSettings } from "./config/vscode-settings";
+import { getSlashCommands } from "./handlers/config.handler";
import { handlers, type BroadcastFn, type HandlerContext, type ReloadWebviewFn, type ShowLogsFn } from "./handlers";
import { BaselineManager, type BaselineSession } from "./managers/baseline.manager";
import { FileManager } from "./managers/file.manager";
@@ -161,6 +163,7 @@ export class BridgeHandler {
...(sessionId === undefined ? {} : { sessionId }),
});
this.fileManager.setSession(webviewId, baselineSession(runtime));
+ void this.broadcastSlashCommands(webviewId);
return runtime;
},
resumeSession: async (sessionId) => {
@@ -182,6 +185,7 @@ export class BridgeHandler {
VSCodeSettings.yoloMode,
);
this.fileManager.setSession(webviewId, baselineSession(runtime));
+ void this.broadcastSlashCommands(webviewId);
return runtime;
},
closeSession: async () => {
@@ -193,6 +197,20 @@ export class BridgeHandler {
};
}
+ /**
+ * The skill catalog only exists once a session does, so the command list the
+ * Webview loaded at startup is missing every skill. Re-push it on session
+ * create/resume rather than making the Webview poll.
+ */
+ private async broadcastSlashCommands(webviewId: string): Promise {
+ try {
+ const commands = await getSlashCommands(undefined, this.createContext(webviewId));
+ this.broadcast(Events.SlashCommandsChanged, commands, webviewId);
+ } catch (error) {
+ this.logRuntimeError("Unable to refresh the slash commands", error);
+ }
+ }
+
private async saveAllDirty(): Promise {
const dirty = vscode.workspace.textDocuments.filter((document) => document.isDirty && !document.isUntitled);
await Promise.all(dirty.map((document) => document.save()));
diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts
index 944b4c30..088b564d 100644
--- a/apps/vscode/src/extension.ts
+++ b/apps/vscode/src/extension.ts
@@ -3,6 +3,7 @@ import * as vscode from "vscode";
import { Events } from "../shared/bridge";
import { PythinkerWebviewProvider } from "./PythinkerWebviewProvider";
import { onSettingsChange, VSCodeSettings } from "./config/vscode-settings";
+import { defaultPermissionMode } from "./runtime/permission-mode";
import { updateLoginContext } from "./utils/context";
let outputChannel: vscode.OutputChannel | undefined;
@@ -48,7 +49,7 @@ export async function activate(context: vscode.ExtensionContext): Promise
});
if (changedKeys.includes("yoloMode")) {
void provider
- ?.setYoloModeForActiveSessions(VSCodeSettings.yoloMode)
+ ?.setPermissionModeForActiveSessions(defaultPermissionMode(VSCodeSettings.yoloMode))
.catch((error) => logError("Unable to update session permission", error));
}
}), vscode.window.registerWebviewViewProvider("pythinker.webview", provider, {
diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts
index 3870cf5c..ba46b4e0 100644
--- a/apps/vscode/src/handlers/chat.handler.ts
+++ b/apps/vscode/src/handlers/chat.handler.ts
@@ -124,7 +124,7 @@ const streamChat: Handler = async (params,
return { done: false };
}
- const slash = parseHostSlashCommand(params.content);
+ const slash = await parseHostSlashCommand(params.content, () => runtime.session.listSkills());
if (slash !== undefined) {
try {
return { done: await runHostSlashCommand(runtime, slash, ctx) };
diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts
index 116fd82a..4804b2eb 100644
--- a/apps/vscode/src/handlers/config.handler.ts
+++ b/apps/vscode/src/handlers/config.handler.ts
@@ -1,4 +1,5 @@
import * as vscode from "vscode";
+import { buildSkillSlashCommands, type SkillSlashCommand } from "@pythoughts/pythinker-code-sdk";
type SdkConfig = any;
import { Methods } from "../../shared/bridge";
@@ -16,11 +17,15 @@ const SLASH_COMMANDS: SlashCommandInfo[] = [
{ name: "init", aliases: [], description: "Analyze the codebase and generate AGENTS.md" },
{ name: "compact", aliases: [], description: "Compact the conversation context" },
{ name: "clear", aliases: ["reset"], description: "Clear the context" },
- { name: "yolo", aliases: [], description: "Toggle YOLO mode (auto-approve tool actions; may still ask questions)" },
+ {
+ name: "yolo",
+ aliases: [],
+ description: "Toggle YOLO mode (auto-approve tool actions; may still ask questions). Usage: /yolo [on|off]",
+ },
{
name: "auto",
aliases: ["afk"],
- description: "Toggle Auto mode (fully autonomous; the agent will not ask questions)",
+ description: "Toggle Auto mode (fully autonomous; the agent will not ask questions). Usage: /auto [on|off]",
},
{ name: "plan", aliases: [], description: "Toggle plan mode. Usage: /plan [on|off|view|clear]" },
{
@@ -83,25 +88,36 @@ const getModels: Handler = async (_, ctx) => {
return toWebviewConfig(await ctx.harness.getConfig({ reload: true }));
};
-const getSlashCommands: Handler = async (_, ctx) => {
- if (!ctx.workDir) return SLASH_COMMANDS;
+/**
+ * Skills are resolved from the workspace, not from a session, so a panel that
+ * has not sent a message yet still lists them. A live session is preferred when
+ * there is one: only it can report the prompts of its MCP connections.
+ */
+export const getSlashCommands: Handler = async (_, ctx) => {
+ const session = ctx.getSession()?.session;
try {
- const skills = await (ctx.harness as any).listWorkspaceSkills?.(ctx.workDir) ?? [];
- const skillCommands = (skills as Array<{ name: string; type: string; description?: string }>)
- .filter((skill) => isUserActivatableSkill(skill.type))
- .toSorted((left, right) => left.name.localeCompare(right.name))
- .map((skill) => ({
- name: `skill:${skill.name}`,
- aliases: [],
- description: skill.description ?? "",
- }));
- return [...SLASH_COMMANDS, ...skillCommands];
+ const skills =
+ session !== undefined
+ ? await session.listSkills()
+ : ctx.workDir !== null
+ ? await ctx.harness.listWorkspaceSkills(ctx.workDir)
+ : [];
+ const { commands } = buildSkillSlashCommands(skills);
+ return [...SLASH_COMMANDS, ...commands.map(toSlashCommandInfo)];
} catch (error) {
- ctx.logError("Unable to list workspace skills", error);
+ ctx.logError("Unable to list skills", error);
return SLASH_COMMANDS;
}
};
+function toSlashCommandInfo(command: SkillSlashCommand): SlashCommandInfo {
+ return {
+ name: command.name,
+ aliases: [...command.aliases],
+ description: command.description,
+ };
+}
+
const showLogs: Handler = async (_, ctx) => {
ctx.showLogs();
return { ok: true };
@@ -150,6 +166,3 @@ function toWebviewModel(id: string, model: any): ModelConfig {
};
}
-function isUserActivatableSkill(type: string | undefined): boolean {
- return type === undefined || type === "prompt" || type === "inline" || type === "flow";
-}
diff --git a/apps/vscode/src/handlers/index.ts b/apps/vscode/src/handlers/index.ts
index fa0a72b1..9631d31f 100644
--- a/apps/vscode/src/handlers/index.ts
+++ b/apps/vscode/src/handlers/index.ts
@@ -1,5 +1,6 @@
import { configHandlers } from "./config.handler";
import { mcpHandlers } from "./mcp.handler";
+import { providerHandlers } from "./provider.handler";
import { sessionHandlers } from "./session.handler";
import { chatHandlers } from "./chat.handler";
import { fileHandlers } from "./file.handler";
@@ -13,6 +14,7 @@ export const handlers: Record> = {
...workspaceHandlers,
...configHandlers,
...mcpHandlers,
+ ...providerHandlers,
...sessionHandlers,
...chatHandlers,
...fileHandlers,
diff --git a/apps/vscode/src/handlers/provider.handler.ts b/apps/vscode/src/handlers/provider.handler.ts
new file mode 100644
index 00000000..c2675188
--- /dev/null
+++ b/apps/vscode/src/handlers/provider.handler.ts
@@ -0,0 +1,143 @@
+import {
+ CatalogProviderError,
+ DEFAULT_CATALOG_URL,
+ catalogConnectionWire,
+ catalogProviderModels,
+ fetchCatalog,
+ importCatalogProvider,
+ type Catalog,
+} from "@pythoughts/pythinker-code-sdk";
+
+import { Events, Methods } from "../../shared/bridge";
+import type {
+ AddCatalogProviderRequest,
+ CatalogProviderSummary,
+ ConfiguredProvider,
+ ProvidersView,
+} from "../../shared/types";
+import type { Handler, HandlerContext } from "./types";
+
+/**
+ * The catalog is a ~1 MB public document that changes rarely. One fetch per
+ * extension host is enough; the modal reads it repeatedly while the user browses.
+ */
+let catalogCache: Promise | undefined;
+
+async function loadCatalog(): Promise {
+ catalogCache ??= fetchCatalog(DEFAULT_CATALOG_URL).catch((error: unknown) => {
+ catalogCache = undefined;
+ throw error;
+ });
+ return catalogCache;
+}
+
+async function readProviders(ctx: HandlerContext): Promise {
+ const config = await ctx.harness.getConfig({ reload: true });
+ const providers: ConfiguredProvider[] = Object.entries(config.providers ?? {})
+ .map(([id, provider]) => toConfiguredProvider(id, provider, config))
+ .toSorted((left, right) => left.id.localeCompare(right.id));
+ return { providers, defaultModel: config.defaultModel ?? null };
+}
+
+function toConfiguredProvider(id: string, provider: any, config: any): ConfiguredProvider {
+ const models = Object.entries(config.models ?? {})
+ .filter(([, alias]) => (alias as any).provider === id)
+ .map(([alias]) => alias);
+ return {
+ id,
+ type: provider.type ?? "unknown",
+ baseUrl: provider.baseUrl,
+ host: hostOf(provider.baseUrl),
+ // Never send the key itself to the Webview; only whether one is configured
+ // and where it comes from. A managed provider authenticates over OAuth and
+ // is required by the schema to carry no key at all, so it is not missing one.
+ keySource:
+ provider.oauth !== undefined
+ ? "oauth"
+ : typeof provider.apiKey === "string" && provider.apiKey.length > 0
+ ? "config"
+ : typeof provider.apiKeyEnvVar === "string" && provider.apiKeyEnvVar.length > 0
+ ? "env"
+ : "none",
+ apiKeyEnvVar: provider.apiKeyEnvVar,
+ catalogUrl: provider.source?.kind === "modelsDev" ? provider.source.url : undefined,
+ models: models.toSorted((left, right) => left.localeCompare(right)),
+ };
+}
+
+export const providerHandlers: Record> = {
+ [Methods.GetProviders]: async (_, ctx): Promise => readProviders(ctx),
+
+ [Methods.GetProviderCatalog]: async (): Promise => {
+ const catalog = await loadCatalog();
+ return Object.entries(catalog)
+ .map(([id, entry]) => toCatalogSummary(id, entry))
+ // A provider that cannot be reached with a single API key has no path
+ // through this UI, so it is not offered.
+ .filter((entry) => entry.wire !== undefined && entry.models.length > 0)
+ .toSorted((left, right) => left.name.localeCompare(right.name));
+ },
+
+ [Methods.AddCatalogProvider]: async (
+ params: AddCatalogProviderRequest,
+ ctx,
+ ): Promise => {
+ const catalog = await loadCatalog();
+ const entry = catalog[params.providerId];
+ if (entry === undefined) {
+ throw new Error(`Provider "${params.providerId}" is not in the catalog.`);
+ }
+ try {
+ await importCatalogProvider(ctx.harness, {
+ providerId: params.providerId,
+ entry,
+ catalogUrl: DEFAULT_CATALOG_URL,
+ apiKey: params.apiKey,
+ apiKeyEnvVar: params.apiKeyEnvVar,
+ defaultModel: params.defaultModel,
+ });
+ } catch (error) {
+ // The import errors are already written for a person to read.
+ if (error instanceof CatalogProviderError) throw new Error(error.message, { cause: error });
+ throw error;
+ }
+ const view = await readProviders(ctx);
+ ctx.broadcast(Events.ProvidersChanged, view);
+ return view;
+ },
+
+ [Methods.RemoveProvider]: async (
+ { providerId }: { providerId: string },
+ ctx,
+ ): Promise => {
+ await ctx.harness.removeProvider(providerId);
+ const view = await readProviders(ctx);
+ ctx.broadcast(Events.ProvidersChanged, view);
+ return view;
+ },
+};
+
+function hostOf(baseUrl: unknown): string | undefined {
+ if (typeof baseUrl !== "string" || baseUrl.length === 0) return undefined;
+ try {
+ return new URL(baseUrl).host;
+ } catch {
+ return undefined;
+ }
+}
+
+function toCatalogSummary(id: string, entry: any): CatalogProviderSummary {
+ const wire = catalogConnectionWire(entry);
+ return {
+ id,
+ name: entry.name ?? id,
+ wire,
+ apiKeyEnvVar: entry.env?.[0],
+ models: catalogProviderModels(entry).map((model) => ({
+ id: model.id,
+ name: model.name ?? model.id,
+ maxContextTokens: model.capability?.max_context_tokens,
+ thinking: model.capability?.thinking === true,
+ })),
+ };
+}
diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts
index 8fefcc55..f9ccafe3 100644
--- a/apps/vscode/src/handlers/slash-command.ts
+++ b/apps/vscode/src/handlers/slash-command.ts
@@ -4,6 +4,12 @@ import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import * as vscode from "vscode";
+import {
+ buildSkillSlashCommands,
+ type PermissionMode,
+ type SkillSummary,
+} from "@pythoughts/pythinker-code-sdk";
+
import type { SessionRuntime } from "../runtime/session-runtime";
import {
buildExportMarkdown,
@@ -32,16 +38,40 @@ export interface HostSlashCommand {
readonly name: string;
readonly args: string;
readonly raw: string;
+ /** Set when the command names a skill; built-in skills are not `skill:`-prefixed. */
+ readonly skillName?: string;
}
-export function parseHostSlashCommand(content: string | readonly unknown[]): HostSlashCommand | undefined {
+/**
+ * `listSkills` is consulted only for a `/word` that is not a host command, so an
+ * ordinary message never pays for it. Anything that resolves to neither a host
+ * command nor a skill is left alone and goes to the model as text.
+ */
+export async function parseHostSlashCommand(
+ content: string | readonly unknown[],
+ listSkills?: () => Promise,
+): Promise {
if (typeof content !== "string") return undefined;
const raw = content.trim();
const match = /^\/([^\s]+)(?:\s+(.*))?\s*$/s.exec(raw);
if (match === null) return undefined;
const name = match[1]!.toLowerCase();
- if (!HOST_COMMANDS.has(name) && !name.startsWith("skill:")) return undefined;
- return { name, args: match[2]?.trim() ?? "", raw };
+ const args = match[2]?.trim() ?? "";
+ if (HOST_COMMANDS.has(name)) return { name, args, raw };
+
+ const skills = listSkills === undefined ? undefined : await listSkills().catch(() => undefined);
+ if (skills === undefined) {
+ // The parser runs on every message that starts with "/", so a catalog
+ // failure must degrade to the prefix check rather than reject and take the
+ // whole send down with it.
+ return name.startsWith("skill:") ? { name, args, raw, skillName: name.slice(6) } : undefined;
+ }
+ const { commandMap } = buildSkillSlashCommands(skills);
+ const skillName = commandMap.get(name) ?? commandMap.get(match[1]!);
+ if (skillName !== undefined) return { name, args, raw, skillName };
+ // A skill the catalog no longer lists still reaches the engine, which reports
+ // the miss far better than silently sending "/skill:foo" to the model.
+ return name.startsWith("skill:") ? { name, args, raw, skillName: name.slice(6) } : undefined;
}
export async function runHostSlashCommand(
@@ -49,8 +79,8 @@ export async function runHostSlashCommand(
command: HostSlashCommand,
ctx: HandlerContext,
): Promise {
- if (command.name.startsWith("skill:")) {
- const skillName = command.name.slice("skill:".length);
+ if (command.skillName !== undefined) {
+ const skillName = command.skillName;
const result = await runtime.runTurnAction(command.raw, async () => {
await runtime.session.activateSkill(skillName, command.args || undefined);
});
@@ -84,11 +114,11 @@ export async function runHostSlashCommand(
emit("The context has been cleared.");
break;
case "yolo":
- await toggleLegacyPermission(runtime, "yolo", emit);
+ await runPermissionCommand(runtime, "yolo", command.args, emit);
break;
case "auto":
case "afk":
- await toggleLegacyPermission(runtime, "afk", emit);
+ await runPermissionCommand(runtime, "auto", command.args, emit);
break;
case "plan":
await runPlanCommand(runtime, command.args, emit);
@@ -114,26 +144,49 @@ export async function runHostSlashCommand(
}
}
-async function toggleLegacyPermission(
+const PERMISSION_MODE_ENABLED_MESSAGE = {
+ yolo: "You only live once! Tool actions will be auto-approved; the agent may still ask questions.",
+ auto: "Auto mode enabled. Questions will be auto-dismissed and tool calls auto-approved.",
+} as const;
+
+const PERMISSION_MODE_DISABLED_MESSAGE = {
+ yolo: "You only die once! Actions will require approval.",
+ auto: "Auto mode disabled. You are back at the keyboard.",
+} as const;
+
+/** `/yolo` and `/auto` accept `on` and `off`, and toggle without an argument — as the CLI does. */
+async function runPermissionCommand(
runtime: SessionRuntime,
- kind: "yolo" | "afk",
+ mode: "yolo" | "auto",
+ args: string,
emit: (text: string) => void,
): Promise {
- const flags = await runtime.toggleLegacyApproval(kind);
+ const subcommand = args.trim().toLowerCase();
+ const requested =
+ subcommand === "on" ? mode : subcommand === "off" ? "manual" : undefined;
- if (kind === "yolo") {
- emit(flags.yolo
- ? "You only live once! Tool actions will be auto-approved; the agent may still ask questions."
- : flags.afk
- ? "Yolo disabled, but Auto is still on — tool calls remain auto-approved."
- : "You only die once! Actions will require approval.");
+ if (requested !== undefined && runtime.permissionMode === requested) {
+ emit(requested === mode ? `${label(mode)} is already on.` : `${label(mode)} is already off.`);
return;
}
- emit(flags.afk
- ? "Auto mode enabled. Questions will be auto-dismissed and tool calls auto-approved."
- : flags.yolo
- ? "Auto mode disabled. You are back at the keyboard. Yolo is still on."
- : "Auto mode disabled. You are back at the keyboard.");
+
+ let current: PermissionMode;
+ if (requested === undefined) {
+ current = await runtime.togglePermissionMode(mode);
+ } else {
+ await runtime.setPermissionMode(requested);
+ current = requested;
+ }
+
+ emit(
+ current === mode
+ ? PERMISSION_MODE_ENABLED_MESSAGE[mode]
+ : PERMISSION_MODE_DISABLED_MESSAGE[mode],
+ );
+}
+
+function label(mode: "yolo" | "auto"): string {
+ return mode === "yolo" ? "YOLO mode" : "Auto mode";
}
async function runPlanCommand(
diff --git a/apps/vscode/src/runtime/event-adapter.ts b/apps/vscode/src/runtime/event-adapter.ts
index dfefa119..0cfa7c32 100644
--- a/apps/vscode/src/runtime/event-adapter.ts
+++ b/apps/vscode/src/runtime/event-adapter.ts
@@ -4,6 +4,7 @@ import type {
DisplayBlock,
LegacyWireEvent,
StatusUpdate,
+ SubagentStatusPayload,
TokenUsage,
TurnBegin,
} from '../../shared/legacy-sdk';
@@ -22,6 +23,9 @@ export interface AdapterTokenUsage {
export interface SubagentParent {
readonly parentAgentId: string;
readonly parentToolCallId: string;
+ readonly subagentName?: string;
+ readonly description?: string;
+ readonly dynamicWorkflowIndex?: number;
}
export interface EventAdapterState {
@@ -89,22 +93,35 @@ export function adaptSdkEvent(
if (sdkEvent.type === 'subagent.spawned') {
const parentAgentId = (sdkEvent as any).parentAgentId ?? (sdkEvent as any).callerAgentId ?? sdkEvent.agentId;
- return {
- state: {
- ...state,
- subagentParents: {
- ...state.subagentParents,
- [sdkEvent.subagentId]: {
- parentAgentId,
- parentToolCallId: scopedToolCallId(
- parentAgentId,
- sdkEvent.parentToolCallId,
- mainAgentId,
- ),
- },
+ const parentToolCallId = scopedToolCallId(parentAgentId, sdkEvent.parentToolCallId, mainAgentId);
+ const nextState: EventAdapterState = {
+ ...state,
+ subagentParents: {
+ ...state.subagentParents,
+ [sdkEvent.subagentId]: {
+ parentAgentId,
+ parentToolCallId,
+ subagentName: sdkEvent.subagentName,
+ description: sdkEvent.description,
+ dynamicWorkflowIndex: sdkEvent.dynamicWorkflowIndex,
},
},
};
+ const statusPayload: SubagentStatusPayload = {
+ parent_tool_call_id: parentToolCallId,
+ agent_id: sdkEvent.subagentId,
+ agent_label: sdkEvent.subagentName,
+ agent_index: sdkEvent.dynamicWorkflowIndex,
+ status: 'spawned',
+ };
+ const routed = routeSubagentEvent(
+ nextState,
+ parentAgentId,
+ { type: 'SubagentStatus', payload: statusPayload },
+ mainAgentId,
+ );
+ if (routed === undefined) return { state: nextState };
+ return { state: nextState, event: withSessionId(routed, sdkEvent.sessionId) };
}
if (sdkEvent.type === 'turn.started') {
@@ -320,6 +337,14 @@ function mapLegacyWireEvent(
}
case 'agent.status.updated':
return mapStatusUpdate(state, sdkEvent);
+ case 'subagent.started':
+ return mapSubagentStatus(state, sdkEvent, 'running');
+ case 'subagent.completed':
+ return mapSubagentStatus(state, sdkEvent, 'done');
+ case 'subagent.failed':
+ return mapSubagentStatus(state, sdkEvent, 'failed');
+ case 'subagent.suspended':
+ return mapSubagentStatus(state, sdkEvent, 'suspended');
case 'compaction.started':
return {
state,
@@ -368,6 +393,32 @@ function mapStatusUpdate(
};
}
+function mapSubagentStatus(
+ state: EventAdapterState,
+ sdkEvent: Extract<
+ Event,
+ { type: 'subagent.started' | 'subagent.completed' | 'subagent.failed' | 'subagent.suspended' }
+ >,
+ status: SubagentStatusPayload['status'],
+): MappedLegacyWireEvent {
+ // subagent.spawned always precedes every other lifecycle event for the same
+ // subagentId, so the parent is always known by the time this runs.
+ const parent = state.subagentParents[sdkEvent.subagentId];
+ if (parent === undefined) return { state };
+
+ const payload: SubagentStatusPayload = {
+ parent_tool_call_id: parent.parentToolCallId,
+ agent_id: sdkEvent.subagentId,
+ agent_label: parent.subagentName,
+ agent_index: parent.dynamicWorkflowIndex,
+ status,
+ error: sdkEvent.type === 'subagent.failed' ? sdkEvent.error : undefined,
+ result_summary: sdkEvent.type === 'subagent.completed' ? sdkEvent.resultSummary : undefined,
+ };
+
+ return { state, event: { type: 'SubagentStatus', payload } };
+}
+
function usageDelta(current: AdapterTokenUsage, previous: AdapterTokenUsage | undefined): TokenUsage {
return {
input_other: delta(current.inputOther, previous?.inputOther),
@@ -414,6 +465,9 @@ function routeSubagentEvent(
type: 'SubagentEvent',
payload: {
parent_tool_call_id: parent.parentToolCallId,
+ agent_id: currentAgentId,
+ agent_label: parent.subagentName,
+ agent_index: parent.dynamicWorkflowIndex,
event: routed,
},
};
diff --git a/apps/vscode/src/runtime/legacy-approval.ts b/apps/vscode/src/runtime/legacy-approval.ts
deleted file mode 100644
index ab3fa850..00000000
--- a/apps/vscode/src/runtime/legacy-approval.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import type { JsonObject, PermissionMode } from "@pythoughts/pythinker-code-sdk";
-
-export const LEGACY_APPROVAL_METADATA_KEY = "vscode_legacy_approval";
-
-export interface LegacyApprovalFlags {
- readonly yolo: boolean;
- readonly afk: boolean;
-}
-
-export function readLegacyApprovalFlags(
- metadata: Readonly> | undefined,
-): LegacyApprovalFlags | undefined {
- const value = metadata?.[LEGACY_APPROVAL_METADATA_KEY];
- return parseLegacyApprovalFlags(value);
-}
-
-function parseLegacyApprovalFlags(value: unknown): LegacyApprovalFlags | undefined {
- if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
- const yolo = Reflect.get(value, "yolo");
- const afk = Reflect.get(value, "afk");
- if (typeof yolo !== "boolean" && typeof afk !== "boolean") return undefined;
- return {
- yolo: typeof yolo === "boolean" ? yolo : false,
- afk: typeof afk === "boolean" ? afk : false,
- };
-}
-
-export function legacyApprovalMetadata(flags: LegacyApprovalFlags): JsonObject {
- return {
- [LEGACY_APPROVAL_METADATA_KEY]: {
- yolo: flags.yolo,
- afk: flags.afk,
- },
- };
-}
-
-export function corePermissionForLegacyApproval(flags: LegacyApprovalFlags): PermissionMode {
- if (flags.afk) return "auto";
- return flags.yolo ? "yolo" : "manual";
-}
-
-/**
- * The global `pythinker.yoloMode` setting is authoritative whenever a session
- * attaches to the runtime; afk stays per-session because it has no global
- * setting counterpart.
- */
-export function withGlobalYoloMode(
- flags: LegacyApprovalFlags,
- yoloMode: boolean,
-): LegacyApprovalFlags {
- return flags.yolo === yoloMode ? flags : { yolo: yoloMode, afk: flags.afk };
-}
diff --git a/apps/vscode/src/runtime/permission-mode.ts b/apps/vscode/src/runtime/permission-mode.ts
new file mode 100644
index 00000000..1b142b44
--- /dev/null
+++ b/apps/vscode/src/runtime/permission-mode.ts
@@ -0,0 +1,45 @@
+import type { JsonObject, PermissionMode, Session } from "@pythoughts/pythinker-code-sdk";
+
+/**
+ * The engine keeps the permission mode in memory only, so a resumed session
+ * would always come back as `manual`. The extension persists the mode the user
+ * chose in session metadata and restores it on attach.
+ */
+export const SESSION_PERMISSION_MODE_KEY = "vscode_permission_mode";
+
+const PERMISSION_MODES: ReadonlySet = new Set(["manual", "auto", "yolo"]);
+
+export function isPermissionMode(value: unknown): value is PermissionMode {
+ return typeof value === "string" && PERMISSION_MODES.has(value as PermissionMode);
+}
+
+export function readPermissionMode(
+ metadata: Readonly> | undefined,
+): PermissionMode | undefined {
+ const value = metadata?.[SESSION_PERMISSION_MODE_KEY];
+ return isPermissionMode(value) ? value : undefined;
+}
+
+export function permissionModeMetadata(mode: PermissionMode): JsonObject {
+ return { [SESSION_PERMISSION_MODE_KEY]: mode };
+}
+
+/**
+ * The session-meta patch is a shallow merge, and the caller-owned bag it lands
+ * in is `custom` — surfaced as `summary.metadata` on the read side. The whole
+ * bag is rewritten so the keys it already holds survive the patch.
+ */
+export async function persistPermissionMode(
+ session: Session,
+ mode: PermissionMode,
+): Promise {
+ const meta = await session.getSessionMetadata();
+ await session.updateSessionMetadata({
+ custom: { ...meta.custom, ...permissionModeMetadata(mode) },
+ });
+}
+
+/** The `pythinker.yoloMode` setting seeds new sessions; it never overrides a stored mode. */
+export function defaultPermissionMode(yoloModeSetting: boolean): PermissionMode {
+ return yoloModeSetting ? "yolo" : "manual";
+}
diff --git a/apps/vscode/src/runtime/pythinker-runtime.ts b/apps/vscode/src/runtime/pythinker-runtime.ts
index 29443438..04340115 100644
--- a/apps/vscode/src/runtime/pythinker-runtime.ts
+++ b/apps/vscode/src/runtime/pythinker-runtime.ts
@@ -1,5 +1,6 @@
import {
createPythinkerHarness,
+ type PermissionMode,
type PythinkerHarness,
type Session,
type SessionSummary,
@@ -7,12 +8,11 @@ import {
import type { RuntimeBroadcast } from "./session-runtime";
import {
- corePermissionForLegacyApproval,
- legacyApprovalMetadata,
- readLegacyApprovalFlags,
- withGlobalYoloMode,
- type LegacyApprovalFlags,
-} from "./legacy-approval";
+ defaultPermissionMode,
+ permissionModeMetadata,
+ persistPermissionMode,
+ readPermissionMode,
+} from "./permission-mode";
import { SessionRuntime } from "./session-runtime";
import { areSameFsPath } from "../utils/fs-path";
@@ -84,7 +84,7 @@ export class PythinkerRuntime {
requestedId === current.id &&
areSameFsPath(current.session.workDir, options.workDir)
) {
- await applySessionSettings(current.session, options, current.legacyApprovalFlags);
+ await applySessionPermission(current.session, current.permissionMode);
await current.announceStatus(options.webviewId);
return current;
}
@@ -92,31 +92,25 @@ export class PythinkerRuntime {
let runtime = requestedId === undefined ? undefined : this.sessions.get(requestedId);
if (runtime !== undefined) {
assertSessionWorkDir(runtime.session, options.workDir);
- await applySessionSettings(runtime.session, options, runtime.legacyApprovalFlags);
+ await applySessionPermission(runtime.session, runtime.permissionMode);
await this.detachView(options.webviewId);
} else {
- const defaultApproval: LegacyApprovalFlags = { yolo: options.yoloMode, afk: false };
+ const seedMode = defaultPermissionMode(options.yoloMode);
const session =
requestedId === undefined
? await this.harness.createSession({
workDir: options.workDir,
model: options.model || undefined,
thinking: normalizeEffort(options.effort),
- permission: corePermissionForLegacyApproval(defaultApproval),
- metadata: legacyApprovalMetadata(defaultApproval),
+ permission: seedMode,
+ metadata: permissionModeMetadata(seedMode),
})
: await this.harness.resumeSession({ id: requestedId });
try {
assertSessionWorkDir(session, options.workDir);
- const storedApproval = readLegacyApprovalFlags(session.summary?.metadata);
- const restoredApproval = storedApproval ?? defaultApproval;
- const approval = withGlobalYoloMode(restoredApproval, options.yoloMode);
- if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) {
- await (session as any).updateMetadata?.(legacyApprovalMetadata(approval));
- }
- await applySessionSettings(session, options, approval);
+ const mode = await restorePermissionMode(session, seedMode);
await this.detachView(options.webviewId);
- runtime = this.wrapSession(session, approval);
+ runtime = this.wrapSession(session, mode);
} catch (error) {
await session.close().catch((closeError: unknown) => {
this.log("Failed to close a rejected session", closeError);
@@ -134,7 +128,7 @@ export class PythinkerRuntime {
async attachResumedSession(
webviewId: string,
session: Session,
- defaultYoloMode = false,
+ yoloModeSetting = false,
): Promise {
const existing = this.sessions.get(session.id);
if (existing !== undefined && this.sessionByView.get(webviewId) === session.id) {
@@ -146,16 +140,8 @@ export class PythinkerRuntime {
let runtime = existing ?? this.sessions.get(session.id);
if (runtime === undefined) {
try {
- const storedApproval = readLegacyApprovalFlags(session.summary?.metadata);
- const restoredApproval = storedApproval ?? { yolo: defaultYoloMode, afk: false };
- const approval = withGlobalYoloMode(restoredApproval, defaultYoloMode);
- if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) {
- await (session as any).updateMetadata?.(legacyApprovalMetadata(approval));
- }
- const status = await session.getStatus();
- const permission = corePermissionForLegacyApproval(approval);
- if (status.permission !== permission) await session.setPermission(permission);
- runtime = this.wrapSession(session, approval);
+ const mode = await restorePermissionMode(session, defaultPermissionMode(yoloModeSetting));
+ runtime = this.wrapSession(session, mode);
} catch (error) {
await session.close().catch((closeError: unknown) => {
this.log("Failed to close a rejected session", closeError);
@@ -200,9 +186,13 @@ export class PythinkerRuntime {
await ((this.harness as any).deleteSession?.(id) ?? Promise.resolve());
}
- async setYoloModeForActiveSessions(enabled: boolean): Promise {
+ /**
+ * Applies an explicit settings change to the live sessions. Attach and resume
+ * never do this — they restore whatever mode the session was left in.
+ */
+ async setPermissionModeForActiveSessions(mode: PermissionMode): Promise {
await Promise.all(
- [...this.sessions.values()].map((session) => session.setLegacyYoloMode(enabled)),
+ [...this.sessions.values()].map((session) => session.setPermissionMode(mode)),
);
}
@@ -215,10 +205,10 @@ export class PythinkerRuntime {
await this.harness.close();
}
- private wrapSession(session: Session, legacyApproval: LegacyApprovalFlags): SessionRuntime {
+ private wrapSession(session: Session, permissionMode: PermissionMode): SessionRuntime {
const runtime = new SessionRuntime({
session,
- legacyApproval,
+ permissionMode,
broadcast: this.broadcast,
captureBaseline: this.captureBaseline,
log: this.log,
@@ -232,24 +222,31 @@ export class PythinkerRuntime {
}
}
-async function applySessionSettings(
+/**
+ * The engine forgets the permission mode when a session is resumed, so the
+ * stored mode is authoritative and the seed only covers sessions that have
+ * never recorded one.
+ */
+async function restorePermissionMode(
session: Session,
- options: OpenSessionOptions,
- legacyApproval: LegacyApprovalFlags,
-): Promise {
- const status = await session.getStatus();
- const permission = corePermissionForLegacyApproval(legacyApproval);
- if (status.permission !== permission) {
- await session.setPermission(permission);
+ seedMode: PermissionMode,
+): Promise {
+ const storedMode = readPermissionMode(session.summary?.metadata);
+ const mode = storedMode ?? seedMode;
+ if (storedMode === undefined) {
+ await persistPermissionMode(session, mode);
}
+ await applySessionPermission(session, mode);
+ return mode;
}
-export function normalizeEffort(effort: string): string {
- return effort.trim() || "off";
+async function applySessionPermission(session: Session, mode: PermissionMode): Promise {
+ const status = await session.getStatus();
+ if (status.permission !== mode) await session.setPermission(mode);
}
-function flagsDiffer(a: LegacyApprovalFlags, b: LegacyApprovalFlags): boolean {
- return a.yolo !== b.yolo || a.afk !== b.afk;
+export function normalizeEffort(effort: string): string {
+ return effort.trim() || "off";
}
function assertSessionWorkDir(session: Pick, expectedWorkDir: string): void {
diff --git a/apps/vscode/src/runtime/replay-adapter.ts b/apps/vscode/src/runtime/replay-adapter.ts
index bd51f226..83f544bf 100644
--- a/apps/vscode/src/runtime/replay-adapter.ts
+++ b/apps/vscode/src/runtime/replay-adapter.ts
@@ -496,6 +496,9 @@ function wrapSubagentEvent(
invocation.parentAgentId,
invocation.parentToolCallId,
),
+ // Replay history carries no persisted label or dynamicWorkflowIndex; the UI
+ // falls back to the id's short form.
+ agent_id: invocation.childAgentId,
event: routed,
},
};
diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts
index f12d6769..cf16e44c 100644
--- a/apps/vscode/src/runtime/session-runtime.ts
+++ b/apps/vscode/src/runtime/session-runtime.ts
@@ -2,6 +2,7 @@ import {
isPythinkerError,
type ContentPart as SdkContentPart,
type Event,
+ type PermissionMode,
type PromptInput,
type Session,
type SessionSummary,
@@ -17,14 +18,14 @@ import {
type EventAdapterState,
type TurnTerminalMetadata,
} from "./event-adapter";
-import { corePermissionForLegacyApproval, type LegacyApprovalFlags } from "./legacy-approval";
+import { persistPermissionMode } from "./permission-mode";
import { ReverseRpcController } from "./reverse-rpc";
export type RuntimeBroadcast = (event: string, data: unknown, webviewId?: string) => void;
export interface SessionRuntimeOptions {
readonly session: Session;
- readonly legacyApproval: LegacyApprovalFlags;
+ readonly permissionMode: PermissionMode;
readonly broadcast: RuntimeBroadcast;
readonly captureBaseline: (
session: Pick,
@@ -83,7 +84,7 @@ export class SessionRuntime {
private exclusiveActionActive = false;
private readonly terminalKeys = new Set();
private suppressedError: SuppressedError | undefined;
- private legacyApproval: LegacyApprovalFlags;
+ private currentPermissionMode: PermissionMode;
private closed = false;
constructor(options: SessionRuntimeOptions) {
@@ -91,13 +92,13 @@ export class SessionRuntime {
this.broadcast = options.broadcast;
this.captureBaseline = options.captureBaseline;
this.log = options.log;
- this.legacyApproval = options.legacyApproval;
+ this.currentPermissionMode = options.permissionMode;
this.reverseRpc = new ReverseRpcController((event) => this.emitStreamEvent(event));
// Forward every approval request to the user. The engine permission mode
- // (mapped from the legacy flags) already auto-approves what yolo/auto
- // allow internally; anything that reaches this handler is an exception
- // (sensitive file, plan review, ask rule) the user must decide on.
+ // already auto-approves what yolo/auto allow internally; anything that
+ // reaches this handler is an exception (sensitive file, plan review, ask
+ // rule) the user must decide on.
this.session.setApprovalHandler((request) => this.reverseRpc.requestApproval(request));
this.session.setQuestionHandler((request) => this.reverseRpc.requestQuestion(request));
this.unsubscribe = this.session.onEvent((event) => this.onSdkEvent(event));
@@ -119,19 +120,24 @@ export class SessionRuntime {
return this.hasActiveWork || this.exclusiveActionActive;
}
- get legacyApprovalFlags(): LegacyApprovalFlags {
- return this.legacyApproval;
+ get permissionMode(): PermissionMode {
+ return this.currentPermissionMode;
}
- async toggleLegacyApproval(kind: keyof LegacyApprovalFlags): Promise {
- const next = { ...this.legacyApproval, [kind]: !this.legacyApproval[kind] };
- await this.applyLegacyApproval(next);
+ /** Toggles between `mode` and `manual`, and returns the mode now in effect. */
+ async togglePermissionMode(mode: Exclude): Promise {
+ const next = this.currentPermissionMode === mode ? "manual" : mode;
+ await this.setPermissionMode(next);
return next;
}
- async setLegacyYoloMode(enabled: boolean): Promise {
- if (this.legacyApproval.yolo === enabled) return;
- await this.applyLegacyApproval({ ...this.legacyApproval, yolo: enabled });
+ async setPermissionMode(mode: PermissionMode): Promise {
+ if (this.currentPermissionMode === mode) return;
+ this.ensureOpen();
+ const status = await this.session.getStatus();
+ if (status.permission !== mode) await this.session.setPermission(mode);
+ await persistPermissionMode(this.session, mode);
+ this.currentPermissionMode = mode;
}
subscribe(webviewId: string): void {
@@ -413,15 +419,6 @@ export class SessionRuntime {
this.webviewIds.clear();
}
- private async applyLegacyApproval(flags: LegacyApprovalFlags): Promise {
- this.ensureOpen();
- const permission = corePermissionForLegacyApproval(flags);
- const status = await this.session.getStatus();
- const permissionChanged = status.permission !== permission;
- if (permissionChanged) await this.session.setPermission(permission);
- this.legacyApproval = flags;
- }
-
private onSdkEvent(event: Event): void {
if (this.closed) return;
diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts
index 648662c4..40330c30 100644
--- a/apps/vscode/test/bridge-handler.test.ts
+++ b/apps/vscode/test/bridge-handler.test.ts
@@ -26,6 +26,8 @@ const host = vi.hoisted(() => {
close: vi.fn(async () => undefined),
getConfig: vi.fn(),
setConfig: vi.fn(async () => undefined),
+ ensureConfigFile: vi.fn(async () => undefined),
+ removeProvider: vi.fn(async () => undefined),
listSessions: vi.fn(async () => []),
resumeSession: vi.fn(),
forkSession: vi.fn(),
@@ -430,7 +432,7 @@ describe("Webview RPC boundary (validates requests before host dispatch)", () =>
describe("Webview config saves (thinking effort persistence parity with the TUI)", () => {
const effortModel = {
- provider: "managed:pythinker-code",
+ provider: "managed:kimi-code",
model: "reasoning",
supportEfforts: ["low", "high", "max"],
defaultEffort: "high",
@@ -521,7 +523,7 @@ function createResumedSession(id: string, workDir: string) {
sessionDir: join("/private/pythinker/sessions", id),
createdAt: 1,
updatedAt: 2,
- metadata: { vscode_legacy_approval: { yolo: false, afk: false } },
+ metadata: { vscode_permission_mode: "manual" },
};
return {
id,
@@ -559,9 +561,105 @@ function createResumedSession(id: string, workDir: string) {
}),
getStatus: async () => ({ permission: "manual" }),
setPermission: async () => undefined,
- updateMetadata: async () => undefined,
+ getSessionMetadata: async () => ({ custom: {} }),
+ updateSessionMetadata: async () => undefined,
setApprovalHandler: () => undefined,
setQuestionHandler: () => undefined,
onEvent: () => () => undefined,
};
}
+
+describe("Webview provider management (writes the same config.toml the CLI reads)", () => {
+ const catalog = {
+ anthropic: {
+ id: "anthropic",
+ name: "Anthropic",
+ api: "https://api.anthropic.com",
+ npm: "@ai-sdk/anthropic",
+ env: ["ANTHROPIC_API_KEY"],
+ models: { m1: { id: "m1", name: "M1", limit: { context: 200000, output: 64000 } } },
+ },
+ unusable: { id: "unusable", name: "Unusable", models: {} },
+ };
+
+ beforeEach(() => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response(JSON.stringify(catalog), { status: 200 })),
+ );
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("lists configured providers without ever sending the API key", async () => {
+ host.harness.getConfig.mockResolvedValue({
+ providers: { anthropic: { type: "anthropic", apiKey: "sk-secret", baseUrl: "https://api.anthropic.com" } },
+ models: { "anthropic/m1": { provider: "anthropic", model: "m1" } },
+ defaultModel: "anthropic/m1",
+ } as never);
+
+ const response = await bridge.handle({ id: "rpc-1", method: Methods.GetProviders }, "view-1");
+
+ const result = (response as { result: any }).result;
+ expect(result.providers).toEqual([
+ expect.objectContaining({ id: "anthropic", keySource: "config", models: ["anthropic/m1"] }),
+ ]);
+ expect(JSON.stringify(result)).not.toContain("sk-secret");
+ });
+
+ it("offers only catalog providers that a single key can reach", async () => {
+ const response = await bridge.handle({ id: "rpc-1", method: Methods.GetProviderCatalog }, "view-1");
+
+ const result = (response as { result: any }).result;
+ expect(result.map((entry: any) => entry.id)).toEqual(["anthropic"]);
+ expect(result[0].models).toEqual([expect.objectContaining({ id: "m1" })]);
+ });
+
+ it("imports a catalog provider into the config", async () => {
+ host.harness.getConfig.mockResolvedValue({ providers: {} } as never);
+
+ const response = await bridge.handle(
+ {
+ id: "rpc-1",
+ method: Methods.AddCatalogProvider,
+ params: { providerId: "anthropic", apiKey: "sk-test", defaultModel: "m1" },
+ },
+ "view-1",
+ );
+
+ expect((response as { error?: unknown }).error).toBeUndefined();
+ expect(host.harness.setConfig).toHaveBeenCalledWith(
+ expect.objectContaining({
+ providers: expect.objectContaining({
+ anthropic: expect.objectContaining({ apiKey: "sk-test" }),
+ }),
+ defaultModel: "anthropic/m1",
+ }),
+ );
+ });
+
+ it("rejects an import with no key rather than writing a broken provider", async () => {
+ host.harness.getConfig.mockResolvedValue({ providers: {} } as never);
+
+ const response = await bridge.handle(
+ { id: "rpc-1", method: Methods.AddCatalogProvider, params: { providerId: "anthropic" } },
+ "view-1",
+ );
+
+ expect((response as { error?: string }).error).toMatch(/needs an API key/);
+ expect(host.harness.setConfig).not.toHaveBeenCalled();
+ });
+
+ it("removes a provider through the harness", async () => {
+ host.harness.getConfig.mockResolvedValue({ providers: {} } as never);
+
+ await bridge.handle(
+ { id: "rpc-1", method: Methods.RemoveProvider, params: { providerId: "anthropic" } },
+ "view-1",
+ );
+
+ expect(host.harness.removeProvider).toHaveBeenCalledWith("anthropic");
+ });
+});
diff --git a/apps/vscode/test/event-adapter.test.ts b/apps/vscode/test/event-adapter.test.ts
index e910edfa..04a18520 100644
--- a/apps/vscode/test/event-adapter.test.ts
+++ b/apps/vscode/test/event-adapter.test.ts
@@ -360,6 +360,8 @@ describe('event adapter (projects SDK events into the legacy Webview contract)',
type: 'SubagentEvent',
payload: {
parent_tool_call_id: 'agent-call-1',
+ agent_id: 'child-1',
+ agent_label: 'coder',
event: {
type: 'ContentPart',
payload: { type: 'text', text: 'Child result' },
@@ -394,6 +396,8 @@ describe('event adapter (projects SDK events into the legacy Webview contract)',
type: 'SubagentEvent',
payload: {
parent_tool_call_id: 'agent-call-1',
+ agent_id: 'child-1',
+ agent_label: 'coder',
event: {
type: 'ToolCall',
payload: {
@@ -410,6 +414,76 @@ describe('event adapter (projects SDK events into the legacy Webview contract)',
});
});
+ it('emits a spawned SubagentStatus the moment a subagent is queued', () => {
+ const result = adaptSdkEvent(createEventAdapterState(), {
+ type: 'subagent.spawned',
+ sessionId: 'session-1',
+ agentId: 'main',
+ subagentId: 'child-1',
+ subagentName: 'explore',
+ parentToolCallId: 'wf-1',
+ parentAgentId: 'main',
+ dynamicWorkflowIndex: 2,
+ runInBackground: false,
+ });
+
+ expect(result.event).toEqual({
+ type: 'SubagentStatus',
+ payload: {
+ parent_tool_call_id: 'wf-1',
+ agent_id: 'child-1',
+ agent_label: 'explore',
+ agent_index: 2,
+ status: 'spawned',
+ },
+ _sessionId: 'session-1',
+ });
+ });
+
+ it.each([
+ ['subagent.started' as const, 'running' as const, {}],
+ ['subagent.suspended' as const, 'suspended' as const, { reason: 'waiting on approval' }],
+ ['subagent.completed' as const, 'done' as const, { resultSummary: 'Explored 3 files' }],
+ ['subagent.failed' as const, 'failed' as const, { error: 'timed out' }],
+ ])('routes each %s lifecycle event to one SubagentStatus on the spawning tool call', (sdkType, status, extra) => {
+ const spawned = adaptSdkEvent(createEventAdapterState(), {
+ type: 'subagent.spawned',
+ sessionId: 'session-1',
+ agentId: 'main',
+ subagentId: 'child-1',
+ subagentName: 'explore',
+ parentToolCallId: 'wf-1',
+ parentAgentId: 'main',
+ dynamicWorkflowIndex: 2,
+ runInBackground: false,
+ });
+
+ const lifecycle = adaptSdkEvent(spawned.state, {
+ type: sdkType,
+ sessionId: 'session-1',
+ agentId: 'main',
+ subagentId: 'child-1',
+ parentToolCallId: 'wf-1',
+ ...extra,
+ } as any);
+
+ const expectedPayload: Record = {
+ parent_tool_call_id: 'wf-1',
+ agent_id: 'child-1',
+ agent_label: 'explore',
+ agent_index: 2,
+ status,
+ };
+ if ('error' in extra) expectedPayload['error'] = (extra as { error: string }).error;
+ if ('resultSummary' in extra) expectedPayload['result_summary'] = (extra as { resultSummary: string }).resultSummary;
+
+ expect(lifecycle.event).toEqual({
+ type: 'SubagentStatus',
+ payload: expectedPayload,
+ _sessionId: 'session-1',
+ });
+ });
+
it('emits compaction begin when SDK compaction starts', () => {
const result = adaptSdkEvent(createEventAdapterState(), {
type: 'compaction.started',
diff --git a/apps/vscode/test/event-handlers.test.ts b/apps/vscode/test/event-handlers.test.ts
new file mode 100644
index 00000000..a82ac0ff
--- /dev/null
+++ b/apps/vscode/test/event-handlers.test.ts
@@ -0,0 +1,272 @@
+/**
+ * Scenario: `DynamicWorkflow` batches stream events for many subagents that share one
+ * parent tool call. Store-level event handling must attribute each agent's steps to that
+ * agent alone, and per-agent status/lane derivation must reflect the wire protocol.
+ * Wiring: the real Zustand chat store; the VS Code bridge is the only replaced boundary.
+ * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/event-handlers.test.ts
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { useChatStore } from "../webview-ui/src/stores/chat.store";
+import { deriveWorkflowLanes, maxLaneStepCount } from "../webview-ui/src/lib/workflow-lanes";
+import type { UIStepItem } from "../webview-ui/src/stores/chat.store";
+
+const boundary = vi.hoisted(() => ({
+ saveConfig: vi.fn(),
+ streamChat: vi.fn(),
+ abortChat: vi.fn(),
+ trackFiles: vi.fn(),
+ toastError: vi.fn(),
+ toastWarning: vi.fn(),
+}));
+
+vi.mock("@/services", () => ({
+ bridge: {
+ saveConfig: boundary.saveConfig,
+ streamChat: boundary.streamChat,
+ abortChat: boundary.abortChat,
+ trackFiles: boundary.trackFiles,
+ },
+}));
+vi.mock("@/components/ui/sonner", () => ({
+ toast: { error: boundary.toastError, warning: boundary.toastWarning },
+}));
+
+beforeEach(() => {
+ boundary.streamChat.mockReset();
+ boundary.abortChat.mockReset();
+ boundary.trackFiles.mockReset();
+ useChatStore.setState({
+ sessionId: null,
+ messages: [],
+ isStreaming: false,
+ isCompacting: false,
+ handshakeReceived: false,
+ draftMedia: [],
+ lastStatus: null,
+ tokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 },
+ activeTokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 },
+ pendingInput: null,
+ queue: [],
+ pendingQuestion: null,
+ planMode: false,
+ });
+});
+
+function startWorkflowTurn() {
+ useChatStore.getState().processEvent({ type: "TurnBegin", payload: { user_input: "run workflow" } });
+ useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 1 } });
+ useChatStore.getState().processEvent({
+ type: "ToolCall",
+ payload: { type: "function", id: "wf-1", function: { name: "DynamicWorkflow", arguments: "{}" } },
+ });
+}
+
+function workflowToolItem(): UIStepItem & { type: "tool_use" } {
+ const last = useChatStore.getState().messages.at(-1)!;
+ const item = last.steps!.at(0)!.items.find((i) => i.type === "tool_use");
+ return item as UIStepItem & { type: "tool_use" };
+}
+
+function wrap(agentId: string, event: { type: string; payload: unknown }, agentIndex?: number) {
+ return {
+ type: "SubagentEvent",
+ payload: {
+ parent_tool_call_id: "wf-1",
+ agent_id: agentId,
+ agent_label: "explore",
+ agent_index: agentIndex,
+ event,
+ },
+ };
+}
+
+describe("Webview DynamicWorkflow per-agent lanes", () => {
+ it("keeps each subagent's streamed text in its own step when two agents interleave", () => {
+ startWorkflowTurn();
+
+ // Agent A opens its step, agent B opens its step, then their text arrives
+ // out of order. Each agent's text must land only in its own step.
+ useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1));
+ useChatStore.getState().processEvent(wrap("agentB", { type: "StepBegin", payload: { n: 1 } }, 2));
+ useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "hello from A" } }, 1));
+ useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "hello from B" } }, 2));
+ useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: ", continued" } }, 1));
+
+ const toolItem = workflowToolItem();
+ const textFor = (agentId: string) =>
+ toolItem
+ .subagent_steps!.filter((s) => s.agentId === agentId)
+ .flatMap((s) => s.items)
+ .filter((i): i is { type: "text"; content: string } => i.type === "text")
+ .map((i) => i.content)
+ .join("");
+
+ expect(textFor("agentA")).toBe("hello from A, continued");
+ expect(textFor("agentB")).toBe("hello from B");
+ });
+
+ it("gives an agent exactly one step per StepBegin, with no leading empty step", () => {
+ startWorkflowTurn();
+
+ // A lane's step count drives its progress bar, so a seeded placeholder step
+ // in front of the agent's own StepBegin would inflate every lane by one.
+ useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1));
+ useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "work" } }, 1));
+ useChatStore.getState().processEvent(wrap("agentB", { type: "StepBegin", payload: { n: 1 } }, 2));
+ useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "work" } }, 2));
+
+ const steps = workflowToolItem().subagent_steps!;
+ expect(steps.filter((s) => s.agentId === "agentA")).toHaveLength(1);
+ expect(steps.filter((s) => s.agentId === "agentB")).toHaveLength(1);
+ expect(steps.every((s) => s.items.length > 0)).toBe(true);
+ });
+
+ it("stamps startedAt/endedAt as a lane's SubagentStatus transitions", () => {
+ startWorkflowTurn();
+
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" },
+ });
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" },
+ });
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "done", result_summary: "Explored 3 files" },
+ });
+
+ const status = workflowToolItem().subagent_status!["agentA"]!;
+ expect(status.status).toBe("done");
+ expect(status.resultSummary).toBe("Explored 3 files");
+ expect(typeof status.startedAt).toBe("number");
+ expect(typeof status.endedAt).toBe("number");
+ });
+
+ it("targets the main agent's steps by array tail when no agent is scoped", () => {
+ // The only remaining no-agent case: applyEventToSteps called directly for
+ // the main agent's own turn, not through a SubagentEvent wrapper.
+ useChatStore.getState().processEvent({ type: "TurnBegin", payload: { user_input: "hello" } });
+ useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 1 } });
+ useChatStore.getState().processEvent({ type: "ContentPart", payload: { type: "text", text: "first" } });
+ useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 2 } });
+ useChatStore.getState().processEvent({ type: "ContentPart", payload: { type: "text", text: "second" } });
+
+ const last = useChatStore.getState().messages.at(-1)!;
+ expect(last.steps!.every((s) => s.agentId === undefined)).toBe(true);
+ const texts = last.steps!
+ .flatMap((s) => s.items)
+ .filter((i): i is { type: "text"; content: string } => i.type === "text")
+ .map((i) => i.content);
+ expect(texts).toEqual(["first", "second"]);
+ });
+
+ it("does not drop a second agent's content that arrives before that agent's own StepBegin", () => {
+ startWorkflowTurn();
+
+ // Agent A is already streaming; agent B's very first event is content,
+ // arriving before B has emitted its own StepBegin.
+ useChatStore.getState().processEvent(wrap("agentA", { type: "StepBegin", payload: { n: 1 } }, 1));
+ useChatStore.getState().processEvent(wrap("agentA", { type: "ContentPart", payload: { type: "text", text: "from A" } }, 1));
+ useChatStore.getState().processEvent(wrap("agentB", { type: "ContentPart", payload: { type: "text", text: "from B" } }, 2));
+
+ const toolItem = workflowToolItem();
+ const textFor = (agentId: string) =>
+ toolItem
+ .subagent_steps!.filter((s) => s.agentId === agentId)
+ .flatMap((s) => s.items)
+ .filter((i): i is { type: "text"; content: string } => i.type === "text")
+ .map((i) => i.content)
+ .join("");
+
+ expect(textFor("agentB")).toBe("from B");
+ expect(textFor("agentA")).toBe("from A");
+ });
+
+ it("marks lanes still spawned/running as failed when the batch's parent ToolResult lands", () => {
+ startWorkflowTurn();
+
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" },
+ });
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" },
+ });
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentB", agent_label: "explore", agent_index: 2, status: "spawned" },
+ });
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentC", agent_label: "explore", agent_index: 3, status: "done" },
+ });
+
+ // The batch is aborted: the parent tool call resolves while agentA (running)
+ // and agentB (spawned) never received a terminal lifecycle event of their own.
+ useChatStore.getState().processEvent({
+ type: "ToolResult",
+ payload: { tool_call_id: "wf-1", return_value: { is_error: true, output: "aborted", message: "", display: [] } },
+ });
+
+ const status = workflowToolItem().subagent_status!;
+ expect(status["agentA"]!.status).toBe("failed");
+ expect(typeof status["agentA"]!.endedAt).toBe("number");
+ expect(status["agentB"]!.status).toBe("failed");
+ expect(typeof status["agentB"]!.endedAt).toBe("number");
+ // Already-terminal lanes are left alone.
+ expect(status["agentC"]!.status).toBe("done");
+ });
+
+ it("leaves spawned/running lanes alone when the parent ToolResult succeeds", () => {
+ startWorkflowTurn();
+
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", agent_label: "explore", agent_index: 1, status: "spawned" },
+ });
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentA", status: "running" },
+ });
+ useChatStore.getState().processEvent({
+ type: "SubagentStatus",
+ payload: { parent_tool_call_id: "wf-1", agent_id: "agentB", status: "spawned" },
+ });
+
+ useChatStore.getState().processEvent({
+ type: "ToolResult",
+ payload: { tool_call_id: "wf-1", return_value: { is_error: false, output: "ok", message: "", display: [] } },
+ });
+
+ expect(workflowToolItem().result).toMatchObject({ is_error: false });
+ const status = workflowToolItem().subagent_status!;
+ expect(status["agentA"]!.status).toBe("running");
+ expect(status["agentA"]!.endedAt).toBeUndefined();
+ expect(status["agentB"]!.status).toBe("spawned");
+ expect(status["agentB"]!.endedAt).toBeUndefined();
+ });
+});
+
+describe("workflow lane derivation", () => {
+ it("groups steps by agent, orders lanes by agentIndex, and sizes the bar to the busiest lane", () => {
+ const steps = [
+ { n: 1, items: [], agentId: "b", agentLabel: "explore", agentIndex: 2 },
+ { n: 1, items: [], agentId: "a", agentLabel: "explore", agentIndex: 1 },
+ { n: 2, items: [], agentId: "a", agentLabel: "explore", agentIndex: 1 },
+ ];
+ const statuses = {
+ a: { status: "done" as const },
+ b: { status: "running" as const },
+ c: { status: "spawned" as const, label: "explore", index: 3 },
+ };
+
+ const lanes = deriveWorkflowLanes(steps, statuses);
+
+ expect(lanes.map((l) => l.agentId)).toEqual(["a", "b", "c"]);
+ expect(lanes.map((l) => l.stepCount)).toEqual([2, 1, 0]);
+ expect(maxLaneStepCount(lanes)).toBe(2);
+ });
+});
diff --git a/apps/vscode/test/publish-retry.test.ts b/apps/vscode/test/publish-retry.test.ts
new file mode 100644
index 00000000..c34f65e6
--- /dev/null
+++ b/apps/vscode/test/publish-retry.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it, vi } from 'vitest';
+
+// @ts-expect-error -- plain .mjs build script, no type declarations
+import { classifyError, publishEachTarget, withRetry } from '../scripts/publish-retry.mjs';
+
+const TARGETS = ['darwin-x64', 'darwin-arm64', 'linux-x64'];
+const FILES = TARGETS.map((target) => `/tmp/${target}.vsix`);
+
+// Backoff is real time; every retry test overrides it so the suite stays fast.
+const FAST = { backoffMs: [0, 0] };
+
+describe('classifyError', () => {
+ it('separates the three failure kinds that need different handling', () => {
+ // The exact string the Marketplace returned mid-publish on 2026-08-05.
+ expect(classifyError(new Error('Request timeout: /_apis/gallery/publishers/example'))).toBe('transient');
+ expect(classifyError(new Error('connect ECONNRESET 13.107.42.16:443'))).toBe('transient');
+ expect(classifyError(new Error('Response code 503 (Service Unavailable)'))).toBe('transient');
+
+ // The exact string the Entra credential path returned.
+ expect(classifyError(new Error('{"message":"The requested operation is not allowed."}'))).toBe('auth');
+ expect(classifyError(new Error('Response code 401 (Unauthorized)'))).toBe('auth');
+
+ expect(classifyError(new Error('Extension entrypoint(s) missing'))).toBe('fatal');
+ });
+});
+
+describe('withRetry', () => {
+ it('retries a transient failure and returns the eventual success', async () => {
+ const action = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('Request timeout'))
+ .mockResolvedValueOnce('published');
+
+ await expect(withRetry(action, { label: 'test', ...FAST })).resolves.toBe('published');
+ expect(action).toHaveBeenCalledTimes(2);
+ });
+
+ it('gives up after the attempt budget and rethrows the last error', async () => {
+ const action = vi.fn().mockRejectedValue(new Error('Request timeout'));
+
+ await expect(withRetry(action, { label: 'test', attempts: 3, ...FAST })).rejects.toThrow('Request timeout');
+ expect(action).toHaveBeenCalledTimes(3);
+ });
+
+ it('does not retry a non-transient failure', async () => {
+ const action = vi.fn().mockRejectedValue(new Error('Response code 401 (Unauthorized)'));
+
+ await expect(withRetry(action, { label: 'test', ...FAST })).rejects.toThrow('401');
+ expect(action).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('publishEachTarget', () => {
+ it('keeps publishing after one target fails, so a flake cannot strand the rest', async () => {
+ const publishOne = vi.fn(async (_file: string, target: string) => {
+ if (target === 'darwin-arm64') throw new Error('Extension rejected');
+ return 'published';
+ });
+
+ await expect(
+ publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Marketplace', publishOne }),
+ ).rejects.toThrow('1 of 3 target(s) failed');
+
+ // The point of the change: linux-x64 is attempted even though darwin-arm64 died.
+ expect(publishOne.mock.calls.map((call) => call[1])).toEqual(TARGETS);
+ });
+
+ it('stops immediately on an auth failure instead of hammering every target', async () => {
+ const publishOne = vi.fn().mockRejectedValue(new Error('Response code 401 (Unauthorized)'));
+
+ await expect(
+ publishEachTarget({ targets: TARGETS, files: FILES, registry: 'Marketplace', publishOne }),
+ ).rejects.toThrow('3 of 3 target(s) failed');
+
+ expect(publishOne).toHaveBeenCalledTimes(1);
+ });
+
+ it('treats an already-published target as success, so a re-run completes', async () => {
+ const publishOne = vi.fn(async (_file: string, target: string) =>
+ target === 'darwin-x64' ? 'skipped' : 'published',
+ );
+
+ const result = await publishEachTarget({
+ targets: TARGETS,
+ files: FILES,
+ registry: 'Marketplace',
+ publishOne,
+ });
+
+ expect(result).toEqual({ published: ['darwin-arm64', 'linux-x64'], skipped: ['darwin-x64'] });
+ });
+});
diff --git a/apps/vscode/test/pythinker-harness.integration.test.ts b/apps/vscode/test/pythinker-harness.integration.test.ts
index 5d1ddafd..61677746 100644
--- a/apps/vscode/test/pythinker-harness.integration.test.ts
+++ b/apps/vscode/test/pythinker-harness.integration.test.ts
@@ -355,32 +355,82 @@ async function runSlash(
raw: string,
ctx = {} as HandlerContext,
): Promise {
- const command = parseHostSlashCommand(raw);
+ return (await startSlash(runtime, raw, ctx))();
+}
+
+/** Parses first and hands back the dispatch, for tests that assert on the busy flag. */
+async function startSlash(
+ runtime: SessionRuntime,
+ raw: string,
+ ctx = {} as HandlerContext,
+): Promise<() => Promise> {
+ const command = await parseHostSlashCommand(raw, () => runtime.session.listSkills());
if (command === undefined) throw new Error(`Expected host slash command: ${raw}`);
- return runHostSlashCommand(runtime, command, ctx);
+ return () => runHostSlashCommand(runtime, command, ctx);
}
describe("VS Code Pythinker harness integration (shares one in-process SDK home)", () => {
- it("only intercepts released slash commands and user-invoked skills", () => {
- expect(parseHostSlashCommand("/plan on")).toEqual({ name: "plan", args: "on", raw: "/plan on" });
- expect(parseHostSlashCommand(" /skill:review carefully ")).toEqual({
+ it("only intercepts released slash commands and user-invoked skills", async () => {
+ await expect(parseHostSlashCommand("/plan on")).resolves.toEqual({
+ name: "plan",
+ args: "on",
+ raw: "/plan on",
+ });
+ await expect(parseHostSlashCommand(" /skill:review carefully ")).resolves.toEqual({
+ name: "skill:review",
+ args: "carefully",
+ raw: "/skill:review carefully",
+ skillName: "review",
+ });
+ await expect(parseHostSlashCommand("/not-a-host-command")).resolves.toBeUndefined();
+ await expect(parseHostSlashCommand([{ type: "text", text: "/clear" }])).resolves.toBeUndefined();
+ });
+
+ it("degrades to the skill prefix when the skill catalog fails", async () => {
+ // The parser runs on every message starting with "/", and its caller in
+ // chat.handler awaits it outside any try block — a rejection here silently
+ // drops the user's message instead of sending it.
+ const listSkills = () => Promise.reject(new Error("engine unavailable"));
+
+ await expect(parseHostSlashCommand("/skill:review carefully", listSkills)).resolves.toEqual({
name: "skill:review",
args: "carefully",
raw: "/skill:review carefully",
+ skillName: "review",
});
- expect(parseHostSlashCommand("/not-a-host-command")).toBeUndefined();
- expect(parseHostSlashCommand([{ type: "text", text: "/clear" }])).toBeUndefined();
+ await expect(parseHostSlashCommand("/plan on", listSkills)).resolves.toEqual({
+ name: "plan",
+ args: "on",
+ raw: "/plan on",
+ });
+ await expect(parseHostSlashCommand("/unknown-thing", listSkills)).resolves.toBeUndefined();
+ });
+
+ it("resolves a built-in skill invoked under its bare name", async () => {
+ const listSkills = async () => [
+ { name: "gen-changesets", description: "", path: "/s", source: "builtin", type: "prompt" },
+ ];
+
+ await expect(
+ parseHostSlashCommand("/gen-changesets", listSkills as never),
+ ).resolves.toMatchObject({ skillName: "gen-changesets" });
+ await expect(
+ parseHostSlashCommand("/still-not-a-command", listSkills as never),
+ ).resolves.toBeUndefined();
});
- it("combines the released slash commands with user-activatable workspace skills", async () => {
+ it("combines the released slash commands with the session's user-activatable skills", async () => {
const commands = await configHandlers[Methods.GetSlashCommands]!(undefined, {
- workDir: "/workspace",
- harness: {
- listWorkspaceSkills: async () => [
- { name: "review", description: "Review changes", path: "/skills/review", source: "user", type: "prompt" },
- { name: "reference-only", description: "Reference", path: "/skills/ref", source: "user", type: "reference" },
- ],
- },
+ getSession: () => ({
+ session: {
+ listSkills: async () => [
+ { name: "review", description: "Review changes", path: "/skills/review", source: "user", type: "prompt" },
+ { name: "reference-only", description: "Reference", path: "/skills/ref", source: "user", type: "reference" },
+ { name: "model-only", description: "Model", path: "/skills/m", source: "user", type: "prompt", userInvocable: false },
+ { name: "builtin-one", description: "Builtin", path: "/skills/b", source: "builtin", type: "prompt" },
+ ],
+ },
+ }),
logError: () => undefined,
} as unknown as HandlerContext);
@@ -394,10 +444,20 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home)
"add-dir",
"export",
"import",
+ "builtin-one",
"skill:review",
]);
});
+ it("falls back to the released commands when no session is open yet", async () => {
+ const commands = await configHandlers[Methods.GetSlashCommands]!(undefined, {
+ getSession: () => undefined,
+ logError: () => undefined,
+ } as unknown as HandlerContext);
+
+ expect((commands as Array<{ name: string }>).some((command) => command.name.startsWith("skill:"))).toBe(false);
+ });
+
it("sends the package version in User-Agent when VS Code prompts the provider", async () => {
const rig = await createRuntimeRig();
routeSuccessfulPrompt(rig.provider);
@@ -1143,7 +1203,8 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home)
await writeFile(join(rig.workDir, "prior.md"), "Enough prior context to compact.");
await runSlash(runtime, "/import prior.md", streamChatContext(rig));
- const command = runSlash(runtime, "/compact keep decisions");
+ const dispatch = await startSlash(runtime, "/compact keep decisions");
+ const command = dispatch();
expect(runtime.isBusy).toBe(true);
await expect(command).resolves.toBe(true);
@@ -1155,37 +1216,55 @@ describe("VS Code Pythinker harness integration (shares one in-process SDK home)
});
});
- it("keeps /yolo and /afk independent when they are combined", async () => {
+ it("moves between the permission modes as /yolo and /auto are used", async () => {
const rig = await createRuntimeRig();
const runtime = await openRuntimeSession(rig);
await runSlash(runtime, "/yolo");
- expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: false });
+ expect(runtime.permissionMode).toBe("yolo");
await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "yolo" });
await runSlash(runtime, "/afk");
- expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: true });
+ expect(runtime.permissionMode).toBe("auto");
await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "auto" });
await runSlash(runtime, "/afk");
- expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: false });
- await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "yolo" });
+ expect(runtime.permissionMode).toBe("manual");
+ await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "manual" });
});
- it("applies the global yolo setting when a closed VS Code session reopens", async () => {
+ it("accepts on and off arguments for the permission commands", async () => {
+ const rig = await createRuntimeRig();
+ const runtime = await openRuntimeSession(rig);
+
+ await runSlash(runtime, "/yolo on");
+ expect(runtime.permissionMode).toBe("yolo");
+
+ await runSlash(runtime, "/yolo on");
+ expect(runtime.permissionMode).toBe("yolo");
+
+ await runSlash(runtime, "/yolo off");
+ expect(runtime.permissionMode).toBe("manual");
+ });
+
+ it("keeps a /yolo session in yolo when it reopens with the setting off", async () => {
const rig = await createRuntimeRig();
const first = await openRuntimeSession(rig);
await runSlash(first, "/yolo");
await rig.runtime.detachView("view-1");
const reopened = await openRuntimeSession(rig, first.id);
- expect(reopened.legacyApprovalFlags).toEqual({ yolo: false, afk: false });
- await expect(reopened.session.getStatus()).resolves.toMatchObject({ permission: "manual" });
- await rig.runtime.detachView("view-1");
- const yoloReopened = await openRuntimeSession(rig, first.id, true);
- expect(yoloReopened.legacyApprovalFlags).toEqual({ yolo: true, afk: false });
- await expect(yoloReopened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" });
+ expect(reopened.permissionMode).toBe("yolo");
+ await expect(reopened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" });
+ });
+
+ it("seeds a session that never chose a mode from the global yolo setting", async () => {
+ const rig = await createRuntimeRig();
+ const first = await openRuntimeSession(rig, undefined, true);
+
+ expect(first.permissionMode).toBe("yolo");
+ await expect(first.session.getStatus()).resolves.toMatchObject({ permission: "yolo" });
});
it("exports current context as Markdown under the workspace", async () => {
diff --git a/apps/vscode/test/pythinker-runtime.test.ts b/apps/vscode/test/pythinker-runtime.test.ts
index 01505f90..30567e79 100644
--- a/apps/vscode/test/pythinker-runtime.test.ts
+++ b/apps/vscode/test/pythinker-runtime.test.ts
@@ -113,9 +113,12 @@ function createFakeSession(
setPermissions.push(permission);
status = { ...status, permission };
},
- async updateMetadata(patch: JsonObject) {
- metadataUpdates.push(patch);
- summary = { ...summary, metadata: { ...summary.metadata, ...patch } };
+ async getSessionMetadata() {
+ return { custom: summary.metadata };
+ },
+ async updateSessionMetadata(patch: { custom?: JsonObject }) {
+ metadataUpdates.push(patch.custom ?? {});
+ summary = { ...summary, metadata: patch.custom };
},
async close() {
closes += 1;
@@ -261,7 +264,7 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => {
model: "kimi-k2",
thinking: "high",
permission: "yolo",
- metadata: { vscode_legacy_approval: { yolo: true, afk: false } },
+ metadata: { vscode_permission_mode: "yolo" },
},
]);
expect(opened.subscribers).toEqual(["view-1"]);
@@ -396,91 +399,95 @@ describe("Pythinker runtime (owns shared SDK sessions for Webviews)", () => {
});
});
- it("uses the yolo setting as the initial value for an unmarked resumed session", async () => {
+ it("seeds an unmarked resumed session from the yolo setting", async () => {
const { runtime, sdk } = createRuntime();
const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" });
await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true }));
- expect(session.metadataUpdates).toEqual([
- { vscode_legacy_approval: { yolo: true, afk: false } },
- ]);
+ expect(session.metadataUpdates).toEqual([{ vscode_permission_mode: "yolo" }]);
});
- it("lets the global yolo setting override a persisted off flag on resume", async () => {
+ it("keeps a stored yolo mode when the global setting is off", async () => {
const { runtime, sdk } = createRuntime();
const session = sdk.addSession(
"saved-1",
"/workspace",
{ permission: "manual" },
- { vscode_legacy_approval: { yolo: false, afk: false } },
+ { vscode_permission_mode: "yolo" },
);
- const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true }));
+ const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false }));
expect(session.setPermissions).toEqual(["yolo"]);
- expect(session.metadataUpdates).toEqual([
- { vscode_legacy_approval: { yolo: true, afk: false } },
- ]);
- expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: false });
+ expect(session.metadataUpdates).toEqual([]);
+ expect(opened.permissionMode).toBe("yolo");
});
- it("lets the global yolo setting disable a persisted session yolo flag on resume", async () => {
+ it("keeps a stored manual mode when the global setting is on", async () => {
const { runtime, sdk } = createRuntime();
const session = sdk.addSession(
"saved-1",
"/workspace",
- { permission: "yolo" },
- { vscode_legacy_approval: { yolo: true, afk: false } },
+ { permission: "manual" },
+ { vscode_permission_mode: "manual" },
);
- const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false }));
+ const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true }));
- expect(session.setPermissions).toEqual(["manual"]);
- expect(session.metadataUpdates).toEqual([
- { vscode_legacy_approval: { yolo: false, afk: false } },
- ]);
- expect(opened.legacyApprovalFlags).toEqual({ yolo: false, afk: false });
+ expect(session.setPermissions).toEqual([]);
+ expect(session.metadataUpdates).toEqual([]);
+ expect(opened.permissionMode).toBe("manual");
});
- it("keeps the persisted afk flag while applying the global yolo setting on resume", async () => {
+ it("restores a stored auto mode", async () => {
const { runtime, sdk } = createRuntime();
const session = sdk.addSession(
"saved-1",
"/workspace",
{ permission: "manual" },
- { vscode_legacy_approval: { yolo: false, afk: true } },
+ { vscode_permission_mode: "auto" },
);
- const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true }));
+ const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false }));
expect(session.setPermissions).toEqual(["auto"]);
- expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: true });
+ expect(opened.permissionMode).toBe("auto");
});
- it("restores persisted afk with core auto permission", async () => {
+ it("ignores an unrecognised stored mode and falls back to the setting", async () => {
const { runtime, sdk } = createRuntime();
const session = sdk.addSession(
"saved-1",
"/workspace",
{ permission: "manual" },
- { vscode_legacy_approval: { yolo: false, afk: true } },
+ { vscode_permission_mode: "nonsense" },
);
- await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false }));
+ const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true }));
- expect(session.setPermissions).toEqual(["auto"]);
+ expect(opened.permissionMode).toBe("yolo");
+ expect(session.metadataUpdates).toEqual([{ vscode_permission_mode: "yolo" }]);
});
- it("changes the setting-backed yolo flag without clearing session afk", async () => {
+ it("applies an explicit settings change to the live sessions", async () => {
const { runtime } = createRuntime();
const opened = await runtime.openSession(openOptions());
- await opened.toggleLegacyApproval("afk");
- await runtime.setYoloModeForActiveSessions(true);
+ await runtime.setPermissionModeForActiveSessions("yolo");
+
+ expect(opened.permissionMode).toBe("yolo");
+ await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" });
+ });
+
+ it("persists a mode change so the next attach restores it", async () => {
+ const { runtime, sdk } = createRuntime();
+ const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" });
+ const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false }));
+
+ await opened.setPermissionMode("yolo");
- expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: true });
- await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "auto" });
+ expect(session.metadataUpdates.at(-1)).toEqual({ vscode_permission_mode: "yolo" });
});
it("keeps a shared session open when one of its Webviews detaches", async () => {
diff --git a/apps/vscode/test/replay-adapter.test.ts b/apps/vscode/test/replay-adapter.test.ts
index 745002d8..3bc2bae4 100644
--- a/apps/vscode/test/replay-adapter.test.ts
+++ b/apps/vscode/test/replay-adapter.test.ts
@@ -555,6 +555,7 @@ describe("replay adapter (renders the public SDK resume state for the Webview)",
type: "SubagentEvent",
payload: {
parent_tool_call_id: "agent-call-1",
+ agent_id: "sub-1",
event: { type: "ContentPart", payload: { type: "text", text: "first child answer" } },
},
}));
@@ -562,6 +563,7 @@ describe("replay adapter (renders the public SDK resume state for the Webview)",
type: "SubagentEvent",
payload: {
parent_tool_call_id: "agent-call-2",
+ agent_id: "sub-1",
event: { type: "ContentPart", payload: { type: "text", text: "second child answer" } },
},
}));
diff --git a/apps/vscode/test/replay-resume.integration.test.ts b/apps/vscode/test/replay-resume.integration.test.ts
index a7f99c46..6d894990 100644
--- a/apps/vscode/test/replay-resume.integration.test.ts
+++ b/apps/vscode/test/replay-resume.integration.test.ts
@@ -298,6 +298,7 @@ describe("VS Code replay from a public Node SDK resume state", () => {
type: "SubagentEvent",
payload: {
parent_tool_call_id: "agent-call-1",
+ agent_id: "agent-0",
event: { type: "StepBegin", payload: { n: 1 } },
},
}),
@@ -307,6 +308,7 @@ describe("VS Code replay from a public Node SDK resume state", () => {
type: "SubagentEvent",
payload: {
parent_tool_call_id: "agent-call-1",
+ agent_id: "agent-0",
event: { type: "ContentPart", payload: { type: "text", text: childAnswer } },
},
}),
diff --git a/apps/vscode/test/session-runtime.test.ts b/apps/vscode/test/session-runtime.test.ts
index 3c476553..deec905e 100644
--- a/apps/vscode/test/session-runtime.test.ts
+++ b/apps/vscode/test/session-runtime.test.ts
@@ -20,7 +20,6 @@ import type {
import { describe, expect, it } from "vitest";
import { Events } from "../shared/bridge";
-import type { LegacyApprovalFlags } from "../src/runtime/legacy-approval";
import { SessionRuntime } from "../src/runtime/session-runtime";
interface BroadcastRecord {
@@ -53,7 +52,7 @@ interface FakeSessionBoundary {
requestQuestion(request: QuestionRequest): Promise>>;
}
-const DEFAULT_LEGACY_APPROVAL: LegacyApprovalFlags = { yolo: false, afk: false };
+const DEFAULT_PERMISSION_MODE: PermissionMode = "manual";
function createFakeSession(): FakeSessionBoundary {
const listeners = new Set<(event: Event) => void>();
@@ -129,13 +128,16 @@ function createFakeSession(): FakeSessionBoundary {
permission = mode;
setPermissions.push(mode);
},
- async updateMetadata(patch: JsonObject) {
+ async getSessionMetadata() {
+ return { custom: summary.metadata };
+ },
+ async updateSessionMetadata(patch: { custom?: JsonObject }) {
if (nextMetadataError !== undefined) {
const error = nextMetadataError;
nextMetadataError = undefined;
throw error;
}
- metadataUpdates.push(patch);
+ metadataUpdates.push(patch.custom ?? {});
},
async close() {
closes += 1;
@@ -173,13 +175,13 @@ function createFakeSession(): FakeSessionBoundary {
};
}
-function createRuntime(legacyApproval = DEFAULT_LEGACY_APPROVAL) {
+function createRuntime(permissionMode = DEFAULT_PERMISSION_MODE) {
const sdk = createFakeSession();
const broadcasts: BroadcastRecord[] = [];
const baselines: BaselineRecord[] = [];
const runtime = new SessionRuntime({
session: sdk.session,
- legacyApproval,
+ permissionMode,
broadcast: (event, data, webviewId) => broadcasts.push({ event, data, webviewId }),
captureBaseline: (session, filePath, webviewIds) => {
baselines.push({ session, filePath, webviewIds });
@@ -570,8 +572,8 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", ()
await expect(pending).resolves.toEqual(expected);
});
- it("forwards SDK approval requests to the Webview in legacy yolo mode", async () => {
- const { runtime, sdk, broadcasts } = createRuntime({ yolo: true, afk: false });
+ it("forwards SDK approval requests to the Webview in yolo mode", async () => {
+ const { runtime, sdk, broadcasts } = createRuntime("yolo");
const pending = sdk.requestApproval({
toolCallId: "tool-yolo",
toolName: "Bash",
@@ -590,13 +592,31 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", ()
await expect(pending).resolves.toEqual({ decision: "approved" });
});
- it("switches core permission when a legacy approval flag is toggled", async () => {
+ it("switches core permission when the mode is toggled on", async () => {
const { runtime, sdk } = createRuntime();
- await runtime.toggleLegacyApproval("afk");
+ await expect(runtime.togglePermissionMode("auto")).resolves.toBe("auto");
expect(sdk.setPermissions).toEqual(["auto"]);
- expect(runtime.legacyApprovalFlags).toEqual({ yolo: false, afk: true });
+ expect(runtime.permissionMode).toBe("auto");
+ });
+
+ it("returns to manual when the same mode is toggled again", async () => {
+ const { runtime, sdk } = createRuntime();
+
+ await runtime.togglePermissionMode("auto");
+ await expect(runtime.togglePermissionMode("auto")).resolves.toBe("manual");
+
+ expect(sdk.setPermissions).toEqual(["auto", "manual"]);
+ expect(runtime.permissionMode).toBe("manual");
+ });
+
+ it("persists each mode change into the session metadata", async () => {
+ const { runtime, sdk } = createRuntime();
+
+ await runtime.setPermissionMode("yolo");
+
+ expect(sdk.metadataUpdates.at(-1)).toMatchObject({ vscode_permission_mode: "yolo" });
});
it("resolves an SDK question when the Webview submits answers", async () => {
@@ -624,8 +644,8 @@ describe("session runtime (adapts one SDK session for subscribed Webviews)", ()
await expect(pending).resolves.toEqual({ answers: { "Choose a target": "Tests" } });
});
- it("keeps SDK questions interactive in legacy yolo mode", async () => {
- const { runtime, sdk, broadcasts } = createRuntime({ yolo: true, afk: false });
+ it("keeps SDK questions interactive in yolo mode", async () => {
+ const { runtime, sdk, broadcasts } = createRuntime("yolo");
const pending = sdk.requestQuestion({
toolCallId: "question-yolo",
questions: [
diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts
index bf4d07bb..c2da2778 100644
--- a/apps/vscode/test/settings-store.test.ts
+++ b/apps/vscode/test/settings-store.test.ts
@@ -39,16 +39,16 @@ import {
import { useChatStore } from "../webview-ui/src/stores/chat.store";
const MODELS = [
- { id: "plain", name: "Plain", provider: "managed:pythinker-code", capabilities: [] },
+ { id: "plain", name: "Plain", provider: "managed:kimi-code", capabilities: [] },
{
id: "reasoning",
name: "Reasoning",
- provider: "managed:pythinker-code",
+ provider: "managed:kimi-code",
capabilities: ["thinking"],
support_efforts: ["low", "high"],
default_effort: "high",
},
- { id: "always", name: "Always", provider: "managed:pythinker-code", capabilities: ["always_thinking"] },
+ { id: "always", name: "Always", provider: "managed:kimi-code", capabilities: ["always_thinking"] },
];
beforeEach(() => {
@@ -145,7 +145,7 @@ describe("Webview model settings persistence", () => {
describe("Webview model metadata", () => {
it("keeps same-named models in separate provider groups", () => {
const groups = groupModelsByProvider([
- { id: "kimi/shared", name: "Shared", provider: "managed:pythinker-code", capabilities: [] },
+ { id: "kimi/shared", name: "Shared", provider: "managed:kimi-code", capabilities: [] },
{ id: "proxy/shared", name: "Shared", provider: "company-proxy", capabilities: [] },
]);
@@ -155,7 +155,7 @@ describe("Webview model metadata", () => {
models: group.models.map((model) => model.id),
}))).toEqual([
{ provider: "company-proxy", label: "company-proxy", models: ["proxy/shared"] },
- { provider: "managed:pythinker-code", label: "Pythinker Code", models: ["kimi/shared"] },
+ { provider: "managed:kimi-code", label: "Kimi Code", models: ["kimi/shared"] },
]);
});
@@ -192,7 +192,7 @@ describe("Webview model metadata", () => {
it("requires Kimi login when the default model uses the managed provider", () => {
expect(requiresManagedProviderLogin([
- { id: "kimi/model", name: "Kimi", provider: "managed:pythinker-code", capabilities: [] },
+ { id: "kimi/model", name: "Kimi", provider: "managed:kimi-code", capabilities: [] },
], "kimi/model", false)).toBe(true);
});
});
diff --git a/apps/vscode/test/slash-menu.test.ts b/apps/vscode/test/slash-menu.test.ts
new file mode 100644
index 00000000..2c85445f
--- /dev/null
+++ b/apps/vscode/test/slash-menu.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ NO_MATCH,
+ rankSlashCommands,
+ scoreCommand,
+} from "../webview-ui/src/components/inputarea/hooks/slash-command-match";
+
+function command(name: string, description = ""): { name: string; description: string; aliases: string[] } {
+ return { name, description, aliases: [] };
+}
+
+const RESEARCH_SKILL = command("skill:research-writing", "Write research reports.");
+const CUSTOM_THEME = command(
+ "custom-theme",
+ // Its letters spell "research" in order, which is exactly what the old
+ // subsequence match over descriptions matched on.
+ "Create or edit a pythinker-code custom color theme — a JSON file of color tokens, then reload the chat.",
+);
+
+function rank(commands: readonly ReturnType[], query: string): string[] {
+ return rankSlashCommands(commands, query).map((entry) => entry.name);
+}
+
+describe("scoreCommand", () => {
+ it("does not match a command whose description merely contains the query's letters", () => {
+ // The old subsequence match over descriptions let "research" through on
+ // "Create or edit ...", which made the menu look unfiltered.
+ expect(scoreCommand(CUSTOM_THEME, "research")).toBe(NO_MATCH);
+ });
+
+ it("finds a namespaced skill by the part the user actually types", () => {
+ expect(rank([CUSTOM_THEME, RESEARCH_SKILL], "research")).toEqual(["skill:research-writing"]);
+ });
+
+ it("ranks a name prefix above a name that only contains the query", () => {
+ expect(rank([command("sub-skill"), command("skills")], "skill")).toEqual(["skills", "sub-skill"]);
+ });
+
+ it("stays forgiving about dropped separators and skipped letters", () => {
+ expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "researchwriting"))).toBe(true);
+ expect(Number.isFinite(scoreCommand(RESEARCH_SKILL, "reswrit"))).toBe(true);
+ });
+
+ it("never matches on the description", () => {
+ // "/sk" used to reach "/yolo" because its description contains those
+ // letters, so the highlight sat on an unrelated command.
+ const yolo = command("yolo", "Skip every approval prompt for this session.");
+ expect(scoreCommand(yolo, "sk")).toBe(NO_MATCH);
+ expect(rank([yolo, command("sub-skill"), command("invoke-skill")], "sk")).toEqual([
+ "sub-skill",
+ "invoke-skill",
+ ]);
+ expect(scoreCommand(CUSTOM_THEME, "color")).toBe(NO_MATCH);
+ });
+});
diff --git a/apps/vscode/test/workspace-paths.test.ts b/apps/vscode/test/workspace-paths.test.ts
index 4cdbcd8b..f1582b7d 100644
--- a/apps/vscode/test/workspace-paths.test.ts
+++ b/apps/vscode/test/workspace-paths.test.ts
@@ -305,7 +305,7 @@ describe("Webview workspace paths (selected-directory containment)", () => {
} as unknown as Session;
const runtime = new SessionRuntime({
session,
- legacyApproval: { yolo: false, afk: false },
+ permissionMode: "manual",
broadcast: vi.fn(),
captureBaseline: (summary, filePath, webviewIds) => {
bridge.captureFileBaseline(summary, filePath, webviewIds);
diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json
index a770d3a7..65ff60c2 100644
--- a/apps/vscode/tsconfig.json
+++ b/apps/vscode/tsconfig.json
@@ -15,5 +15,12 @@
}
},
"include": ["src/**/*", "shared/**/*", "test/**/*"],
- "exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts", "test/app-init.test.ts"]
+ "exclude": [
+ "dist",
+ "node_modules",
+ "webview-ui",
+ "test/settings-store.test.ts",
+ "test/app-init.test.ts",
+ "test/event-handlers.test.ts"
+ ]
}
diff --git a/apps/vscode/webview-ui/public/pythinker-logo.png b/apps/vscode/webview-ui/public/pythinker-logo.png
index 865d7529..4fdf646b 100644
Binary files a/apps/vscode/webview-ui/public/pythinker-logo.png and b/apps/vscode/webview-ui/public/pythinker-logo.png differ
diff --git a/apps/vscode/webview-ui/public/pythinker_animated.svg b/apps/vscode/webview-ui/public/pythinker_animated.svg
new file mode 100644
index 00000000..bf23b5bc
--- /dev/null
+++ b/apps/vscode/webview-ui/public/pythinker_animated.svg
@@ -0,0 +1,79 @@
+
diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx
index 2495bc2c..f066d5f0 100644
--- a/apps/vscode/webview-ui/src/App.tsx
+++ b/apps/vscode/webview-ui/src/App.tsx
@@ -4,6 +4,7 @@ import { Header } from "./components/Header";
import { ChatArea } from "./components/ChatArea";
import { InputArea } from "./components/inputarea/InputArea";
import { MCPServersModal } from "./components/MCPServersModal";
+import { ProvidersModal } from "./components/ProvidersModal";
import { WorkDirModal } from "./components/WorkDirModal";
import { SettingsDialog } from "./components/SettingsDialog";
import { ConfigErrorScreen } from "./components/ConfigErrorScreen";
@@ -18,7 +19,7 @@ import "./styles/index.css";
function MainContent({ onAuthAction }: { onAuthAction: () => void }) {
const { processEvent, startNewConversation, sessionId } = useChatStore();
- const { setMCPServers, setExtensionConfig, extensionConfig } = useSettingsStore();
+ const { setMCPServers, setExtensionConfig, extensionConfig, setWireSlashCommands } = useSettingsStore();
useEffect(() => {
return bridge.on(Events.StreamEvent, (event: UIStreamEvent) => {
@@ -40,6 +41,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) {
useEffect(() => {
const unsubs = [
bridge.on(Events.MCPServersChanged, setMCPServers),
+ bridge.on(Events.SlashCommandsChanged, setWireSlashCommands),
bridge.on(Events.ExtensionConfigChanged, ({ config }: { config: ExtensionConfig }) => setExtensionConfig(config)),
bridge.on(Events.FocusInput, () => document.querySelector("textarea")?.focus()),
bridge.on(Events.NewConversation, () => {
@@ -49,7 +51,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) {
}),
];
return () => unsubs.forEach((u) => u());
- }, [setMCPServers, setExtensionConfig, startNewConversation]);
+ }, [setMCPServers, setExtensionConfig, setWireSlashCommands, startNewConversation]);
useEffect(() => {
if (!extensionConfig.enableNewConversationShortcut) return;
@@ -74,6 +76,7 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) {