diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs
index 842e7a6c..4366872e 100644
--- a/build/esbuild.config.mjs
+++ b/build/esbuild.config.mjs
@@ -1,5 +1,5 @@
import esbuild from "esbuild";
-import { spawn } from "node:child_process";
+import { spawn, spawnSync } from "node:child_process";
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { builtinModules, createRequire } from "node:module";
import path from "node:path";
@@ -51,6 +51,7 @@ export const cliMarketplacePluginsDir = path.join(cliDistDir, "marketplace", "pl
export const coreMarketplacePluginsDir = path.join(coreDistDir, "marketplace", "plugins");
export const electronMarketplacePluginsDir = path.join(electronDistDir, "marketplace", "plugins");
export const marketplacePluginsDir = electronMarketplacePluginsDir;
+export const marketplacePluginsInputDir = path.join(projectRoot, "marketplace", "plugins");
export const appAssetsInput = path.join(electronRoot, "assets");
export const modelCatalogInput = path.join(coreRoot, "models.json");
export const cliModelCatalogOutput = path.join(cliDistDir, "models.json");
@@ -147,6 +148,8 @@ export function copyBrowserRendererHtml() {
export function copyMarketplacePlugins() {
ensureDist();
+ buildMarketplacePlugin("agent-console");
+ copyMarketplacePlugin("agent-console");
}
export function syncUiRendererToRuntimeDists() {
@@ -181,6 +184,47 @@ function copyRendererPageHtml(input, output, scriptName, options = {}) {
writeFileSync(output, html, "utf8");
}
+function buildMarketplacePlugin(pluginId) {
+ const pluginRoot = path.join(marketplacePluginsInputDir, pluginId);
+ const packageJson = path.join(pluginRoot, "package.json");
+ if (!existsSync(packageJson)) {
+ return;
+ }
+
+ const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
+ const result = spawnSync(npmCommand, ["run", "build"], {
+ cwd: pluginRoot,
+ shell: false,
+ stdio: "inherit"
+ });
+ if (result.error) {
+ throw result.error;
+ }
+ if (result.status !== 0) {
+ throw new Error(`Plugin ${pluginId} build failed with exit code ${result.status ?? "unknown"}.`);
+ }
+}
+
+function copyMarketplacePlugin(pluginId) {
+ const pluginRoot = path.join(marketplacePluginsInputDir, pluginId);
+ const outputRoots = [cliMarketplacePluginsDir, coreMarketplacePluginsDir, electronMarketplacePluginsDir];
+ const runtimeFiles = ["plugin.json", "index.cjs"];
+ const rendererInput = path.join(pluginRoot, "dist", "renderer");
+ for (const outputRoot of outputRoots) {
+ const outputDir = path.join(outputRoot, pluginId);
+ mkdirSync(outputDir, { recursive: true });
+ for (const fileName of runtimeFiles) {
+ const input = path.join(pluginRoot, fileName);
+ if (existsSync(input)) {
+ cpSync(input, path.join(outputDir, fileName));
+ }
+ }
+ if (existsSync(rendererInput)) {
+ cpSync(rendererInput, path.join(outputDir, "dist", "renderer"), { recursive: true });
+ }
+ }
+}
+
function hasScriptTag(html, scriptTag) {
const sourceMatch = scriptTag.match(/\bsrc="([^"]+)"/);
return sourceMatch ? html.includes(sourceMatch[1]) : html.includes(scriptTag);
diff --git a/marketplace/plugins/agent-console/build.mjs b/marketplace/plugins/agent-console/build.mjs
new file mode 100644
index 00000000..da9f9ffd
--- /dev/null
+++ b/marketplace/plugins/agent-console/build.mjs
@@ -0,0 +1,124 @@
+import esbuild from "esbuild";
+import { existsSync, readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const pluginRoot = path.dirname(fileURLToPath(import.meta.url));
+const workspaceRoot = path.resolve(pluginRoot, "..", "..", "..");
+const fallbackAppRoot = path.resolve(process.env.CCR_AGENT_CONSOLE_APP_PATH || "/Users/jinhuilee/products/CCR/app");
+const fallbackAliasSkipPackages = new Set([
+ "@tailwindcss/vite",
+ "@vitejs/plugin-react",
+ "electron",
+ "typescript",
+ "vite"
+]);
+const buildMain = !process.argv.includes("--renderer-only");
+const buildRenderer = !process.argv.includes("--main-only");
+
+if (buildMain) {
+ await esbuild.build({
+ banner: {
+ js: [
+ "// Generated by marketplace/plugins/agent-console/build.mjs.",
+ "// Do not edit this file directly; edit src/index.cjs and run npm run build."
+ ].join("\n")
+ },
+ bundle: false,
+ entryPoints: [path.join(pluginRoot, "src", "index.cjs")],
+ format: "cjs",
+ legalComments: "none",
+ logLevel: "info",
+ outfile: path.join(pluginRoot, "index.cjs"),
+ platform: "node",
+ target: "node22"
+ });
+}
+
+if (buildRenderer) {
+ const { build } = await importPackage("vite");
+ const react = (await importPackage("@vitejs/plugin-react")).default;
+ const tailwindcss = (await importPackage("@tailwindcss/vite")).default;
+
+ await build({
+ base: "./",
+ build: {
+ emptyOutDir: true,
+ outDir: path.join(pluginRoot, "dist", "renderer"),
+ rollupOptions: {
+ input: {
+ main: path.join(pluginRoot, "src", "renderer", "pages", "home", "index.html"),
+ spotlight: path.join(pluginRoot, "src", "renderer", "pages", "spotlight", "index.html")
+ }
+ }
+ },
+ esbuild: { legalComments: "none" },
+ optimizeDeps: {
+ exclude: ["electron"]
+ },
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: rendererAliases()
+ },
+ root: path.join(pluginRoot, "src", "renderer")
+ });
+}
+
+async function importPackage(specifier) {
+ return import(pathToFileURL(resolvePackage(specifier)).href);
+}
+
+function resolvePackage(specifier) {
+ const attempts = [
+ pluginRoot,
+ workspaceRoot,
+ fallbackAppRoot
+ ];
+ for (const base of attempts) {
+ try {
+ return createRequire(path.join(base, "package.json")).resolve(specifier);
+ } catch {
+ // Try the next project root.
+ }
+ }
+ throw new Error(`Unable to resolve ${specifier}. Run npm install in the plugin project or set CCR_AGENT_CONSOLE_APP_PATH to a built Agent Console app checkout.`);
+}
+
+function rendererAliases() {
+ return [
+ { find: "@", replacement: path.join(pluginRoot, "src", "renderer") },
+ ...fallbackAppDependencyAliases()
+ ];
+}
+
+function fallbackAppDependencyAliases() {
+ const packageJsonPath = path.join(fallbackAppRoot, "package.json");
+ const nodeModules = path.join(fallbackAppRoot, "node_modules");
+ if (!existsSync(packageJsonPath) || !existsSync(nodeModules)) {
+ return [];
+ }
+
+ let packageJson;
+ try {
+ packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
+ } catch {
+ return [];
+ }
+
+ const names = new Set([
+ ...Object.keys(packageJson.dependencies || {}),
+ ...Object.keys(packageJson.devDependencies || {})
+ ]);
+ const aliases = [];
+ for (const name of names) {
+ if (fallbackAliasSkipPackages.has(name)) {
+ continue;
+ }
+ const modulePath = path.join(nodeModules, ...name.split("/"));
+ if (existsSync(modulePath)) {
+ aliases.push({ find: name, replacement: modulePath });
+ }
+ }
+ return aliases;
+}
diff --git a/marketplace/plugins/agent-console/index.cjs b/marketplace/plugins/agent-console/index.cjs
index 9b704ede..c43b6c89 100644
--- a/marketplace/plugins/agent-console/index.cjs
+++ b/marketplace/plugins/agent-console/index.cjs
@@ -1,13 +1,14 @@
+// Generated by marketplace/plugins/agent-console/build.mjs.
+// Do not edit this file directly; edit src/index.cjs and run npm run build.
"use strict";
-
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
-
const PLUGIN_ID = "agent-console";
const DEFAULT_APP_ROOT = "/Users/jinhuilee/products/CCR/app";
+const DEFAULT_RENDERER_ROOT = path.join(__dirname, "dist", "renderer");
const DEFAULT_ROUTE_PREFIX = "/plugins/agent-console";
const DEFAULT_RENDERER_ENTRY_PATH = "/pages/home/";
const DEFAULT_LAUNCHER_NAME = "Agent Console";
@@ -15,30 +16,24 @@ const LEGACY_LAUNCHER_NAME = "CCR Agent Console";
const MAC_LAUNCHER_APPS_DIR_NAME = "CCR Apps";
const DEFAULT_LAUNCHER_BUNDLE_ID = "com.claudecoderouter.plugin.agent-console.launcher";
const READY_PREFIX = "AGENT_CONSOLE_HEADLESS_READY ";
-const DEFAULT_STARTUP_WAIT_MS = 15000;
+const DEFAULT_STARTUP_WAIT_MS = 15e3;
+const DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS = 128e3;
const OPENAI_REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
+const OPENAI_EXTENDED_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
let modelCatalogIndex;
-
module.exports = {
async setup(ctx) {
const options = isRecord(ctx.pluginConfig) ? ctx.pluginConfig : {};
const routePrefix = normalizeRoutePrefix(stringValue(options.routePrefix) || DEFAULT_ROUTE_PREFIX);
const appRoot = resolveAppRoot(options);
+ const fallbackRendererRoot = path.join(appRoot, "dist", "renderer");
const rendererRoot = path.resolve(
- stringValue(options.rendererRoot) ||
- stringValue(options.pwaRoot) ||
- path.join(appRoot, "dist", "renderer")
+ stringValue(options.rendererRoot) || stringValue(options.pwaRoot) || (rendererDistExists(DEFAULT_RENDERER_ROOT) ? DEFAULT_RENDERER_ROOT : fallbackRendererRoot)
);
const electronPath = resolveElectronPath(options, appRoot);
const launchMode = stringValue(options.launchMode || options.startMode).toLowerCase();
const launchApp = options.launch !== false;
- const launchOnSetup = launchApp && (
- options.launch === true ||
- options.launchOnSetup === true ||
- options.launchOnStartup === true ||
- launchMode === "startup" ||
- launchMode === "eager"
- );
+ const launchOnSetup = launchApp && (options.launch === true || options.launchOnSetup === true || options.launchOnStartup === true || launchMode === "startup" || launchMode === "eager");
const startupWaitMs = parsePositiveInteger(options.startupWaitMs) || DEFAULT_STARTUP_WAIT_MS;
const bridgeHost = stringValue(options.bridgeHost) || "127.0.0.1";
const bridgePort = parsePort(options.bridgePort) || await pickOpenPort(bridgeHost);
@@ -50,6 +45,7 @@ module.exports = {
const launcherBundleId = stringValue(options.launcherBundleId) || DEFAULT_LAUNCHER_BUNDLE_ID;
const runtimeConfigFile = path.join(ctx.paths.pluginDataDir, "ccr-runtime-config.json");
const modelCatalogFile = path.join(ctx.paths.pluginDataDir, "ccr-codex-model-catalog.json");
+ fs.mkdirSync(ctx.paths.pluginDataDir, { recursive: true });
const runtimeConfig = buildRuntimeConfig(ctx.config, {
apiKey: gatewayApiKey,
defaultModel: stringValue(options.defaultModel),
@@ -57,6 +53,25 @@ module.exports = {
modelCatalogFile,
openAiBaseUrl: stringValue(options.openAiBaseUrl)
});
+ const codexModelCatalog = buildCodexModelCatalog(runtimeConfig.models);
+ fs.writeFileSync(modelCatalogFile, `${JSON.stringify(codexModelCatalog, null, 2)}
+`, "utf8");
+ const codexRuntime = ensureAgentConsoleCodexRuntime(ctx, options, runtimeConfig);
+ const claudeCodeRuntime = ensureAgentConsoleClaudeCodeRuntime(ctx, options, runtimeConfig, codexRuntime.runtimeFile);
+ if (codexRuntime.command) {
+ runtimeConfig.codex = {
+ ...isRecord(runtimeConfig.codex) ? runtimeConfig.codex : {},
+ command: codexRuntime.command,
+ env: codexRuntime.env
+ };
+ }
+ if (claudeCodeRuntime.command) {
+ runtimeConfig.claudeCode = {
+ ...isRecord(runtimeConfig.claudeCode) ? runtimeConfig.claudeCode : {},
+ command: claudeCodeRuntime.command,
+ env: claudeCodeRuntime.env
+ };
+ }
const runtime = {
appRoot,
appUrl,
@@ -81,27 +96,20 @@ module.exports = {
runtimeConfigFile,
startPromise: null,
startupWaitMs,
- startedAt: new Date().toISOString()
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
};
-
if (!fs.existsSync(path.join(rendererRoot, "pages", "home", "index.html"))) {
- ctx.logger.warn(`Agent Console Electron renderer dist is missing at ${rendererRoot}. Run npm run build in the app project before opening it.`);
+ ctx.logger.warn(`Agent Console Electron renderer dist is missing at ${rendererRoot}. Run npm --prefix marketplace/plugins/agent-console run build in the CCR project before opening it.`);
}
-
- fs.mkdirSync(ctx.paths.pluginDataDir, { recursive: true });
- fs.writeFileSync(runtimeConfigFile, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
- fs.writeFileSync(modelCatalogFile, `${JSON.stringify(buildCodexModelCatalog(runtimeConfig.models), null, 2)}\n`, "utf8");
-
- const launcher = canUsePermission(ctx, "system-launcher")
- ? ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId)
- : {
- error: "Agent Console system launcher requires the system-launcher permission.",
- installed: false
- };
+ fs.writeFileSync(runtimeConfigFile, `${JSON.stringify(runtimeConfig, null, 2)}
+`, "utf8");
+ const launcher = canUsePermission(ctx, "system-launcher") ? ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId) : {
+ error: "Agent Console system launcher requires the system-launcher permission.",
+ installed: false
+ };
runtime.launcherError = launcher.error || "";
runtime.launcherInstalled = launcher.installed;
runtime.launcherPath = launcher.path || "";
-
if (!launchApp) {
runtime.ready = true;
} else if (launchOnSetup) {
@@ -111,7 +119,6 @@ module.exports = {
ctx.logger.warn(`Agent Console startup launch failed: ${formatError(error)}`);
}
}
-
ctx.registerGatewayRoute({
auth: "none",
id: "agent-console-status",
@@ -121,7 +128,6 @@ module.exports = {
helpers.sendJson(response, 200, statusPayload(runtime));
}
});
-
ctx.registerGatewayRoute({
auth: "none",
id: "agent-console-renderer",
@@ -131,7 +137,6 @@ module.exports = {
await serveRenderer(ctx, runtime, options, request, response);
}
});
-
ctx.registerApp({
description: "Agent Console Electron renderer backed by the local CCR gateway.",
icon: "terminal-square",
@@ -139,12 +144,10 @@ module.exports = {
name: "Agent Console",
url: appUrl
});
-
ctx.logger.info(`Agent Console registered at ${appUrl}`);
if (runtime.launcherInstalled) {
ctx.logger.info(`Agent Console system launcher is available at ${runtime.launcherPath}.`);
}
-
return {
stop(event) {
stopAgentConsole(runtime);
@@ -155,7 +158,6 @@ module.exports = {
};
}
};
-
function startAgentConsole(ctx, runtime, options) {
if (!runtime.launchApp) return;
if (runtime.child) return;
@@ -165,33 +167,26 @@ function startAgentConsole(ctx, runtime, options) {
ctx.logger.warn(runtime.lastError);
throw new Error(runtime.lastError);
}
-
runtime.ready = false;
runtime.readyPayload = null;
runtime.lastError = "";
- runtime.runtimeStartedAt = new Date().toISOString();
+ runtime.runtimeStartedAt = (/* @__PURE__ */ new Date()).toISOString();
runtime.child = launchAgentConsole(ctx, runtime, options);
}
-
async function ensureAgentConsoleStarted(ctx, runtime, options) {
if (!runtime.launchApp || runtime.ready) {
return;
}
-
if (!runtime.child) {
if (!runtime.startPromise) {
- runtime.startPromise = Promise.resolve()
- .then(() => startAgentConsole(ctx, runtime, options))
- .finally(() => {
- runtime.startPromise = null;
- });
+ runtime.startPromise = Promise.resolve().then(() => startAgentConsole(ctx, runtime, options)).finally(() => {
+ runtime.startPromise = null;
+ });
}
await runtime.startPromise;
}
-
await waitForAgentConsoleReady(runtime, runtime.startupWaitMs);
}
-
async function waitForAgentConsoleReady(runtime, timeoutMs) {
const deadline = Date.now() + timeoutMs;
while (Date.now() <= deadline) {
@@ -205,15 +200,12 @@ async function waitForAgentConsoleReady(runtime, timeoutMs) {
}
throw new Error(`Agent Console headless runtime did not become ready within ${timeoutMs}ms.${runtime.lastError ? ` Last error: ${runtime.lastError}` : ""}`);
}
-
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
-
function launchAgentConsole(ctx, runtime, options) {
const userDataDir = path.resolve(stringValue(options.userDataDir) || path.join(ctx.paths.pluginDataDir, "user-data"));
fs.mkdirSync(userDataDir, { recursive: true });
-
const env = {
...process.env,
AGENT_APP_PWA_BRIDGE_HOST: runtime.bridgeHost,
@@ -224,15 +216,13 @@ function launchAgentConsole(ctx, runtime, options) {
ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING || "1"
};
delete env.ELECTRON_RUN_AS_NODE;
-
- const args = [...(normalizeStringArray(options.electronArgs) || []), runtime.appRoot];
+ const args = [...normalizeStringArray(options.electronArgs) || [], runtime.appRoot];
const child = spawn(runtime.electronPath, args, {
cwd: runtime.appRoot,
env,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true
});
-
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => handleAgentConsoleOutput(ctx, runtime, chunk));
@@ -248,11 +238,9 @@ function launchAgentConsole(ctx, runtime, options) {
runtime.lastError = `Agent Console exited with code ${code ?? "null"} signal ${signal ?? "null"}.`;
ctx.logger.warn(runtime.lastError);
});
-
ctx.logger.info(`Launching Agent Console headless runtime with ${runtime.electronPath}.`);
return child;
}
-
function handleAgentConsoleOutput(ctx, runtime, chunk) {
for (const line of String(chunk).split(/\r?\n/)) {
const trimmed = line.trim();
@@ -270,14 +258,13 @@ function handleAgentConsoleOutput(ctx, runtime, chunk) {
continue;
}
if (/failed|error/i.test(trimmed)) {
- runtime.lastError = trimmed.slice(0, 1000);
+ runtime.lastError = trimmed.slice(0, 1e3);
ctx.logger.warn(trimmed);
} else {
ctx.logger.debug(trimmed);
}
}
}
-
function stopAgentConsole(runtime) {
const child = runtime.child;
if (!child) return;
@@ -288,10 +275,9 @@ function stopAgentConsole(runtime) {
if (child.exitCode === null && !child.killed) {
child.kill("SIGKILL");
}
- }, 3000).unref();
+ }, 3e3).unref();
}
}
-
function statusPayload(runtime) {
return {
appRoot: runtime.appRoot,
@@ -314,17 +300,10 @@ function statusPayload(runtime) {
routePrefix: runtime.routePrefix,
runtimeStartedAt: runtime.runtimeStartedAt,
runtimeConfigFile: runtime.runtimeConfigFile,
- runtimeState: runtime.launchApp
- ? runtime.ready
- ? "ready"
- : runtime.child
- ? "starting"
- : "idle"
- : "disabled",
+ runtimeState: runtime.launchApp ? runtime.ready ? "ready" : runtime.child ? "starting" : "idle" : "disabled",
startedAt: runtime.startedAt
};
}
-
function ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId) {
if (options.systemLauncher === false || options.createSystemLauncher === false) {
return { installed: false };
@@ -335,20 +314,16 @@ function ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId) {
installed: false
};
}
-
const launcherName = stringValue(options.launcherName) || DEFAULT_LAUNCHER_NAME;
const explicitLauncherPath = Boolean(stringValue(options.launcherPath));
const launcherPath = path.resolve(
- stringValue(options.launcherPath) ||
- defaultMacLauncherAppPath(launcherName)
+ stringValue(options.launcherPath) || defaultMacLauncherAppPath(launcherName)
);
-
if (!explicitLauncherPath) {
const legacyLauncherPaths = [legacyMacLauncherAppPath(launcherName)];
if (launcherName === DEFAULT_LAUNCHER_NAME) {
legacyLauncherPaths.push(legacyMacLauncherAppPath(LEGACY_LAUNCHER_NAME));
}
-
for (const legacyPath of legacyLauncherPaths) {
try {
migrateLegacyMacLauncherApp({
@@ -361,7 +336,6 @@ function ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId) {
}
}
}
-
try {
installMacLauncherApp({
bundleId: launcherBundleId,
@@ -383,11 +357,9 @@ function ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId) {
};
}
}
-
function canUsePermission(ctx, permission) {
return Array.isArray(ctx.permissions) && ctx.permissions.includes(permission);
}
-
function migrateLegacyMacLauncherApp({ bundleId, legacyPath, launcherPath }) {
if (legacyPath === launcherPath || fs.existsSync(launcherPath) || !fs.existsSync(legacyPath)) {
return;
@@ -395,44 +367,35 @@ function migrateLegacyMacLauncherApp({ bundleId, legacyPath, launcherPath }) {
if (!fs.statSync(legacyPath).isDirectory()) {
return;
}
-
const infoPath = path.join(legacyPath, "Contents", "Info.plist");
if (!fs.existsSync(infoPath)) {
return;
}
-
const info = fs.readFileSync(infoPath, "utf8");
if (!info.includes(`${escapeXml(bundleId)}`)) {
return;
}
-
fs.mkdirSync(path.dirname(launcherPath), { recursive: true });
fs.renameSync(legacyPath, launcherPath);
}
-
function defaultMacLauncherAppPath(launcherName) {
return path.join(macLauncherAppsDir(), `${safeMacFileName(launcherName)}.app`);
}
-
function legacyMacLauncherAppPath(launcherName) {
return path.join(os.homedir(), "Applications", `${safeMacFileName(launcherName)}.app`);
}
-
function macLauncherAppsDir() {
return path.join(os.homedir(), "Applications", MAC_LAUNCHER_APPS_DIR_NAME);
}
-
function installMacLauncherApp({ bundleId, launcherName, launcherPath, launcherUrl }) {
if (fs.existsSync(launcherPath) && !fs.statSync(launcherPath).isDirectory()) {
throw new Error(`${launcherPath} exists and is not a directory.`);
}
-
const contentsDir = path.join(launcherPath, "Contents");
const macOsDir = path.join(contentsDir, "MacOS");
const resourcesDir = path.join(contentsDir, "Resources");
const executableName = safeMacExecutableName(launcherName);
const executablePath = path.join(macOsDir, executableName);
-
fs.mkdirSync(macOsDir, { recursive: true });
fs.mkdirSync(resourcesDir, { recursive: true });
writeTextIfChanged(path.join(contentsDir, "Info.plist"), macLauncherInfoPlist({
@@ -442,14 +405,12 @@ function installMacLauncherApp({ bundleId, launcherName, launcherPath, launcherU
}));
writeTextIfChanged(path.join(contentsDir, "PkgInfo"), "APPL????");
writeTextIfChanged(executablePath, macLauncherScript(launcherUrl));
- fs.chmodSync(executablePath, 0o755);
+ fs.chmodSync(executablePath, 493);
}
-
function removeSystemLauncher(ctx, runtime, bundleId) {
if (process.platform !== "darwin" || !runtime.launcherInstalled || !runtime.launcherPath) {
return;
}
-
try {
uninstallMacLauncherApp({
bundleId,
@@ -461,7 +422,6 @@ function removeSystemLauncher(ctx, runtime, bundleId) {
ctx.logger.warn(`Failed to remove Agent Console system launcher: ${formatError(error)}`);
}
}
-
function uninstallMacLauncherApp({ bundleId, launcherPath }) {
const resolvedLauncherPath = path.resolve(launcherPath);
if (!resolvedLauncherPath.endsWith(".app") || !fs.existsSync(resolvedLauncherPath)) {
@@ -470,27 +430,22 @@ function uninstallMacLauncherApp({ bundleId, launcherPath }) {
if (!fs.statSync(resolvedLauncherPath).isDirectory()) {
return;
}
-
const infoPath = path.join(resolvedLauncherPath, "Contents", "Info.plist");
if (!fs.existsSync(infoPath)) {
return;
}
-
const info = fs.readFileSync(infoPath, "utf8");
if (!info.includes(`${escapeXml(bundleId)}`)) {
return;
}
-
fs.rmSync(resolvedLauncherPath, { force: true, recursive: true });
if (path.dirname(resolvedLauncherPath) === macLauncherAppsDir()) {
try {
fs.rmdirSync(macLauncherAppsDir());
} catch {
- // Keep the shared launcher directory when it still contains other apps.
}
}
}
-
function macLauncherInfoPlist({ bundleId, executableName, launcherName }) {
return `
@@ -522,7 +477,6 @@ function macLauncherInfoPlist({ bundleId, executableName, launcherName }) {
`;
}
-
function macLauncherScript(launcherUrl) {
const quotedUrl = shellSingleQuote(launcherUrl);
return `#!/bin/sh
@@ -532,7 +486,6 @@ fi
/usr/bin/open ${quotedUrl}
`;
}
-
function writeTextIfChanged(filePath, content) {
if (fs.existsSync(filePath)) {
try {
@@ -540,33 +493,22 @@ function writeTextIfChanged(filePath, content) {
return;
}
} catch {
- // Fall through and rewrite unreadable stale files.
}
}
fs.writeFileSync(filePath, content, "utf8");
}
-
function safeMacFileName(value) {
return value.replace(/[/:]/g, "-").trim() || "Agent Console";
}
-
function safeMacExecutableName(value) {
return value.replace(/[^A-Za-z0-9_-]+/g, "").trim() || "AgentConsole";
}
-
function shellSingleQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
-
function escapeXml(value) {
- return String(value)
- .replace(/&/g, "&")
- .replace(//g, ">")
- .replace(/"/g, """)
- .replace(/'/g, "'");
+ return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
}
-
async function serveRenderer(ctx, runtime, options, request, response) {
const requestUrl = new URL(request.url || "/", "http://localhost");
const routePath = requestUrl.pathname;
@@ -588,9 +530,7 @@ async function serveRenderer(ctx, runtime, options, request, response) {
response.end(agentConsolePreloadScript(runtime.bridgeUrl));
return;
}
- const candidateFile = relativeFilePath
- ? path.join(runtime.rendererRoot, relativeFilePath)
- : path.join(runtime.rendererRoot, "pages", "home", "index.html");
+ const candidateFile = relativeFilePath ? path.join(runtime.rendererRoot, relativeFilePath) : path.join(runtime.rendererRoot, "pages", "home", "index.html");
const filePath = safeFilePath(runtime.rendererRoot, candidateFile) || path.join(runtime.rendererRoot, "pages", "home", "index.html");
const resolvedFile = directoryIndexFile(filePath) || (fileExists(filePath) ? filePath : "");
const fallbackFile = path.join(runtime.rendererRoot, "pages", "home", "index.html");
@@ -599,7 +539,6 @@ async function serveRenderer(ctx, runtime, options, request, response) {
sendText(response, 404, "Agent Console renderer asset was not found.");
return;
}
-
const isRendererHtml = path.basename(existingFile) === "index.html";
if (isRendererHtml) {
try {
@@ -609,13 +548,11 @@ async function serveRenderer(ctx, runtime, options, request, response) {
return;
}
}
-
if (request.method === "HEAD") {
response.writeHead(200, headersForFile(existingFile));
response.end();
return;
}
-
if (isRendererHtml) {
const html = fs.readFileSync(existingFile, "utf8");
response.writeHead(200, {
@@ -625,11 +562,9 @@ async function serveRenderer(ctx, runtime, options, request, response) {
response.end(injectAgentConsolePreload(html, runtime.routePrefix));
return;
}
-
response.writeHead(200, headersForFile(existingFile));
fs.createReadStream(existingFile).pipe(response);
}
-
function injectAgentConsolePreload(html, routePrefix) {
const script = ``;
if (html.includes(script)) {
@@ -640,14 +575,12 @@ function injectAgentConsolePreload(html, routePrefix) {
}
return `${script}${html}`;
}
-
function buildRendererAppUrl(gatewayUrl, routePrefix, bridgeUrl) {
const params = new URLSearchParams();
params.set("mode", "main");
params.set("agentBridge", bridgeUrl);
return `${gatewayUrl}${routePrefix}${DEFAULT_RENDERER_ENTRY_PATH}?${params.toString()}`;
}
-
function rendererEntryLocation(routePrefix, search) {
const params = new URLSearchParams(String(search || "").replace(/^\?/, ""));
if (!params.has("mode")) {
@@ -656,7 +589,6 @@ function rendererEntryLocation(routePrefix, search) {
const query = params.toString();
return `${routePrefix}${DEFAULT_RENDERER_ENTRY_PATH}${query ? `?${query}` : ""}`;
}
-
function agentConsolePreloadScript(bridgeUrl) {
return `
(() => {
@@ -802,7 +734,7 @@ function agentConsolePreloadScript(bridgeUrl) {
":root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel p,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel li,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel blockquote{color:var(--markdown-foreground)!important;text-shadow:0 1px 2px rgba(0,0,0,.42)!important;}",
":root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel h1,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel h2,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel h3,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel h4{color:var(--markdown-heading-foreground)!important;}",
":root[data-window-mode='small-chat'] .small-chat-window .chatbot-user-message{background:rgba(18,30,50,.62)!important;border-color:rgba(226,232,240,.18)!important;color:rgba(250,252,255,.98)!important;-webkit-backdrop-filter:blur(18px) saturate(1.24)!important;backdrop-filter:blur(18px) saturate(1.24)!important;}",
- ":root[data-window-mode='small-chat'] .small-chat-window .home-composer,:root[data-window-mode='small-chat'] .small-chat-window .chat-floating-status,:root[data-window-mode='small-chat'] .small-chat-window .chat-floating-composer{background:rgba(10,17,29,.68)!important;border-color:rgba(226,232,240,.2)!important;color:rgba(250,252,255,.98)!important;-webkit-backdrop-filter:blur(30px) saturate(1.42)!important;backdrop-filter:blur(30px) saturate(1.42)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .home-composer,:root[data-window-mode='small-chat'] .small-chat-window .chat-floating-composer{background:rgba(10,17,29,.68)!important;border-color:rgba(226,232,240,.2)!important;color:rgba(250,252,255,.98)!important;-webkit-backdrop-filter:blur(30px) saturate(1.42)!important;backdrop-filter:blur(30px) saturate(1.42)!important;}",
":root[data-window-mode='small-chat'] .small-chat-window textarea,:root[data-window-mode='small-chat'] .small-chat-window input{color:rgba(250,252,255,.98)!important;}",
":root[data-window-mode='small-chat'] .small-chat-window textarea::placeholder,:root[data-window-mode='small-chat'] .small-chat-window input::placeholder{color:rgba(226,232,240,.76)!important;}",
":root[data-window-mode='small-chat'] .small-chat-window .home-composer-toolbar{background:rgba(255,255,255,.055)!important;}",
@@ -1229,7 +1161,6 @@ function agentConsolePreloadScript(bridgeUrl) {
})();
`;
}
-
function directoryIndexFile(filePath) {
try {
if (!fs.statSync(filePath).isDirectory()) {
@@ -1241,18 +1172,15 @@ function directoryIndexFile(filePath) {
return "";
}
}
-
function shouldFallbackToHome(relativeFilePath) {
return !relativeFilePath || !path.extname(relativeFilePath);
}
-
function headersForFile(filePath) {
return {
"cache-control": isImmutableAsset(filePath) ? "public, max-age=31536000, immutable" : "no-cache",
"content-type": contentType(filePath)
};
}
-
function contentType(filePath) {
const extension = path.extname(filePath).toLowerCase();
if (extension === ".html") return "text/html; charset=utf-8";
@@ -1268,16 +1196,14 @@ function contentType(filePath) {
if (extension === ".woff2") return "font/woff2";
return "application/octet-stream";
}
-
function isImmutableAsset(filePath) {
return path.normalize(filePath).split(path.sep).includes("assets");
}
-
function sendText(response, statusCode, message) {
response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
- response.end(`${message}\n`);
+ response.end(`${message}
+`);
}
-
function safeFilePath(root, candidate) {
const resolvedRoot = path.resolve(root);
const resolvedCandidate = path.resolve(candidate);
@@ -1285,7 +1211,6 @@ function safeFilePath(root, candidate) {
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return "";
return resolvedCandidate;
}
-
function fileExists(filePath) {
try {
return fs.statSync(filePath).isFile();
@@ -1293,17 +1218,14 @@ function fileExists(filePath) {
return false;
}
}
-
+function rendererDistExists(rendererRoot) {
+ return fileExists(path.join(rendererRoot, "pages", "home", "index.html"));
+}
function buildRuntimeConfig(config, options) {
const gatewayUrl = trimTrailingSlash(options.gatewayUrl || configuredGatewayUrl(config));
const openAiBaseUrl = trimTrailingSlash(options.openAiBaseUrl || `${gatewayUrl}/v1`);
const models = availableGatewayModels(config);
- const defaultModel = options.defaultModel ||
- stringValue(config?.Router?.default) ||
- models.find((model) => model.isDefault)?.model ||
- models[0]?.model ||
- "";
-
+ const defaultModel = options.defaultModel || stringValue(config?.Router?.default) || models.find((model) => model.isDefault)?.model || models[0]?.model || "";
return {
apiKey: options.apiKey || configuredGatewayApiKey(config),
claudeCode: {
@@ -1321,33 +1243,457 @@ function buildRuntimeConfig(config, options) {
openAiBaseUrl
};
}
-
+function ensureAgentConsoleCodexRuntime(ctx, options, runtimeConfig) {
+ if (options.codexMiddleware === false) {
+ return { command: "", env: {}, runtimeFile: "" };
+ }
+ const providerId = "claude-code-router";
+ const binDir = path.join(ctx.paths.pluginDataDir, "bin");
+ const codexHome = path.resolve(stringValue(options.codexHome) || path.join(ctx.paths.pluginDataDir, "codex-home"));
+ const configFile = path.join(codexHome, "config.toml");
+ const runtimeFile = path.join(binDir, "ccr-codex-cli-middleware.js");
+ const commandFile = path.join(binDir, process.platform === "win32" ? "ccr-agent-console-codex.cmd" : "ccr-agent-console-codex");
+ const realCodexCli = stringValue(options.codexCliPath || options.codexCommand) || "codex";
+ const model = stringValue(runtimeConfig.codex?.defaultModel) || stringValue(runtimeConfig.defaultModel) || runtimeConfig.models?.[0]?.model || "";
+ const openAiBaseUrl = trimTrailingSlash(stringValue(runtimeConfig.openAiBaseUrl) || `${stringValue(runtimeConfig.gatewayUrl)}/v1`);
+ const apiKey = stringValue(runtimeConfig.apiKey);
+ const modelCatalogFile = stringValue(runtimeConfig.codex?.modelCatalogFile) || path.join(ctx.paths.pluginDataDir, "ccr-codex-model-catalog.json");
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(codexHome, { recursive: true, mode: 448 });
+ writeTextIfChanged(configFile, agentConsoleCodexConfigToml({
+ apiKey,
+ baseUrl: openAiBaseUrl,
+ model,
+ modelCatalogFile,
+ providerId
+ }));
+ try {
+ fs.chmodSync(configFile, 384);
+ } catch {
+ }
+ const runtimeScript = agentConsoleCodexMiddlewareRuntimeScript();
+ writeTextIfChanged(runtimeFile, runtimeScript);
+ writeTextIfChanged(commandFile, process.platform === "win32" ? agentConsoleCodexMiddlewareCmd({
+ codexHome,
+ model,
+ modelCatalogFile,
+ providerId,
+ realCodexCli,
+ runtimeFile
+ }) : agentConsoleCodexMiddlewareShell({
+ codexHome,
+ model,
+ modelCatalogFile,
+ providerId,
+ realCodexCli,
+ runtimeFile
+ }));
+ try {
+ fs.chmodSync(runtimeFile, 493);
+ fs.chmodSync(commandFile, 493);
+ } catch {
+ }
+ return {
+ command: commandFile,
+ env: {
+ CODEX_HOME: codexHome,
+ CCR_CODEX_MODEL_CATALOG_FILE: modelCatalogFile,
+ CCR_CODEX_MODEL_PROVIDER: providerId,
+ CCR_CODEX_PROFILE: providerId,
+ CCR_CODEX_PROFILE_CONFIG_FORMAT: "separate_profile_files",
+ CCR_CODEX_REMOTE_FRONTEND_MODE: "app",
+ CCR_PROFILE_SCOPE: "ccr",
+ CODEXL_CODEX_MODEL_CATALOG_FILE: modelCatalogFile,
+ CODEXL_CODEX_MODEL_PROVIDER: providerId,
+ CODEXL_CODEX_PROFILE: providerId,
+ CODEXL_CODEX_PROFILE_CONFIG_FORMAT: "separate_profile_files"
+ },
+ runtimeFile
+ };
+}
+function ensureAgentConsoleClaudeCodeRuntime(ctx, options, runtimeConfig, sharedRuntimeFile) {
+ if (options.claudeCodeMiddleware === false || options.claudeMiddleware === false) {
+ return { command: "", env: {} };
+ }
+ const binDir = path.join(ctx.paths.pluginDataDir, "bin");
+ const settingsDir = path.join(ctx.paths.pluginDataDir, "claude-code", "claude");
+ const settingsFile = path.join(settingsDir, "settings.json");
+ const runtimeFile = ensureAgentConsoleCliMiddlewareRuntime(ctx, sharedRuntimeFile);
+ const apiKeyHelperFile = path.join(binDir, process.platform === "win32" ? "ccr-agent-console-claude-code-api-key.cmd" : "ccr-agent-console-claude-code-api-key");
+ const commandFile = path.join(binDir, process.platform === "win32" ? "ccr-agent-console-claude-code.cmd" : "ccr-agent-console-claude-code");
+ const realClaudeCli = stringValue(options.claudeCodeCommand || options.claudeCodeCliPath || options.claudeCommand || options.claudeCliPath) || "claude";
+ const model = stringValue(runtimeConfig.claudeCode?.defaultModel) || stringValue(runtimeConfig.defaultModel) || runtimeConfig.models?.[0]?.model || "";
+ const gatewayUrl = trimTrailingSlash(stringValue(runtimeConfig.gatewayUrl));
+ const apiKey = stringValue(runtimeConfig.apiKey);
+ const baseEnv = agentConsoleClaudeCodeBaseEnv({ gatewayUrl, model, settingsDir });
+ const remoteEndpoint = `${gatewayUrl}/__ccr/remote`;
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(settingsDir, { recursive: true, mode: 448 });
+ writeTextIfChanged(settingsFile, agentConsoleClaudeCodeSettingsJson({
+ apiKeyHelperFile,
+ env: baseEnv
+ }));
+ writeTextIfChanged(apiKeyHelperFile, process.platform === "win32" ? agentConsoleApiKeyHelperCmd(apiKey) : agentConsoleApiKeyHelperShell(apiKey));
+ writeTextIfChanged(commandFile, process.platform === "win32" ? agentConsoleClaudeCodeMiddlewareCmd({
+ apiKeyHelperFile,
+ baseEnv,
+ realClaudeCli,
+ remoteEndpoint,
+ runtimeFile
+ }) : agentConsoleClaudeCodeMiddlewareShell({
+ apiKeyHelperFile,
+ baseEnv,
+ realClaudeCli,
+ remoteEndpoint,
+ runtimeFile
+ }));
+ try {
+ fs.chmodSync(settingsFile, 384);
+ fs.chmodSync(apiKeyHelperFile, 448);
+ fs.chmodSync(commandFile, 493);
+ } catch {
+ }
+ return {
+ command: commandFile,
+ env: {
+ ...baseEnv,
+ CCR_CLAUDE_CODE_WRAPPER: "1",
+ CCR_REAL_CLAUDE_CODE_BIN: realClaudeCli,
+ CODEXL_CLAUDE_CODE_BIN: realClaudeCli,
+ CCR_REMOTE_SYNC_API_KEY_HELPER: apiKeyHelperFile,
+ CCR_REMOTE_SYNC_ENABLED: "1",
+ CCR_REMOTE_SYNC_ENDPOINT: remoteEndpoint,
+ CCR_REMOTE_SYNC_PROFILE_ID: "agent-console-claude-code",
+ CCR_REMOTE_SYNC_PROFILE_NAME: "Agent Console Claude Code"
+ }
+ };
+}
+function ensureAgentConsoleCliMiddlewareRuntime(ctx, runtimeFile) {
+ const binDir = path.join(ctx.paths.pluginDataDir, "bin");
+ const file = stringValue(runtimeFile) || path.join(binDir, "ccr-codex-cli-middleware.js");
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ writeTextIfChanged(file, agentConsoleCodexMiddlewareRuntimeScript());
+ try {
+ fs.chmodSync(file, 493);
+ } catch {
+ }
+ return file;
+}
+function agentConsoleClaudeCodeBaseEnv({ gatewayUrl, model, settingsDir }) {
+ const env = {
+ ANTHROPIC_API_BASE_URL: gatewayUrl,
+ ANTHROPIC_BASE_URL: gatewayUrl,
+ CLAUDE_AGENT_API_BASE_URL: gatewayUrl,
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
+ CLAUDE_CONFIG_DIR: settingsDir
+ };
+ if (model) {
+ env.ANTHROPIC_MODEL = model;
+ env.CCR_CLAUDE_CODE_MODEL = model;
+ env.CODEXL_CLAUDE_CODE_MODEL = model;
+ }
+ const timezoneEnv = agentConsoleClaudeCodeTimezoneEnv();
+ return Object.keys(timezoneEnv).length ? { ...env, ...timezoneEnv } : env;
+}
+function agentConsoleClaudeCodeSettingsJson({ apiKeyHelperFile, env }) {
+ return `${JSON.stringify({
+ apiKeyHelper: process.platform === "win32" ? `"${apiKeyHelperFile}"` : apiKeyHelperFile,
+ env
+ }, null, 2)}
+`;
+}
+function agentConsoleApiKeyHelperShell(apiKey) {
+ return [
+ "#!/bin/sh",
+ `printf '%s\\n' ${shellSingleQuote(apiKey)}`,
+ ""
+ ].join("\n");
+}
+function agentConsoleApiKeyHelperCmd(apiKey) {
+ return [
+ "@echo off",
+ `echo ${cmdValue(apiKey)}`,
+ ""
+ ].join("\r\n");
+}
+function agentConsoleClaudeCodeMiddlewareShell({ apiKeyHelperFile, baseEnv, realClaudeCli, remoteEndpoint, runtimeFile }) {
+ return [
+ "#!/bin/sh",
+ ...Object.entries(baseEnv).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
+ "export CCR_CLAUDE_CODE_WRAPPER=1",
+ `export CCR_REAL_CLAUDE_CODE_BIN=${shellSingleQuote(realClaudeCli)}`,
+ `export CODEXL_CLAUDE_CODE_BIN=${shellSingleQuote(realClaudeCli)}`,
+ 'if [ -z "${CCR_PROFILE_SURFACE:-}" ]; then CCR_PROFILE_SURFACE=app; fi',
+ "export CCR_PROFILE_SURFACE",
+ 'if [ -z "${CCR_REMOTE_SYNC_ENABLED:-}" ]; then CCR_REMOTE_SYNC_ENABLED=1; fi',
+ `if [ -z "\${CCR_REMOTE_SYNC_ENDPOINT:-}" ]; then CCR_REMOTE_SYNC_ENDPOINT=${shellSingleQuote(remoteEndpoint)}; fi`,
+ `if [ -z "\${CCR_REMOTE_SYNC_API_KEY_HELPER:-}" ]; then CCR_REMOTE_SYNC_API_KEY_HELPER=${shellSingleQuote(apiKeyHelperFile)}; fi`,
+ 'if [ -z "${CCR_REMOTE_SYNC_PROFILE_ID:-}" ]; then CCR_REMOTE_SYNC_PROFILE_ID=agent-console-claude-code; fi',
+ `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_NAME:-}" ]; then CCR_REMOTE_SYNC_PROFILE_NAME='Agent Console Claude Code'; fi`,
+ "export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_HELPER CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME",
+ 'if [ -n "${CCR_NODE_BIN:-}" ]; then',
+ ` exec "$CCR_NODE_BIN" ${shellSingleQuote(runtimeFile)} "$@"`,
+ "fi",
+ "if command -v node >/dev/null 2>&1; then",
+ ` exec node ${shellSingleQuote(runtimeFile)} "$@"`,
+ "fi",
+ `ELECTRON_RUN_AS_NODE=1 exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(runtimeFile)} "$@"`,
+ ""
+ ].join("\n");
+}
+function agentConsoleClaudeCodeMiddlewareCmd({ apiKeyHelperFile, baseEnv, realClaudeCli, remoteEndpoint, runtimeFile }) {
+ const quotedRuntime = cmdQuote(runtimeFile);
+ const quotedHost = cmdQuote(process.execPath);
+ return [
+ "@echo off",
+ ...Object.entries(baseEnv).map(([key, value]) => cmdSetLine(key, value)),
+ cmdSetLine("CCR_CLAUDE_CODE_WRAPPER", "1"),
+ cmdSetLine("CCR_REAL_CLAUDE_CODE_BIN", realClaudeCli),
+ cmdSetLine("CODEXL_CLAUDE_CODE_BIN", realClaudeCli),
+ `if not defined CCR_PROFILE_SURFACE ${cmdSetLine("CCR_PROFILE_SURFACE", "app")}`,
+ `if not defined CCR_REMOTE_SYNC_ENABLED ${cmdSetLine("CCR_REMOTE_SYNC_ENABLED", "1")}`,
+ `if not defined CCR_REMOTE_SYNC_ENDPOINT ${cmdSetLine("CCR_REMOTE_SYNC_ENDPOINT", remoteEndpoint)}`,
+ `if not defined CCR_REMOTE_SYNC_API_KEY_HELPER ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_HELPER", apiKeyHelperFile)}`,
+ `if not defined CCR_REMOTE_SYNC_PROFILE_ID ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_ID", "agent-console-claude-code")}`,
+ `if not defined CCR_REMOTE_SYNC_PROFILE_NAME ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_NAME", "Agent Console Claude Code")}`,
+ "if defined CCR_NODE_BIN (",
+ ` "%CCR_NODE_BIN%" ${quotedRuntime} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ "where node >nul 2>nul",
+ "if %ERRORLEVEL%==0 (",
+ ` node ${quotedRuntime} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ 'set "ELECTRON_RUN_AS_NODE=1"',
+ `${quotedHost} ${quotedRuntime} %*`,
+ "exit /b %ERRORLEVEL%",
+ ""
+ ].join("\r\n");
+}
+function agentConsoleClaudeCodeTimezoneEnv() {
+ let timeZone = "";
+ try {
+ timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
+ } catch {
+ return {};
+ }
+ const normalized = timeZone.trim().toLowerCase();
+ return [
+ "asia/chongqing",
+ "asia/chungking",
+ "asia/harbin",
+ "asia/kashgar",
+ "asia/shanghai",
+ "asia/urumqi",
+ "china standard time",
+ "prc"
+ ].includes(normalized) ? { TZ: "UTC" } : {};
+}
+function agentConsoleCodexConfigToml({ apiKey, baseUrl, model, modelCatalogFile, providerId }) {
+ return [
+ `model_provider = ${tomlString(providerId)}`,
+ `model = ${tomlString(model)}`,
+ `model_catalog_json = ${tomlString(modelCatalogFile)}`,
+ "",
+ `[model_providers.${tomlKey(providerId)}]`,
+ `name = ${tomlString("Claude Code Router")}`,
+ `base_url = ${tomlString(baseUrl)}`,
+ `experimental_bearer_token = ${tomlString(apiKey)}`,
+ 'wire_api = "responses"',
+ ""
+ ].join("\n");
+}
+function agentConsoleCodexMiddlewareShell({ codexHome, model, modelCatalogFile, providerId, realCodexCli, runtimeFile }) {
+ return [
+ "#!/bin/sh",
+ `export CODEX_HOME=${shellSingleQuote(codexHome)}`,
+ 'if [ -z "${CCR_REAL_CODEX_CLI_PATH:-}" ]; then',
+ ` CCR_REAL_CODEX_CLI_PATH=${shellSingleQuote(realCodexCli)}`,
+ "fi",
+ "export CCR_REAL_CODEX_CLI_PATH",
+ `export CCR_CODEX_PROFILE=${shellSingleQuote(providerId)}`,
+ `export CCR_CODEX_MODEL=${shellSingleQuote(model)}`,
+ `export CCR_CODEX_MODEL_CATALOG_FILE=${shellSingleQuote(modelCatalogFile)}`,
+ `export CCR_CODEX_MODEL_PROVIDER=${shellSingleQuote(providerId)}`,
+ "export CCR_CODEX_PROFILE_CONFIG_FORMAT=separate_profile_files",
+ "export CCR_PROFILE_SCOPE=ccr",
+ "export CCR_CODEX_REMOTE_FRONTEND_MODE=app",
+ 'if [ -z "${CODEXL_REAL_CODEX_CLI_PATH:-}" ]; then',
+ " CODEXL_REAL_CODEX_CLI_PATH=$CCR_REAL_CODEX_CLI_PATH",
+ "fi",
+ "export CODEXL_REAL_CODEX_CLI_PATH",
+ `export CODEXL_CODEX_PROFILE=${shellSingleQuote(providerId)}`,
+ `export CODEXL_CODEX_MODEL=${shellSingleQuote(model)}`,
+ `export CODEXL_CODEX_MODEL_CATALOG_FILE=${shellSingleQuote(modelCatalogFile)}`,
+ `export CODEXL_CODEX_MODEL_PROVIDER=${shellSingleQuote(providerId)}`,
+ "export CODEXL_CODEX_PROFILE_CONFIG_FORMAT=separate_profile_files",
+ "export CODEXL_CODEX_CORE_MODE=app",
+ 'if [ -n "${CCR_NODE_BIN:-}" ]; then',
+ ` exec "$CCR_NODE_BIN" ${shellSingleQuote(runtimeFile)} "$@"`,
+ "fi",
+ "if command -v node >/dev/null 2>&1; then",
+ ` exec node ${shellSingleQuote(runtimeFile)} "$@"`,
+ "fi",
+ `ELECTRON_RUN_AS_NODE=1 exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(runtimeFile)} "$@"`,
+ ""
+ ].join("\n");
+}
+function agentConsoleCodexMiddlewareCmd({ codexHome, model, modelCatalogFile, providerId, realCodexCli, runtimeFile }) {
+ const quotedRuntime = cmdQuote(runtimeFile);
+ const quotedHost = cmdQuote(process.execPath);
+ return [
+ "@echo off",
+ cmdSetLine("CODEX_HOME", codexHome),
+ `if not defined CCR_REAL_CODEX_CLI_PATH ${cmdSetLine("CCR_REAL_CODEX_CLI_PATH", realCodexCli)}`,
+ cmdSetLine("CCR_CODEX_PROFILE", providerId),
+ cmdSetLine("CCR_CODEX_MODEL", model),
+ cmdSetLine("CCR_CODEX_MODEL_CATALOG_FILE", modelCatalogFile),
+ cmdSetLine("CCR_CODEX_MODEL_PROVIDER", providerId),
+ cmdSetLine("CCR_CODEX_PROFILE_CONFIG_FORMAT", "separate_profile_files"),
+ cmdSetLine("CCR_PROFILE_SCOPE", "ccr"),
+ cmdSetLine("CCR_CODEX_REMOTE_FRONTEND_MODE", "app"),
+ 'if not defined CODEXL_REAL_CODEX_CLI_PATH set "CODEXL_REAL_CODEX_CLI_PATH=%CCR_REAL_CODEX_CLI_PATH%"',
+ cmdSetLine("CODEXL_CODEX_PROFILE", providerId),
+ cmdSetLine("CODEXL_CODEX_MODEL", model),
+ cmdSetLine("CODEXL_CODEX_MODEL_CATALOG_FILE", modelCatalogFile),
+ cmdSetLine("CODEXL_CODEX_MODEL_PROVIDER", providerId),
+ cmdSetLine("CODEXL_CODEX_PROFILE_CONFIG_FORMAT", "separate_profile_files"),
+ cmdSetLine("CODEXL_CODEX_CORE_MODE", "app"),
+ "if defined CCR_NODE_BIN (",
+ ` "%CCR_NODE_BIN%" ${quotedRuntime} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ "where node >nul 2>nul",
+ "if %ERRORLEVEL%==0 (",
+ ` node ${quotedRuntime} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ 'set "ELECTRON_RUN_AS_NODE=1"',
+ `${quotedHost} ${quotedRuntime} %*`,
+ "exit /b %ERRORLEVEL%",
+ ""
+ ].join("\r\n");
+}
+function agentConsoleCodexMiddlewareRuntimeScript() {
+ const moduleRuntime = agentConsoleCodexMiddlewareRuntimeFromModule();
+ if (moduleRuntime) return moduleRuntime;
+ const source = fs.readFileSync(agentConsoleCodexMiddlewareSourceFile(), "utf8");
+ const marker = "return String.raw`";
+ const start = source.indexOf(marker);
+ if (start < 0) {
+ throw new Error("Unable to locate Codex middleware runtime template.");
+ }
+ const templateStart = start + marker.length;
+ for (let index = templateStart; index < source.length; index += 1) {
+ if (source[index] !== "`" || isEscapedTemplateBacktick(source, index)) continue;
+ return source.slice(templateStart, index).replace(/\\`/g, "`");
+ }
+ throw new Error("Unable to read Codex middleware runtime template.");
+}
+function agentConsoleCodexMiddlewareRuntimeFromModule() {
+ const candidates = [
+ "@ccr/core/agents/codex/cli-middleware-runtime",
+ "@claude-code-router/core/agents/codex/cli-middleware-runtime"
+ ];
+ for (const candidate of candidates) {
+ try {
+ const runtimeModule = require(candidate);
+ if (typeof runtimeModule?.codexCliMiddlewareRuntimeScript !== "function") continue;
+ const script = runtimeModule.codexCliMiddlewareRuntimeScript();
+ if (script) return script;
+ } catch {
+ }
+ }
+ return "";
+}
+function agentConsoleCodexMiddlewareSourceFile() {
+ const candidates = [
+ stringValue(process.env.CCR_CODEX_MIDDLEWARE_RUNTIME_SOURCE),
+ path.resolve(__dirname, "../../../packages/core/src/agents/codex/cli-middleware-runtime.ts"),
+ path.resolve(process.cwd(), "packages/core/src/agents/codex/cli-middleware-runtime.ts")
+ ].filter(Boolean);
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ throw new Error("Codex middleware runtime source was not found.");
+}
+function isEscapedTemplateBacktick(source, index) {
+ let slashCount = 0;
+ for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
+ slashCount += 1;
+ }
+ return slashCount % 2 === 1;
+}
+function tomlString(value) {
+ return JSON.stringify(String(value ?? ""));
+}
+function tomlKey(value) {
+ const key = String(value || "").trim();
+ return /^[A-Za-z0-9_]+$/.test(key) ? key : tomlString(key);
+}
+function cmdSetLine(key, value) {
+ return `set "${key}=${cmdValue(value)}"`;
+}
+function cmdValue(value) {
+ return String(value ?? "").replace(/"/g, '""');
+}
+function cmdQuote(value) {
+ return `"${cmdValue(value)}"`;
+}
function buildCodexModelCatalog(models) {
return {
models: models.map((model, index) => {
const reasoning = codexModelReasoningProfile(model.model);
+ const contextWindowTokens = readCatalogPositiveInteger(model.contextWindowTokens || model.context_window_tokens) || modelContextWindowTokens(model.model);
return {
additional_speed_tiers: [],
+ apply_patch_tool_type: "freeform",
availability_nux: null,
base_instructions: "You are Codex, a coding agent.",
+ context_window: contextWindowTokens,
+ default_verbosity: "low",
+ defaultReasoningEffort: reasoning.defaultReasoningEffort || null,
default_reasoning_level: reasoning.defaultReasoningLevel,
+ default_reasoning_effort: reasoning.defaultReasoningEffort || null,
default_reasoning_summary: "none",
description: `CCR gateway model ${model.model}`,
+ displayName: model.displayName || model.model,
display_name: model.displayName || model.model,
+ effective_context_window_percent: 95,
+ experimental_supported_tools: [],
+ id: model.model,
+ input_modalities: ["text", "image"],
+ max_context_window: contextWindowTokens,
+ model: model.model,
priority: index,
service_tiers: [],
shell_type: "shell_command",
slug: model.model,
+ support_verbosity: true,
supported_in_api: true,
+ supportedReasoningEfforts: reasoning.supportedReasoningLevels.map(reasoningEffortOption),
+ supported_reasoning_efforts: reasoning.supportedReasoningEfforts,
supported_reasoning_levels: reasoning.supportedReasoningLevels,
+ supports_image_detail_original: true,
+ supports_parallel_tool_calls: true,
supports_reasoning_summaries: reasoning.supportsReasoning,
+ supports_search_tool: false,
+ truncation_policy: { mode: "tokens", limit: 1e4 },
upgrade: null,
- visibility: "list"
+ visibility: "list",
+ web_search_tool_type: "text"
};
})
};
}
-
+function reasoningEffortOption(level) {
+ return {
+ description: level.description,
+ reasoningEffort: level.effort,
+ reasoning_effort: level.effort
+ };
+}
function availableGatewayModels(config) {
const baseEntries = [];
for (const provider of Array.isArray(config?.Providers) ? config.Providers : []) {
@@ -1364,7 +1710,6 @@ function availableGatewayModels(config) {
}));
}
}
-
const virtualEntries = [];
for (const profile of Array.isArray(config?.virtualModelProfiles) ? config.virtualModelProfiles : []) {
if (!isVisibleVirtualProfile(profile)) continue;
@@ -1395,38 +1740,34 @@ function availableGatewayModels(config) {
}));
}
}
-
return uniqueModels([...baseEntries, ...virtualEntries]);
}
-
function runtimeModelEntry(model, options = {}) {
+ const contextModel = options.contextModel || options.reasoningModel || model;
const reasoning = codexModelReasoningProfile(options.reasoningModel || model);
+ const contextWindowTokens = modelContextWindowTokens(contextModel);
return {
+ contextWindowTokens,
+ context_window_tokens: contextWindowTokens,
displayName: options.displayName || model,
id: options.id || model,
isDefault: options.isDefault === true,
model,
- ...(reasoning.defaultReasoningEffort ? { defaultReasoningEffort: reasoning.defaultReasoningEffort } : {}),
+ ...reasoning.defaultReasoningEffort ? { defaultReasoningEffort: reasoning.defaultReasoningEffort } : {},
supportedReasoningEfforts: reasoning.supportedReasoningEfforts,
supportedSpeeds: []
};
}
-
function displayModelName(provider, modelName, fallback) {
const displayNames = isRecord(provider.modelDisplayNames) ? provider.modelDisplayNames : {};
return stringValue(displayNames[modelName]) || fallback;
}
-
function codexModelReasoningProfile(model) {
const entry = findModelCatalogEntry(model);
const capabilities = isRecord(entry?.capabilities) ? entry.capabilities : {};
const effortConfig = modelCatalogReasoningEffortConfig(entry, providerNameFromModel(model));
- const fallbackEfforts = effortConfig.efforts.length === 0 && openAiGptReasoningFallbackApplies(model)
- ? OPENAI_REASONING_EFFORTS
- : [];
- const reasoningConfig = fallbackEfforts.length > 0
- ? { ...effortConfig, defaultEffort: "medium", efforts: fallbackEfforts, supportsReasoning: true }
- : effortConfig;
+ const fallbackEfforts = effortConfig.efforts.length === 0 ? openAiGptReasoningFallbackEfforts(model) : [];
+ const reasoningConfig = fallbackEfforts.length > 0 ? { ...effortConfig, defaultEffort: "medium", efforts: fallbackEfforts, supportsReasoning: true } : effortConfig;
const supportsReasoning = capabilities.reasoning === true || reasoningConfig.supportsReasoning;
return {
defaultReasoningEffort: defaultReasoningEffort(reasoningConfig),
@@ -1436,25 +1777,38 @@ function codexModelReasoningProfile(model) {
supportsReasoning
};
}
-
-function openAiGptReasoningFallbackApplies(model) {
- const provider = normalizeModelCatalogToken(providerNameFromModel(model));
+function modelContextWindowTokens(model) {
+ return modelCatalogMaxInputTokens(findModelCatalogEntry(model)) || DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS;
+}
+function modelCatalogMaxInputTokens(entry) {
+ const limits = isRecord(entry?.limits) ? entry.limits : {};
+ return Math.max(
+ 0,
+ readCatalogPositiveInteger(limits.contextTokens),
+ readCatalogPositiveInteger(limits.inputTokens)
+ );
+}
+function openAiGptReasoningFallbackEfforts(model) {
const modelName = normalizeModelCatalogToken(modelNameFromModel(model));
- const openAiProvider = provider.includes("openai") || provider.includes("codex-api");
- return openAiProvider && (/^gpt-[0-9]/.test(modelName) || /^o[0-9]/.test(modelName));
+ if (openAiGptSupportsXHighFallback(modelName)) return OPENAI_EXTENDED_REASONING_EFFORTS;
+ return /^gpt-[0-9]/.test(modelName) || /^o[0-9]/.test(modelName) ? OPENAI_REASONING_EFFORTS : [];
+}
+function openAiGptSupportsXHighFallback(modelName) {
+ const match = modelName.match(/^gpt-(\d+)(?:[.-](\d+))?/);
+ if (!match) return false;
+ const major = Number.parseInt(match[1], 10);
+ const minor = Number.parseInt(match[2] || "0", 10);
+ return major > 5 || major === 5 && minor >= 6;
}
-
function modelCatalogReasoningEffortConfig(entry, providerName) {
if (!entry) {
return { defaultEffort: "", efforts: [], supportsReasoning: false };
}
-
const records = sourceRecordsForProvider(entry.sourceRecords, providerName);
const metadataValues = [
entry.metadata,
...records.map((record) => record.metadata)
].filter(isRecord);
-
let defaultEffort = "";
let supportsReasoning = false;
const efforts = [];
@@ -1472,33 +1826,24 @@ function modelCatalogReasoningEffortConfig(entry, providerName) {
}
}
}
-
return { defaultEffort, efforts, supportsReasoning };
}
-
function sourceRecordsForProvider(records, providerName) {
const normalizedProviderName = normalizeModelCatalogToken(providerName);
if (!normalizedProviderName) return [];
return records.filter((record) => {
const provider = normalizeModelCatalogToken(record.provider);
const displayName = normalizeModelCatalogToken(record.providerName);
- return [provider, displayName].some((value) =>
- value &&
- (
- value === normalizedProviderName ||
- value.includes(normalizedProviderName) ||
- normalizedProviderName.includes(value)
- )
+ return [provider, displayName].some(
+ (value) => value && (value === normalizedProviderName || value.includes(normalizedProviderName) || normalizedProviderName.includes(value))
);
});
}
-
function reasoningConfigFromMetadata(metadata) {
const efforts = [];
let defaultEffort = "";
let supportsReasoning = false;
-
- const reasoning = isRecord(metadata.reasoning) ? metadata.reasoning : undefined;
+ const reasoning = isRecord(metadata.reasoning) ? metadata.reasoning : void 0;
if (reasoning) {
supportsReasoning = true;
for (const effort of normalizeReasoningEfforts(reasoning.supported_efforts)) {
@@ -1508,7 +1853,6 @@ function reasoningConfigFromMetadata(metadata) {
}
defaultEffort = normalizeReasoningEffort(reasoning.default_effort);
}
-
const options = Array.isArray(metadata.reasoningOptions) ? metadata.reasoningOptions : [];
for (const option of options) {
if (!isRecord(option)) continue;
@@ -1524,18 +1868,12 @@ function reasoningConfigFromMetadata(metadata) {
}
}
}
-
return { defaultEffort, efforts, supportsReasoning };
}
-
function normalizeReasoningEfforts(value) {
if (!Array.isArray(value)) return [];
- return value
- .map(normalizeReasoningEffort)
- .filter(Boolean)
- .filter((effort, index, efforts) => efforts.indexOf(effort) === index);
+ return value.map(normalizeReasoningEffort).filter(Boolean).filter((effort, index, efforts) => efforts.indexOf(effort) === index);
}
-
function normalizeReasoningEffort(value) {
const normalized = stringValue(value).toLowerCase().replace(/[_\s-]+/g, "");
if (!normalized || normalized === "default") return "";
@@ -1547,16 +1885,13 @@ function normalizeReasoningEffort(value) {
if (normalized === "xhigh" || normalized === "extrahigh" || normalized === "max") return "xhigh";
return "";
}
-
function defaultReasoningLevel(config) {
if (!config.defaultEffort || config.defaultEffort === "none") return null;
return config.efforts.includes(config.defaultEffort) ? config.defaultEffort : null;
}
-
function defaultReasoningEffort(config) {
return defaultReasoningLevel(config) || "";
}
-
function reasoningLevel(effort) {
const descriptions = {
high: "High reasoning",
@@ -1571,7 +1906,6 @@ function reasoningLevel(effort) {
description: descriptions[effort] || `${effort} reasoning`
};
}
-
function findModelCatalogEntry(model) {
const index = loadModelCatalogIndex();
const candidates = modelCatalogLookupKeys(model);
@@ -1579,37 +1913,31 @@ function findModelCatalogEntry(model) {
const entry = index.byKey.get(key);
if (entry) return entry;
}
-
for (const key of candidates) {
const modelKey = modelCatalogLastSegmentKey(key);
if (!modelKey) continue;
const entry = index.byModelKey.get(modelKey);
if (entry) return entry;
}
-
- return undefined;
+ return void 0;
}
-
function loadModelCatalogIndex() {
if (modelCatalogIndex) return modelCatalogIndex;
-
const payload = loadModelCatalogPayload();
modelCatalogIndex = buildModelCatalogIndex(payload);
return modelCatalogIndex;
}
-
function loadModelCatalogPayload() {
for (const candidate of modelCatalogPathCandidates()) {
if (!fs.existsSync(candidate)) continue;
try {
return JSON.parse(fs.readFileSync(candidate, "utf8"));
} catch {
- return undefined;
+ return void 0;
}
}
- return undefined;
+ return void 0;
}
-
function modelCatalogPathCandidates() {
return uniqueStrings([
stringValue(process.env.CCR_MODEL_CATALOG_PATH),
@@ -1625,20 +1953,16 @@ function modelCatalogPathCandidates() {
path.resolve(__dirname, "..", "..", "..", "packages", "cli", "models.json")
]);
}
-
function buildModelCatalogIndex(payload) {
- const byKey = new Map();
- const byModelKey = new Map();
+ const byKey = /* @__PURE__ */ new Map();
+ const byModelKey = /* @__PURE__ */ new Map();
const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : [];
-
for (const item of models) {
const entry = parseModelCatalogEntry(item);
if (!entry) continue;
-
for (const key of modelCatalogEntryKeys(entry)) {
byKey.set(key, entry);
}
-
const shortKeys = uniqueStrings([
entry.model ? normalizeModelCatalogToken(entry.model) : "",
...entry.aliases.map((alias) => modelCatalogLastSegmentKey(normalizeModelCatalogKey(alias)))
@@ -1646,35 +1970,43 @@ function buildModelCatalogIndex(payload) {
for (const key of shortKeys) {
if (!key) continue;
if (byModelKey.has(key) && byModelKey.get(key) !== entry) {
- byModelKey.set(key, undefined);
+ byModelKey.set(key, void 0);
} else {
byModelKey.set(key, entry);
}
}
}
-
return { byKey, byModelKey };
}
-
function parseModelCatalogEntry(value) {
- if (!isRecord(value)) return undefined;
+ if (!isRecord(value)) return void 0;
const id = stringValue(value.id);
- if (!id) return undefined;
+ if (!id) return void 0;
return {
aliases: uniqueStrings([id, ...stringListValue(value.aliases)]),
- capabilities: isRecord(value.capabilities) ? value.capabilities : undefined,
+ capabilities: isRecord(value.capabilities) ? value.capabilities : void 0,
id,
- metadata: isRecord(value.metadata) ? value.metadata : undefined,
+ limits: modelCatalogLimitsValue(value.limits),
+ metadata: isRecord(value.metadata) ? value.metadata : void 0,
model: stringValue(value.model),
providers: stringListValue(value.providers),
sourceRecords: sourceRecordListValue(value.sourceRecords)
};
}
-
+function modelCatalogLimitsValue(value) {
+ if (!isRecord(value)) return void 0;
+ const limits = {
+ contextTokens: readCatalogPositiveInteger(value.contextTokens),
+ inputTokens: readCatalogPositiveInteger(value.inputTokens)
+ };
+ return limits.contextTokens || limits.inputTokens ? limits : void 0;
+}
+function readCatalogPositiveInteger(value) {
+ return parsePositiveInteger(value);
+}
function sourceRecordListValue(value) {
return Array.isArray(value) ? value.filter(isRecord) : [];
}
-
function modelCatalogEntryKeys(entry) {
return uniqueStrings([
normalizeModelCatalogKey(entry.id),
@@ -1682,48 +2014,26 @@ function modelCatalogEntryKeys(entry) {
...entry.providers.map((provider) => entry.model ? normalizeModelCatalogKey(`${provider}/${entry.model}`) : "")
]);
}
-
function modelCatalogLookupKeys(value) {
const raw = String(value || "").trim();
const normalized = normalizeModelCatalogKey(raw);
- const withoutClaudePrefix = raw.toLowerCase().startsWith("claude-") && raw.includes("/")
- ? normalizeModelCatalogKey(raw.replace(/^claude-/i, ""))
- : "";
+ const withoutClaudePrefix = raw.toLowerCase().startsWith("claude-") && raw.includes("/") ? normalizeModelCatalogKey(raw.replace(/^claude-/i, "")) : "";
return uniqueStrings([normalized, withoutClaudePrefix]);
}
-
function normalizeModelCatalogKey(value) {
- return String(value || "")
- .trim()
- .split("/")
- .map(normalizeModelCatalogToken)
- .filter(Boolean)
- .join("/");
+ return String(value || "").trim().split("/").map(normalizeModelCatalogToken).filter(Boolean).join("/");
}
-
function normalizeModelCatalogToken(value) {
- return String(value || "")
- .trim()
- .replace(/^hf:/i, "")
- .replace(/^@/, "")
- .replace(/[_\s]+/g, "-")
- .replace(/-+/g, "-")
- .toLowerCase();
+ return String(value || "").trim().replace(/^hf:/i, "").replace(/^@/, "").replace(/[_\s]+/g, "-").replace(/-+/g, "-").toLowerCase();
}
-
function modelCatalogLastSegmentKey(value) {
return value.split("/").filter(Boolean).at(-1) || "";
}
-
function isVisibleVirtualProfile(profile) {
- return profile &&
- profile.enabled !== false &&
- profile.materialization?.enabled !== false &&
- profile.materialization?.includeInGatewayModels !== false;
+ return profile && profile.enabled !== false && profile.materialization?.enabled !== false && profile.materialization?.includeInGatewayModels !== false;
}
-
function uniqueModels(models) {
- const seen = new Set();
+ const seen = /* @__PURE__ */ new Set();
const result = [];
for (const model of models) {
if (!model.model || seen.has(model.model)) continue;
@@ -1732,30 +2042,25 @@ function uniqueModels(models) {
}
return result;
}
-
function providerNameFromModel(model) {
const index = model.indexOf("/");
return index >= 0 ? model.slice(0, index) : "Fusion";
}
-
function modelNameFromModel(model) {
const index = model.indexOf("/");
return index >= 0 ? model.slice(index + 1) : model;
}
-
function configuredGatewayUrl(config) {
const gateway = isRecord(config?.gateway) ? config.gateway : {};
const host = normalizeGatewayHost(stringValue(gateway.host) || stringValue(config?.HOST) || "127.0.0.1");
const port = parsePort(gateway.port) || parsePort(config?.PORT) || 3456;
return `http://${host}:${port}`;
}
-
function normalizeGatewayHost(host) {
if (!host || host === "0.0.0.0") return "127.0.0.1";
if (host === "::" || host === "[::]") return "[::1]";
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
-
function configuredGatewayApiKey(config) {
const apiKey = stringValue(config?.APIKEY);
if (apiKey) return apiKey;
@@ -1766,7 +2071,6 @@ function configuredGatewayApiKey(config) {
}
return "";
}
-
function resolveAppRoot(options) {
const candidates = [
stringValue(options.appRoot),
@@ -1777,14 +2081,11 @@ function resolveAppRoot(options) {
].filter(Boolean);
return path.resolve(expandHomePath(candidates[0]));
}
-
function resolveElectronPath(options, appRoot) {
const configured = stringValue(options.electronPath) || stringValue(process.env.CCR_AGENT_CONSOLE_ELECTRON_PATH);
const candidates = [
configured,
- process.platform === "win32"
- ? path.join(appRoot, "node_modules", ".bin", "electron.cmd")
- : path.join(appRoot, "node_modules", ".bin", "electron"),
+ process.platform === "win32" ? path.join(appRoot, "node_modules", ".bin", "electron.cmd") : path.join(appRoot, "node_modules", ".bin", "electron"),
path.join(appRoot, "node_modules", "electron", "dist", process.platform === "darwin" ? "Electron.app/Contents/MacOS/Electron" : "electron")
].filter(Boolean);
for (const candidate of candidates) {
@@ -1793,7 +2094,6 @@ function resolveElectronPath(options, appRoot) {
}
return "";
}
-
function pickOpenPort(host) {
return new Promise((resolve, reject) => {
const server = net.createServer();
@@ -1811,35 +2111,29 @@ function pickOpenPort(host) {
});
});
}
-
function parsePort(value) {
const port = typeof value === "number" ? value : Number.parseInt(String(value || ""), 10);
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 0;
}
-
function parsePositiveInteger(value) {
const integer = typeof value === "number" ? value : Number.parseInt(String(value || ""), 10);
return Number.isInteger(integer) && integer > 0 ? integer : 0;
}
-
function normalizeRoutePrefix(value) {
const trimmed = value.trim();
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return prefixed.replace(/\/+$/, "") || DEFAULT_ROUTE_PREFIX;
}
-
function normalizeStringArray(value) {
- if (!Array.isArray(value)) return undefined;
+ if (!Array.isArray(value)) return void 0;
const items = value.map((item) => stringValue(item)).filter(Boolean);
- return items.length ? items : undefined;
+ return items.length ? items : void 0;
}
-
function stringListValue(value) {
return Array.isArray(value) ? value.map((item) => stringValue(item)).filter(Boolean) : [];
}
-
function uniqueStrings(values) {
- const seen = new Set();
+ const seen = /* @__PURE__ */ new Set();
const strings = [];
for (const value of values) {
const trimmed = stringValue(value);
@@ -1849,15 +2143,12 @@ function uniqueStrings(values) {
}
return strings;
}
-
function stringValue(value) {
return typeof value === "string" ? value.trim() : "";
}
-
function isRecord(value) {
return value && typeof value === "object" && !Array.isArray(value);
}
-
function expandHomePath(value) {
if (!value.startsWith("~")) return value;
if (value === "~") return os.homedir();
@@ -1866,11 +2157,9 @@ function expandHomePath(value) {
}
return value;
}
-
function trimTrailingSlash(value) {
return value.replace(/\/+$/, "");
}
-
function formatError(error) {
return error instanceof Error ? error.message : String(error);
}
diff --git a/marketplace/plugins/agent-console/package.json b/marketplace/plugins/agent-console/package.json
new file mode 100644
index 00000000..793f9175
--- /dev/null
+++ b/marketplace/plugins/agent-console/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@claude-code-router/plugin-agent-console",
+ "version": "0.0.0",
+ "private": true,
+ "type": "commonjs",
+ "main": "index.cjs",
+ "scripts": {
+ "build": "node build.mjs",
+ "build:main": "node build.mjs --main-only",
+ "build:renderer": "node build.mjs --renderer-only",
+ "check": "node --check src/index.cjs && npm run build && node --check index.cjs"
+ },
+ "dependencies": {
+ "@radix-ui/react-slot": "^1.2.4",
+ "@tiptap/core": "^3.26.1",
+ "@tiptap/extension-link": "^3.26.1",
+ "@tiptap/extension-placeholder": "^3.26.1",
+ "@tiptap/pm": "^3.26.1",
+ "@tiptap/react": "^3.26.1",
+ "@tiptap/starter-kit": "^3.26.1",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "dompurify": "^3.4.7",
+ "lucide-react": "^1.17.0",
+ "marked": "^18.0.4",
+ "monaco-editor": "^0.55.1",
+ "motion": "^12.40.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "tailwind-merge": "^3.6.0",
+ "tailwindcss": "^4.3.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.3.0",
+ "@types/node": "^22.10.2",
+ "@types/react": "^18.3.18",
+ "@types/react-dom": "^18.3.5",
+ "@vitejs/plugin-react": "^4.3.4",
+ "esbuild": "^0.27.7",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.6"
+ }
+}
diff --git a/marketplace/plugins/agent-console/src/index.cjs b/marketplace/plugins/agent-console/src/index.cjs
new file mode 100644
index 00000000..c958f066
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/index.cjs
@@ -0,0 +1,2411 @@
+"use strict";
+
+const { spawn } = require("node:child_process");
+const fs = require("node:fs");
+const net = require("node:net");
+const os = require("node:os");
+const path = require("node:path");
+
+const PLUGIN_ID = "agent-console";
+const DEFAULT_APP_ROOT = "/Users/jinhuilee/products/CCR/app";
+const DEFAULT_RENDERER_ROOT = path.join(__dirname, "dist", "renderer");
+const DEFAULT_ROUTE_PREFIX = "/plugins/agent-console";
+const DEFAULT_RENDERER_ENTRY_PATH = "/pages/home/";
+const DEFAULT_LAUNCHER_NAME = "Agent Console";
+const LEGACY_LAUNCHER_NAME = "CCR Agent Console";
+const MAC_LAUNCHER_APPS_DIR_NAME = "CCR Apps";
+const DEFAULT_LAUNCHER_BUNDLE_ID = "com.claudecoderouter.plugin.agent-console.launcher";
+const READY_PREFIX = "AGENT_CONSOLE_HEADLESS_READY ";
+const DEFAULT_STARTUP_WAIT_MS = 15000;
+const DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS = 128000;
+const OPENAI_REASONING_EFFORTS = ["minimal", "low", "medium", "high"];
+const OPENAI_EXTENDED_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"];
+let modelCatalogIndex;
+
+module.exports = {
+ async setup(ctx) {
+ const options = isRecord(ctx.pluginConfig) ? ctx.pluginConfig : {};
+ const routePrefix = normalizeRoutePrefix(stringValue(options.routePrefix) || DEFAULT_ROUTE_PREFIX);
+ const appRoot = resolveAppRoot(options);
+ const fallbackRendererRoot = path.join(appRoot, "dist", "renderer");
+ const rendererRoot = path.resolve(
+ stringValue(options.rendererRoot) ||
+ stringValue(options.pwaRoot) ||
+ (rendererDistExists(DEFAULT_RENDERER_ROOT) ? DEFAULT_RENDERER_ROOT : fallbackRendererRoot)
+ );
+ const electronPath = resolveElectronPath(options, appRoot);
+ const launchMode = stringValue(options.launchMode || options.startMode).toLowerCase();
+ const launchApp = options.launch !== false;
+ const launchOnSetup = launchApp && (
+ options.launch === true ||
+ options.launchOnSetup === true ||
+ options.launchOnStartup === true ||
+ launchMode === "startup" ||
+ launchMode === "eager"
+ );
+ const startupWaitMs = parsePositiveInteger(options.startupWaitMs) || DEFAULT_STARTUP_WAIT_MS;
+ const bridgeHost = stringValue(options.bridgeHost) || "127.0.0.1";
+ const bridgePort = parsePort(options.bridgePort) || await pickOpenPort(bridgeHost);
+ const bridgeUrl = `ws://${bridgeHost}:${bridgePort}/pwa`;
+ const gatewayUrl = trimTrailingSlash(stringValue(options.gatewayUrl) || configuredGatewayUrl(ctx.config));
+ const gatewayApiKey = stringValue(options.gatewayApiKey) || configuredGatewayApiKey(ctx.config);
+ const appUrl = buildRendererAppUrl(gatewayUrl, routePrefix, bridgeUrl);
+ const launcherUrl = `ccr://plugin/${encodeURIComponent(PLUGIN_ID)}/open`;
+ const launcherBundleId = stringValue(options.launcherBundleId) || DEFAULT_LAUNCHER_BUNDLE_ID;
+ const runtimeConfigFile = path.join(ctx.paths.pluginDataDir, "ccr-runtime-config.json");
+ const modelCatalogFile = path.join(ctx.paths.pluginDataDir, "ccr-codex-model-catalog.json");
+ fs.mkdirSync(ctx.paths.pluginDataDir, { recursive: true });
+ const runtimeConfig = buildRuntimeConfig(ctx.config, {
+ apiKey: gatewayApiKey,
+ defaultModel: stringValue(options.defaultModel),
+ gatewayUrl,
+ modelCatalogFile,
+ openAiBaseUrl: stringValue(options.openAiBaseUrl)
+ });
+ const codexModelCatalog = buildCodexModelCatalog(runtimeConfig.models);
+ fs.writeFileSync(modelCatalogFile, `${JSON.stringify(codexModelCatalog, null, 2)}\n`, "utf8");
+ const codexRuntime = ensureAgentConsoleCodexRuntime(ctx, options, runtimeConfig);
+ const claudeCodeRuntime = ensureAgentConsoleClaudeCodeRuntime(ctx, options, runtimeConfig, codexRuntime.runtimeFile);
+ if (codexRuntime.command) {
+ runtimeConfig.codex = {
+ ...(isRecord(runtimeConfig.codex) ? runtimeConfig.codex : {}),
+ command: codexRuntime.command,
+ env: codexRuntime.env
+ };
+ }
+ if (claudeCodeRuntime.command) {
+ runtimeConfig.claudeCode = {
+ ...(isRecord(runtimeConfig.claudeCode) ? runtimeConfig.claudeCode : {}),
+ command: claudeCodeRuntime.command,
+ env: claudeCodeRuntime.env
+ };
+ }
+ const runtime = {
+ appRoot,
+ appUrl,
+ bridgeHost,
+ bridgePort,
+ bridgeUrl,
+ child: null,
+ electronPath,
+ lastError: "",
+ launchApp,
+ launchOnOpen: launchApp && !launchOnSetup,
+ launchOnSetup,
+ launcherError: "",
+ launcherInstalled: false,
+ launcherPath: "",
+ launcherUrl,
+ rendererRoot,
+ ready: false,
+ readyPayload: null,
+ routePrefix,
+ runtimeStartedAt: null,
+ runtimeConfigFile,
+ startPromise: null,
+ startupWaitMs,
+ startedAt: new Date().toISOString()
+ };
+
+ if (!fs.existsSync(path.join(rendererRoot, "pages", "home", "index.html"))) {
+ ctx.logger.warn(`Agent Console Electron renderer dist is missing at ${rendererRoot}. Run npm --prefix marketplace/plugins/agent-console run build in the CCR project before opening it.`);
+ }
+
+ fs.writeFileSync(runtimeConfigFile, `${JSON.stringify(runtimeConfig, null, 2)}\n`, "utf8");
+
+ const launcher = canUsePermission(ctx, "system-launcher")
+ ? ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId)
+ : {
+ error: "Agent Console system launcher requires the system-launcher permission.",
+ installed: false
+ };
+ runtime.launcherError = launcher.error || "";
+ runtime.launcherInstalled = launcher.installed;
+ runtime.launcherPath = launcher.path || "";
+
+ if (!launchApp) {
+ runtime.ready = true;
+ } else if (launchOnSetup) {
+ try {
+ startAgentConsole(ctx, runtime, options);
+ } catch (error) {
+ ctx.logger.warn(`Agent Console startup launch failed: ${formatError(error)}`);
+ }
+ }
+
+ ctx.registerGatewayRoute({
+ auth: "none",
+ id: "agent-console-status",
+ methods: ["GET"],
+ path: `${routePrefix}/__status`,
+ handler(_request, response, helpers) {
+ helpers.sendJson(response, 200, statusPayload(runtime));
+ }
+ });
+
+ ctx.registerGatewayRoute({
+ auth: "none",
+ id: "agent-console-renderer",
+ methods: ["GET", "HEAD"],
+ pathPrefix: routePrefix,
+ async handler(request, response) {
+ await serveRenderer(ctx, runtime, options, request, response);
+ }
+ });
+
+ ctx.registerApp({
+ description: "Agent Console Electron renderer backed by the local CCR gateway.",
+ icon: "terminal-square",
+ id: PLUGIN_ID,
+ name: "Agent Console",
+ url: appUrl
+ });
+
+ ctx.logger.info(`Agent Console registered at ${appUrl}`);
+ if (runtime.launcherInstalled) {
+ ctx.logger.info(`Agent Console system launcher is available at ${runtime.launcherPath}.`);
+ }
+
+ return {
+ stop(event) {
+ stopAgentConsole(runtime);
+ if (event?.reason === "disabled") {
+ removeSystemLauncher(ctx, runtime, launcherBundleId);
+ }
+ }
+ };
+ }
+};
+
+function startAgentConsole(ctx, runtime, options) {
+ if (!runtime.launchApp) return;
+ if (runtime.child) return;
+ if (!runtime.electronPath) {
+ runtime.ready = false;
+ runtime.lastError = `Electron executable was not found under ${runtime.appRoot}.`;
+ ctx.logger.warn(runtime.lastError);
+ throw new Error(runtime.lastError);
+ }
+
+ runtime.ready = false;
+ runtime.readyPayload = null;
+ runtime.lastError = "";
+ runtime.runtimeStartedAt = new Date().toISOString();
+ runtime.child = launchAgentConsole(ctx, runtime, options);
+}
+
+async function ensureAgentConsoleStarted(ctx, runtime, options) {
+ if (!runtime.launchApp || runtime.ready) {
+ return;
+ }
+
+ if (!runtime.child) {
+ if (!runtime.startPromise) {
+ runtime.startPromise = Promise.resolve()
+ .then(() => startAgentConsole(ctx, runtime, options))
+ .finally(() => {
+ runtime.startPromise = null;
+ });
+ }
+ await runtime.startPromise;
+ }
+
+ await waitForAgentConsoleReady(runtime, runtime.startupWaitMs);
+}
+
+async function waitForAgentConsoleReady(runtime, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() <= deadline) {
+ if (runtime.ready) {
+ return;
+ }
+ if (!runtime.child) {
+ throw new Error(runtime.lastError || "Agent Console headless runtime exited before it became ready.");
+ }
+ await delay(100);
+ }
+ throw new Error(`Agent Console headless runtime did not become ready within ${timeoutMs}ms.${runtime.lastError ? ` Last error: ${runtime.lastError}` : ""}`);
+}
+
+function delay(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function launchAgentConsole(ctx, runtime, options) {
+ const userDataDir = path.resolve(stringValue(options.userDataDir) || path.join(ctx.paths.pluginDataDir, "user-data"));
+ fs.mkdirSync(userDataDir, { recursive: true });
+
+ const env = {
+ ...process.env,
+ AGENT_APP_PWA_BRIDGE_HOST: runtime.bridgeHost,
+ AGENT_APP_PWA_BRIDGE_PORT: String(runtime.bridgePort),
+ AGENT_CONSOLE_CCR_CONFIG_FILE: runtime.runtimeConfigFile,
+ AGENT_CONSOLE_HEADLESS: "1",
+ AGENT_CONSOLE_USER_DATA_DIR: userDataDir,
+ ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING || "1"
+ };
+ delete env.ELECTRON_RUN_AS_NODE;
+
+ const args = [...(normalizeStringArray(options.electronArgs) || []), runtime.appRoot];
+ const child = spawn(runtime.electronPath, args, {
+ cwd: runtime.appRoot,
+ env,
+ stdio: ["ignore", "pipe", "pipe"],
+ windowsHide: true
+ });
+
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => handleAgentConsoleOutput(ctx, runtime, chunk));
+ child.stderr.on("data", (chunk) => handleAgentConsoleOutput(ctx, runtime, chunk));
+ child.once("error", (error) => {
+ runtime.ready = false;
+ runtime.lastError = error.message;
+ ctx.logger.error("Agent Console failed to launch.", error);
+ });
+ child.once("exit", (code, signal) => {
+ runtime.child = null;
+ runtime.ready = false;
+ runtime.lastError = `Agent Console exited with code ${code ?? "null"} signal ${signal ?? "null"}.`;
+ ctx.logger.warn(runtime.lastError);
+ });
+
+ ctx.logger.info(`Launching Agent Console headless runtime with ${runtime.electronPath}.`);
+ return child;
+}
+
+function handleAgentConsoleOutput(ctx, runtime, chunk) {
+ for (const line of String(chunk).split(/\r?\n/)) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ if (trimmed.startsWith(READY_PREFIX)) {
+ const payloadText = trimmed.slice(READY_PREFIX.length).trim();
+ try {
+ runtime.readyPayload = JSON.parse(payloadText);
+ } catch {
+ runtime.readyPayload = { raw: payloadText };
+ }
+ runtime.ready = true;
+ runtime.lastError = "";
+ ctx.logger.info("Agent Console headless runtime is ready.");
+ continue;
+ }
+ if (/failed|error/i.test(trimmed)) {
+ runtime.lastError = trimmed.slice(0, 1000);
+ ctx.logger.warn(trimmed);
+ } else {
+ ctx.logger.debug(trimmed);
+ }
+ }
+}
+
+function stopAgentConsole(runtime) {
+ const child = runtime.child;
+ if (!child) return;
+ runtime.child = null;
+ if (!child.killed) {
+ child.kill("SIGTERM");
+ setTimeout(() => {
+ if (child.exitCode === null && !child.killed) {
+ child.kill("SIGKILL");
+ }
+ }, 3000).unref();
+ }
+}
+
+function statusPayload(runtime) {
+ return {
+ appRoot: runtime.appRoot,
+ appUrl: runtime.appUrl,
+ bridgeUrl: runtime.bridgeUrl,
+ childPid: runtime.child?.pid ?? null,
+ electronPath: runtime.electronPath || null,
+ lastError: runtime.lastError,
+ launchApp: runtime.launchApp,
+ launchOnOpen: runtime.launchOnOpen,
+ launchOnSetup: runtime.launchOnSetup,
+ launcherError: runtime.launcherError,
+ launcherInstalled: runtime.launcherInstalled,
+ launcherPath: runtime.launcherPath,
+ launcherUrl: runtime.launcherUrl,
+ pwaRoot: runtime.rendererRoot,
+ ready: runtime.ready,
+ readyPayload: runtime.readyPayload,
+ rendererRoot: runtime.rendererRoot,
+ routePrefix: runtime.routePrefix,
+ runtimeStartedAt: runtime.runtimeStartedAt,
+ runtimeConfigFile: runtime.runtimeConfigFile,
+ runtimeState: runtime.launchApp
+ ? runtime.ready
+ ? "ready"
+ : runtime.child
+ ? "starting"
+ : "idle"
+ : "disabled",
+ startedAt: runtime.startedAt
+ };
+}
+
+function ensureSystemLauncher(ctx, options, launcherUrl, launcherBundleId) {
+ if (options.systemLauncher === false || options.createSystemLauncher === false) {
+ return { installed: false };
+ }
+ if (process.platform !== "darwin") {
+ return {
+ error: `System launcher creation is only implemented for macOS. Current platform: ${process.platform}.`,
+ installed: false
+ };
+ }
+
+ const launcherName = stringValue(options.launcherName) || DEFAULT_LAUNCHER_NAME;
+ const explicitLauncherPath = Boolean(stringValue(options.launcherPath));
+ const launcherPath = path.resolve(
+ stringValue(options.launcherPath) ||
+ defaultMacLauncherAppPath(launcherName)
+ );
+
+ if (!explicitLauncherPath) {
+ const legacyLauncherPaths = [legacyMacLauncherAppPath(launcherName)];
+ if (launcherName === DEFAULT_LAUNCHER_NAME) {
+ legacyLauncherPaths.push(legacyMacLauncherAppPath(LEGACY_LAUNCHER_NAME));
+ }
+
+ for (const legacyPath of legacyLauncherPaths) {
+ try {
+ migrateLegacyMacLauncherApp({
+ bundleId: launcherBundleId,
+ legacyPath,
+ launcherPath
+ });
+ } catch (error) {
+ ctx.logger.warn(`Failed to rename legacy Agent Console launcher: ${formatError(error)}`);
+ }
+ }
+ }
+
+ try {
+ installMacLauncherApp({
+ bundleId: launcherBundleId,
+ launcherName,
+ launcherPath,
+ launcherUrl
+ });
+ return {
+ installed: true,
+ path: launcherPath
+ };
+ } catch (error) {
+ const message = `Failed to install Agent Console system launcher: ${formatError(error)}`;
+ ctx.logger.warn(message);
+ return {
+ error: message,
+ installed: false,
+ path: launcherPath
+ };
+ }
+}
+
+function canUsePermission(ctx, permission) {
+ return Array.isArray(ctx.permissions) && ctx.permissions.includes(permission);
+}
+
+function migrateLegacyMacLauncherApp({ bundleId, legacyPath, launcherPath }) {
+ if (legacyPath === launcherPath || fs.existsSync(launcherPath) || !fs.existsSync(legacyPath)) {
+ return;
+ }
+ if (!fs.statSync(legacyPath).isDirectory()) {
+ return;
+ }
+
+ const infoPath = path.join(legacyPath, "Contents", "Info.plist");
+ if (!fs.existsSync(infoPath)) {
+ return;
+ }
+
+ const info = fs.readFileSync(infoPath, "utf8");
+ if (!info.includes(`${escapeXml(bundleId)}`)) {
+ return;
+ }
+
+ fs.mkdirSync(path.dirname(launcherPath), { recursive: true });
+ fs.renameSync(legacyPath, launcherPath);
+}
+
+function defaultMacLauncherAppPath(launcherName) {
+ return path.join(macLauncherAppsDir(), `${safeMacFileName(launcherName)}.app`);
+}
+
+function legacyMacLauncherAppPath(launcherName) {
+ return path.join(os.homedir(), "Applications", `${safeMacFileName(launcherName)}.app`);
+}
+
+function macLauncherAppsDir() {
+ return path.join(os.homedir(), "Applications", MAC_LAUNCHER_APPS_DIR_NAME);
+}
+
+function installMacLauncherApp({ bundleId, launcherName, launcherPath, launcherUrl }) {
+ if (fs.existsSync(launcherPath) && !fs.statSync(launcherPath).isDirectory()) {
+ throw new Error(`${launcherPath} exists and is not a directory.`);
+ }
+
+ const contentsDir = path.join(launcherPath, "Contents");
+ const macOsDir = path.join(contentsDir, "MacOS");
+ const resourcesDir = path.join(contentsDir, "Resources");
+ const executableName = safeMacExecutableName(launcherName);
+ const executablePath = path.join(macOsDir, executableName);
+
+ fs.mkdirSync(macOsDir, { recursive: true });
+ fs.mkdirSync(resourcesDir, { recursive: true });
+ writeTextIfChanged(path.join(contentsDir, "Info.plist"), macLauncherInfoPlist({
+ bundleId,
+ executableName,
+ launcherName
+ }));
+ writeTextIfChanged(path.join(contentsDir, "PkgInfo"), "APPL????");
+ writeTextIfChanged(executablePath, macLauncherScript(launcherUrl));
+ fs.chmodSync(executablePath, 0o755);
+}
+
+function removeSystemLauncher(ctx, runtime, bundleId) {
+ if (process.platform !== "darwin" || !runtime.launcherInstalled || !runtime.launcherPath) {
+ return;
+ }
+
+ try {
+ uninstallMacLauncherApp({
+ bundleId,
+ launcherPath: runtime.launcherPath
+ });
+ runtime.launcherInstalled = false;
+ runtime.launcherPath = "";
+ } catch (error) {
+ ctx.logger.warn(`Failed to remove Agent Console system launcher: ${formatError(error)}`);
+ }
+}
+
+function uninstallMacLauncherApp({ bundleId, launcherPath }) {
+ const resolvedLauncherPath = path.resolve(launcherPath);
+ if (!resolvedLauncherPath.endsWith(".app") || !fs.existsSync(resolvedLauncherPath)) {
+ return;
+ }
+ if (!fs.statSync(resolvedLauncherPath).isDirectory()) {
+ return;
+ }
+
+ const infoPath = path.join(resolvedLauncherPath, "Contents", "Info.plist");
+ if (!fs.existsSync(infoPath)) {
+ return;
+ }
+
+ const info = fs.readFileSync(infoPath, "utf8");
+ if (!info.includes(`${escapeXml(bundleId)}`)) {
+ return;
+ }
+
+ fs.rmSync(resolvedLauncherPath, { force: true, recursive: true });
+ if (path.dirname(resolvedLauncherPath) === macLauncherAppsDir()) {
+ try {
+ fs.rmdirSync(macLauncherAppsDir());
+ } catch {
+ // Keep the shared launcher directory when it still contains other apps.
+ }
+ }
+}
+
+function macLauncherInfoPlist({ bundleId, executableName, launcherName }) {
+ return `
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ ${escapeXml(launcherName)}
+ CFBundleExecutable
+ ${escapeXml(executableName)}
+ CFBundleIdentifier
+ ${escapeXml(bundleId)}
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ ${escapeXml(launcherName)}
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 10.15
+ NSHighResolutionCapable
+
+
+
+`;
+}
+
+function macLauncherScript(launcherUrl) {
+ const quotedUrl = shellSingleQuote(launcherUrl);
+ return `#!/bin/sh
+if /usr/bin/open -b com.claudecoderouter.desktop ${quotedUrl} >/dev/null 2>&1; then
+ exit 0
+fi
+/usr/bin/open ${quotedUrl}
+`;
+}
+
+function writeTextIfChanged(filePath, content) {
+ if (fs.existsSync(filePath)) {
+ try {
+ if (fs.readFileSync(filePath, "utf8") === content) {
+ return;
+ }
+ } catch {
+ // Fall through and rewrite unreadable stale files.
+ }
+ }
+ fs.writeFileSync(filePath, content, "utf8");
+}
+
+function safeMacFileName(value) {
+ return value.replace(/[/:]/g, "-").trim() || "Agent Console";
+}
+
+function safeMacExecutableName(value) {
+ return value.replace(/[^A-Za-z0-9_-]+/g, "").trim() || "AgentConsole";
+}
+
+function shellSingleQuote(value) {
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
+}
+
+function escapeXml(value) {
+ return String(value)
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+async function serveRenderer(ctx, runtime, options, request, response) {
+ const requestUrl = new URL(request.url || "/", "http://localhost");
+ const routePath = requestUrl.pathname;
+ if (routePath === runtime.routePrefix || routePath === `${runtime.routePrefix}/`) {
+ response.writeHead(308, {
+ "cache-control": "no-store",
+ "location": rendererEntryLocation(runtime.routePrefix, requestUrl.search)
+ });
+ response.end();
+ return;
+ }
+ const relativeUrlPath = routePath.slice(runtime.routePrefix.length) || "/";
+ const relativeFilePath = decodeURIComponent(relativeUrlPath.split("/").filter(Boolean).join("/"));
+ if (relativeFilePath === "__agent-console-preload.js") {
+ response.writeHead(200, {
+ "cache-control": "no-store",
+ "content-type": "text/javascript; charset=utf-8"
+ });
+ response.end(agentConsolePreloadScript(runtime.bridgeUrl));
+ return;
+ }
+ const candidateFile = relativeFilePath
+ ? path.join(runtime.rendererRoot, relativeFilePath)
+ : path.join(runtime.rendererRoot, "pages", "home", "index.html");
+ const filePath = safeFilePath(runtime.rendererRoot, candidateFile) || path.join(runtime.rendererRoot, "pages", "home", "index.html");
+ const resolvedFile = directoryIndexFile(filePath) || (fileExists(filePath) ? filePath : "");
+ const fallbackFile = path.join(runtime.rendererRoot, "pages", "home", "index.html");
+ const existingFile = resolvedFile || (shouldFallbackToHome(relativeFilePath) ? fallbackFile : "");
+ if (!safeFilePath(runtime.rendererRoot, existingFile) || !fileExists(existingFile)) {
+ sendText(response, 404, "Agent Console renderer asset was not found.");
+ return;
+ }
+
+ const isRendererHtml = path.basename(existingFile) === "index.html";
+ if (isRendererHtml) {
+ try {
+ await ensureAgentConsoleStarted(ctx, runtime, options);
+ } catch (error) {
+ sendText(response, 503, `Agent Console runtime is not available. ${formatError(error)}`);
+ return;
+ }
+ }
+
+ if (request.method === "HEAD") {
+ response.writeHead(200, headersForFile(existingFile));
+ response.end();
+ return;
+ }
+
+ if (isRendererHtml) {
+ const html = fs.readFileSync(existingFile, "utf8");
+ response.writeHead(200, {
+ ...headersForFile(existingFile),
+ "cache-control": "no-store"
+ });
+ response.end(injectAgentConsolePreload(html, runtime.routePrefix));
+ return;
+ }
+
+ response.writeHead(200, headersForFile(existingFile));
+ fs.createReadStream(existingFile).pipe(response);
+}
+
+function injectAgentConsolePreload(html, routePrefix) {
+ const script = ``;
+ if (html.includes(script)) {
+ return html;
+ }
+ if (html.includes("")) {
+ return html.replace("", `${script}`);
+ }
+ return `${script}${html}`;
+}
+
+function buildRendererAppUrl(gatewayUrl, routePrefix, bridgeUrl) {
+ const params = new URLSearchParams();
+ params.set("mode", "main");
+ params.set("agentBridge", bridgeUrl);
+ return `${gatewayUrl}${routePrefix}${DEFAULT_RENDERER_ENTRY_PATH}?${params.toString()}`;
+}
+
+function rendererEntryLocation(routePrefix, search) {
+ const params = new URLSearchParams(String(search || "").replace(/^\?/, ""));
+ if (!params.has("mode")) {
+ params.set("mode", "main");
+ }
+ const query = params.toString();
+ return `${routePrefix}${DEFAULT_RENDERER_ENTRY_PATH}${query ? `?${query}` : ""}`;
+}
+
+function agentConsolePreloadScript(bridgeUrl) {
+ return `
+(() => {
+ if (window.agentConsole) return;
+ window.__AGENT_CONSOLE_BRIDGE_URLS__ = ${JSON.stringify([bridgeUrl])};
+ try { window.localStorage.setItem("agentConsolePwaBridgeUrl", ${JSON.stringify(bridgeUrl)}); } catch (_) {}
+
+ class AgentConsoleBridge {
+ constructor() {
+ this.connectPromise = null;
+ this.nextId = 1;
+ this.pending = new Map();
+ this.socket = null;
+ this.subscribers = new Map();
+ }
+ invoke(channel, ...args) {
+ return this.ensureSocket().then((socket) => new Promise((resolve, reject) => {
+ const id = this.nextId++;
+ this.pending.set(id, { reject, resolve });
+ socket.send(JSON.stringify({ args, channel, id, type: "invoke" }));
+ }));
+ }
+ send(channel, ...args) {
+ void this.ensureSocket().then((socket) => {
+ socket.send(JSON.stringify({ args, channel, type: "send" }));
+ });
+ }
+ on(channel, callback) {
+ let callbacks = this.subscribers.get(channel);
+ if (!callbacks) {
+ callbacks = new Set();
+ this.subscribers.set(channel, callbacks);
+ }
+ callbacks.add(callback);
+ return () => callbacks.delete(callback);
+ }
+ ensureSocket() {
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) return Promise.resolve(this.socket);
+ if (this.connectPromise) return this.connectPromise;
+ this.connectPromise = this.connect().finally(() => {
+ this.connectPromise = null;
+ });
+ return this.connectPromise;
+ }
+ connect() {
+ const urls = Array.isArray(window.__AGENT_CONSOLE_BRIDGE_URLS__) && window.__AGENT_CONSOLE_BRIDGE_URLS__.length
+ ? window.__AGENT_CONSOLE_BRIDGE_URLS__
+ : [window.localStorage.getItem("agentConsolePwaBridgeUrl")].filter(Boolean);
+ let index = 0;
+ const tryNext = (lastError) => {
+ const url = urls[index++];
+ if (!url) return Promise.reject(lastError || new Error("Agent Console bridge is not available."));
+ return new Promise((resolve, reject) => {
+ const socket = new WebSocket(url);
+ const timer = window.setTimeout(() => {
+ socket.close();
+ reject(new Error("Agent Console bridge connection timed out."));
+ }, 5000);
+ socket.addEventListener("open", () => {
+ window.clearTimeout(timer);
+ this.socket = socket;
+ socket.addEventListener("message", (event) => this.handleMessage(event));
+ socket.addEventListener("close", () => this.handleClose(socket));
+ resolve(socket);
+ }, { once: true });
+ socket.addEventListener("error", () => {
+ window.clearTimeout(timer);
+ reject(new Error("Failed to connect Agent Console bridge."));
+ }, { once: true });
+ }).catch(tryNext);
+ };
+ return tryNext();
+ }
+ handleClose(socket) {
+ if (this.socket === socket) this.socket = null;
+ for (const pending of this.pending.values()) {
+ pending.reject(new Error("Agent Console bridge disconnected."));
+ }
+ this.pending.clear();
+ }
+ handleMessage(event) {
+ if (typeof event.data !== "string") return;
+ let message;
+ try { message = JSON.parse(event.data); } catch (_) { return; }
+ if (message.type === "event") {
+ for (const callback of this.subscribers.get(message.channel) || []) callback(message.payload);
+ return;
+ }
+ if (message.type !== "result" || typeof message.id !== "number") return;
+ const pending = this.pending.get(message.id);
+ if (!pending) return;
+ this.pending.delete(message.id);
+ if (message.error) {
+ const error = new Error(message.error.message || "Agent Console bridge request failed.");
+ error.name = message.error.name || error.name;
+ if (message.error.stack) error.stack = message.error.stack;
+ pending.reject(error);
+ } else {
+ pending.resolve(message.result);
+ }
+ }
+ }
+
+ const bridge = new AgentConsoleBridge();
+ const invoke = (channel) => (...args) => bridge.invoke(channel, ...args);
+ const on = (channel) => (callback) => bridge.on(channel, callback);
+ const unavailable = (feature) => () => Promise.reject(new Error(feature + " is not available in CCR plugin mode."));
+ const isRecord = (value) => value && typeof value === "object" && !Array.isArray(value);
+ let activeProjectPath = null;
+ const withActiveProject = (payload) => {
+ if (!activeProjectPath || (payload && typeof payload === "object" && !Array.isArray(payload) && (payload.cwd || payload.projectPath))) {
+ return payload;
+ }
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
+ return { ...payload, projectPath: activeProjectPath };
+ }
+ return { projectPath: activeProjectPath };
+ };
+ const invokeWithActiveProject = (channel) => (payload) => bridge.invoke(channel, withActiveProject(payload));
+ const browserUnavailableState = () => Promise.resolve({
+ activeTabId: null,
+ hiddenHostReady: false,
+ importedProfiles: [],
+ origins: [],
+ settings: {
+ browserUseEnabled: false,
+ coachmarkDismissed: true,
+ hiddenHostEnabled: false,
+ requireOriginApproval: true
+ },
+ tabs: []
+ });
+ const isSmallChatMode = () => new URLSearchParams(window.location.search).get("mode") === "small-chat";
+ const ensurePluginSmallWindowVisuals = () => {
+ if (!isSmallChatMode() || document.getElementById("agent-console-plugin-small-window-styles")) return;
+ const style = document.createElement("style");
+ style.id = "agent-console-plugin-small-window-styles";
+ style.textContent = [
+ ":root[data-window-mode='small-chat'] .small-chat-window{--foreground:rgba(250,252,255,.98);--muted:rgba(148,163,184,.12);--muted-foreground:rgba(226,232,240,.84);--card-foreground:rgba(250,252,255,.98);--accent:rgba(20,184,166,.14);--accent-foreground:rgba(204,251,241,.98);--popover:rgba(8,13,24,.92);--popover-foreground:rgba(241,245,249,.94);--primary:#5eead4;--secondary-foreground:rgba(241,245,249,.95);--border:rgba(226,232,240,.2);--card:rgba(12,20,34,.64);--chatbot-foreground:rgba(250,252,255,.98);--chatbot-user-message-foreground:rgba(250,252,255,.98);--markdown-foreground:rgba(242,246,252,.95);--markdown-heading-foreground:rgba(255,255,255,.98);--markdown-blockquote-foreground:rgba(226,232,240,.86);--markdown-link-foreground:#93c5fd;--markdown-inline-code-background:rgba(15,23,42,.72);--markdown-inline-code-foreground:#e0f2fe;--markdown-code-block-background:rgba(5,10,18,.72);--markdown-code-block-foreground:rgba(241,245,249,.96);--markdown-code-block-border:rgba(226,232,240,.18);background:rgba(7,12,22,.62)!important;color:rgba(250,252,255,.98)!important;text-shadow:0 1px 2px rgba(0,0,0,.48)!important;box-shadow:0 24px 70px rgba(0,0,0,.36),inset 0 1px 0 rgba(255,255,255,.14)!important;-webkit-backdrop-filter:blur(28px) saturate(1.28) brightness(.86)!important;backdrop-filter:blur(28px) saturate(1.28) brightness(.86)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window::before{background:linear-gradient(180deg,rgba(255,255,255,.06),transparent 28%),linear-gradient(180deg,rgba(2,6,12,.2),rgba(2,6,12,.34))!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window-header{background:linear-gradient(180deg,rgba(7,12,22,.54),rgba(7,12,22,.18) 76%,transparent)!important;border-color:rgba(226,232,240,.14)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .chatbot-bottom-overlay{background:linear-gradient(180deg,transparent 0%,rgba(7,12,22,.24) 42%,rgba(7,12,22,.62) 100%)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel p,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel li,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel blockquote{color:var(--markdown-foreground)!important;text-shadow:0 1px 2px rgba(0,0,0,.42)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel h1,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel h2,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel h3,:root[data-window-mode='small-chat'] .small-chat-window .markdown-stream-panel h4{color:var(--markdown-heading-foreground)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .chatbot-user-message{background:rgba(18,30,50,.62)!important;border-color:rgba(226,232,240,.18)!important;color:rgba(250,252,255,.98)!important;-webkit-backdrop-filter:blur(18px) saturate(1.24)!important;backdrop-filter:blur(18px) saturate(1.24)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .home-composer,:root[data-window-mode='small-chat'] .small-chat-window .chat-floating-composer{background:rgba(10,17,29,.68)!important;border-color:rgba(226,232,240,.2)!important;color:rgba(250,252,255,.98)!important;-webkit-backdrop-filter:blur(30px) saturate(1.42)!important;backdrop-filter:blur(30px) saturate(1.42)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window textarea,:root[data-window-mode='small-chat'] .small-chat-window input{color:rgba(250,252,255,.98)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window textarea::placeholder,:root[data-window-mode='small-chat'] .small-chat-window input::placeholder{color:rgba(226,232,240,.76)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .home-composer-toolbar{background:rgba(255,255,255,.055)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .bg-popover,:root[data-window-mode='small-chat'] .small-chat-window .codex-dialog,:root[data-window-mode='small-chat'] .small-chat-window [role='menu']{background:rgba(8,13,24,.94)!important;border-color:rgba(148,163,184,.28)!important;color:rgba(241,245,249,.94)!important;box-shadow:0 18px 48px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.08)!important;-webkit-backdrop-filter:blur(24px) saturate(1.24) brightness(.9)!important;backdrop-filter:blur(24px) saturate(1.24) brightness(.9)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .text-popover-foreground{color:rgba(241,245,249,.92)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .codex-dialog .text-muted-foreground,:root[data-window-mode='small-chat'] .small-chat-window [role='menu'] .text-muted-foreground,:root[data-window-mode='small-chat'] .small-chat-window .bg-popover .text-muted-foreground{color:rgba(203,213,225,.72)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .codex-dialog button,:root[data-window-mode='small-chat'] .small-chat-window [role='menu'] button,:root[data-window-mode='small-chat'] .small-chat-window .bg-popover button{color:rgba(226,232,240,.9)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .codex-dialog button:hover,:root[data-window-mode='small-chat'] .small-chat-window .codex-dialog button:focus-visible,:root[data-window-mode='small-chat'] .small-chat-window [role='menu'] button:hover,:root[data-window-mode='small-chat'] .small-chat-window [role='menu'] button:focus-visible,:root[data-window-mode='small-chat'] .small-chat-window .bg-popover button:hover,:root[data-window-mode='small-chat'] .small-chat-window .bg-popover button:focus-visible{background:rgba(148,163,184,.14)!important;color:rgba(255,255,255,.96)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .codex-dialog button.bg-accent,:root[data-window-mode='small-chat'] .small-chat-window [role='menu'] button.bg-accent,:root[data-window-mode='small-chat'] .small-chat-window .bg-popover button.bg-accent{background:rgba(20,184,166,.16)!important;color:rgba(204,251,241,.98)!important;box-shadow:inset 0 0 0 1px rgba(45,212,191,.22)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window .codex-dialog button.bg-accent:hover,:root[data-window-mode='small-chat'] .small-chat-window .codex-dialog button.bg-accent:focus-visible,:root[data-window-mode='small-chat'] .small-chat-window [role='menu'] button.bg-accent:hover,:root[data-window-mode='small-chat'] .small-chat-window [role='menu'] button.bg-accent:focus-visible,:root[data-window-mode='small-chat'] .small-chat-window .bg-popover button.bg-accent:hover,:root[data-window-mode='small-chat'] .small-chat-window .bg-popover button.bg-accent:focus-visible{background:rgba(20,184,166,.22)!important;color:rgba(240,253,250,.98)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window-header button{color:rgba(226,232,240,.78)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window-header button:hover,:root[data-window-mode='small-chat'] .small-chat-window-header button:focus-visible{background:rgba(148,163,184,.14)!important;color:rgba(255,255,255,.96)!important;box-shadow:inset 0 0 0 1px rgba(255,255,255,.1)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window-header button:active{background:rgba(148,163,184,.2)!important;color:rgba(255,255,255,.98)!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window-header button.text-primary{background:rgba(20,184,166,.1)!important;color:#99f6e4!important;}",
+ ":root[data-window-mode='small-chat'] .small-chat-window-header button.text-primary:hover,:root[data-window-mode='small-chat'] .small-chat-window-header button.text-primary:focus-visible{background:rgba(20,184,166,.16)!important;color:#ccfbf1!important;}"
+ ].join("");
+ document.head.appendChild(style);
+ };
+ ensurePluginSmallWindowVisuals();
+ const getSmallWindowId = () => {
+ const value = new URLSearchParams(window.location.search).get("windowId");
+ if (!value || !/^\\d+$/.test(value)) return null;
+ const id = Number(value);
+ return Number.isSafeInteger(id) ? id : null;
+ };
+ let smallWindowPinned = false;
+ const getSmallWindowState = () => Promise.resolve({
+ id: getSmallWindowId(),
+ isSmallWindow: isSmallChatMode(),
+ minHeight: 460,
+ minWidth: 360,
+ pinned: smallWindowPinned
+ });
+ const openSmallWindow = (payload) => {
+ const windowId = Date.now();
+ const url = new URL(window.location.href);
+ url.searchParams.set("mode", "small-chat");
+ url.searchParams.set("windowId", String(windowId));
+ url.searchParams.delete("openingTransition");
+ const threadId = isRecord(payload) && typeof payload.threadId === "string" ? payload.threadId.trim() : "";
+ if (threadId) {
+ url.searchParams.set("threadId", threadId);
+ } else {
+ url.searchParams.delete("threadId");
+ }
+ const childWindow = window.open(
+ url.toString(),
+ "agent-console-small-chat-" + windowId,
+ "popup,width=420,height=640,resizable=yes"
+ );
+ if (!childWindow) {
+ return Promise.reject(new Error("Unable to open Agent Console small window."));
+ }
+ try { childWindow.focus(); } catch (_) {}
+ return Promise.resolve({ success: true });
+ };
+ const setSmallWindowPinned = (payload) => {
+ smallWindowPinned = Boolean(isRecord(payload) && payload.pinned);
+ if (isSmallChatMode()) {
+ try {
+ window.open("ccr-plugin-window://set-pinned?pinned=" + (smallWindowPinned ? "1" : "0"), "_blank", "noopener,noreferrer");
+ } catch (_) {}
+ }
+ return getSmallWindowState();
+ };
+ const menuState = { close: null };
+ const ensureMenuStyles = () => {
+ if (document.getElementById("agent-console-plugin-menu-styles")) return;
+ const style = document.createElement("style");
+ style.id = "agent-console-plugin-menu-styles";
+ style.textContent = [
+ ".agent-console-plugin-menu{position:fixed;z-index:2147483647;min-width:198px;max-width:320px;padding:6px;border:1px solid color-mix(in srgb,var(--border,#d4d4d8) 85%,transparent);border-radius:8px;background:var(--popover,var(--background,#fff));color:var(--popover-foreground,var(--foreground,#111827));box-shadow:0 16px 48px rgba(15,23,42,.2);font:13px system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;}",
+ ".agent-console-plugin-menu-submenu{position:absolute;left:calc(100% + 6px);top:-6px;display:none;min-width:198px;max-width:320px;padding:6px;border:1px solid color-mix(in srgb,var(--border,#d4d4d8) 85%,transparent);border-radius:8px;background:var(--popover,var(--background,#fff));color:var(--popover-foreground,var(--foreground,#111827));box-shadow:0 16px 48px rgba(15,23,42,.2);}",
+ ".agent-console-plugin-menu-row{position:relative;}",
+ ".agent-console-plugin-menu-row:hover>.agent-console-plugin-menu-submenu,.agent-console-plugin-menu-row:focus-within>.agent-console-plugin-menu-submenu{display:block;}",
+ ".agent-console-plugin-menu-item{box-sizing:border-box;display:flex;width:100%;height:28px;align-items:center;gap:8px;border:0;border-radius:6px;background:transparent;color:inherit;padding:0 10px;text-align:left;white-space:nowrap;font:inherit;}",
+ ".agent-console-plugin-menu-item:not([aria-disabled='true']):hover,.agent-console-plugin-menu-item:not([aria-disabled='true']):focus-visible{background:var(--accent,#f4f4f5);color:var(--accent-foreground,inherit);outline:none;}",
+ ".agent-console-plugin-menu-item[aria-disabled='true']{opacity:.45;}",
+ ".agent-console-plugin-menu-icon{width:16px;height:16px;flex:0 0 16px;object-fit:contain;}",
+ ".agent-console-plugin-menu-label{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;}",
+ ".agent-console-plugin-menu-arrow{margin-left:16px;opacity:.65;}",
+ ".agent-console-plugin-menu-separator{height:1px;margin:5px 6px;background:var(--border,#e4e4e7);}"
+ ].join("");
+ document.head.appendChild(style);
+ };
+ const normalizeMenuItems = (value, depth) => {
+ if (!Array.isArray(value)) return [];
+ const items = [];
+ for (const item of value) {
+ if (!isRecord(item)) continue;
+ if (item.type === "separator") {
+ items.push({ type: "separator" });
+ continue;
+ }
+ const label = typeof item.label === "string" ? item.label : "";
+ if (!label) continue;
+ const submenu = depth < 4 ? normalizeMenuItems(item.submenu, depth + 1) : [];
+ const hasSubmenu = submenu.some((child) => child.type !== "separator");
+ const id = typeof item.id === "string" ? item.id : "";
+ if (!hasSubmenu && !id) continue;
+ items.push({
+ enabled: item.enabled !== false,
+ icon: typeof item.icon === "string" && item.icon.startsWith("data:image/") ? item.icon : "",
+ id,
+ label,
+ submenu: hasSubmenu ? submenu : [],
+ type: "normal"
+ });
+ }
+ return trimMenuSeparators(items);
+ };
+ const trimMenuSeparators = (items) => {
+ const next = [];
+ let lastWasSeparator = true;
+ for (const item of items) {
+ if (item.type === "separator") {
+ if (!lastWasSeparator) next.push(item);
+ lastWasSeparator = true;
+ } else {
+ next.push(item);
+ lastWasSeparator = false;
+ }
+ }
+ while (next.length && next[next.length - 1].type === "separator") next.pop();
+ return next;
+ };
+ const hasMenuAction = (items) => items.some((item) => item.type !== "separator" && (item.id || hasMenuAction(item.submenu || [])));
+ const renderMenuItems = (items, parent, settle) => {
+ for (const item of items) {
+ if (item.type === "separator") {
+ const separator = document.createElement("div");
+ separator.className = "agent-console-plugin-menu-separator";
+ separator.setAttribute("role", "separator");
+ parent.appendChild(separator);
+ continue;
+ }
+ const row = document.createElement("div");
+ row.className = "agent-console-plugin-menu-row";
+ const button = document.createElement("button");
+ button.className = "agent-console-plugin-menu-item";
+ button.type = "button";
+ button.setAttribute("role", "menuitem");
+ if (!item.enabled) {
+ button.setAttribute("aria-disabled", "true");
+ button.tabIndex = -1;
+ }
+ if (item.icon) {
+ const icon = document.createElement("img");
+ icon.alt = "";
+ icon.className = "agent-console-plugin-menu-icon";
+ icon.src = item.icon;
+ button.appendChild(icon);
+ }
+ const label = document.createElement("span");
+ label.className = "agent-console-plugin-menu-label";
+ label.textContent = item.label;
+ button.appendChild(label);
+ if (item.submenu && item.submenu.length) {
+ const arrow = document.createElement("span");
+ arrow.className = "agent-console-plugin-menu-arrow";
+ arrow.textContent = "\\u203a";
+ button.appendChild(arrow);
+ } else if (item.id) {
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (item.enabled) settle(item.id);
+ });
+ }
+ row.appendChild(button);
+ if (item.submenu && item.submenu.length) {
+ const submenu = document.createElement("div");
+ submenu.className = "agent-console-plugin-menu-submenu";
+ submenu.setAttribute("role", "menu");
+ renderMenuItems(item.submenu, submenu, settle);
+ row.appendChild(submenu);
+ }
+ parent.appendChild(row);
+ }
+ };
+ const popupNativeMenu = (payload) => {
+ const record = isRecord(payload) ? payload : {};
+ const items = normalizeMenuItems(record.items, 0);
+ if (!hasMenuAction(items)) return Promise.resolve({ actionId: null, success: true });
+ ensureMenuStyles();
+ if (menuState.close) menuState.close(null);
+ return new Promise((resolve) => {
+ let settled = false;
+ const menu = document.createElement("div");
+ menu.className = "agent-console-plugin-menu";
+ menu.setAttribute("role", "menu");
+ menu.tabIndex = -1;
+ const settle = (actionId) => {
+ if (settled) return;
+ settled = true;
+ if (menu.parentNode) menu.parentNode.removeChild(menu);
+ document.removeEventListener("mousedown", onDocumentMouseDown, true);
+ document.removeEventListener("keydown", onDocumentKeyDown, true);
+ if (menuState.close === settle) menuState.close = null;
+ resolve({ actionId: actionId || null, success: true });
+ };
+ const onDocumentMouseDown = (event) => {
+ if (!menu.contains(event.target)) settle(null);
+ };
+ const onDocumentKeyDown = (event) => {
+ if (event.key === "Escape") settle(null);
+ };
+ renderMenuItems(items, menu, settle);
+ document.body.appendChild(menu);
+ const width = menu.offsetWidth || 220;
+ const height = menu.offsetHeight || 32;
+ const requestedX = Number.isFinite(record.x) ? Math.round(record.x) : Math.round(window.innerWidth / 2 - width / 2);
+ const requestedY = Number.isFinite(record.y) ? Math.round(record.y) : Math.round(window.innerHeight / 2 - height / 2);
+ const x = Math.max(8, Math.min(requestedX, window.innerWidth - width - 8));
+ const y = Math.max(8, Math.min(requestedY, window.innerHeight - height - 8));
+ menu.style.left = x + "px";
+ menu.style.top = y + "px";
+ menuState.close = settle;
+ setTimeout(() => {
+ document.addEventListener("mousedown", onDocumentMouseDown, true);
+ document.addEventListener("keydown", onDocumentKeyDown, true);
+ menu.focus({ preventScroll: true });
+ }, 0);
+ });
+ };
+
+ window.agentConsole = {
+ agent: {
+ abortRun: invoke("agent-console:agent:abort-run"),
+ addExistingProject: invoke("agent-console:agent:add-existing-project"),
+ checkoutProjectBranch: invoke("agent-console:agent:checkout-project-branch"),
+ createBlankProject: invoke("agent-console:agent:create-blank-project"),
+ deleteThread: invoke("agent-console:agent:delete-thread"),
+ forkThread: invoke("agent-console:agent:fork-thread"),
+ getProviderCapabilities: invoke("agent-console:agent:get-provider-capabilities"),
+ getProviderSessionMessages: invoke("agent-console:agent:get-provider-session-messages"),
+ getThreadMessages: invoke("agent-console:agent:get-thread-messages"),
+ getUsageAnalytics: invoke("agent-console:agent:get-usage-analytics"),
+ listPendingInteractions: invoke("agent-console:agent:list-pending-interactions"),
+ listProjectBranches: invoke("agent-console:agent:list-project-branches"),
+ listProjects: invoke("agent-console:agent:list-projects"),
+ listProviderSessions: invoke("agent-console:agent:list-provider-sessions"),
+ listProviders: invoke("agent-console:agent:list-providers"),
+ onEvent: on("agent-console:agent:event"),
+ renameThread: invoke("agent-console:agent:rename-thread"),
+ removeProject: invoke("agent-console:agent:remove-project"),
+ restoreProviderSession: invoke("agent-console:agent:restore-provider-session"),
+ resolveApproval: invoke("agent-console:agent:resolve-approval"),
+ resolveQuestion: invoke("agent-console:agent:resolve-question"),
+ sendMessage: invoke("agent-console:send-message"),
+ startThread: invoke("agent-console:start-thread")
+ },
+ automations: {
+ create: invoke("agent-console:automation:create"),
+ delete: invoke("agent-console:automation:delete"),
+ list: invoke("agent-console:automation:list"),
+ onEvent: on("agent-console:automation:event"),
+ runNow: invoke("agent-console:automation:run-now"),
+ setEnabled: invoke("agent-console:automation:set-enabled"),
+ update: invoke("agent-console:automation:update")
+ },
+ browser: {
+ activateTab: unavailable("Browser tools"),
+ callAutomationTool: unavailable("Browser automation"),
+ closeTab: unavailable("Browser tools"),
+ createTab: unavailable("Browser tools"),
+ dismissCoachmark: browserUnavailableState,
+ getAutomationMcpAddress: unavailable("Browser automation"),
+ getState: browserUnavailableState,
+ goBack: unavailable("Browser tools"),
+ goForward: unavailable("Browser tools"),
+ importProfile: unavailable("Browser profile import"),
+ listProfileImportCandidates: () => Promise.resolve([]),
+ navigate: unavailable("Browser tools"),
+ onStateChange: () => () => {},
+ reload: unavailable("Browser tools"),
+ setBounds: () => Promise.resolve({ success: true }),
+ setOriginAutomationAllowed: browserUnavailableState,
+ setTheme: () => Promise.resolve({ success: true }),
+ updateSettings: browserUnavailableState,
+ stop: unavailable("Browser tools")
+ },
+ bot: {
+ connect: invoke("agent-console:bot:connect"),
+ createIntegration: invoke("agent-console:bot:create-integration"),
+ disconnect: invoke("agent-console:bot:disconnect"),
+ getStatus: invoke("agent-console:bot:status"),
+ getIntegrationStatus: invoke("agent-console:bot:integration-status"),
+ listChannels: invoke("agent-console:bot:list-channels"),
+ listEvents: invoke("agent-console:bot:list-events"),
+ listIntegrations: invoke("agent-console:bot:list-integrations"),
+ processNext: invoke("agent-console:bot:process-next"),
+ startQrLogin: invoke("agent-console:bot:start-qr-login"),
+ startIntegration: invoke("agent-console:bot:start-integration"),
+ stopIntegration: invoke("agent-console:bot:stop-integration"),
+ waitQrLogin: invoke("agent-console:bot:wait-qr-login")
+ },
+ clipboard: { writeText: invoke("agent-console:clipboard:write-text") },
+ workspace: {
+ setActiveProject: (payload) => {
+ activeProjectPath = payload && typeof payload === "object" ? payload.projectPath || payload.cwd || null : null;
+ return bridge.invoke("agent-console:workspace:set-active-project", payload);
+ }
+ },
+ files: {
+ chooseAttachments: invoke("agent-console:files:choose-attachments"),
+ createFile: invokeWithActiveProject("agent-console:files:create-file"),
+ getRoot: invokeWithActiveProject("agent-console:files:get-root"),
+ readDirectory: invokeWithActiveProject("agent-console:files:read-directory"),
+ readFile: invokeWithActiveProject("agent-console:files:read-file"),
+ writeFile: invokeWithActiveProject("agent-console:files:write-file")
+ },
+ ipc: {
+ invoke: (channel, ...args) => bridge.invoke(channel, ...args),
+ send: (channel, ...args) => bridge.send(channel, ...args),
+ on: (channel, callback) => bridge.on(channel, callback)
+ },
+ nativeMenu: { popup: popupNativeMenu },
+ git: {
+ applyShelf: invoke("agent-console:git:apply-shelf"),
+ checkoutBranch: invoke("agent-console:git:checkout-branch"),
+ checkoutRevision: invoke("agent-console:git:checkout-revision"),
+ cherryPickCommit: invoke("agent-console:git:cherry-pick-commit"),
+ commit: invoke("agent-console:git:commit"),
+ compareWithLocal: invoke("agent-console:git:compare-with-local"),
+ createAutosquashCommit: invoke("agent-console:git:create-autosquash-commit"),
+ createBranchAtCommit: invoke("agent-console:git:create-branch-at-commit"),
+ createPatch: invoke("agent-console:git:create-patch"),
+ createShelf: invoke("agent-console:git:create-shelf"),
+ createTagAtCommit: invoke("agent-console:git:create-tag-at-commit"),
+ discard: invoke("agent-console:git:discard"),
+ dropCommit: invoke("agent-console:git:drop-commit"),
+ dropShelf: invoke("agent-console:git:drop-shelf"),
+ editCommitMessage: invoke("agent-console:git:edit-commit-message"),
+ fetch: invoke("agent-console:git:fetch"),
+ getCommitDetails: invoke("agent-console:git:get-commit-details"),
+ getDiff: invoke("agent-console:git:get-diff"),
+ getFileDiff: invoke("agent-console:git:get-file-diff"),
+ getInteractiveRebasePlan: invoke("agent-console:git:get-interactive-rebase-plan"),
+ getLog: invoke("agent-console:git:get-log"),
+ getState: invoke("agent-console:git:get-state"),
+ mergeBranch: invoke("agent-console:git:merge-branch"),
+ pull: invoke("agent-console:git:pull"),
+ push: invoke("agent-console:git:push"),
+ pushUpToCommit: invoke("agent-console:git:push-up-to-commit"),
+ rebaseBranch: invoke("agent-console:git:rebase-branch"),
+ resetCurrentBranchToCommit: invoke("agent-console:git:reset-current-branch-to-commit"),
+ revertCommit: invoke("agent-console:git:revert-commit"),
+ runInteractiveRebase: invoke("agent-console:git:run-interactive-rebase"),
+ showRepositoryAtRevision: invoke("agent-console:git:show-repository-at-revision"),
+ stage: invoke("agent-console:git:stage"),
+ undoCommit: invoke("agent-console:git:undo-commit"),
+ unstage: invoke("agent-console:git:unstage"),
+ viewCommitInBrowser: invoke("agent-console:git:view-commit-in-browser")
+ },
+ shell: {
+ getEnvironment: invoke("agent-console:environment"),
+ showItemInFolder: invoke("agent-console:shell:show-item-in-folder"),
+ startThread: invoke("agent-console:start-thread"),
+ sendMessage: invoke("agent-console:send-message"),
+ runCommand: invoke("agent-console:run-command"),
+ updateSetting: invoke("agent-console:update-setting")
+ },
+ settings: {
+ get: invoke("agent-console:settings:get"),
+ resetSpotlightShortcut: invoke("agent-console:settings:reset-spotlight-shortcut"),
+ setAgentEnvironment: invoke("agent-console:settings:set-agent-environment"),
+ setAgentProviderEnabled: invoke("agent-console:settings:set-agent-provider-enabled"),
+ setAgentProviders: invoke("agent-console:settings:set-agent-providers"),
+ setSubagents: invoke("agent-console:settings:set-subagents"),
+ setSpotlightShortcut: invoke("agent-console:settings:set-spotlight-shortcut")
+ },
+ plugins: {
+ get: invoke("agent-console:plugins:get"),
+ reload: invoke("agent-console:plugins:reload"),
+ install: invoke("agent-console:plugins:install"),
+ update: invoke("agent-console:plugins:update"),
+ uninstall: invoke("agent-console:plugins:uninstall"),
+ enable: invoke("agent-console:plugins:enable"),
+ disable: invoke("agent-console:plugins:disable"),
+ grantPermissions: invoke("agent-console:plugins:grant-permissions"),
+ revokePermissions: invoke("agent-console:plugins:revoke-permissions"),
+ setConfiguration: invoke("agent-console:plugins:set-configuration"),
+ onCommand: on("agent-console:plugins:command")
+ },
+ smallWindow: {
+ close: () => {
+ window.close();
+ return Promise.resolve({ success: true });
+ },
+ create: openSmallWindow,
+ getState: getSmallWindowState,
+ notifyOpeningTransitionReady: getSmallWindowState,
+ onOpeningTransitionStart: () => () => {},
+ setPinned: setSmallWindowPinned
+ },
+ voice: { transcribeAudio: invoke("agent-console:voice:transcribe") },
+ terminal: {
+ activateSession: invoke("agent-console:terminal:activate-session"),
+ closeSession: invoke("agent-console:terminal:close-session"),
+ createSession: invoke("agent-console:terminal:create-session"),
+ getBacklog: invoke("agent-console:terminal:get-backlog"),
+ getState: invoke("agent-console:terminal:get-state"),
+ killSession: invoke("agent-console:terminal:kill-session"),
+ onOutput: on("agent-console:terminal:output"),
+ onStateChange: on("agent-console:terminal:state-changed"),
+ resize: invoke("agent-console:terminal:resize"),
+ write: invoke("agent-console:terminal:write")
+ },
+ toolhub: {
+ clearCache: invoke("toolhub:clear-cache"),
+ getSettings: invoke("toolhub:get-settings"),
+ installServer: invoke("toolhub:install-server"),
+ listServers: invoke("toolhub:list-servers"),
+ listTools: invoke("toolhub:list-tools"),
+ removeServer: invoke("toolhub:remove-server"),
+ refresh: invoke("toolhub:refresh"),
+ setBuiltinMcpServerEnabled: invoke("toolhub:set-builtin-mcp-server-enabled"),
+ setEnabled: invoke("toolhub:set-enabled"),
+ setLlmConfig: invoke("toolhub:set-llm-config"),
+ updateServer: invoke("toolhub:update-server")
+ }
+ };
+})();
+`;
+}
+
+function directoryIndexFile(filePath) {
+ try {
+ if (!fs.statSync(filePath).isDirectory()) {
+ return "";
+ }
+ const indexFile = path.join(filePath, "index.html");
+ return fileExists(indexFile) ? indexFile : "";
+ } catch {
+ return "";
+ }
+}
+
+function shouldFallbackToHome(relativeFilePath) {
+ return !relativeFilePath || !path.extname(relativeFilePath);
+}
+
+function headersForFile(filePath) {
+ return {
+ "cache-control": isImmutableAsset(filePath) ? "public, max-age=31536000, immutable" : "no-cache",
+ "content-type": contentType(filePath)
+ };
+}
+
+function contentType(filePath) {
+ const extension = path.extname(filePath).toLowerCase();
+ if (extension === ".html") return "text/html; charset=utf-8";
+ if (extension === ".js" || extension === ".mjs") return "text/javascript; charset=utf-8";
+ if (extension === ".css") return "text/css; charset=utf-8";
+ if (extension === ".json") return "application/json; charset=utf-8";
+ if (extension === ".svg") return "image/svg+xml";
+ if (extension === ".png") return "image/png";
+ if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
+ if (extension === ".webp") return "image/webp";
+ if (extension === ".ico") return "image/x-icon";
+ if (extension === ".woff") return "font/woff";
+ if (extension === ".woff2") return "font/woff2";
+ return "application/octet-stream";
+}
+
+function isImmutableAsset(filePath) {
+ return path.normalize(filePath).split(path.sep).includes("assets");
+}
+
+function sendText(response, statusCode, message) {
+ response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
+ response.end(`${message}\n`);
+}
+
+function safeFilePath(root, candidate) {
+ const resolvedRoot = path.resolve(root);
+ const resolvedCandidate = path.resolve(candidate);
+ const relativePath = path.relative(resolvedRoot, resolvedCandidate);
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) return "";
+ return resolvedCandidate;
+}
+
+function fileExists(filePath) {
+ try {
+ return fs.statSync(filePath).isFile();
+ } catch {
+ return false;
+ }
+}
+
+function rendererDistExists(rendererRoot) {
+ return fileExists(path.join(rendererRoot, "pages", "home", "index.html"));
+}
+
+function buildRuntimeConfig(config, options) {
+ const gatewayUrl = trimTrailingSlash(options.gatewayUrl || configuredGatewayUrl(config));
+ const openAiBaseUrl = trimTrailingSlash(options.openAiBaseUrl || `${gatewayUrl}/v1`);
+ const models = availableGatewayModels(config);
+ const defaultModel = options.defaultModel ||
+ stringValue(config?.Router?.default) ||
+ models.find((model) => model.isDefault)?.model ||
+ models[0]?.model ||
+ "";
+
+ return {
+ apiKey: options.apiKey || configuredGatewayApiKey(config),
+ claudeCode: {
+ defaultModel,
+ models
+ },
+ codex: {
+ defaultModel,
+ modelCatalogFile: options.modelCatalogFile,
+ models
+ },
+ defaultModel,
+ gatewayUrl,
+ models,
+ openAiBaseUrl
+ };
+}
+
+function ensureAgentConsoleCodexRuntime(ctx, options, runtimeConfig) {
+ if (options.codexMiddleware === false) {
+ return { command: "", env: {}, runtimeFile: "" };
+ }
+
+ const providerId = "claude-code-router";
+ const binDir = path.join(ctx.paths.pluginDataDir, "bin");
+ const codexHome = path.resolve(stringValue(options.codexHome) || path.join(ctx.paths.pluginDataDir, "codex-home"));
+ const configFile = path.join(codexHome, "config.toml");
+ const runtimeFile = path.join(binDir, "ccr-codex-cli-middleware.js");
+ const commandFile = path.join(binDir, process.platform === "win32" ? "ccr-agent-console-codex.cmd" : "ccr-agent-console-codex");
+ const realCodexCli = stringValue(options.codexCliPath || options.codexCommand) || "codex";
+ const model = stringValue(runtimeConfig.codex?.defaultModel) || stringValue(runtimeConfig.defaultModel) || runtimeConfig.models?.[0]?.model || "";
+ const openAiBaseUrl = trimTrailingSlash(stringValue(runtimeConfig.openAiBaseUrl) || `${stringValue(runtimeConfig.gatewayUrl)}/v1`);
+ const apiKey = stringValue(runtimeConfig.apiKey);
+ const modelCatalogFile = stringValue(runtimeConfig.codex?.modelCatalogFile) || path.join(ctx.paths.pluginDataDir, "ccr-codex-model-catalog.json");
+
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(codexHome, { recursive: true, mode: 0o700 });
+ writeTextIfChanged(configFile, agentConsoleCodexConfigToml({
+ apiKey,
+ baseUrl: openAiBaseUrl,
+ model,
+ modelCatalogFile,
+ providerId
+ }));
+ try {
+ fs.chmodSync(configFile, 0o600);
+ } catch {
+ // Best effort on filesystems that do not support chmod.
+ }
+
+ const runtimeScript = agentConsoleCodexMiddlewareRuntimeScript();
+ writeTextIfChanged(runtimeFile, runtimeScript);
+ writeTextIfChanged(commandFile, process.platform === "win32"
+ ? agentConsoleCodexMiddlewareCmd({
+ codexHome,
+ model,
+ modelCatalogFile,
+ providerId,
+ realCodexCli,
+ runtimeFile
+ })
+ : agentConsoleCodexMiddlewareShell({
+ codexHome,
+ model,
+ modelCatalogFile,
+ providerId,
+ realCodexCli,
+ runtimeFile
+ }));
+ try {
+ fs.chmodSync(runtimeFile, 0o755);
+ fs.chmodSync(commandFile, 0o755);
+ } catch {
+ // Best effort on filesystems that do not support chmod.
+ }
+
+ return {
+ command: commandFile,
+ env: {
+ CODEX_HOME: codexHome,
+ CCR_CODEX_MODEL_CATALOG_FILE: modelCatalogFile,
+ CCR_CODEX_MODEL_PROVIDER: providerId,
+ CCR_CODEX_PROFILE: providerId,
+ CCR_CODEX_PROFILE_CONFIG_FORMAT: "separate_profile_files",
+ CCR_CODEX_REMOTE_FRONTEND_MODE: "app",
+ CCR_PROFILE_SCOPE: "ccr",
+ CODEXL_CODEX_MODEL_CATALOG_FILE: modelCatalogFile,
+ CODEXL_CODEX_MODEL_PROVIDER: providerId,
+ CODEXL_CODEX_PROFILE: providerId,
+ CODEXL_CODEX_PROFILE_CONFIG_FORMAT: "separate_profile_files"
+ },
+ runtimeFile
+ };
+}
+
+function ensureAgentConsoleClaudeCodeRuntime(ctx, options, runtimeConfig, sharedRuntimeFile) {
+ if (options.claudeCodeMiddleware === false || options.claudeMiddleware === false) {
+ return { command: "", env: {} };
+ }
+
+ const binDir = path.join(ctx.paths.pluginDataDir, "bin");
+ const settingsDir = path.join(ctx.paths.pluginDataDir, "claude-code", "claude");
+ const settingsFile = path.join(settingsDir, "settings.json");
+ const runtimeFile = ensureAgentConsoleCliMiddlewareRuntime(ctx, sharedRuntimeFile);
+ const apiKeyHelperFile = path.join(binDir, process.platform === "win32" ? "ccr-agent-console-claude-code-api-key.cmd" : "ccr-agent-console-claude-code-api-key");
+ const commandFile = path.join(binDir, process.platform === "win32" ? "ccr-agent-console-claude-code.cmd" : "ccr-agent-console-claude-code");
+ const realClaudeCli = stringValue(options.claudeCodeCommand || options.claudeCodeCliPath || options.claudeCommand || options.claudeCliPath) || "claude";
+ const model = stringValue(runtimeConfig.claudeCode?.defaultModel) || stringValue(runtimeConfig.defaultModel) || runtimeConfig.models?.[0]?.model || "";
+ const gatewayUrl = trimTrailingSlash(stringValue(runtimeConfig.gatewayUrl));
+ const apiKey = stringValue(runtimeConfig.apiKey);
+ const baseEnv = agentConsoleClaudeCodeBaseEnv({ gatewayUrl, model, settingsDir });
+ const remoteEndpoint = `${gatewayUrl}/__ccr/remote`;
+
+ fs.mkdirSync(binDir, { recursive: true });
+ fs.mkdirSync(settingsDir, { recursive: true, mode: 0o700 });
+ writeTextIfChanged(settingsFile, agentConsoleClaudeCodeSettingsJson({
+ apiKeyHelperFile,
+ env: baseEnv
+ }));
+ writeTextIfChanged(apiKeyHelperFile, process.platform === "win32"
+ ? agentConsoleApiKeyHelperCmd(apiKey)
+ : agentConsoleApiKeyHelperShell(apiKey));
+ writeTextIfChanged(commandFile, process.platform === "win32"
+ ? agentConsoleClaudeCodeMiddlewareCmd({
+ apiKeyHelperFile,
+ baseEnv,
+ realClaudeCli,
+ remoteEndpoint,
+ runtimeFile
+ })
+ : agentConsoleClaudeCodeMiddlewareShell({
+ apiKeyHelperFile,
+ baseEnv,
+ realClaudeCli,
+ remoteEndpoint,
+ runtimeFile
+ }));
+ try {
+ fs.chmodSync(settingsFile, 0o600);
+ fs.chmodSync(apiKeyHelperFile, 0o700);
+ fs.chmodSync(commandFile, 0o755);
+ } catch {
+ // Best effort on filesystems that do not support chmod.
+ }
+
+ return {
+ command: commandFile,
+ env: {
+ ...baseEnv,
+ CCR_CLAUDE_CODE_WRAPPER: "1",
+ CCR_REAL_CLAUDE_CODE_BIN: realClaudeCli,
+ CODEXL_CLAUDE_CODE_BIN: realClaudeCli,
+ CCR_REMOTE_SYNC_API_KEY_HELPER: apiKeyHelperFile,
+ CCR_REMOTE_SYNC_ENABLED: "1",
+ CCR_REMOTE_SYNC_ENDPOINT: remoteEndpoint,
+ CCR_REMOTE_SYNC_PROFILE_ID: "agent-console-claude-code",
+ CCR_REMOTE_SYNC_PROFILE_NAME: "Agent Console Claude Code"
+ }
+ };
+}
+
+function ensureAgentConsoleCliMiddlewareRuntime(ctx, runtimeFile) {
+ const binDir = path.join(ctx.paths.pluginDataDir, "bin");
+ const file = stringValue(runtimeFile) || path.join(binDir, "ccr-codex-cli-middleware.js");
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ writeTextIfChanged(file, agentConsoleCodexMiddlewareRuntimeScript());
+ try {
+ fs.chmodSync(file, 0o755);
+ } catch {
+ // Best effort on filesystems that do not support chmod.
+ }
+ return file;
+}
+
+function agentConsoleClaudeCodeBaseEnv({ gatewayUrl, model, settingsDir }) {
+ const env = {
+ ANTHROPIC_API_BASE_URL: gatewayUrl,
+ ANTHROPIC_BASE_URL: gatewayUrl,
+ CLAUDE_AGENT_API_BASE_URL: gatewayUrl,
+ CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
+ CLAUDE_CONFIG_DIR: settingsDir
+ };
+ if (model) {
+ env.ANTHROPIC_MODEL = model;
+ env.CCR_CLAUDE_CODE_MODEL = model;
+ env.CODEXL_CLAUDE_CODE_MODEL = model;
+ }
+ const timezoneEnv = agentConsoleClaudeCodeTimezoneEnv();
+ return Object.keys(timezoneEnv).length ? { ...env, ...timezoneEnv } : env;
+}
+
+function agentConsoleClaudeCodeSettingsJson({ apiKeyHelperFile, env }) {
+ return `${JSON.stringify({
+ apiKeyHelper: process.platform === "win32" ? `"${apiKeyHelperFile}"` : apiKeyHelperFile,
+ env
+ }, null, 2)}\n`;
+}
+
+function agentConsoleApiKeyHelperShell(apiKey) {
+ return [
+ "#!/bin/sh",
+ `printf '%s\\n' ${shellSingleQuote(apiKey)}`,
+ ""
+ ].join("\n");
+}
+
+function agentConsoleApiKeyHelperCmd(apiKey) {
+ return [
+ "@echo off",
+ `echo ${cmdValue(apiKey)}`,
+ ""
+ ].join("\r\n");
+}
+
+function agentConsoleClaudeCodeMiddlewareShell({ apiKeyHelperFile, baseEnv, realClaudeCli, remoteEndpoint, runtimeFile }) {
+ return [
+ "#!/bin/sh",
+ ...Object.entries(baseEnv).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`),
+ "export CCR_CLAUDE_CODE_WRAPPER=1",
+ `export CCR_REAL_CLAUDE_CODE_BIN=${shellSingleQuote(realClaudeCli)}`,
+ `export CODEXL_CLAUDE_CODE_BIN=${shellSingleQuote(realClaudeCli)}`,
+ "if [ -z \"${CCR_PROFILE_SURFACE:-}\" ]; then CCR_PROFILE_SURFACE=app; fi",
+ "export CCR_PROFILE_SURFACE",
+ "if [ -z \"${CCR_REMOTE_SYNC_ENABLED:-}\" ]; then CCR_REMOTE_SYNC_ENABLED=1; fi",
+ `if [ -z "\${CCR_REMOTE_SYNC_ENDPOINT:-}" ]; then CCR_REMOTE_SYNC_ENDPOINT=${shellSingleQuote(remoteEndpoint)}; fi`,
+ `if [ -z "\${CCR_REMOTE_SYNC_API_KEY_HELPER:-}" ]; then CCR_REMOTE_SYNC_API_KEY_HELPER=${shellSingleQuote(apiKeyHelperFile)}; fi`,
+ "if [ -z \"${CCR_REMOTE_SYNC_PROFILE_ID:-}\" ]; then CCR_REMOTE_SYNC_PROFILE_ID=agent-console-claude-code; fi",
+ "if [ -z \"${CCR_REMOTE_SYNC_PROFILE_NAME:-}\" ]; then CCR_REMOTE_SYNC_PROFILE_NAME='Agent Console Claude Code'; fi",
+ "export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_HELPER CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME",
+ "if [ -n \"${CCR_NODE_BIN:-}\" ]; then",
+ ` exec "$CCR_NODE_BIN" ${shellSingleQuote(runtimeFile)} "$@"`,
+ "fi",
+ "if command -v node >/dev/null 2>&1; then",
+ ` exec node ${shellSingleQuote(runtimeFile)} "$@"`,
+ "fi",
+ `ELECTRON_RUN_AS_NODE=1 exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(runtimeFile)} "$@"`,
+ ""
+ ].join("\n");
+}
+
+function agentConsoleClaudeCodeMiddlewareCmd({ apiKeyHelperFile, baseEnv, realClaudeCli, remoteEndpoint, runtimeFile }) {
+ const quotedRuntime = cmdQuote(runtimeFile);
+ const quotedHost = cmdQuote(process.execPath);
+ return [
+ "@echo off",
+ ...Object.entries(baseEnv).map(([key, value]) => cmdSetLine(key, value)),
+ cmdSetLine("CCR_CLAUDE_CODE_WRAPPER", "1"),
+ cmdSetLine("CCR_REAL_CLAUDE_CODE_BIN", realClaudeCli),
+ cmdSetLine("CODEXL_CLAUDE_CODE_BIN", realClaudeCli),
+ `if not defined CCR_PROFILE_SURFACE ${cmdSetLine("CCR_PROFILE_SURFACE", "app")}`,
+ `if not defined CCR_REMOTE_SYNC_ENABLED ${cmdSetLine("CCR_REMOTE_SYNC_ENABLED", "1")}`,
+ `if not defined CCR_REMOTE_SYNC_ENDPOINT ${cmdSetLine("CCR_REMOTE_SYNC_ENDPOINT", remoteEndpoint)}`,
+ `if not defined CCR_REMOTE_SYNC_API_KEY_HELPER ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_HELPER", apiKeyHelperFile)}`,
+ `if not defined CCR_REMOTE_SYNC_PROFILE_ID ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_ID", "agent-console-claude-code")}`,
+ `if not defined CCR_REMOTE_SYNC_PROFILE_NAME ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_NAME", "Agent Console Claude Code")}`,
+ "if defined CCR_NODE_BIN (",
+ ` "%CCR_NODE_BIN%" ${quotedRuntime} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ "where node >nul 2>nul",
+ "if %ERRORLEVEL%==0 (",
+ ` node ${quotedRuntime} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ "set \"ELECTRON_RUN_AS_NODE=1\"",
+ `${quotedHost} ${quotedRuntime} %*`,
+ "exit /b %ERRORLEVEL%",
+ ""
+ ].join("\r\n");
+}
+
+function agentConsoleClaudeCodeTimezoneEnv() {
+ let timeZone = "";
+ try {
+ timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
+ } catch {
+ return {};
+ }
+ const normalized = timeZone.trim().toLowerCase();
+ return [
+ "asia/chongqing",
+ "asia/chungking",
+ "asia/harbin",
+ "asia/kashgar",
+ "asia/shanghai",
+ "asia/urumqi",
+ "china standard time",
+ "prc"
+ ].includes(normalized)
+ ? { TZ: "UTC" }
+ : {};
+}
+
+function agentConsoleCodexConfigToml({ apiKey, baseUrl, model, modelCatalogFile, providerId }) {
+ return [
+ `model_provider = ${tomlString(providerId)}`,
+ `model = ${tomlString(model)}`,
+ `model_catalog_json = ${tomlString(modelCatalogFile)}`,
+ "",
+ `[model_providers.${tomlKey(providerId)}]`,
+ `name = ${tomlString("Claude Code Router")}`,
+ `base_url = ${tomlString(baseUrl)}`,
+ `experimental_bearer_token = ${tomlString(apiKey)}`,
+ 'wire_api = "responses"',
+ ""
+ ].join("\n");
+}
+
+function agentConsoleCodexMiddlewareShell({ codexHome, model, modelCatalogFile, providerId, realCodexCli, runtimeFile }) {
+ return [
+ "#!/bin/sh",
+ `export CODEX_HOME=${shellSingleQuote(codexHome)}`,
+ "if [ -z \"${CCR_REAL_CODEX_CLI_PATH:-}\" ]; then",
+ ` CCR_REAL_CODEX_CLI_PATH=${shellSingleQuote(realCodexCli)}`,
+ "fi",
+ "export CCR_REAL_CODEX_CLI_PATH",
+ `export CCR_CODEX_PROFILE=${shellSingleQuote(providerId)}`,
+ `export CCR_CODEX_MODEL=${shellSingleQuote(model)}`,
+ `export CCR_CODEX_MODEL_CATALOG_FILE=${shellSingleQuote(modelCatalogFile)}`,
+ `export CCR_CODEX_MODEL_PROVIDER=${shellSingleQuote(providerId)}`,
+ "export CCR_CODEX_PROFILE_CONFIG_FORMAT=separate_profile_files",
+ "export CCR_PROFILE_SCOPE=ccr",
+ "export CCR_CODEX_REMOTE_FRONTEND_MODE=app",
+ "if [ -z \"${CODEXL_REAL_CODEX_CLI_PATH:-}\" ]; then",
+ " CODEXL_REAL_CODEX_CLI_PATH=$CCR_REAL_CODEX_CLI_PATH",
+ "fi",
+ "export CODEXL_REAL_CODEX_CLI_PATH",
+ `export CODEXL_CODEX_PROFILE=${shellSingleQuote(providerId)}`,
+ `export CODEXL_CODEX_MODEL=${shellSingleQuote(model)}`,
+ `export CODEXL_CODEX_MODEL_CATALOG_FILE=${shellSingleQuote(modelCatalogFile)}`,
+ `export CODEXL_CODEX_MODEL_PROVIDER=${shellSingleQuote(providerId)}`,
+ "export CODEXL_CODEX_PROFILE_CONFIG_FORMAT=separate_profile_files",
+ "export CODEXL_CODEX_CORE_MODE=app",
+ "if [ -n \"${CCR_NODE_BIN:-}\" ]; then",
+ ` exec "$CCR_NODE_BIN" ${shellSingleQuote(runtimeFile)} "$@"`,
+ "fi",
+ "if command -v node >/dev/null 2>&1; then",
+ ` exec node ${shellSingleQuote(runtimeFile)} "$@"`,
+ "fi",
+ `ELECTRON_RUN_AS_NODE=1 exec ${shellSingleQuote(process.execPath)} ${shellSingleQuote(runtimeFile)} "$@"`,
+ ""
+ ].join("\n");
+}
+
+function agentConsoleCodexMiddlewareCmd({ codexHome, model, modelCatalogFile, providerId, realCodexCli, runtimeFile }) {
+ const quotedRuntime = cmdQuote(runtimeFile);
+ const quotedHost = cmdQuote(process.execPath);
+ return [
+ "@echo off",
+ cmdSetLine("CODEX_HOME", codexHome),
+ `if not defined CCR_REAL_CODEX_CLI_PATH ${cmdSetLine("CCR_REAL_CODEX_CLI_PATH", realCodexCli)}`,
+ cmdSetLine("CCR_CODEX_PROFILE", providerId),
+ cmdSetLine("CCR_CODEX_MODEL", model),
+ cmdSetLine("CCR_CODEX_MODEL_CATALOG_FILE", modelCatalogFile),
+ cmdSetLine("CCR_CODEX_MODEL_PROVIDER", providerId),
+ cmdSetLine("CCR_CODEX_PROFILE_CONFIG_FORMAT", "separate_profile_files"),
+ cmdSetLine("CCR_PROFILE_SCOPE", "ccr"),
+ cmdSetLine("CCR_CODEX_REMOTE_FRONTEND_MODE", "app"),
+ "if not defined CODEXL_REAL_CODEX_CLI_PATH set \"CODEXL_REAL_CODEX_CLI_PATH=%CCR_REAL_CODEX_CLI_PATH%\"",
+ cmdSetLine("CODEXL_CODEX_PROFILE", providerId),
+ cmdSetLine("CODEXL_CODEX_MODEL", model),
+ cmdSetLine("CODEXL_CODEX_MODEL_CATALOG_FILE", modelCatalogFile),
+ cmdSetLine("CODEXL_CODEX_MODEL_PROVIDER", providerId),
+ cmdSetLine("CODEXL_CODEX_PROFILE_CONFIG_FORMAT", "separate_profile_files"),
+ cmdSetLine("CODEXL_CODEX_CORE_MODE", "app"),
+ "if defined CCR_NODE_BIN (",
+ ` "%CCR_NODE_BIN%" ${quotedRuntime} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ "where node >nul 2>nul",
+ "if %ERRORLEVEL%==0 (",
+ ` node ${quotedRuntime} %*`,
+ " exit /b %ERRORLEVEL%",
+ ")",
+ "set \"ELECTRON_RUN_AS_NODE=1\"",
+ `${quotedHost} ${quotedRuntime} %*`,
+ "exit /b %ERRORLEVEL%",
+ ""
+ ].join("\r\n");
+}
+
+function agentConsoleCodexMiddlewareRuntimeScript() {
+ const moduleRuntime = agentConsoleCodexMiddlewareRuntimeFromModule();
+ if (moduleRuntime) return moduleRuntime;
+
+ const source = fs.readFileSync(agentConsoleCodexMiddlewareSourceFile(), "utf8");
+ const marker = "return String.raw`";
+ const start = source.indexOf(marker);
+ if (start < 0) {
+ throw new Error("Unable to locate Codex middleware runtime template.");
+ }
+ const templateStart = start + marker.length;
+ for (let index = templateStart; index < source.length; index += 1) {
+ if (source[index] !== "`" || isEscapedTemplateBacktick(source, index)) continue;
+ return source.slice(templateStart, index).replace(/\\`/g, "`");
+ }
+ throw new Error("Unable to read Codex middleware runtime template.");
+}
+
+function agentConsoleCodexMiddlewareRuntimeFromModule() {
+ const candidates = [
+ "@ccr/core/agents/codex/cli-middleware-runtime",
+ "@claude-code-router/core/agents/codex/cli-middleware-runtime"
+ ];
+ for (const candidate of candidates) {
+ try {
+ const runtimeModule = require(candidate);
+ if (typeof runtimeModule?.codexCliMiddlewareRuntimeScript !== "function") continue;
+ const script = runtimeModule.codexCliMiddlewareRuntimeScript();
+ if (script) return script;
+ } catch {
+ // The marketplace plugin can run without core package subpath exports.
+ }
+ }
+ return "";
+}
+
+function agentConsoleCodexMiddlewareSourceFile() {
+ const candidates = [
+ stringValue(process.env.CCR_CODEX_MIDDLEWARE_RUNTIME_SOURCE),
+ path.resolve(__dirname, "../../../packages/core/src/agents/codex/cli-middleware-runtime.ts"),
+ path.resolve(process.cwd(), "packages/core/src/agents/codex/cli-middleware-runtime.ts")
+ ].filter(Boolean);
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ throw new Error("Codex middleware runtime source was not found.");
+}
+
+function isEscapedTemplateBacktick(source, index) {
+ let slashCount = 0;
+ for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
+ slashCount += 1;
+ }
+ return slashCount % 2 === 1;
+}
+
+function tomlString(value) {
+ return JSON.stringify(String(value ?? ""));
+}
+
+function tomlKey(value) {
+ const key = String(value || "").trim();
+ return /^[A-Za-z0-9_]+$/.test(key) ? key : tomlString(key);
+}
+
+function cmdSetLine(key, value) {
+ return `set "${key}=${cmdValue(value)}"`;
+}
+
+function cmdValue(value) {
+ return String(value ?? "").replace(/"/g, '""');
+}
+
+function cmdQuote(value) {
+ return `"${cmdValue(value)}"`;
+}
+
+function buildCodexModelCatalog(models) {
+ return {
+ models: models.map((model, index) => {
+ const reasoning = codexModelReasoningProfile(model.model);
+ const contextWindowTokens = readCatalogPositiveInteger(model.contextWindowTokens || model.context_window_tokens) ||
+ modelContextWindowTokens(model.model);
+ return {
+ additional_speed_tiers: [],
+ apply_patch_tool_type: "freeform",
+ availability_nux: null,
+ base_instructions: "You are Codex, a coding agent.",
+ context_window: contextWindowTokens,
+ default_verbosity: "low",
+ defaultReasoningEffort: reasoning.defaultReasoningEffort || null,
+ default_reasoning_level: reasoning.defaultReasoningLevel,
+ default_reasoning_effort: reasoning.defaultReasoningEffort || null,
+ default_reasoning_summary: "none",
+ description: `CCR gateway model ${model.model}`,
+ displayName: model.displayName || model.model,
+ display_name: model.displayName || model.model,
+ effective_context_window_percent: 95,
+ experimental_supported_tools: [],
+ id: model.model,
+ input_modalities: ["text", "image"],
+ max_context_window: contextWindowTokens,
+ model: model.model,
+ priority: index,
+ service_tiers: [],
+ shell_type: "shell_command",
+ slug: model.model,
+ support_verbosity: true,
+ supported_in_api: true,
+ supportedReasoningEfforts: reasoning.supportedReasoningLevels.map(reasoningEffortOption),
+ supported_reasoning_efforts: reasoning.supportedReasoningEfforts,
+ supported_reasoning_levels: reasoning.supportedReasoningLevels,
+ supports_image_detail_original: true,
+ supports_parallel_tool_calls: true,
+ supports_reasoning_summaries: reasoning.supportsReasoning,
+ supports_search_tool: false,
+ truncation_policy: { mode: "tokens", limit: 10000 },
+ upgrade: null,
+ visibility: "list",
+ web_search_tool_type: "text"
+ };
+ })
+ };
+}
+
+function reasoningEffortOption(level) {
+ return {
+ description: level.description,
+ reasoningEffort: level.effort,
+ reasoning_effort: level.effort
+ };
+}
+
+function availableGatewayModels(config) {
+ const baseEntries = [];
+ for (const provider of Array.isArray(config?.Providers) ? config.Providers : []) {
+ const providerName = stringValue(provider.name || provider.id || provider.provider);
+ if (!providerName || !Array.isArray(provider.models)) continue;
+ for (const rawModel of provider.models) {
+ const modelName = stringValue(rawModel);
+ if (!modelName) continue;
+ const id = `${providerName}/${modelName}`;
+ baseEntries.push(runtimeModelEntry(id, {
+ displayName: displayModelName(provider, modelName, id),
+ isDefault: id === stringValue(config?.Router?.default),
+ model: id
+ }));
+ }
+ }
+
+ const virtualEntries = [];
+ for (const profile of Array.isArray(config?.virtualModelProfiles) ? config.virtualModelProfiles : []) {
+ if (!isVisibleVirtualProfile(profile)) continue;
+ const displayName = stringValue(profile.displayName || profile.key || profile.id);
+ for (const entry of baseEntries) {
+ for (const prefix of normalizeStringArray(profile.match?.prefixes) || []) {
+ const model = `${providerNameFromModel(entry.model)}/${prefix}${modelNameFromModel(entry.model)}`;
+ virtualEntries.push(runtimeModelEntry(model, {
+ displayName: displayName || `${prefix}${entry.displayName}`,
+ model,
+ reasoningModel: entry.model
+ }));
+ }
+ for (const suffix of normalizeStringArray(profile.match?.suffixes) || []) {
+ const model = `${providerNameFromModel(entry.model)}/${modelNameFromModel(entry.model)}${suffix}`;
+ virtualEntries.push(runtimeModelEntry(model, {
+ displayName: displayName || `${entry.displayName}${suffix}`,
+ model,
+ reasoningModel: entry.model
+ }));
+ }
+ }
+ for (const alias of normalizeStringArray(profile.match?.exactAliases) || []) {
+ const model = alias.toLowerCase().startsWith("fusion/") ? alias : `Fusion/${alias}`;
+ virtualEntries.push(runtimeModelEntry(model, {
+ displayName: displayName || alias,
+ model
+ }));
+ }
+ }
+
+ return uniqueModels([...baseEntries, ...virtualEntries]);
+}
+
+function runtimeModelEntry(model, options = {}) {
+ const contextModel = options.contextModel || options.reasoningModel || model;
+ const reasoning = codexModelReasoningProfile(options.reasoningModel || model);
+ const contextWindowTokens = modelContextWindowTokens(contextModel);
+ return {
+ contextWindowTokens,
+ context_window_tokens: contextWindowTokens,
+ displayName: options.displayName || model,
+ id: options.id || model,
+ isDefault: options.isDefault === true,
+ model,
+ ...(reasoning.defaultReasoningEffort ? { defaultReasoningEffort: reasoning.defaultReasoningEffort } : {}),
+ supportedReasoningEfforts: reasoning.supportedReasoningEfforts,
+ supportedSpeeds: []
+ };
+}
+
+function displayModelName(provider, modelName, fallback) {
+ const displayNames = isRecord(provider.modelDisplayNames) ? provider.modelDisplayNames : {};
+ return stringValue(displayNames[modelName]) || fallback;
+}
+
+function codexModelReasoningProfile(model) {
+ const entry = findModelCatalogEntry(model);
+ const capabilities = isRecord(entry?.capabilities) ? entry.capabilities : {};
+ const effortConfig = modelCatalogReasoningEffortConfig(entry, providerNameFromModel(model));
+ const fallbackEfforts = effortConfig.efforts.length === 0
+ ? openAiGptReasoningFallbackEfforts(model)
+ : [];
+ const reasoningConfig = fallbackEfforts.length > 0
+ ? { ...effortConfig, defaultEffort: "medium", efforts: fallbackEfforts, supportsReasoning: true }
+ : effortConfig;
+ const supportsReasoning = capabilities.reasoning === true || reasoningConfig.supportsReasoning;
+ return {
+ defaultReasoningEffort: defaultReasoningEffort(reasoningConfig),
+ defaultReasoningLevel: defaultReasoningLevel(reasoningConfig),
+ supportedReasoningEfforts: reasoningConfig.efforts,
+ supportedReasoningLevels: reasoningConfig.efforts.map(reasoningLevel),
+ supportsReasoning
+ };
+}
+
+function modelContextWindowTokens(model) {
+ return modelCatalogMaxInputTokens(findModelCatalogEntry(model)) || DEFAULT_CODEX_CONTEXT_WINDOW_TOKENS;
+}
+
+function modelCatalogMaxInputTokens(entry) {
+ const limits = isRecord(entry?.limits) ? entry.limits : {};
+ return Math.max(
+ 0,
+ readCatalogPositiveInteger(limits.contextTokens),
+ readCatalogPositiveInteger(limits.inputTokens)
+ );
+}
+
+function openAiGptReasoningFallbackEfforts(model) {
+ const modelName = normalizeModelCatalogToken(modelNameFromModel(model));
+ if (openAiGptSupportsXHighFallback(modelName)) return OPENAI_EXTENDED_REASONING_EFFORTS;
+ return /^gpt-[0-9]/.test(modelName) || /^o[0-9]/.test(modelName)
+ ? OPENAI_REASONING_EFFORTS
+ : [];
+}
+
+function openAiGptSupportsXHighFallback(modelName) {
+ const match = modelName.match(/^gpt-(\d+)(?:[.-](\d+))?/);
+ if (!match) return false;
+ const major = Number.parseInt(match[1], 10);
+ const minor = Number.parseInt(match[2] || "0", 10);
+ return major > 5 || (major === 5 && minor >= 6);
+}
+
+function modelCatalogReasoningEffortConfig(entry, providerName) {
+ if (!entry) {
+ return { defaultEffort: "", efforts: [], supportsReasoning: false };
+ }
+
+ const records = sourceRecordsForProvider(entry.sourceRecords, providerName);
+ const metadataValues = [
+ entry.metadata,
+ ...records.map((record) => record.metadata)
+ ].filter(isRecord);
+
+ let defaultEffort = "";
+ let supportsReasoning = false;
+ const efforts = [];
+ for (const metadata of metadataValues) {
+ const config = reasoningConfigFromMetadata(metadata);
+ if (config.supportsReasoning) {
+ supportsReasoning = true;
+ }
+ if (!defaultEffort && config.defaultEffort) {
+ defaultEffort = config.defaultEffort;
+ }
+ for (const effort of config.efforts) {
+ if (!efforts.includes(effort)) {
+ efforts.push(effort);
+ }
+ }
+ }
+
+ return { defaultEffort, efforts, supportsReasoning };
+}
+
+function sourceRecordsForProvider(records, providerName) {
+ const normalizedProviderName = normalizeModelCatalogToken(providerName);
+ if (!normalizedProviderName) return [];
+ return records.filter((record) => {
+ const provider = normalizeModelCatalogToken(record.provider);
+ const displayName = normalizeModelCatalogToken(record.providerName);
+ return [provider, displayName].some((value) =>
+ value &&
+ (
+ value === normalizedProviderName ||
+ value.includes(normalizedProviderName) ||
+ normalizedProviderName.includes(value)
+ )
+ );
+ });
+}
+
+function reasoningConfigFromMetadata(metadata) {
+ const efforts = [];
+ let defaultEffort = "";
+ let supportsReasoning = false;
+
+ const reasoning = isRecord(metadata.reasoning) ? metadata.reasoning : undefined;
+ if (reasoning) {
+ supportsReasoning = true;
+ for (const effort of normalizeReasoningEfforts(reasoning.supported_efforts)) {
+ if (!efforts.includes(effort)) {
+ efforts.push(effort);
+ }
+ }
+ defaultEffort = normalizeReasoningEffort(reasoning.default_effort);
+ }
+
+ const options = Array.isArray(metadata.reasoningOptions) ? metadata.reasoningOptions : [];
+ for (const option of options) {
+ if (!isRecord(option)) continue;
+ const type = stringValue(option.type).toLowerCase();
+ if (type === "toggle" || type === "budget_tokens") {
+ supportsReasoning = true;
+ }
+ if (type !== "effort") continue;
+ supportsReasoning = true;
+ for (const effort of normalizeReasoningEfforts(option.values)) {
+ if (!efforts.includes(effort)) {
+ efforts.push(effort);
+ }
+ }
+ }
+
+ return { defaultEffort, efforts, supportsReasoning };
+}
+
+function normalizeReasoningEfforts(value) {
+ if (!Array.isArray(value)) return [];
+ return value
+ .map(normalizeReasoningEffort)
+ .filter(Boolean)
+ .filter((effort, index, efforts) => efforts.indexOf(effort) === index);
+}
+
+function normalizeReasoningEffort(value) {
+ const normalized = stringValue(value).toLowerCase().replace(/[_\s-]+/g, "");
+ if (!normalized || normalized === "default") return "";
+ if (normalized === "none" || normalized === "off" || normalized === "disabled") return "none";
+ if (normalized === "minimal") return "minimal";
+ if (normalized === "low") return "low";
+ if (normalized === "medium") return "medium";
+ if (normalized === "high") return "high";
+ if (normalized === "xhigh" || normalized === "extrahigh" || normalized === "max") return "xhigh";
+ return "";
+}
+
+function defaultReasoningLevel(config) {
+ if (!config.defaultEffort || config.defaultEffort === "none") return null;
+ return config.efforts.includes(config.defaultEffort) ? config.defaultEffort : null;
+}
+
+function defaultReasoningEffort(config) {
+ return defaultReasoningLevel(config) || "";
+}
+
+function reasoningLevel(effort) {
+ const descriptions = {
+ high: "High reasoning",
+ low: "Low reasoning",
+ medium: "Medium reasoning",
+ minimal: "Minimal reasoning",
+ none: "No reasoning",
+ xhigh: "Extra high reasoning"
+ };
+ return {
+ effort,
+ description: descriptions[effort] || `${effort} reasoning`
+ };
+}
+
+function findModelCatalogEntry(model) {
+ const index = loadModelCatalogIndex();
+ const candidates = modelCatalogLookupKeys(model);
+ for (const key of candidates) {
+ const entry = index.byKey.get(key);
+ if (entry) return entry;
+ }
+
+ for (const key of candidates) {
+ const modelKey = modelCatalogLastSegmentKey(key);
+ if (!modelKey) continue;
+ const entry = index.byModelKey.get(modelKey);
+ if (entry) return entry;
+ }
+
+ return undefined;
+}
+
+function loadModelCatalogIndex() {
+ if (modelCatalogIndex) return modelCatalogIndex;
+
+ const payload = loadModelCatalogPayload();
+ modelCatalogIndex = buildModelCatalogIndex(payload);
+ return modelCatalogIndex;
+}
+
+function loadModelCatalogPayload() {
+ for (const candidate of modelCatalogPathCandidates()) {
+ if (!fs.existsSync(candidate)) continue;
+ try {
+ return JSON.parse(fs.readFileSync(candidate, "utf8"));
+ } catch {
+ return undefined;
+ }
+ }
+ return undefined;
+}
+
+function modelCatalogPathCandidates() {
+ return uniqueStrings([
+ stringValue(process.env.CCR_MODEL_CATALOG_PATH),
+ stringValue(process.env.CCR_MODELS_JSON_PATH),
+ path.resolve(process.cwd(), "models.json"),
+ path.resolve(process.cwd(), "packages", "core", "models.json"),
+ path.resolve(process.cwd(), "packages", "cli", "models.json"),
+ path.resolve(__dirname, "models.json"),
+ path.resolve(__dirname, "..", "models.json"),
+ path.resolve(__dirname, "..", "..", "models.json"),
+ path.resolve(__dirname, "..", "..", "..", "models.json"),
+ path.resolve(__dirname, "..", "..", "..", "packages", "core", "models.json"),
+ path.resolve(__dirname, "..", "..", "..", "packages", "cli", "models.json")
+ ]);
+}
+
+function buildModelCatalogIndex(payload) {
+ const byKey = new Map();
+ const byModelKey = new Map();
+ const models = isRecord(payload) && Array.isArray(payload.models) ? payload.models : [];
+
+ for (const item of models) {
+ const entry = parseModelCatalogEntry(item);
+ if (!entry) continue;
+
+ for (const key of modelCatalogEntryKeys(entry)) {
+ byKey.set(key, entry);
+ }
+
+ const shortKeys = uniqueStrings([
+ entry.model ? normalizeModelCatalogToken(entry.model) : "",
+ ...entry.aliases.map((alias) => modelCatalogLastSegmentKey(normalizeModelCatalogKey(alias)))
+ ]);
+ for (const key of shortKeys) {
+ if (!key) continue;
+ if (byModelKey.has(key) && byModelKey.get(key) !== entry) {
+ byModelKey.set(key, undefined);
+ } else {
+ byModelKey.set(key, entry);
+ }
+ }
+ }
+
+ return { byKey, byModelKey };
+}
+
+function parseModelCatalogEntry(value) {
+ if (!isRecord(value)) return undefined;
+ const id = stringValue(value.id);
+ if (!id) return undefined;
+ return {
+ aliases: uniqueStrings([id, ...stringListValue(value.aliases)]),
+ capabilities: isRecord(value.capabilities) ? value.capabilities : undefined,
+ id,
+ limits: modelCatalogLimitsValue(value.limits),
+ metadata: isRecord(value.metadata) ? value.metadata : undefined,
+ model: stringValue(value.model),
+ providers: stringListValue(value.providers),
+ sourceRecords: sourceRecordListValue(value.sourceRecords)
+ };
+}
+
+function modelCatalogLimitsValue(value) {
+ if (!isRecord(value)) return undefined;
+ const limits = {
+ contextTokens: readCatalogPositiveInteger(value.contextTokens),
+ inputTokens: readCatalogPositiveInteger(value.inputTokens)
+ };
+ return limits.contextTokens || limits.inputTokens ? limits : undefined;
+}
+
+function readCatalogPositiveInteger(value) {
+ return parsePositiveInteger(value);
+}
+
+function sourceRecordListValue(value) {
+ return Array.isArray(value) ? value.filter(isRecord) : [];
+}
+
+function modelCatalogEntryKeys(entry) {
+ return uniqueStrings([
+ normalizeModelCatalogKey(entry.id),
+ ...entry.aliases.map(normalizeModelCatalogKey),
+ ...entry.providers.map((provider) => entry.model ? normalizeModelCatalogKey(`${provider}/${entry.model}`) : "")
+ ]);
+}
+
+function modelCatalogLookupKeys(value) {
+ const raw = String(value || "").trim();
+ const normalized = normalizeModelCatalogKey(raw);
+ const withoutClaudePrefix = raw.toLowerCase().startsWith("claude-") && raw.includes("/")
+ ? normalizeModelCatalogKey(raw.replace(/^claude-/i, ""))
+ : "";
+ return uniqueStrings([normalized, withoutClaudePrefix]);
+}
+
+function normalizeModelCatalogKey(value) {
+ return String(value || "")
+ .trim()
+ .split("/")
+ .map(normalizeModelCatalogToken)
+ .filter(Boolean)
+ .join("/");
+}
+
+function normalizeModelCatalogToken(value) {
+ return String(value || "")
+ .trim()
+ .replace(/^hf:/i, "")
+ .replace(/^@/, "")
+ .replace(/[_\s]+/g, "-")
+ .replace(/-+/g, "-")
+ .toLowerCase();
+}
+
+function modelCatalogLastSegmentKey(value) {
+ return value.split("/").filter(Boolean).at(-1) || "";
+}
+
+function isVisibleVirtualProfile(profile) {
+ return profile &&
+ profile.enabled !== false &&
+ profile.materialization?.enabled !== false &&
+ profile.materialization?.includeInGatewayModels !== false;
+}
+
+function uniqueModels(models) {
+ const seen = new Set();
+ const result = [];
+ for (const model of models) {
+ if (!model.model || seen.has(model.model)) continue;
+ seen.add(model.model);
+ result.push(model);
+ }
+ return result;
+}
+
+function providerNameFromModel(model) {
+ const index = model.indexOf("/");
+ return index >= 0 ? model.slice(0, index) : "Fusion";
+}
+
+function modelNameFromModel(model) {
+ const index = model.indexOf("/");
+ return index >= 0 ? model.slice(index + 1) : model;
+}
+
+function configuredGatewayUrl(config) {
+ const gateway = isRecord(config?.gateway) ? config.gateway : {};
+ const host = normalizeGatewayHost(stringValue(gateway.host) || stringValue(config?.HOST) || "127.0.0.1");
+ const port = parsePort(gateway.port) || parsePort(config?.PORT) || 3456;
+ return `http://${host}:${port}`;
+}
+
+function normalizeGatewayHost(host) {
+ if (!host || host === "0.0.0.0") return "127.0.0.1";
+ if (host === "::" || host === "[::]") return "[::1]";
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
+}
+
+function configuredGatewayApiKey(config) {
+ const apiKey = stringValue(config?.APIKEY);
+ if (apiKey) return apiKey;
+ const apiKeys = Array.isArray(config?.APIKEYS) ? config.APIKEYS : [];
+ for (const entry of apiKeys) {
+ const key = stringValue(entry?.key || entry?.apiKey || entry?.value);
+ if (key) return key;
+ }
+ return "";
+}
+
+function resolveAppRoot(options) {
+ const candidates = [
+ stringValue(options.appRoot),
+ stringValue(options.appPath),
+ stringValue(process.env.CCR_AGENT_CONSOLE_APP_PATH),
+ stringValue(process.env.AGENT_CONSOLE_APP_PATH),
+ DEFAULT_APP_ROOT
+ ].filter(Boolean);
+ return path.resolve(expandHomePath(candidates[0]));
+}
+
+function resolveElectronPath(options, appRoot) {
+ const configured = stringValue(options.electronPath) || stringValue(process.env.CCR_AGENT_CONSOLE_ELECTRON_PATH);
+ const candidates = [
+ configured,
+ process.platform === "win32"
+ ? path.join(appRoot, "node_modules", ".bin", "electron.cmd")
+ : path.join(appRoot, "node_modules", ".bin", "electron"),
+ path.join(appRoot, "node_modules", "electron", "dist", process.platform === "darwin" ? "Electron.app/Contents/MacOS/Electron" : "electron")
+ ].filter(Boolean);
+ for (const candidate of candidates) {
+ const resolved = path.resolve(expandHomePath(candidate));
+ if (fs.existsSync(resolved)) return resolved;
+ }
+ return "";
+}
+
+function pickOpenPort(host) {
+ return new Promise((resolve, reject) => {
+ const server = net.createServer();
+ server.once("error", reject);
+ server.listen(0, host, () => {
+ const address = server.address();
+ const port = address && typeof address !== "string" ? address.port : 0;
+ server.close((error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve(port);
+ }
+ });
+ });
+ });
+}
+
+function parsePort(value) {
+ const port = typeof value === "number" ? value : Number.parseInt(String(value || ""), 10);
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 0;
+}
+
+function parsePositiveInteger(value) {
+ const integer = typeof value === "number" ? value : Number.parseInt(String(value || ""), 10);
+ return Number.isInteger(integer) && integer > 0 ? integer : 0;
+}
+
+function normalizeRoutePrefix(value) {
+ const trimmed = value.trim();
+ const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
+ return prefixed.replace(/\/+$/, "") || DEFAULT_ROUTE_PREFIX;
+}
+
+function normalizeStringArray(value) {
+ if (!Array.isArray(value)) return undefined;
+ const items = value.map((item) => stringValue(item)).filter(Boolean);
+ return items.length ? items : undefined;
+}
+
+function stringListValue(value) {
+ return Array.isArray(value) ? value.map((item) => stringValue(item)).filter(Boolean) : [];
+}
+
+function uniqueStrings(values) {
+ const seen = new Set();
+ const strings = [];
+ for (const value of values) {
+ const trimmed = stringValue(value);
+ if (!trimmed || seen.has(trimmed)) continue;
+ seen.add(trimmed);
+ strings.push(trimmed);
+ }
+ return strings;
+}
+
+function stringValue(value) {
+ return typeof value === "string" ? value.trim() : "";
+}
+
+function isRecord(value) {
+ return value && typeof value === "object" && !Array.isArray(value);
+}
+
+function expandHomePath(value) {
+ if (!value.startsWith("~")) return value;
+ if (value === "~") return os.homedir();
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
+ return path.join(os.homedir(), value.slice(2));
+ }
+ return value;
+}
+
+function trimTrailingSlash(value) {
+ return value.replace(/\/+$/, "");
+}
+
+function formatError(error) {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/assets/agent-logos/claude-code.png b/marketplace/plugins/agent-console/src/renderer/assets/agent-logos/claude-code.png
new file mode 100644
index 00000000..7045039a
Binary files /dev/null and b/marketplace/plugins/agent-console/src/renderer/assets/agent-logos/claude-code.png differ
diff --git a/marketplace/plugins/agent-console/src/renderer/assets/agent-logos/codex.png b/marketplace/plugins/agent-console/src/renderer/assets/agent-logos/codex.png
new file mode 100644
index 00000000..912415ea
Binary files /dev/null and b/marketplace/plugins/agent-console/src/renderer/assets/agent-logos/codex.png differ
diff --git a/marketplace/plugins/agent-console/src/renderer/assets/codexTemplate@2x.png b/marketplace/plugins/agent-console/src/renderer/assets/codexTemplate@2x.png
new file mode 100644
index 00000000..5cd4b00b
Binary files /dev/null and b/marketplace/plugins/agent-console/src/renderer/assets/codexTemplate@2x.png differ
diff --git a/marketplace/plugins/agent-console/src/renderer/benchmarks/markdown-rendering/index.html b/marketplace/plugins/agent-console/src/renderer/benchmarks/markdown-rendering/index.html
new file mode 100644
index 00000000..1b823822
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/benchmarks/markdown-rendering/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Markdown Rendering Benchmark
+
+
+
+
+
+
diff --git a/marketplace/plugins/agent-console/src/renderer/benchmarks/markdown-rendering/main.ts b/marketplace/plugins/agent-console/src/renderer/benchmarks/markdown-rendering/main.ts
new file mode 100644
index 00000000..b7b13892
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/benchmarks/markdown-rendering/main.ts
@@ -0,0 +1,524 @@
+import DOMPurify from "dompurify";
+import { marked } from "marked";
+
+type BenchmarkMethodId = "full-inner-html" | "full-fragment-replace" | "block-fragment-tail";
+
+type BenchmarkScenario = {
+ id: string;
+ label: string;
+ markdown: string;
+ step: number;
+};
+
+type BenchmarkResult = {
+ childCount: number;
+ markdownLength: number;
+ method: BenchmarkMethodId;
+ msPerPatch: number;
+ patchCount: number;
+ scenario: string;
+ totalMs: number;
+};
+
+type BenchmarkSummary = {
+ environment: string;
+ repetitions: number;
+ results: BenchmarkResult[];
+ winners: Array<{
+ ratioToWinner: Record;
+ scenario: string;
+ winner: BenchmarkMethodId;
+ }>;
+};
+
+type BenchmarkOptions = {
+ repetitions?: number;
+};
+
+type BenchmarkWindow = Window & {
+ __runMarkdownBenchmarks?: (options?: BenchmarkOptions) => Promise;
+};
+
+const root = document.getElementById("benchmark-root");
+
+if (!root) {
+ throw new Error("Benchmark root not found");
+}
+
+marked.use({
+ async: false,
+ breaks: false,
+ gfm: true,
+ pedantic: false
+});
+
+root.innerHTML = `
+
+ Markdown Rendering Benchmark
+ Run window.__runMarkdownBenchmarks() from the browser console.
+ Idle
+
+
+`;
+
+const output = document.getElementById("benchmark-output") as HTMLPreElement;
+const viewport = document.getElementById("benchmark-viewport") as HTMLDivElement;
+const target = document.getElementById("benchmark-target") as HTMLDivElement;
+
+const style = document.createElement("style");
+style.textContent = `
+ body {
+ margin: 0;
+ background: #f7f8f8;
+ color: #20242a;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ }
+
+ .benchmark-shell {
+ display: grid;
+ grid-template-rows: auto auto minmax(160px, 1fr) minmax(320px, 62vh);
+ gap: 12px;
+ min-height: 100vh;
+ padding: 24px;
+ }
+
+ h1 {
+ margin: 0;
+ font-size: 20px;
+ }
+
+ p {
+ margin: 0;
+ }
+
+ #benchmark-output {
+ overflow: auto;
+ margin: 0;
+ border: 1px solid #dce1e4;
+ border-radius: 8px;
+ background: #10151d;
+ color: #d8e2ee;
+ font-size: 12px;
+ line-height: 1.5;
+ padding: 12px;
+ white-space: pre-wrap;
+ }
+
+ #benchmark-viewport {
+ overflow: auto;
+ border: 1px solid #dce1e4;
+ border-radius: 8px;
+ background: #ffffff;
+ padding: 20px;
+ }
+
+ .markdown-stream-panel > * + *,
+ .markdown-tail > * + * {
+ margin-top: 1rem;
+ }
+
+ .markdown-stream-panel > .markdown-tail:empty {
+ display: none;
+ }
+
+ .markdown-stream-panel h1,
+ .markdown-stream-panel h2,
+ .markdown-stream-panel h3 {
+ margin: 0;
+ line-height: 1.2;
+ }
+
+ .markdown-stream-panel p,
+ .markdown-stream-panel li,
+ .markdown-stream-panel blockquote {
+ font-size: 15px;
+ line-height: 1.72;
+ }
+
+ .markdown-stream-panel pre {
+ overflow-x: auto;
+ border-radius: 8px;
+ background: #10151d;
+ color: #d8e2ee;
+ padding: 12px;
+ }
+
+ .markdown-stream-panel table {
+ width: 100%;
+ border-collapse: collapse;
+ }
+
+ .markdown-stream-panel th,
+ .markdown-stream-panel td {
+ border-top: 1px solid #e7ecef;
+ padding: 7px 9px;
+ text-align: left;
+ }
+
+ .benchmark-cursor {
+ display: inline-block;
+ width: 7px;
+ height: 1em;
+ background: #0f766e;
+ }
+`;
+document.head.appendChild(style);
+
+const methods: Array<{
+ id: BenchmarkMethodId;
+ run: (markdown: string, indices: number[]) => BenchmarkResult;
+}> = [
+ {
+ id: "full-inner-html",
+ run: runFullInnerHtml
+ },
+ {
+ id: "full-fragment-replace",
+ run: runFullFragmentReplace
+ },
+ {
+ id: "block-fragment-tail",
+ run: runBlockFragmentTail
+ }
+];
+
+async function runMarkdownBenchmarks(options: BenchmarkOptions = {}) {
+ const repetitions = options.repetitions ?? 4;
+ const scenarios = createScenarios();
+ const results: BenchmarkResult[] = [];
+
+ delete output.dataset.summary;
+ output.dataset.done = "false";
+ output.textContent = "Running...";
+
+ for (const scenario of scenarios) {
+ const indices = createPatchIndices(scenario.markdown.length, scenario.step);
+ for (const method of methods) {
+ const samples: BenchmarkResult[] = [];
+ method.run(scenario.markdown, indices);
+ await nextFrame();
+
+ for (let iteration = 0; iteration < repetitions; iteration += 1) {
+ await nextFrame();
+ samples.push(method.run(scenario.markdown, indices));
+ }
+
+ const medianSample = medianBy(samples, (sample) => sample.totalMs);
+ results.push({
+ ...medianSample,
+ method: method.id,
+ scenario: scenario.label
+ });
+ output.textContent = formatSummary(buildSummary(results, repetitions));
+ }
+ }
+
+ const summary = buildSummary(results, repetitions);
+ output.textContent = formatSummary(summary);
+ output.dataset.done = "true";
+ output.dataset.summary = JSON.stringify(summary);
+ return summary;
+}
+
+function runFullInnerHtml(markdown: string, indices: number[]): BenchmarkResult {
+ target.replaceChildren();
+ const startedAt = performance.now();
+
+ for (const index of indices) {
+ const final = index >= markdown.length;
+ target.innerHTML = `${renderHtml(markdown.slice(0, index))}${final ? "" : ''}`;
+ viewport.scrollTop = viewport.scrollHeight;
+ }
+
+ return createResult("full-inner-html", markdown, indices, performance.now() - startedAt);
+}
+
+function runFullFragmentReplace(markdown: string, indices: number[]): BenchmarkResult {
+ target.replaceChildren();
+ const startedAt = performance.now();
+
+ for (const index of indices) {
+ const final = index >= markdown.length;
+ const fragment = renderFragment(markdown.slice(0, index));
+ if (!final) fragment.append(createCursor());
+ target.replaceChildren(fragment);
+ viewport.scrollTop = viewport.scrollHeight;
+ }
+
+ return createResult("full-fragment-replace", markdown, indices, performance.now() - startedAt);
+}
+
+function runBlockFragmentTail(markdown: string, indices: number[]): BenchmarkResult {
+ target.replaceChildren();
+
+ const tail = document.createElement("div");
+ tail.className = "markdown-tail";
+ const cursor = createCursor();
+ target.replaceChildren(tail, cursor);
+
+ let committedIndex = 0;
+ let previousTail = "";
+ const startedAt = performance.now();
+
+ for (const index of indices) {
+ const final = index >= markdown.length;
+ const commitBoundary = final ? markdown.length : findStableCommitBoundary(markdown, committedIndex, index);
+
+ if (commitBoundary > committedIndex) {
+ target.insertBefore(renderFragment(markdown.slice(committedIndex, commitBoundary)), tail);
+ committedIndex = commitBoundary;
+ previousTail = "";
+ }
+
+ const tailMarkdown = markdown.slice(committedIndex, index);
+ if (tailMarkdown !== previousTail) {
+ tail.replaceChildren(renderFragment(tailMarkdown));
+ previousTail = tailMarkdown;
+ }
+
+ if (final) {
+ tail.remove();
+ cursor.remove();
+ }
+
+ viewport.scrollTop = viewport.scrollHeight;
+ }
+
+ return createResult("block-fragment-tail", markdown, indices, performance.now() - startedAt);
+}
+
+function renderHtml(markdown: string) {
+ return DOMPurify.sanitize(marked.parse(markdown) as string, {
+ ADD_ATTR: ["target", "rel"],
+ USE_PROFILES: { html: true }
+ });
+}
+
+function renderFragment(markdown: string) {
+ const html = marked.parse(markdown) as string;
+ return DOMPurify.sanitize(html, {
+ ADD_ATTR: ["target", "rel"],
+ RETURN_DOM_FRAGMENT: true,
+ USE_PROFILES: { html: true }
+ }) as unknown as DocumentFragment;
+}
+
+function createCursor() {
+ const cursor = document.createElement("span");
+ cursor.className = "benchmark-cursor";
+ cursor.setAttribute("aria-hidden", "true");
+ return cursor;
+}
+
+function createResult(method: BenchmarkMethodId, markdown: string, indices: number[], totalMs: number): BenchmarkResult {
+ return {
+ childCount: target.children.length,
+ markdownLength: markdown.length,
+ method,
+ msPerPatch: totalMs / indices.length,
+ patchCount: indices.length,
+ scenario: "",
+ totalMs
+ };
+}
+
+function createPatchIndices(length: number, step: number) {
+ const indices: number[] = [];
+ for (let index = step; index < length; index += step) {
+ indices.push(index);
+ }
+ if (indices[indices.length - 1] !== length) indices.push(length);
+ return indices;
+}
+
+function createScenarios(): BenchmarkScenario[] {
+ return [
+ {
+ id: "short",
+ label: "short mixed markdown",
+ markdown: createMarkdownFixture(6),
+ step: 48
+ },
+ {
+ id: "medium",
+ label: "medium mixed markdown",
+ markdown: createMarkdownFixture(28),
+ step: 96
+ },
+ {
+ id: "long",
+ label: "long mixed markdown",
+ markdown: createMarkdownFixture(110),
+ step: 160
+ },
+ {
+ id: "code-heavy",
+ label: "code and table heavy",
+ markdown: createCodeHeavyFixture(64),
+ step: 128
+ }
+ ];
+}
+
+function createMarkdownFixture(sections: number) {
+ const chunks: string[] = ["# 流式 Markdown 性能测试\n"];
+
+ for (let index = 0; index < sections; index += 1) {
+ chunks.push(`
+## Section ${index + 1}
+
+这是一段用于模拟 LLM 回复的 Markdown 文本。它包含 **加粗内容**、\`inlineCode\`、列表、表格和代码块,用来让解析与 DOM 更新都接近真实聊天场景。
+
+- 第一条说明当前 section 的业务含义。
+- 第二条包含一个较长的句子,模拟模型连续输出时的自然语言段落。
+- 第三条包含 \`requestAnimationFrame\`、\`DocumentFragment\` 和 \`replaceChildren\`。
+
+| metric | value | note |
+| --- | --- | --- |
+| section | ${index + 1} | mixed |
+| patch | ${index * 7 + 3} | deterministic |
+
+\`\`\`ts
+function renderSection${index}(value: string) {
+ return value.trim().toUpperCase();
+}
+\`\`\`
+
+> 完成的 block 应该被冻结,后续 patch 只更新 tail。
+`);
+ }
+
+ return chunks.join("\n");
+}
+
+function createCodeHeavyFixture(sections: number) {
+ const chunks: string[] = ["# Code Heavy Stream\n"];
+
+ for (let index = 0; index < sections; index += 1) {
+ chunks.push(`
+### Patch Group ${index + 1}
+
+\`\`\`tsx
+const row${index} = Array.from({ length: 8 }, (_, column) => ({
+ id: \`${index}-\${column}\`,
+ label: "streaming markdown benchmark",
+ active: column % 2 === 0
+}));
+\`\`\`
+
+| column | parse | dom | layout |
+| --- | ---: | ---: | ---: |
+| ${index} | ${index * 3 + 1} | ${index * 5 + 2} | ${index * 7 + 3} |
+| ${index + 1} | ${index * 3 + 4} | ${index * 5 + 6} | ${index * 7 + 8} |
+`);
+ }
+
+ return chunks.join("\n");
+}
+
+function findStableCommitBoundary(markdown: string, startIndex: number, visibleIndex: number) {
+ let cursor = startIndex;
+ let stableBoundary = startIndex;
+ let inFence = false;
+
+ while (cursor < visibleIndex) {
+ const nextLineBreak = markdown.indexOf("\n", cursor);
+ if (nextLineBreak < 0 || nextLineBreak >= visibleIndex) break;
+
+ const lineEnd = nextLineBreak + 1;
+ const line = markdown.slice(cursor, nextLineBreak).trim();
+ const fenceLine = line.startsWith("```");
+
+ if (fenceLine) {
+ inFence = !inFence;
+ if (!inFence) stableBoundary = lineEnd;
+ } else if (!inFence && isStableBlockLine(line)) {
+ stableBoundary = lineEnd;
+ }
+
+ cursor = lineEnd;
+ }
+
+ return stableBoundary;
+}
+
+function isStableBlockLine(line: string) {
+ if (!line) return true;
+ if (/^#{1,6}\s/.test(line)) return true;
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(line)) return true;
+ return false;
+}
+
+function buildSummary(results: BenchmarkResult[], repetitions: number): BenchmarkSummary {
+ const scenarios = [...new Set(results.map((result) => result.scenario))];
+
+ return {
+ environment: navigator.userAgent,
+ repetitions,
+ results,
+ winners: scenarios.map((scenario) => {
+ const scenarioResults = results.filter((result) => result.scenario === scenario);
+ const winner = scenarioResults.reduce((best, result) => (result.totalMs < best.totalMs ? result : best), scenarioResults[0]);
+ const ratioToWinner = Object.fromEntries(
+ scenarioResults.map((result) => [result.method, result.totalMs / winner.totalMs])
+ ) as Record;
+
+ return {
+ ratioToWinner,
+ scenario,
+ winner: winner.method
+ };
+ })
+ };
+}
+
+function formatSummary(summary: BenchmarkSummary) {
+ return JSON.stringify(
+ {
+ repetitions: summary.repetitions,
+ results: summary.results.map((result) => ({
+ scenario: result.scenario,
+ method: result.method,
+ totalMs: Number(result.totalMs.toFixed(2)),
+ msPerPatch: Number(result.msPerPatch.toFixed(3)),
+ patchCount: result.patchCount,
+ markdownLength: result.markdownLength,
+ childCount: result.childCount
+ })),
+ winners: summary.winners.map((winner) => ({
+ scenario: winner.scenario,
+ winner: winner.winner,
+ ratioToWinner: Object.fromEntries(
+ Object.entries(winner.ratioToWinner).map(([method, ratio]) => [method, Number(ratio.toFixed(2))])
+ )
+ }))
+ },
+ null,
+ 2
+ );
+}
+
+function medianBy(items: T[], selector: (item: T) => number) {
+ const sorted = [...items].sort((first, second) => selector(first) - selector(second));
+ return sorted[Math.floor(sorted.length / 2)];
+}
+
+function nextFrame() {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+}
+
+(window as BenchmarkWindow).__runMarkdownBenchmarks = runMarkdownBenchmarks;
+
+const params = new URLSearchParams(window.location.search);
+if (params.has("autorun")) {
+ const repetitions = Number(params.get("repetitions") ?? 4);
+ window.setTimeout(() => {
+ void runMarkdownBenchmarks({
+ repetitions: Number.isFinite(repetitions) && repetitions > 0 ? repetitions : 4
+ });
+ }, 100);
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/components/ui/badge.tsx b/marketplace/plugins/agent-console/src/renderer/components/ui/badge.tsx
new file mode 100644
index 00000000..b1da92ed
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/components/ui/badge.tsx
@@ -0,0 +1,32 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+ "inline-flex min-w-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium leading-4",
+ {
+ variants: {
+ variant: {
+ default: "border-transparent bg-primary/10 text-primary",
+ secondary: "border-border bg-secondary text-secondary-foreground",
+ success: "border-emerald-200 bg-emerald-50 text-emerald-700",
+ warning: "border-amber-200 bg-amber-50 text-amber-700",
+ danger: "border-red-200 bg-red-50 text-red-700",
+ outline: "border-border bg-background text-muted-foreground"
+ }
+ },
+ defaultVariants: {
+ variant: "default"
+ }
+ }
+);
+
+export interface BadgeProps
+ extends React.HTMLAttributes,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return ;
+}
+
+export { Badge, badgeVariants };
diff --git a/marketplace/plugins/agent-console/src/renderer/components/ui/button.tsx b/marketplace/plugins/agent-console/src/renderer/components/ui/button.tsx
new file mode 100644
index 00000000..3177d850
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/components/ui/button.tsx
@@ -0,0 +1,47 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-3 text-[12px] font-medium outline-none transition-[background-color,border-color,color,box-shadow] duration-150 focus-visible:ring-2 focus-visible:ring-ring/20 disabled:pointer-events-none disabled:opacity-45",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/95",
+ secondary: "border-border bg-card text-secondary-foreground shadow-[0_1px_1px_rgba(0,0,0,.04)] hover:bg-muted",
+ ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
+ outline: "border-border bg-card text-foreground shadow-[0_1px_1px_rgba(0,0,0,.03)] hover:bg-muted",
+ destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/95",
+ subtle: "bg-secondary text-muted-foreground hover:bg-muted hover:text-foreground"
+ },
+ size: {
+ sm: "h-7 rounded-md px-2 text-[11px]",
+ default: "h-8 px-3",
+ icon: "h-7 w-7 px-0",
+ iconSm: "h-7 w-7 px-0"
+ }
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default"
+ }
+ }
+);
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return ;
+ }
+);
+
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
diff --git a/marketplace/plugins/agent-console/src/renderer/components/ui/input.tsx b/marketplace/plugins/agent-console/src/renderer/components/ui/input.tsx
new file mode 100644
index 00000000..2c428fb3
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Input = React.forwardRef>(
+ ({ className, type, ...props }, ref) => (
+
+ )
+);
+
+Input.displayName = "Input";
+
+export { Input };
diff --git a/marketplace/plugins/agent-console/src/renderer/components/ui/select.tsx b/marketplace/plugins/agent-console/src/renderer/components/ui/select.tsx
new file mode 100644
index 00000000..20b56f5c
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/components/ui/select.tsx
@@ -0,0 +1,176 @@
+import * as React from "react";
+import { createPortal } from "react-dom";
+import { Check, ChevronDown } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export type SelectOption = {
+ disabled?: boolean;
+ icon?: React.ComponentType<{ className?: string }>;
+ label: string;
+ value: string;
+};
+
+export interface SelectProps
+ extends Omit, "children" | "onChange" | "value"> {
+ menuClassName?: string;
+ onOpenChange?: (open: boolean) => void;
+ onValueChange?: (value: string) => void;
+ options: SelectOption[];
+ selectClassName?: string;
+ value?: string;
+}
+
+const Select = React.forwardRef(
+ ({ className, disabled, menuClassName, onOpenChange, onValueChange, options, selectClassName, value, ...props }, ref) => {
+ const [open, setOpen] = React.useState(false);
+ const [menuStyle, setMenuStyle] = React.useState({});
+ const listboxId = React.useId();
+ const menuRef = React.useRef(null);
+ const rootRef = React.useRef(null);
+ const selectedOption = options.find((option) => option.value === value) ?? options[0];
+ const SelectedIcon = selectedOption?.icon;
+
+ const updateMenuPosition = React.useCallback(() => {
+ const root = rootRef.current;
+ if (!root || typeof window === "undefined") return;
+
+ const rect = root.getBoundingClientRect();
+ const margin = 8;
+ const maxWidth = Math.min(360, window.innerWidth - margin * 2);
+ const menuWidth = Math.max(rect.width, Math.min(260, maxWidth));
+ const left = Math.max(margin, Math.min(rect.left, window.innerWidth - menuWidth - margin));
+ const below = window.innerHeight - rect.bottom - margin;
+ const above = rect.top - margin;
+ const openAbove = below < 180 && above > below;
+ const availableHeight = Math.max(160, openAbove ? above : below);
+
+ setMenuStyle({
+ bottom: openAbove ? window.innerHeight - rect.top + 4 : undefined,
+ left,
+ maxHeight: availableHeight,
+ maxWidth,
+ minWidth: rect.width,
+ position: "fixed",
+ top: openAbove ? undefined : rect.bottom + 4,
+ zIndex: 1000
+ });
+ }, []);
+
+ React.useLayoutEffect(() => {
+ onOpenChange?.(open);
+ return () => {
+ if (open) {
+ onOpenChange?.(false);
+ }
+ };
+ }, [onOpenChange, open]);
+
+ React.useLayoutEffect(() => {
+ if (!open) return;
+
+ updateMenuPosition();
+ window.addEventListener("resize", updateMenuPosition);
+ window.addEventListener("scroll", updateMenuPosition, true);
+
+ return () => {
+ window.removeEventListener("resize", updateMenuPosition);
+ window.removeEventListener("scroll", updateMenuPosition, true);
+ };
+ }, [open, updateMenuPosition]);
+
+ React.useEffect(() => {
+ if (!open) return;
+
+ const closeOnOutsidePointer = (event: PointerEvent) => {
+ const target = event.target as Node;
+ if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) {
+ setOpen(false);
+ }
+ };
+
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setOpen(false);
+ }
+ };
+
+ document.addEventListener("pointerdown", closeOnOutsidePointer);
+ document.addEventListener("keydown", closeOnEscape);
+
+ return () => {
+ document.removeEventListener("pointerdown", closeOnOutsidePointer);
+ document.removeEventListener("keydown", closeOnEscape);
+ };
+ }, [open]);
+
+ const menu = open ? (
+
+ {options.map((option) => {
+ const OptionIcon = option.icon;
+ const selected = option.value === selectedOption?.value;
+
+ return (
+
+ );
+ })}
+
+ ) : null;
+
+ return (
+
+
+
+ {menu && typeof document !== "undefined" ? createPortal(menu, document.body) : menu}
+
+ );
+ }
+);
+
+Select.displayName = "Select";
+
+export { Select };
diff --git a/marketplace/plugins/agent-console/src/renderer/components/ui/textarea.tsx b/marketplace/plugins/agent-console/src/renderer/components/ui/textarea.tsx
new file mode 100644
index 00000000..32f770e2
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/components/ui/textarea.tsx
@@ -0,0 +1,19 @@
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Textarea = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+
+Textarea.displayName = "Textarea";
+
+export { Textarea };
diff --git a/marketplace/plugins/agent-console/src/renderer/components/ui/toast.tsx b/marketplace/plugins/agent-console/src/renderer/components/ui/toast.tsx
new file mode 100644
index 00000000..6b2c5551
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/components/ui/toast.tsx
@@ -0,0 +1,219 @@
+import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
+import { AlertCircle, CheckCircle2, Info, TriangleAlert, X, type LucideIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export type ToastVariant = "error" | "info" | "success" | "warning";
+
+export type ToastAction = {
+ label: string;
+ onClick: () => void;
+};
+
+export type ToastInput = {
+ actions?: ToastAction[];
+ content?: ReactNode;
+ durationMs?: number;
+ title: ReactNode;
+ variant?: ToastVariant;
+};
+
+type ToastRecord = ToastInput & {
+ id: string;
+ variant: ToastVariant;
+};
+
+type ToastContextValue = {
+ dismissToast: (id: string) => void;
+ showToast: (toast: ToastInput) => string;
+ success: (toast: Omit) => string;
+ error: (toast: Omit) => string;
+ warning: (toast: Omit) => string;
+ info: (toast: Omit) => string;
+};
+
+const ToastContext = createContext(null);
+const defaultToastDurationMs = 5000;
+const toastViewportMaxHeight = "min(70vh, calc(100vh - 32px))";
+let nextToastId = 1;
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState([]);
+
+ const dismissToast = useCallback((id: string) => {
+ setToasts((currentToasts) => currentToasts.filter((toast) => toast.id !== id));
+ }, []);
+
+ const showToast = useCallback((toast: ToastInput) => {
+ const id = `toast-${nextToastId++}`;
+ const nextToast: ToastRecord = {
+ ...toast,
+ id,
+ variant: toast.variant ?? "info"
+ };
+
+ setToasts((currentToasts) => [...currentToasts, nextToast]);
+ return id;
+ }, []);
+
+ const dismissTopToast = useCallback(() => {
+ setToasts((currentToasts) => currentToasts.slice(1));
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ dismissToast,
+ error: (toast) => showToast({ ...toast, variant: "error" }),
+ info: (toast) => showToast({ ...toast, variant: "info" }),
+ showToast,
+ success: (toast) => showToast({ ...toast, variant: "success" }),
+ warning: (toast) => showToast({ ...toast, variant: "warning" })
+ }),
+ [dismissToast, showToast]
+ );
+
+ return (
+
+ {children}
+
+
+ );
+}
+
+export function useToast() {
+ const context = useContext(ToastContext);
+ if (!context) {
+ throw new Error("useToast must be used inside ToastProvider");
+ }
+ return context;
+}
+
+function ToastViewport({
+ dismissToast,
+ dismissTopToast,
+ toasts
+}: {
+ dismissToast: (id: string) => void;
+ dismissTopToast: () => void;
+ toasts: ToastRecord[];
+}) {
+ const viewportRef = useRef(null);
+
+ useLayoutEffect(() => {
+ const viewport = viewportRef.current;
+ if (!viewport || toasts.length <= 1) return;
+ if (viewport.scrollHeight > viewport.clientHeight + 1) {
+ dismissTopToast();
+ }
+ }, [dismissTopToast, toasts]);
+
+ if (!toasts.length) return null;
+
+ return (
+
+ {toasts.map((toast) => (
+
+ ))}
+
+ );
+}
+
+function ToastCard({
+ dismissToast,
+ toast
+}: {
+ dismissToast: (id: string) => void;
+ toast: ToastRecord;
+}) {
+ const timeoutRef = useRef(null);
+ const { Icon, iconClassName } = getToastVariantStyle(toast.variant);
+
+ useEffect(() => {
+ const durationMs = toast.durationMs ?? defaultToastDurationMs;
+ if (durationMs <= 0) return undefined;
+
+ timeoutRef.current = window.setTimeout(() => dismissToast(toast.id), durationMs);
+ return () => {
+ if (timeoutRef.current !== null) window.clearTimeout(timeoutRef.current);
+ };
+ }, [dismissToast, toast.durationMs, toast.id]);
+
+ const dismiss = () => dismissToast(toast.id);
+
+ return (
+
+
+
+
+
+
+
{toast.title}
+ {toast.content ?
{toast.content}
: null}
+
+
+
+ {toast.actions?.length ? (
+
+ {toast.actions.map((action) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+function getToastVariantStyle(variant: ToastVariant): {
+ Icon: LucideIcon;
+ iconClassName: string;
+} {
+ if (variant === "success") {
+ return {
+ Icon: CheckCircle2,
+ iconClassName: "text-[#12805c]"
+ };
+ }
+ if (variant === "error") {
+ return {
+ Icon: AlertCircle,
+ iconClassName: "text-destructive"
+ };
+ }
+ if (variant === "warning") {
+ return {
+ Icon: TriangleAlert,
+ iconClassName: "text-[#b7791f]"
+ };
+ }
+
+ return {
+ Icon: Info,
+ iconClassName: "text-primary"
+ };
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/lib/i18n.tsx b/marketplace/plugins/agent-console/src/renderer/lib/i18n.tsx
new file mode 100644
index 00000000..b69e8309
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/lib/i18n.tsx
@@ -0,0 +1,1700 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
+
+export type Locale = "zh" | "en";
+export type TranslationParams = Record;
+
+const localeStorageKey = "agent-console:locale";
+
+const zh = {
+ "app.defaultTitle": "Codex Agent Console",
+ "browser.addressAria": "浏览器地址",
+ "browser.back": "后退",
+ "browser.blankPage": "空白页",
+ "browser.browserUseEnabled": "启用 browser-use",
+ "browser.closeTab": "关闭 {title}",
+ "browser.coachmarkBody": "可从这里导入浏览器 profile、管理 origin 授权,并控制自动化工具是否能读取或操作页面。",
+ "browser.coachmarkTitle": "浏览器自动化已接入 origin 状态",
+ "browser.dismissCoachmark": "关闭提示",
+ "browser.forward": "前进",
+ "browser.hiddenHost": "隐藏 WebView Host",
+ "browser.hiddenHostReady": "已就绪",
+ "browser.hiddenHostStopped": "已停止",
+ "browser.importProfile": "导入 profile",
+ "browser.importedProfileSummary": "{count} 个书签",
+ "browser.newTab": "新建浏览器标签页",
+ "browser.newTabTitle": "新标签页",
+ "browser.noOrigin": "暂无访问来源",
+ "browser.originAllowed": "已允许自动化",
+ "browser.originBlocked": "未允许自动化",
+ "browser.originExternal": "外部站点",
+ "browser.originFile": "本地文件",
+ "browser.originLocal": "本地站点",
+ "browser.originOpaque": "内部页面",
+ "browser.originState": "Origin 状态",
+ "browser.placeholder": "输入网址或搜索",
+ "browser.profileImportEmpty": "未发现可导入的 Chromium profile",
+ "browser.profileImportTitle": "Profile 导入",
+ "browser.reload": "重新加载",
+ "browser.requireOriginApproval": "Origin 授权",
+ "browser.settings": "浏览器设置",
+ "browser.stopLoading": "停止加载",
+ "agent.allow": "允许本次",
+ "agent.allowSession": "允许本会话",
+ "agent.apiUnavailable": "当前运行环境没有暴露 agent 接口。",
+ "agent.approvalResolveFailed": "审批结果发送失败。",
+ "agent.approvalTitle": "权限审批",
+ "agent.approvalUnknownMethod": "未知请求",
+ "agent.approvalChoiceAllow": "是",
+ "agent.approvalChoiceAllowSession": "是,且对于以后续内容开头的命令不再询问",
+ "agent.approvalChoiceDeny": "否,请告知 {agent} 如何调整",
+ "agent.approvalDenyMessageLabel": "说明如何调整",
+ "agent.approvalDenyMessagePlaceholder": "输入要告知 {agent} 的调整说明",
+ "agent.approvalToolParameters": "工具参数",
+ "agent.browserMcp": "浏览器 MCP",
+ "agent.defaultModel": "默认模型",
+ "agent.deny": "拒绝",
+ "agent.effort": "思考程度",
+ "agent.effort.high": "高",
+ "agent.effort.low": "低",
+ "agent.effort.max": "最大",
+ "agent.effort.medium": "中",
+ "agent.effort.minimal": "极少",
+ "agent.effort.none": "无",
+ "agent.effort.xhigh": "超高",
+ "agent.questionCustomPlaceholder": "输入其他答案",
+ "agent.questionResolveFailed": "问题回答发送失败。",
+ "agent.questionSubmit": "提交回答",
+ "agent.questionSelectPlaceholder": "请选择",
+ "agent.questionTitle": "需要用户交互",
+ "agent.questionUnanswered": "暂不回答",
+ "agent.sheetBack": "上一题",
+ "agent.sheetNext": "下一步",
+ "agent.sheetSkip": "跳过",
+ "agent.sheetSubmit": "提交",
+ "agent.model": "模型",
+ "agent.permission.auto": "替我审批",
+ "agent.permission.full": "完全访问",
+ "agent.permission.request": "请求批准",
+ "agent.permissions": "权限",
+ "agent.provider": "Agent",
+ "agent.runFailed": "Agent 运行失败。",
+ "agent.sendFailed": "消息发送失败。",
+ "agent.runSettings": "模型、思考与速度",
+ "agent.speed": "速度",
+ "agent.speed.default": "默认",
+ "agent.speed.fast": "快",
+ "agent.subagents": "Subagents",
+ "agent.subagentsCount": "{count} 个 Subagent",
+ "agent.subagentsEmpty": "暂无可挂载的 Subagent",
+ "agent.thinking": "思考中",
+ "agent.toastTitle": "Agent",
+ "agent.toolCompleted": "完成",
+ "agent.toolError": "错误",
+ "agent.toolFailed": "失败",
+ "agent.toolInput": "输入",
+ "agent.toolOutput": "结果",
+ "agent.toolRunning": "运行中",
+ "chat.batch": "32ms 批处理",
+ "chat.branchMessage": "分叉会话",
+ "chat.copyMessage": "复制消息",
+ "chat.domIsland": "DOM 岛",
+ "chat.editMessage": "编辑消息",
+ "chat.markdownMode": "Markdown",
+ "chat.messageCount": "{count} 条消息",
+ "chat.messageActions": "消息操作",
+ "chat.openSmallWindow": "打开小窗口",
+ "chat.placeholder": "输入消息",
+ "chat.pauseStream": "暂停流式输出",
+ "chat.qualityExtraHigh": "极高",
+ "chat.ready": "就绪",
+ "chat.resumeStream": "继续流式输出",
+ "chat.sanitized": "已净化",
+ "chat.send": "发送消息",
+ "chat.stopRecording": "停止录音",
+ "chat.title": "Markdown Chatbot",
+ "chat.transcribingVoice": "正在转录语音",
+ "contextWindow.aria": "上下文窗口:已用 {used},窗口 {limit}",
+ "contextWindow.percent": "占比",
+ "contextWindow.title": "上下文窗口",
+ "contextWindow.unknown": "未知",
+ "contextWindow.used": "已用",
+ "common.back": "返回",
+ "common.cancel": "取消",
+ "common.loading": "加载中",
+ "common.close": "关闭",
+ "common.clear": "清空",
+ "common.edit": "编辑",
+ "common.forward": "前进",
+ "common.optional": "可选",
+ "common.remove": "移除",
+ "common.save": "保存",
+ "project.remove": "移除项目",
+ "project.removeConfirm": "从侧栏移除项目“{name}”及其会话记录?磁盘文件不会被删除。",
+ "project.removeFailed": "移除项目失败。",
+ "project.showInFinder": "在 Finder 中显示",
+ "project.showInFinderFailed": "无法在 Finder 中显示项目。",
+ "project.toastTitle": "项目",
+ "editor.closeUnsavedConfirm": "关闭未保存的文件 '{name}'?",
+ "editor.closeTab": "关闭",
+ "editor.empty": "从文件树选择文件开始编辑",
+ "editor.loading": "读取文件...",
+ "editor.openFromTree": "从文件树打开文件",
+ "editor.savedNotice": "已保存 {path}",
+ "editor.unavailable": "文件编辑器在桌面壳中可用。",
+ "docs.collaboration": "Agent",
+ "docs.agentMentionAria": "选择 Agent",
+ "docs.commandBulletList": "项目列表",
+ "docs.commandCodeBlock": "代码块",
+ "docs.commandDivider": "分割线",
+ "docs.commandHeading1": "标题 1",
+ "docs.commandHeading2": "标题 2",
+ "docs.commandHeading3": "标题 3",
+ "docs.commandAgentTask": "{agent} 任务",
+ "docs.commandOrderedList": "编号列表",
+ "docs.commandParagraph": "正文",
+ "docs.commandQuote": "引用",
+ "docs.commandTask": "任务",
+ "docs.discovering": "查找 Markdown...",
+ "docs.editorAria": "Docs 富文本编辑器",
+ "docs.editorPlaceholder": "直接编写文档,使用 @agent 召唤 agent,或使用 / 插入命令。",
+ "docs.emptyDescription": "打开现有 Markdown 文档,或创建一个 Docs 文档。",
+ "docs.emptyTitle": "没有打开文档",
+ "docs.fileApiUnavailable": "Docs 编辑器在桌面壳中可用。",
+ "docs.loading": "加载 Docs 编辑器...",
+ "docs.noDocument": "没有打开 Markdown 文档。",
+ "docs.noDocuments": "没有 Markdown 文档",
+ "docs.noMatchingAgents": "没有匹配 agent",
+ "docs.noMatchingSlashCommands": "没有匹配命令",
+ "docs.openThread": "打开线程",
+ "docs.openUnsavedConfirm": "当前文档还有未保存内容,仍要切换文档?",
+ "docs.outputBlocks": "{count} 个 agent 输出块",
+ "docs.projectUnavailable": "没有可用项目上下文。",
+ "docs.refresh": "刷新文档",
+ "docs.runApproval": "等待权限审批",
+ "docs.runCompleted": "已完成并写回文档",
+ "docs.runFailed": "运行失败",
+ "docs.runIdle": "空闲",
+ "docs.runQuestion": "等待用户交互",
+ "docs.runRunning": "正在生成输出",
+ "docs.runStarting": "正在创建线程",
+ "docs.selectDocument": "选择文档",
+ "docs.slashCommandAria": "选择命令",
+ "docs.title": "Docs",
+ "docs.toastTitle": "Docs",
+ "docs.unknownReferences": "未匹配到当前 agent provider: {agents}",
+ "fileTree.aria": "文件树",
+ "fileTree.emptyDirectory": "空目录",
+ "fileTree.directoryErrorTitle": "无法读取目录",
+ "fileTree.loadingDirectory": "读取中...",
+ "fileTree.loadingEditor": "加载文件编辑器...",
+ "fileTree.loadingWorkspace": "读取工作区...",
+ "fileTree.reload": "重新加载",
+ "fileTree.unavailableDescription": "文件树在桌面壳中可用。",
+ "fileTree.unavailableTitle": "无法连接文件系统",
+ "fileTree.workspaceErrorTitle": "无法读取工作区",
+ "git.allBranches": "所有分支",
+ "git.allDates": "所有日期",
+ "git.allPaths": "所有路径",
+ "git.allUsers": "所有用户",
+ "git.amend": "修补提交",
+ "git.apply": "应用",
+ "git.back": "返回",
+ "git.branch": "分支",
+ "git.cancel": "取消",
+ "git.checkout": "检出",
+ "git.checkoutBranchConfirm": "检出分支 '{branch}'?",
+ "git.checkoutRevision": "检出此版本",
+ "git.checkoutRevisionConfirm": "在 detached HEAD 中检出版本 {hash}?",
+ "git.checkedOutNotice": "已检出 {hash}。",
+ "git.changes": "改动",
+ "git.cherryPick": "拣选",
+ "git.cherryPickConfirm": "拣选 {hash}?",
+ "git.cherryPickNotice": "已拣选 {hash}。",
+ "git.close": "关闭",
+ "git.collapse": "折叠",
+ "git.commit": "提交",
+ "git.commitAndPush": "提交并推送...",
+ "git.commitDiffEmpty": "没有提交差异",
+ "git.commitDiffPreviewTitle": "提交 {hash} 的差异",
+ "git.commitMessage": "提交信息",
+ "git.commitOptions": "提交选项",
+ "git.compareWithLocal": "与本地比较",
+ "git.compareWithLocalTitle": "比较 {hash} 与本地",
+ "git.copyRevision": "复制版本号",
+ "git.copyRevisionNotice": "已复制版本 {hash}。",
+ "git.createAutosquashConfirm": "从 {count} 个选中文件为 {hash} 创建 {mode} 提交?",
+ "git.createBranch": "创建",
+ "git.createBranchDescription": "在 {hash} 创建分支。",
+ "git.createFixupNotice": "已为 {hash} 创建 fixup 提交。",
+ "git.createPatch": "创建补丁...",
+ "git.createPatchNotice": "已创建补丁: {path}",
+ "git.createSquashNotice": "已为 {hash} 创建 squash 提交。",
+ "git.createTagDescription": "在 {hash} 创建标签。",
+ "git.createdBranchNotice": "已创建分支 '{name}'。",
+ "git.createdTagNotice": "已创建标签 '{name}'。",
+ "git.date": "日期",
+ "git.diff": "差异",
+ "git.diffPreviewTitle": "{path} 的差异",
+ "git.discardConfirm": "丢弃 {count} 个选中文件的改动?",
+ "git.drop": "删除",
+ "git.dropCommit": "删除提交",
+ "git.dropCommitConfirm": "从当前分支历史中删除提交 {hash}?",
+ "git.droppedNotice": "已删除 {hash}。",
+ "git.editCommitMessage": "编辑提交信息...",
+ "git.editCommitMessageDescription": "编辑 {hash} 的提交信息。",
+ "git.errorTitle": "Git 错误",
+ "git.expand": "展开",
+ "git.fetch": "获取",
+ "git.fileCount": "{count} 个文件",
+ "git.filterBranch": "分支",
+ "git.filterDate": "日期",
+ "git.filterPaths": "路径",
+ "git.filterUser": "用户",
+ "git.fixup": "Fixup...",
+ "git.goToChildCommit": "转到子提交",
+ "git.goToParentCommit": "转到父提交",
+ "git.hard": "Hard",
+ "git.hardDescription": "移动 HEAD、索引和工作区到所选版本。",
+ "git.headCurrentBranch": "HEAD(当前分支)",
+ "git.inline": "行内",
+ "git.inBranches": "存在于 {count} 个分支:",
+ "git.interactiveRebase": "从这里交互式变基...",
+ "git.interactiveRebaseDescription": "支持命令: pick、squash、fixup、drop。",
+ "git.interactiveRebaseTitle": "从这里交互式变基",
+ "git.keep": "Keep",
+ "git.keepDescription": "移动 HEAD,并在 Git 能安全处理时保留本地改动。",
+ "git.last30Days": "最近 30 天",
+ "git.last7Days": "最近 7 天",
+ "git.local": "本地",
+ "git.log": "日志",
+ "git.merge": "合并",
+ "git.mergeConfirm": "将 '{branch}' 合并到 '{current}'?",
+ "git.mixed": "Mixed",
+ "git.mixedDescription": "移动 HEAD 并重置索引,保留工作区文件。",
+ "git.more": "更多",
+ "git.newBranch": "新建分支",
+ "git.newBranchMenu": "新建分支...",
+ "git.newTag": "新建标签",
+ "git.newTagMenu": "新建标签...",
+ "git.noChangedFiles": "没有变更文件",
+ "git.noCommitSelected": "选择提交以查看变更",
+ "git.noLocalDiff": "没有本地差异",
+ "git.noShelves": "没有搁置项",
+ "git.onDate": "{date}",
+ "git.openedRevisionNotice": "已打开版本快照: {path}",
+ "git.openedUrlNotice": "已打开 {url}",
+ "git.branchName": "分支名",
+ "git.preview": "预览",
+ "git.pull": "拉取",
+ "git.pullRebase": "拉取并变基",
+ "git.push": "推送",
+ "git.pushUpToCommit": "推送到这里...",
+ "git.pushUpToConfirm": "将当前分支推送到 {hash}?",
+ "git.pushedUpToNotice": "已推送到 {hash}。",
+ "git.rebase": "变基",
+ "git.rebaseOnto": "变基到此处",
+ "git.rebaseOntoConfirm": "将 '{current}' 变基到 '{branch}'?",
+ "git.refresh": "刷新",
+ "git.remote": "远程",
+ "git.repository": "仓库",
+ "git.reset": "重置",
+ "git.resetCurrentBranch": "重置当前分支",
+ "git.resetCurrentBranchDescription": "将 '{branch}' 重置到 {hash}。",
+ "git.resetCurrentBranchNotice": "已将当前分支重置到 {hash}。",
+ "git.resetCurrentBranchToHere": "将当前分支重置到这里...",
+ "git.resizeHorizontal": "调整 Git 上下区域",
+ "git.resizeVertical": "调整 Git 左右区域",
+ "git.revertCommit": "还原提交",
+ "git.revertCommitConfirm": "还原提交 {hash}?",
+ "git.revertedNotice": "已还原 {hash}。",
+ "git.rollback": "回滚",
+ "git.save": "保存",
+ "git.search": "搜索",
+ "git.searchChanges": "搜索改动",
+ "git.selectCommitDiff": "差异",
+ "git.shelf": "搁置",
+ "git.shelfName": "搁置名称",
+ "git.shelveSelectedChanges": "搁置选中改动",
+ "git.showAll": "显示全部",
+ "git.showLess": "收起",
+ "git.showRepositoryAtRevision": "显示此版本的仓库",
+ "git.soft": "Soft",
+ "git.softDescription": "移动 HEAD,保持索引和工作区不变。",
+ "git.split": "分栏",
+ "git.squashInto": "Squash 到此处...",
+ "git.stage": "暂存",
+ "git.startRebase": "开始变基",
+ "git.startedInteractiveRebaseNotice": "已从 {hash} 开始交互式变基。",
+ "git.tagName": "标签名",
+ "git.textOrHash": "文本或哈希",
+ "git.today": "今天",
+ "git.undoCommit": "撤销提交...",
+ "git.undoCommitConfirm": "撤销提交 {hash} 并保留已暂存改动?",
+ "git.undidNotice": "已撤销 {hash}。",
+ "git.unstage": "取消暂存",
+ "git.unavailable": "Git 在 Electron 壳中可用。",
+ "git.unversionedFiles": "未版本控制文件",
+ "git.updatedCommitMessageNotice": "已更新 {hash} 的提交信息。",
+ "git.viewInBrowser": "在浏览器中查看",
+ "git.yesterdayAt": "昨天 {time}",
+ "language.en": "English",
+ "language.label": "语言",
+ "language.zh": "中文",
+ "mobile.tab.chat": "聊天",
+ "mobile.tab.sessions": "会话",
+ "mobile.tab.settings": "设置",
+ "mobile.tab.tools": "工具",
+ "newSession.addMenu": "添加菜单",
+ "newSession.addNewSubagent": "添加新 Subagent",
+ "newSession.attach": "添加附件",
+ "newSession.attachSubagents": "添加 Subagent",
+ "newSession.attachFilesFailed": "添加附件失败。",
+ "newSession.branch": "分支",
+ "newSession.branchName": "main",
+ "newSession.branchSwitchFailed": "切换分支失败。",
+ "newSession.connectionMode": "连接模式",
+ "newSession.connectEmail.description": "总结电子邮件中利益相关方的请求",
+ "newSession.connectEmail.title": "连接电子邮件",
+ "newSession.connectFiles.description": "审查结果、研究资料和计划",
+ "newSession.connectFiles.title": "连接文件",
+ "newSession.connectMessages.description": "从近期团队讨论中获取背景信息",
+ "newSession.connectMessages.title": "连接消息传送",
+ "newSession.localMode": "本地模式",
+ "newSession.modeUnavailable": "未配置",
+ "newSession.model": "5.5",
+ "newSession.modelQuality": "超高",
+ "newSession.newBlankProject": "新建空白项目",
+ "newSession.noGitBranch": "无 Git 分支",
+ "newSession.noProjects": "暂无项目",
+ "newSession.placeholder": "随心输入",
+ "newSession.project": "项目",
+ "newSession.projectAddFailed": "添加项目失败。",
+ "newSession.projectCreateFailed": "新建项目失败。",
+ "newSession.projectMenu": "项目菜单",
+ "newSession.projectUnavailable": "没有可用于创建会话的真实项目。",
+ "newSession.question": "我们应该在 {workspace} 中构建什么?",
+ "newSession.questionPrefix": "我们应该在",
+ "newSession.questionSuffix": "中构建什么?",
+ "newSession.localBranch": "本地分支",
+ "newSession.remoteBranch": "远程分支",
+ "newSession.remoteMode": "远端模式",
+ "newSession.removeAttachment": "移除附件 {name}",
+ "newSession.sshMode": "SSH 模式",
+ "newSession.title": "新会话",
+ "newSession.useExistingFolder": "使用现有文件夹",
+ "newSession.voiceInput": "语音输入设置",
+ "rightSidebar.addTabAria": "添加右侧栏标签页",
+ "rightSidebar.addTabMenuAria": "选择要添加的右侧栏标签页",
+ "rightSidebar.browser.label": "浏览器",
+ "rightSidebar.browser.title": "浏览器",
+ "rightSidebar.docs.label": "Docs",
+ "rightSidebar.docs.title": "Docs",
+ "rightSidebar.editor.label": "编辑",
+ "rightSidebar.editor.title": "文件编辑器",
+ "rightSidebar.files.label": "文件",
+ "rightSidebar.files.title": "文件树",
+ "rightSidebar.git.label": "Git",
+ "rightSidebar.git.title": "Git",
+ "rightSidebar.noAvailableTabs": "没有可添加的标签页",
+ "rightSidebar.selectAria": "选择右侧栏插件",
+ "rightSidebar.terminal.label": "终端",
+ "rightSidebar.terminal.title": "终端",
+ "settings.appearance.compactDensity.description": "减少列表和设置页中的垂直间距。",
+ "settings.appearance.compactDensity.label": "紧凑密度",
+ "settings.appearance.homeTheme.default": "默认配置",
+ "settings.appearance.homeTheme.description": "使用 colors、CSS 变量和 sections 覆盖应用、聊天 Markdown 与首页样式。",
+ "settings.appearance.homeTheme.invalid": "JSON 无效,当前使用默认主题。",
+ "settings.appearance.homeTheme.label": "主题自定义",
+ "settings.appearance.homeTheme.reset": "恢复默认",
+ "settings.appearance.language.description": "切换界面显示语言。",
+ "settings.appearance.reduceMotion.description": "降低面板切换和流式内容的动画强度。",
+ "settings.appearance.reduceMotion.label": "减少动效",
+ "settings.appearance.theme.description": "跟随系统或固定使用指定主题。",
+ "settings.appearance.theme.label": "主题",
+ "settings.backToApp": "返回应用",
+ "settings.general.autoSaveDrafts.description": "输入框内容在切换页面和会话时保留。",
+ "settings.general.autoSaveDrafts.label": "自动保存草稿",
+ "settings.general.restoreLastThread.description": "应用启动后回到最后选中的项目和会话。",
+ "settings.general.restoreLastThread.label": "启动时恢复上次会话",
+ "settings.agents.add": "添加 Agent",
+ "settings.agents.apiUnavailable": "当前运行环境没有暴露设置接口。",
+ "settings.agents.args": "参数",
+ "settings.agents.builtIn": "内置",
+ "settings.agents.builtInDescription": "由应用内置适配器管理。",
+ "settings.agents.command": "命令",
+ "settings.agents.commandRequired": "命令不能为空。",
+ "settings.agents.configure": "设置 {agent}",
+ "settings.agents.custom": "自定义",
+ "settings.agents.delete": "删除 Agent",
+ "settings.agents.deleteConfirm": "删除 {agent}?",
+ "settings.agents.description": "描述",
+ "settings.agents.duplicateId": "{id} 已存在。",
+ "settings.agents.disabled": "已禁用",
+ "settings.agents.disabledToast": "Agent 已禁用。",
+ "settings.agents.enabled": "已启用",
+ "settings.agents.enabledAria": "切换 {agent} 启用状态",
+ "settings.agents.enabledToast": "Agent 已启用。",
+ "settings.agents.id": "ID",
+ "settings.agents.idRequired": "Agent ID 不能为空。",
+ "settings.agents.invalidId": "{id} 不是有效的 Agent ID。",
+ "settings.agents.invalidTimeout": "超时时间必须是正数毫秒。",
+ "settings.agents.installCommand": "安装命令",
+ "settings.agents.label": "名称",
+ "settings.agents.logo": "Logo",
+ "settings.agents.logoDescription": "支持 PNG、JPG、WebP、GIF 或 SVG,最大 1MB。",
+ "settings.agents.logoInvalid": "请选择有效的图片文件。",
+ "settings.agents.logoRemove": "移除",
+ "settings.agents.logoTooLarge": "Logo 文件不能超过 1MB。",
+ "settings.agents.logoUpload": "上传 Logo",
+ "settings.agents.models": "模型",
+ "settings.agents.modelsCount": "{count} 个模型",
+ "settings.agents.newAgent": "新建 Agent",
+ "settings.agents.save": "保存 Agent",
+ "settings.agents.saveBeforeEnv": "保存 Agent 后可以配置环境变量。",
+ "settings.agents.saveFailed": "保存 Agent 设置失败。",
+ "settings.agents.savedToast": "Agent 设置已保存。",
+ "settings.agents.remoteCommand": "远端命令",
+ "settings.agents.sshTarget": "SSH 目标",
+ "settings.agents.sshUrlRequired": "SSH 模式需要 ssh://、host 或 user@host 目标。",
+ "settings.agents.timeout": "超时毫秒",
+ "settings.agents.toastTitle": "Agents",
+ "settings.agents.transport": "连接方式",
+ "settings.agents.transportSsh": "SSH 模式",
+ "settings.agents.transportStdio": "本地模式",
+ "settings.agents.transportWebsocket": "远端模式",
+ "settings.agents.unsavedInline": "保存后会刷新 Agent 列表。",
+ "settings.agents.url": "URL",
+ "settings.agents.urlRequired": "当前连接方式需要 URL。",
+ "settings.agents.websocketUrlRequired": "远端模式需要 ws:// 或 wss:// URL。",
+ "settings.subagents.add": "添加 Subagent",
+ "settings.subagents.addTool": "添加工具 MCP",
+ "settings.subagents.apiUnavailable": "当前运行环境没有暴露设置接口。",
+ "settings.subagents.configure": "设置 {agent}",
+ "settings.subagents.defaultDescription": "可在任务中挂载,并由主 Agent 按需调用。",
+ "settings.subagents.delete": "删除 Subagent",
+ "settings.subagents.deleteConfirm": "删除 {agent}?",
+ "settings.subagents.description": "描述",
+ "settings.subagents.descriptionPlaceholder": "说明这个 Subagent 适合处理的任务和边界。",
+ "settings.subagents.descriptionRequired": "Subagent 描述不能为空。",
+ "settings.subagents.duplicateId": "{id} 已存在。",
+ "settings.subagents.empty": "还没有 Subagent。添加一个由 Codex 或 Claude Code 驱动的专用 Agent。",
+ "settings.subagents.editTool": "编辑工具 MCP",
+ "settings.subagents.id": "ID",
+ "settings.subagents.idRequired": "Subagent ID 不能为空。",
+ "settings.subagents.invalidId": "{id} 不是有效的 Subagent ID。",
+ "settings.subagents.label": "名称",
+ "settings.subagents.labelRequired": "Subagent 名称不能为空。",
+ "settings.subagents.model": "模型",
+ "settings.subagents.newSubagent": "新建 Subagent",
+ "settings.subagents.noTools": "还没有工具 MCP。",
+ "settings.subagents.provider": "基础 Agent",
+ "settings.subagents.providerRequired": "请选择基础 Agent。",
+ "settings.subagents.save": "保存 Subagent",
+ "settings.subagents.saveFailed": "保存 Subagent 失败。",
+ "settings.subagents.savedToast": "Subagent 设置已保存。",
+ "settings.subagents.systemPrompt": "系统提示词",
+ "settings.subagents.systemPromptPlaceholder": "描述这个 Subagent 的角色、边界和输出风格。",
+ "settings.subagents.systemPromptRequired": "Subagent 系统提示词不能为空。",
+ "settings.subagents.title": "Subagents",
+ "settings.subagents.toastTitle": "Subagents",
+ "settings.subagents.tools": "工具 MCP",
+ "settings.subagents.toolsDialogDescription": "仅此 Subagent 可用。",
+ "settings.subagents.toolsEmpty": "工具 JSON 中没有有效的 MCP server。",
+ "settings.subagents.toolsInvalid": "工具 JSON 格式无效。",
+ "settings.subagents.toolsRequired": "工具 MCP JSON 不能为空。",
+ "settings.subagents.unsavedInline": "保存后可在任务发送时挂载。",
+ "settings.agentEnvironment.add": "添加变量",
+ "settings.agentEnvironment.apiUnavailable": "当前运行环境没有暴露设置接口。",
+ "settings.agentEnvironment.duplicateName": "{name} 已重复。",
+ "settings.agentEnvironment.importButton": "导入",
+ "settings.agentEnvironment.importDescription": "选择内置源模板,或填写三方模板链接;填写链接时会优先从链接导入。",
+ "settings.agentEnvironment.importFromTemplate": "从模板导入环境变量",
+ "settings.agentEnvironment.importTemplate": "导入变量",
+ "settings.agentEnvironment.importTitle": "导入环境变量",
+ "settings.agentEnvironment.invalidName": "{name} 不是有效的环境变量名。",
+ "settings.agentEnvironment.name": "变量名",
+ "settings.agentEnvironment.nameRequired": "变量名不能为空。",
+ "settings.agentEnvironment.provider.description": "选择要应用这些环境变量的 agent。",
+ "settings.agentEnvironment.provider.label": "Agent",
+ "settings.agentEnvironment.remove": "移除环境变量 {name}",
+ "settings.agentEnvironment.save": "保存",
+ "settings.agentEnvironment.saveFailed": "保存环境变量失败。",
+ "settings.agentEnvironment.savedInline": "已保存。",
+ "settings.agentEnvironment.savedToast": "已保存 {agent} 的环境变量。",
+ "settings.agentEnvironment.template": "内置源模板",
+ "settings.agentEnvironment.templateEmpty": "模板内容不能为空。",
+ "settings.agentEnvironment.templateFetchFailed": "加载模板失败。",
+ "settings.agentEnvironment.templateInvalid": "模板中未找到有效的环境变量配置。",
+ "settings.agentEnvironment.templateInvalidJson": "请粘贴有效的环境变量 JSON 或 .env 模板。",
+ "settings.agentEnvironment.templateInvalidLine": "第 {line} 行不是有效的 KEY=value 格式。",
+ "settings.agentEnvironment.templateLink": "三方模板链接",
+ "settings.agentEnvironment.templateLinkFetchFailed": "加载三方模板链接失败。",
+ "settings.agentEnvironment.templateMissing": "请选择内置源模板,或填写三方模板链接。",
+ "settings.agentEnvironment.templateNoResults": "没有匹配的模板。",
+ "settings.agentEnvironment.templateSearchPlaceholder": "搜索模板",
+ "settings.agentEnvironment.templateSourceEmpty": "内置源暂无可用模板。",
+ "settings.agentEnvironment.templateSourceFetchFailed": "加载内置模板源失败。",
+ "settings.agentEnvironment.templateSourceInvalid": "内置模板源格式无效。",
+ "settings.agentEnvironment.templateSourceLoading": "正在加载内置模板源...",
+ "settings.agentEnvironment.templateText": "模板内容",
+ "settings.agentEnvironment.toastTitle": "Agent 环境变量",
+ "settings.agentEnvironment.unnamed": "未命名变量",
+ "settings.agentEnvironment.unsavedInline": "保存后会在新的 agent 消息中生效。",
+ "settings.agentEnvironment.value": "值",
+ "settings.agentEnvironment.valuePlaceholder": "变量值",
+ "settings.agentEnvironment.variables.label": "环境变量",
+ "settings.group.appearance": "界面",
+ "settings.group.agentEnvironment": "Agent 环境变量",
+ "settings.group.commands": "命令审批",
+ "settings.group.integrations": "插件与连接",
+ "settings.group.shortcuts": "快捷键",
+ "settings.group.startup": "启动与会话",
+ "settings.group.voiceApi": "语音转录 API",
+ "settings.botGateway.addChannel": "添加 Bot",
+ "settings.botGateway.actionFailed": "Bot 操作失败。",
+ "settings.botGateway.apiUnavailable": "当前运行环境没有暴露 Bot 接口。",
+ "settings.botGateway.configureIntegration": "使用此配置填充表单",
+ "settings.botGateway.credentialsRequiredForOverwrite": "该 Integration 已有敏感凭据;覆盖保存前需要重新填写 credentials。",
+ "settings.botGateway.description": "通过 npm 安装的 Bot Gateway stdio CLI 接收 IM 消息,并把 agent 回复发回对应会话。",
+ "settings.botGateway.enable": "启用 Bot",
+ "settings.botGateway.enableDescription": "开启后可以添加 Bot,并查看与管理已添加的 Bot。",
+ "settings.botGateway.integrationActionFailed": "Integration 操作失败。",
+ "settings.botGateway.integrationSaveFailed": "保存 Bot integration 失败。",
+ "settings.botGateway.integrationSaved": "Bot integration 已保存。",
+ "settings.botGateway.integrationStarted": "Integration 已启动。",
+ "settings.botGateway.integrationStopped": "Integration 已停止。",
+ "settings.botGateway.hideToken": "隐藏 Token",
+ "settings.botGateway.noIntegrations": "当前没有已添加的 Bot。",
+ "settings.botGateway.platform": "平台",
+ "settings.botGateway.qrStatus.alreadyBound": "已绑定",
+ "settings.botGateway.qrStatus.confirmed": "已连接",
+ "settings.botGateway.qrStatus.expired": "已过期",
+ "settings.botGateway.qrStatus.failed": "连接失败",
+ "settings.botGateway.qrStatus.idle": "等待生成",
+ "settings.botGateway.qrStatus.needsVerification": "需要验证",
+ "settings.botGateway.qrStatus.pending": "等待扫码",
+ "settings.botGateway.qrStatus.scanned": "已扫码",
+ "settings.botGateway.qrStatus.starting": "正在生成二维码",
+ "settings.botGateway.refreshFailed": "刷新 Bot 状态失败。",
+ "settings.botGateway.saveIntegration": "保存配置",
+ "settings.botGateway.showToken": "显示 Token",
+ "settings.botGateway.startIntegration": "启动 integration",
+ "settings.botGateway.started": "Bot 已开启。",
+ "settings.botGateway.stopIntegration": "停止 integration",
+ "settings.botGateway.stopped": "Bot 已关闭。",
+ "settings.botGateway.title": "Bot",
+ "settings.botGateway.weixinQrConfirmed": "微信 iLink 已连接。",
+ "settings.botGateway.weixinQrEmpty": "二维码暂不可用,请重新生成。",
+ "settings.botGateway.weixinQrRefresh": "重新生成",
+ "settings.botGateway.weixinQrStarting": "正在生成微信 iLink 二维码...",
+ "settings.botGateway.weixinQrTitle": "使用手机微信扫码连接 iLink",
+ "settings.integration.addPlugin": "添加插件",
+ "settings.integration.addMarketplacePlugin": "添加",
+ "settings.integration.availableStatus": "可安装",
+ "settings.integration.descriptionLabel": "描述",
+ "settings.integration.installPlugin": "安装插件",
+ "settings.integration.installUrlLabel": "安装 URL",
+ "settings.integration.installUnavailable": "本地包不可用",
+ "settings.integration.installedPlugins": "插件",
+ "settings.integration.installedStatus": "已安装",
+ "settings.integration.marketplaceOnlyStatus": "仅市场",
+ "settings.integration.marketplaceTitle": "插件列表",
+ "settings.integration.noDescription": "暂无描述。",
+ "settings.integration.noInstalledPlugins": "还没有安装插件。",
+ "settings.integration.noMarketplaceEntries": "没有可用的插件市场条目。",
+ "settings.integration.noMarketplaceSearchResults": "没有匹配的插件。",
+ "settings.integration.searchPlugins": "搜索插件",
+ "settings.integration.sourceFilter": "插件来源",
+ "settings.integration.sourceLabel": "来源",
+ "settings.integration.source.all": "all",
+ "settings.integration.source.bundled": "内置插件",
+ "settings.integration.source.claude": "Claude App",
+ "settings.integration.source.codex": "Codex App",
+ "settings.integration.source.development": "开发插件",
+ "settings.integration.source.marketplace": "Agent App",
+ "settings.integration.source.user": "本地插件",
+ "settings.integration.pluginsDescription": "Codex App、Claude App 和本应用插件都可以作为插件来源;启用并授权后,Claude Code、Codex 和其它 agent 可以使用其中的 MCP 工具。",
+ "settings.integration.updatePlugin": "更新",
+ "settings.integration.versionLabel": "版本",
+ "settings.menuAria": "设置菜单",
+ "slash.category.plugins": "插件",
+ "slash.category.prompts": "提示词",
+ "slash.explain.description": "让 agent 解释当前问题、代码或上下文。",
+ "slash.explain.prompt": "解释这段上下文的关键点、相关风险,以及下一步应该怎么做。",
+ "slash.explain.title": "解释上下文",
+ "slash.fix.description": "让 agent 定位并修复当前问题。",
+ "slash.fix.prompt": "定位当前问题的根因,直接实现修复,并说明验证方式。",
+ "slash.fix.title": "修复问题",
+ "slash.menuAria": "Slash 命令",
+ "slash.review.description": "按代码审查方式检查风险、回归和缺失测试。",
+ "slash.review.prompt": "请以代码审查的方式检查当前改动,优先列出 bug、回归风险和缺失测试,并给出文件/行号引用。",
+ "slash.review.title": "代码审查",
+ "slash.tests.description": "让 agent 补充或运行相关测试。",
+ "slash.tests.prompt": "为当前改动补充或运行最相关的测试,优先覆盖高风险路径,并总结结果。",
+ "slash.tests.title": "测试当前改动",
+ "slash.unavailable.description": "这个插件命令已经声明,但还没有可执行处理器。",
+ "settings.permissions.approvals.description": "运行 shell 命令前要求用户确认,适合敏感工作区。",
+ "settings.permissions.approvals.label": "命令审批",
+ "settings.permissions.dangerous.description": "删除文件、重置分支等操作始终二次确认。",
+ "settings.permissions.dangerous.label": "危险操作确认",
+ "settings.permissions.network.description": "允许代理请求外部网络访问。",
+ "settings.permissions.network.label": "网络访问",
+ "settings.returnTitle": "返回 {label}",
+ "settings.section.agents.label": "Agents",
+ "settings.section.appearance.label": "外观",
+ "settings.section.general.label": "通用设置",
+ "settings.section.integrations.label": "集成",
+ "settings.section.permissions.label": "权限与审批",
+ "settings.section.toolhub.label": "ToolHub",
+ "settings.shortcut.apiUnavailable": "当前运行环境没有暴露设置接口。",
+ "settings.shortcut.fallbackToast": "系统未接受 {shortcut},当前生效的是 {registered}。",
+ "settings.shortcut.invalidCombination": "请按下包含修饰键的组合键。",
+ "settings.shortcut.recording": "按键...",
+ "settings.shortcut.reset": "重置默认快捷键",
+ "settings.shortcut.resetToast": "已重置为 {shortcut}。",
+ "settings.shortcut.saveFailed": "保存快捷键失败。",
+ "settings.shortcut.savedToast": "已保存 {shortcut}。",
+ "settings.shortcut.spotlight.description": "用于弹出或隐藏快速输入窗口。点击当前快捷键后按下新的组合键。",
+ "settings.shortcut.spotlight.label": "快速输入窗口",
+ "settings.shortcut.toastTitle": "快捷键设置",
+ "settings.toolhub.apiUnavailable": "当前运行环境没有暴露 ToolHub 接口。",
+ "settings.toolhub.addEnv": "添加环境变量",
+ "settings.toolhub.args": "参数,每行一个",
+ "settings.toolhub.authentication": "Authentication",
+ "settings.toolhub.authApiKey": "API Key",
+ "settings.toolhub.authBasic": "Basic Auth",
+ "settings.toolhub.authBearer": "Bearer Token",
+ "settings.toolhub.authHeaderName": "Header 名称",
+ "settings.toolhub.authNone": "无",
+ "settings.toolhub.authPassword": "密码",
+ "settings.toolhub.authToken": "Token",
+ "settings.toolhub.authUsername": "用户名",
+ "settings.toolhub.authValue": "认证值",
+ "settings.toolhub.builtin.automations.description": "加载到 ToolHub 后,可通过 ToolHub 管理和运行自动化定时任务。",
+ "settings.toolhub.builtin.automations.label": "自动化定时任务",
+ "settings.toolhub.builtin.browser.description": "加载到 ToolHub 后,可通过 ToolHub 使用内置浏览器自动化能力。",
+ "settings.toolhub.builtin.browser.label": "浏览器自动化",
+ "settings.toolhub.builtin.location.description": "加载到 ToolHub 后,可通过电脑系统定位权限获取当前经纬度和精度。",
+ "settings.toolhub.builtin.location.label": "定位",
+ "settings.toolhub.builtin.userInteraction.description": "加载到 ToolHub 后,Agent 可在缺少必要信息时弹出表单交互并等待用户回答。",
+ "settings.toolhub.builtin.userInteraction.label": "用户交互",
+ "settings.toolhub.builtinServers": "内置 MCP",
+ "settings.toolhub.builtinTag": "内置",
+ "settings.toolhub.builtinUpdatedToast": "已更新内置 MCP {label}。",
+ "settings.toolhub.cacheClearedToast": "ToolHub 缓存已清除。",
+ "settings.toolhub.command": "启动命令",
+ "settings.toolhub.clearCache": "清除缓存",
+ "settings.toolhub.clearCacheConfirm": "清除 ToolHub 缓存?",
+ "settings.toolhub.clearCacheDescription": "清除内存中的 resolve 与 MCP client 状态,并删除本地 MCP 工具列表缓存。",
+ "settings.toolhub.clearCacheFailed": "清除 ToolHub 缓存失败。",
+ "settings.toolhub.connectionDirect": "Direct",
+ "settings.toolhub.connectionProxy": "Via proxy",
+ "settings.toolhub.connectionType": "Connection Type",
+ "settings.toolhub.disableServer": "禁用 MCP Server {id}",
+ "settings.toolhub.disabledToast": "ToolHub 已关闭。",
+ "settings.toolhub.editServer": "编辑 MCP Server",
+ "settings.toolhub.enableDescription": "开启前需要先配置 ToolHub 使用的 LLM Base URL、API Key 和 Model。",
+ "settings.toolhub.enableLabel": "启用 ToolHub",
+ "settings.toolhub.enableServer": "启用 MCP Server {id}",
+ "settings.toolhub.enabledToast": "ToolHub 已开启。",
+ "settings.toolhub.env": "环境变量",
+ "settings.toolhub.envKey": "Key",
+ "settings.toolhub.envValue": "Value",
+ "settings.toolhub.formConfig": "表单配置",
+ "settings.toolhub.install": "安装",
+ "settings.toolhub.installedServers": "MCP Servers",
+ "settings.toolhub.installServer": "安装 MCP Server",
+ "settings.toolhub.importJsonConfig": "JSON 配置",
+ "settings.toolhub.invalidAuthentication": "请填写完整的 Authentication 配置。",
+ "settings.toolhub.invalidCommand": "stdio MCP Server 需要填写启动命令。",
+ "settings.toolhub.invalidEnvKey": "第 {line} 行环境变量名无效。",
+ "settings.toolhub.invalidHttpUrl": "HTTP MCP Server 需要填写 http:// 或 https:// URL。",
+ "settings.toolhub.invalidId": "MCP Server ID 必须以字母或数字开头,且只能包含字母、数字、下划线或短横线。",
+ "settings.toolhub.invalidImportJson": "请粘贴包含 mcpServers 的有效 JSON。",
+ "settings.toolhub.invalidImportServer": "JSON 中未找到有效 MCP Server 配置。",
+ "settings.toolhub.invalidLlmBaseUrl": "LLM Base URL 需要是 http:// 或 https:// URL。",
+ "settings.toolhub.invalidServer": "请填写有效的 MCP Server 配置。",
+ "settings.toolhub.llmApiKey": "API Key",
+ "settings.toolhub.llmApiKeyRequired": "API Key 不能为空。",
+ "settings.toolhub.llmBaseUrl": "Base URL",
+ "settings.toolhub.llmBaseUrlRequired": "Base URL 不能为空。",
+ "settings.toolhub.llmConfigDescription": "用于 ToolHub resolve 的 OpenAI 兼容接口;Base URL、API Key 和 Model 都填写后才能启用 ToolHub。",
+ "settings.toolhub.llmModel": "Model",
+ "settings.toolhub.llmModelRequired": "Model 不能为空。",
+ "settings.toolhub.llmSavedToast": "ToolHub LLM 配置已保存。",
+ "settings.toolhub.llmSettings": "LLM 配置",
+ "settings.toolhub.llmSettingsButton": "设置 LLM 配置",
+ "settings.toolhub.noServers": "还没有安装三方 MCP Server。",
+ "settings.toolhub.removeEnv": "移除环境变量",
+ "settings.toolhub.removeConfirm": "移除 MCP Server “{id}”?",
+ "settings.toolhub.saveFailed": "保存 ToolHub 设置失败。",
+ "settings.toolhub.serverInstalledToast": "已安装 MCP Server {id}。",
+ "settings.toolhub.serverDisabled": "已禁用",
+ "settings.toolhub.serverEnabled": "已启用",
+ "settings.toolhub.serverLabel": "显示名称",
+ "settings.toolhub.serverRemovedToast": "已移除 MCP Server {id}。",
+ "settings.toolhub.serverUpdatedToast": "已更新 MCP Server {id}。",
+ "settings.toolhub.toastTitle": "ToolHub",
+ "settings.toolhub.transport": "传输方式",
+ "settings.toolhub.url": "HTTP URL",
+ "searchDialog.empty": "没有匹配的对话",
+ "searchDialog.placeholder": "搜索对话",
+ "searchDialog.recent": "近期对话",
+ "searchDialog.title": "搜索对话",
+ "thread.assistantRole": "助手",
+ "thread.copy": "复制",
+ "thread.copyFailed": "复制失败。",
+ "thread.copyMarkdown": "复制 Markdown",
+ "thread.copyMarkdownSuccess": "已复制 Markdown。",
+ "thread.copySessionId": "复制会话 ID",
+ "thread.copySessionIdSuccess": "已复制会话 ID。",
+ "thread.branchFailed": "分叉对话失败。",
+ "thread.branchSuccess": "已创建分叉对话。",
+ "thread.branchUnavailable": "当前还没有可分叉的对话。",
+ "thread.delete": "删除会话",
+ "thread.deleteConfirm": "删除会话“{title}”?",
+ "thread.deleteFailed": "删除会话失败。",
+ "thread.menu": "对话菜单",
+ "thread.menuFailed": "打开对话菜单失败。",
+ "thread.openSmallWindow": "小窗口打开",
+ "thread.rename": "重命名对话",
+ "thread.renameFailed": "重命名对话失败。",
+ "thread.renamePrompt": "请输入新的对话名称",
+ "thread.toastTitle": "对话",
+ "thread.userRole": "用户",
+ "settings.theme.dark": "深色",
+ "settings.theme.light": "浅色",
+ "settings.theme.system": "系统",
+ "smallWindow.apiUnavailable": "当前运行环境没有暴露小窗口接口。",
+ "smallWindow.close": "关闭小窗口",
+ "smallWindow.openFailed": "打开小窗口失败。",
+ "smallWindow.pin": "置顶并锁定窗口",
+ "smallWindow.pinFailed": "切换置顶状态失败。",
+ "smallWindow.title": "小窗口聊天",
+ "smallWindow.toastTitle": "小窗口",
+ "smallWindow.unpin": "取消置顶并解锁窗口",
+ "settings.voice.apiKeyMissing": "填写 API Key 后才能使用语音输入。",
+ "settings.voice.apiKey": "API Key",
+ "settings.voice.configDescription": "配置用于语音输入的 OpenAI 兼容音频转录接口。Endpoint 会自动补全 /audio/transcriptions 路径。",
+ "settings.voice.configReady": "语音输入可以使用当前配置发起转录请求。",
+ "settings.voice.configStatus": "配置状态",
+ "settings.voice.configureButton": "设置语音转录配置",
+ "settings.voice.configured": "已配置",
+ "settings.voice.endpoint": "API Endpoint",
+ "settings.voice.language": "语言",
+ "settings.voice.model": "模型",
+ "settings.voice.notConfigured": "未配置",
+ "settings.voice.prompt": "提示词",
+ "settings.voice.settingsTitle": "语音转录配置",
+ "sidebar.automations": "自动化",
+ "sidebar.botOperation": "Bot 配置",
+ "sidebar.collapseLeft": "收起左侧栏",
+ "sidebar.collapseRight": "收起右侧栏",
+ "sidebar.expandLeft": "展开左侧栏",
+ "sidebar.expandRight": "展开右侧栏",
+ "sidebar.mobileOperation": "手机操作",
+ "sidebar.newSession": "新会话",
+ "sidebar.noSessions": "暂无会话",
+ "sidebar.plugins": "插件",
+ "sidebar.projectsSessions": "项目 / 会话",
+ "sidebar.repositories": "Repositories",
+ "sidebar.resizeLeft": "调整左侧栏宽度",
+ "sidebar.resizeRight": "调整右侧栏宽度",
+ "sidebar.search": "搜索",
+ "sidebar.settings": "设置",
+ "terminal.closeSession": "关闭 {title}",
+ "terminal.newTerminal": "新建终端",
+ "terminal.unavailable": "终端在 Electron 壳中可用。",
+ "voice.emptyAudio": "没有录到可转录的音频。",
+ "voice.missingApiKey": "请先填写语音转录 API Key。",
+ "voice.noApi": "当前运行环境没有暴露语音转录接口。",
+ "voice.noMicrophone": "当前环境不支持麦克风录音。",
+ "voice.recordingFailed": "录音失败。",
+ "voice.toastTitle": "语音输入",
+ "voice.transcriptionCompleted": "转录文本已插入输入框。",
+ "voice.transcriptionEmpty": "转录结果为空。",
+ "voice.transcriptionFailed": "语音转录失败。",
+ "voice.start": "开始语音输入"
+} as const;
+
+const en = {
+ "app.defaultTitle": "Codex Agent Console",
+ "browser.addressAria": "Browser address",
+ "browser.back": "Back",
+ "browser.blankPage": "Blank page",
+ "browser.browserUseEnabled": "Enable browser-use",
+ "browser.closeTab": "Close {title}",
+ "browser.coachmarkBody": "Import browser profiles, manage origin approval, and control whether automation tools can read or operate pages.",
+ "browser.coachmarkTitle": "Browser automation now tracks origins",
+ "browser.dismissCoachmark": "Dismiss tip",
+ "browser.forward": "Forward",
+ "browser.hiddenHost": "Hidden WebView host",
+ "browser.hiddenHostReady": "Ready",
+ "browser.hiddenHostStopped": "Stopped",
+ "browser.importProfile": "Import profile",
+ "browser.importedProfileSummary": "{count} bookmarks",
+ "browser.newTab": "New browser tab",
+ "browser.newTabTitle": "New tab",
+ "browser.noOrigin": "No origins yet",
+ "browser.originAllowed": "Automation allowed",
+ "browser.originBlocked": "Automation blocked",
+ "browser.originExternal": "External site",
+ "browser.originFile": "Local file",
+ "browser.originLocal": "Local site",
+ "browser.originOpaque": "Internal page",
+ "browser.originState": "Origin state",
+ "browser.placeholder": "Enter a URL or search",
+ "browser.profileImportEmpty": "No Chromium profiles found",
+ "browser.profileImportTitle": "Profile import",
+ "browser.reload": "Reload",
+ "browser.requireOriginApproval": "Origin approval",
+ "browser.settings": "Browser settings",
+ "browser.stopLoading": "Stop loading",
+ "agent.allow": "Allow once",
+ "agent.allowSession": "Allow session",
+ "agent.apiUnavailable": "The agent API is not available in this runtime.",
+ "agent.approvalResolveFailed": "Failed to send the approval decision.",
+ "agent.approvalTitle": "Permission Approval",
+ "agent.approvalUnknownMethod": "Unknown request",
+ "agent.approvalChoiceAllow": "Yes",
+ "agent.approvalChoiceAllowSession": "Yes, and do not ask again for future commands starting with",
+ "agent.approvalChoiceDeny": "No, tell {agent} how to adjust",
+ "agent.approvalDenyMessageLabel": "Adjustment instructions",
+ "agent.approvalDenyMessagePlaceholder": "Enter what {agent} should do instead",
+ "agent.approvalToolParameters": "Tool parameters",
+ "agent.browserMcp": "Browser MCP",
+ "agent.defaultModel": "Default model",
+ "agent.deny": "Deny",
+ "agent.effort": "Thinking",
+ "agent.effort.high": "High",
+ "agent.effort.low": "Low",
+ "agent.effort.max": "Max",
+ "agent.effort.medium": "Medium",
+ "agent.effort.minimal": "Minimal",
+ "agent.effort.none": "None",
+ "agent.effort.xhigh": "X-high",
+ "agent.questionCustomPlaceholder": "Enter another answer",
+ "agent.questionSelectPlaceholder": "Select an answer",
+ "agent.questionResolveFailed": "Failed to send the answer.",
+ "agent.questionSubmit": "Submit answer",
+ "agent.questionTitle": "User Interaction Required",
+ "agent.questionUnanswered": "Skip",
+ "agent.sheetBack": "Back",
+ "agent.sheetNext": "Next",
+ "agent.sheetSkip": "Skip",
+ "agent.sheetSubmit": "Submit",
+ "agent.model": "Model",
+ "agent.permission.auto": "Approve for me",
+ "agent.permission.full": "Full access",
+ "agent.permission.request": "Request approval",
+ "agent.permissions": "Permissions",
+ "agent.provider": "Agent",
+ "agent.runFailed": "Agent run failed.",
+ "agent.sendFailed": "Failed to send the message.",
+ "agent.runSettings": "Model, Thinking, and Speed",
+ "agent.speed": "Speed",
+ "agent.speed.default": "Default",
+ "agent.speed.fast": "Fast",
+ "agent.subagents": "Subagents",
+ "agent.subagentsCount": "{count} subagents",
+ "agent.subagentsEmpty": "No subagents are available",
+ "agent.thinking": "Thinking",
+ "agent.toastTitle": "Agent",
+ "agent.toolCompleted": "Completed",
+ "agent.toolError": "Error",
+ "agent.toolFailed": "Failed",
+ "agent.toolInput": "Input",
+ "agent.toolOutput": "Output",
+ "agent.toolRunning": "Running",
+ "chat.batch": "32ms batch",
+ "chat.branchMessage": "Fork conversation",
+ "chat.copyMessage": "Copy message",
+ "chat.domIsland": "DOM island",
+ "chat.editMessage": "Edit message",
+ "chat.markdownMode": "Markdown",
+ "chat.messageCount": "{count} messages",
+ "chat.messageActions": "Message actions",
+ "chat.openSmallWindow": "Open small window",
+ "chat.placeholder": "Enter a message",
+ "chat.pauseStream": "Pause stream",
+ "chat.qualityExtraHigh": "Extra High",
+ "chat.ready": "ready",
+ "chat.resumeStream": "Resume stream",
+ "chat.sanitized": "sanitized",
+ "chat.send": "Send message",
+ "chat.stopRecording": "Stop recording",
+ "chat.title": "Markdown Chatbot",
+ "chat.transcribingVoice": "Transcribing voice",
+ "contextWindow.aria": "Context window: {used} used, {limit} window",
+ "contextWindow.percent": "Percent",
+ "contextWindow.title": "Context window",
+ "contextWindow.unknown": "Unknown",
+ "contextWindow.used": "Used",
+ "common.back": "Back",
+ "common.cancel": "Cancel",
+ "common.loading": "Loading",
+ "common.close": "Close",
+ "common.clear": "Clear",
+ "common.edit": "Edit",
+ "common.forward": "Forward",
+ "common.optional": "Optional",
+ "common.remove": "Remove",
+ "common.save": "Save",
+ "project.remove": "Remove Project",
+ "project.removeConfirm": "Remove project \"{name}\" and its session records from the sidebar? Files on disk will not be deleted.",
+ "project.removeFailed": "Failed to remove project.",
+ "project.showInFinder": "Show in Finder",
+ "project.showInFinderFailed": "Failed to show project in Finder.",
+ "project.toastTitle": "Project",
+ "editor.closeUnsavedConfirm": "Close unsaved file '{name}'?",
+ "editor.closeTab": "Close",
+ "editor.empty": "Choose a file from the file tree to start editing",
+ "editor.loading": "Reading file...",
+ "editor.openFromTree": "Open a file from the file tree",
+ "editor.savedNotice": "Saved {path}",
+ "editor.unavailable": "The file editor is available in the desktop shell.",
+ "docs.collaboration": "Agent",
+ "docs.agentMentionAria": "Choose agent",
+ "docs.commandBulletList": "Bullet list",
+ "docs.commandCodeBlock": "Code block",
+ "docs.commandDivider": "Divider",
+ "docs.commandHeading1": "Heading 1",
+ "docs.commandHeading2": "Heading 2",
+ "docs.commandHeading3": "Heading 3",
+ "docs.commandAgentTask": "{agent} task",
+ "docs.commandOrderedList": "Numbered list",
+ "docs.commandParagraph": "Text",
+ "docs.commandQuote": "Quote",
+ "docs.commandTask": "Task",
+ "docs.discovering": "Finding Markdown...",
+ "docs.editorAria": "Docs rich text editor",
+ "docs.editorPlaceholder": "Write the document directly. Use @agent to summon an agent, or / to insert commands.",
+ "docs.emptyDescription": "Open an existing Markdown document, or create a Docs document.",
+ "docs.emptyTitle": "No document open",
+ "docs.fileApiUnavailable": "The Docs editor is available in the desktop shell.",
+ "docs.loading": "Loading Docs editor...",
+ "docs.noDocument": "No Markdown document is open.",
+ "docs.noDocuments": "No Markdown documents",
+ "docs.noMatchingAgents": "No matching agents",
+ "docs.noMatchingSlashCommands": "No matching commands",
+ "docs.openThread": "Open thread",
+ "docs.openUnsavedConfirm": "This document has unsaved changes. Switch documents anyway?",
+ "docs.outputBlocks": "{count} agent output blocks",
+ "docs.projectUnavailable": "No project context is available.",
+ "docs.refresh": "Refresh documents",
+ "docs.runApproval": "Waiting for approval",
+ "docs.runCompleted": "Completed and written back",
+ "docs.runFailed": "Run failed",
+ "docs.runIdle": "Idle",
+ "docs.runQuestion": "Waiting for user input",
+ "docs.runRunning": "Generating output",
+ "docs.runStarting": "Creating thread",
+ "docs.selectDocument": "Select document",
+ "docs.slashCommandAria": "Choose command",
+ "docs.title": "Docs",
+ "docs.toastTitle": "Docs",
+ "docs.unknownReferences": "No matching agent provider: {agents}",
+ "fileTree.aria": "File tree",
+ "fileTree.emptyDirectory": "Empty directory",
+ "fileTree.directoryErrorTitle": "Cannot read directory",
+ "fileTree.loadingDirectory": "Loading...",
+ "fileTree.loadingEditor": "Loading file editor...",
+ "fileTree.loadingWorkspace": "Reading workspace...",
+ "fileTree.reload": "Reload",
+ "fileTree.unavailableDescription": "The file tree is available in the desktop shell.",
+ "fileTree.unavailableTitle": "Cannot connect to the file system",
+ "fileTree.workspaceErrorTitle": "Cannot read workspace",
+ "git.allBranches": "All branches",
+ "git.allDates": "All dates",
+ "git.allPaths": "All paths",
+ "git.allUsers": "All users",
+ "git.amend": "Amend",
+ "git.apply": "Apply",
+ "git.back": "Back",
+ "git.branch": "Branch",
+ "git.cancel": "Cancel",
+ "git.checkout": "Checkout",
+ "git.checkoutBranchConfirm": "Checkout branch '{branch}'?",
+ "git.checkoutRevision": "Checkout Revision",
+ "git.checkoutRevisionConfirm": "Checkout revision {hash} in detached HEAD?",
+ "git.checkedOutNotice": "Checked out {hash}.",
+ "git.changes": "Changes",
+ "git.cherryPick": "Cherry-Pick",
+ "git.cherryPickConfirm": "Cherry-pick {hash}?",
+ "git.cherryPickNotice": "Cherry-picked {hash}.",
+ "git.close": "Close",
+ "git.collapse": "Collapse",
+ "git.commit": "Commit",
+ "git.commitAndPush": "Commit and Push...",
+ "git.commitDiffEmpty": "No commit diff",
+ "git.commitDiffPreviewTitle": "Diff for commit {hash}",
+ "git.commitMessage": "Commit Message",
+ "git.commitOptions": "Commit options",
+ "git.compareWithLocal": "Compare with Local",
+ "git.compareWithLocalTitle": "Compare {hash} with Local",
+ "git.copyRevision": "Copy Revision Number",
+ "git.copyRevisionNotice": "Copied revision {hash}.",
+ "git.createAutosquashConfirm": "Create a {mode} commit for {hash} from {count} selected file(s)?",
+ "git.createBranch": "Create",
+ "git.createBranchDescription": "Create a branch at {hash}.",
+ "git.createFixupNotice": "Created fixup commit for {hash}.",
+ "git.createPatch": "Create Patch...",
+ "git.createPatchNotice": "Created patch: {path}",
+ "git.createSquashNotice": "Created squash commit for {hash}.",
+ "git.createTagDescription": "Create a tag at {hash}.",
+ "git.createdBranchNotice": "Created branch '{name}'.",
+ "git.createdTagNotice": "Created tag '{name}'.",
+ "git.date": "Date",
+ "git.diff": "Diff",
+ "git.diffPreviewTitle": "Diff for {path}",
+ "git.discardConfirm": "Discard changes in {count} selected file(s)?",
+ "git.drop": "Drop",
+ "git.dropCommit": "Drop Commit",
+ "git.dropCommitConfirm": "Drop commit {hash} from the current branch history?",
+ "git.droppedNotice": "Dropped {hash}.",
+ "git.editCommitMessage": "Edit Commit Message...",
+ "git.editCommitMessageDescription": "Edit the message for {hash}.",
+ "git.errorTitle": "Git Error",
+ "git.expand": "Expand",
+ "git.fetch": "Fetch",
+ "git.fileCount": "{count} files",
+ "git.filterBranch": "Branch",
+ "git.filterDate": "Date",
+ "git.filterPaths": "Paths",
+ "git.filterUser": "User",
+ "git.fixup": "Fixup...",
+ "git.goToChildCommit": "Go to Child Commit",
+ "git.goToParentCommit": "Go to Parent Commit",
+ "git.hard": "Hard",
+ "git.hardDescription": "Move HEAD, index, and working tree to the selected revision.",
+ "git.headCurrentBranch": "HEAD (Current Branch)",
+ "git.inline": "Inline",
+ "git.inBranches": "In {count} branches:",
+ "git.interactiveRebase": "Interactively Rebase from Here...",
+ "git.interactiveRebaseDescription": "Supported commands: pick, squash, fixup, drop.",
+ "git.interactiveRebaseTitle": "Interactively Rebase from Here",
+ "git.keep": "Keep",
+ "git.keepDescription": "Move HEAD and keep local changes when Git can do so safely.",
+ "git.last30Days": "Last 30 days",
+ "git.last7Days": "Last 7 days",
+ "git.local": "Local",
+ "git.log": "Log",
+ "git.merge": "Merge",
+ "git.mergeConfirm": "Merge '{branch}' into '{current}'?",
+ "git.mixed": "Mixed",
+ "git.mixedDescription": "Move HEAD and reset the index while keeping working tree files.",
+ "git.more": "More",
+ "git.newBranch": "New Branch",
+ "git.newBranchMenu": "New Branch...",
+ "git.newTag": "New Tag",
+ "git.newTagMenu": "New Tag...",
+ "git.noChangedFiles": "No changed files",
+ "git.noCommitSelected": "Select commit to view changes",
+ "git.noLocalDiff": "No local diff",
+ "git.noShelves": "No shelves",
+ "git.onDate": "{date}",
+ "git.openedRevisionNotice": "Opened revision snapshot: {path}",
+ "git.openedUrlNotice": "Opened {url}",
+ "git.branchName": "Branch name",
+ "git.preview": "Preview",
+ "git.pull": "Pull",
+ "git.pullRebase": "Pull Rebase",
+ "git.push": "Push",
+ "git.pushUpToCommit": "Push All up to Here...",
+ "git.pushUpToConfirm": "Push current branch up to {hash}?",
+ "git.pushedUpToNotice": "Pushed up to {hash}.",
+ "git.rebase": "Rebase",
+ "git.rebaseOnto": "Rebase Onto",
+ "git.rebaseOntoConfirm": "Rebase '{current}' onto '{branch}'?",
+ "git.refresh": "Refresh",
+ "git.remote": "Remote",
+ "git.repository": "Repository",
+ "git.reset": "Reset",
+ "git.resetCurrentBranch": "Reset Current Branch",
+ "git.resetCurrentBranchDescription": "Reset '{branch}' to {hash}.",
+ "git.resetCurrentBranchNotice": "Reset current branch to {hash}.",
+ "git.resetCurrentBranchToHere": "Reset Current Branch to Here...",
+ "git.resizeHorizontal": "Resize Git top and bottom panes",
+ "git.resizeVertical": "Resize Git left and right panes",
+ "git.revertCommit": "Revert Commit",
+ "git.revertCommitConfirm": "Revert commit {hash}?",
+ "git.revertedNotice": "Reverted {hash}.",
+ "git.rollback": "Rollback",
+ "git.save": "Save",
+ "git.search": "Search",
+ "git.searchChanges": "Search changes",
+ "git.selectCommitDiff": "Diff",
+ "git.shelf": "Shelf",
+ "git.shelfName": "Shelf name",
+ "git.shelveSelectedChanges": "Shelve Selected Changes",
+ "git.showAll": "Show all",
+ "git.showLess": "Show less",
+ "git.showRepositoryAtRevision": "Show Repository at Revision",
+ "git.soft": "Soft",
+ "git.softDescription": "Move HEAD and keep index and working tree unchanged.",
+ "git.split": "Split",
+ "git.squashInto": "Squash Into...",
+ "git.stage": "Stage",
+ "git.startRebase": "Start Rebase",
+ "git.startedInteractiveRebaseNotice": "Started interactive rebase from {hash}.",
+ "git.tagName": "Tag name",
+ "git.textOrHash": "Text or hash",
+ "git.today": "Today",
+ "git.undoCommit": "Undo Commit...",
+ "git.undoCommitConfirm": "Undo commit {hash} and keep its changes staged?",
+ "git.undidNotice": "Undid {hash}.",
+ "git.unstage": "Unstage",
+ "git.unavailable": "Git is available in the Electron shell.",
+ "git.unversionedFiles": "Unversioned Files",
+ "git.updatedCommitMessageNotice": "Updated commit message for {hash}.",
+ "git.viewInBrowser": "View in browser",
+ "git.yesterdayAt": "Yesterday {time}",
+ "language.en": "English",
+ "language.label": "Language",
+ "language.zh": "中文",
+ "mobile.tab.chat": "Chat",
+ "mobile.tab.sessions": "Sessions",
+ "mobile.tab.settings": "Settings",
+ "mobile.tab.tools": "Tools",
+ "newSession.addMenu": "Add menu",
+ "newSession.addNewSubagent": "Add new subagent",
+ "newSession.attach": "Add attachment",
+ "newSession.attachSubagents": "Add subagent",
+ "newSession.attachFilesFailed": "Failed to attach files.",
+ "newSession.branch": "Branch",
+ "newSession.branchName": "main",
+ "newSession.branchSwitchFailed": "Failed to switch branch.",
+ "newSession.connectionMode": "Connection mode",
+ "newSession.connectEmail.description": "Summarize stakeholder requests from email",
+ "newSession.connectEmail.title": "Connect email",
+ "newSession.connectFiles.description": "Review findings, research, and plans",
+ "newSession.connectFiles.title": "Connect files",
+ "newSession.connectMessages.description": "Pull background from recent team discussions",
+ "newSession.connectMessages.title": "Connect messages",
+ "newSession.localMode": "Local mode",
+ "newSession.modeUnavailable": "Not configured",
+ "newSession.model": "5.5",
+ "newSession.modelQuality": "Extra high",
+ "newSession.newBlankProject": "New blank project",
+ "newSession.noGitBranch": "No Git branch",
+ "newSession.noProjects": "No projects",
+ "newSession.placeholder": "Type anything",
+ "newSession.project": "Project",
+ "newSession.projectAddFailed": "Failed to add project.",
+ "newSession.projectCreateFailed": "Failed to create project.",
+ "newSession.projectMenu": "Project menu",
+ "newSession.projectUnavailable": "There is no real project available for a new session.",
+ "newSession.question": "What should we build in {workspace}?",
+ "newSession.questionPrefix": "What should we build in",
+ "newSession.questionSuffix": "?",
+ "newSession.localBranch": "Local branch",
+ "newSession.remoteBranch": "Remote branch",
+ "newSession.remoteMode": "Remote mode",
+ "newSession.removeAttachment": "Remove attachment {name}",
+ "newSession.sshMode": "SSH mode",
+ "newSession.title": "New session",
+ "newSession.useExistingFolder": "Use existing folder",
+ "newSession.voiceInput": "Voice input settings",
+ "rightSidebar.addTabAria": "Add right sidebar tab",
+ "rightSidebar.addTabMenuAria": "Choose a right sidebar tab to add",
+ "rightSidebar.browser.label": "Browser",
+ "rightSidebar.browser.title": "Browser",
+ "rightSidebar.docs.label": "Docs",
+ "rightSidebar.docs.title": "Docs",
+ "rightSidebar.editor.label": "Edit",
+ "rightSidebar.editor.title": "File editor",
+ "rightSidebar.files.label": "Files",
+ "rightSidebar.files.title": "File tree",
+ "rightSidebar.git.label": "Git",
+ "rightSidebar.git.title": "Git",
+ "rightSidebar.noAvailableTabs": "No more tabs to add",
+ "rightSidebar.selectAria": "Select right sidebar plugin",
+ "rightSidebar.terminal.label": "Terminal",
+ "rightSidebar.terminal.title": "Terminal",
+ "settings.appearance.compactDensity.description": "Reduce vertical spacing in lists and settings pages.",
+ "settings.appearance.compactDensity.label": "Compact density",
+ "settings.appearance.homeTheme.default": "Default config",
+ "settings.appearance.homeTheme.description": "Use colors, CSS variables, and sections to override app, chat Markdown, and home styles.",
+ "settings.appearance.homeTheme.invalid": "Invalid JSON. The default theme is active.",
+ "settings.appearance.homeTheme.label": "Theme customizations",
+ "settings.appearance.homeTheme.reset": "Reset",
+ "settings.appearance.language.description": "Switch the interface display language.",
+ "settings.appearance.reduceMotion.description": "Reduce motion intensity for panel switches and streaming content.",
+ "settings.appearance.reduceMotion.label": "Reduce motion",
+ "settings.appearance.theme.description": "Follow the system or use a fixed theme.",
+ "settings.appearance.theme.label": "Theme",
+ "settings.backToApp": "Back to app",
+ "settings.general.autoSaveDrafts.description": "Keep composer content when switching pages and sessions.",
+ "settings.general.autoSaveDrafts.label": "Auto-save drafts",
+ "settings.general.restoreLastThread.description": "Return to the last selected project and session on startup.",
+ "settings.general.restoreLastThread.label": "Restore last session on startup",
+ "settings.agents.add": "Add Agent",
+ "settings.agents.apiUnavailable": "The current runtime does not expose the settings API.",
+ "settings.agents.args": "Arguments",
+ "settings.agents.builtIn": "Built in",
+ "settings.agents.builtInDescription": "Managed by the app's built-in adapter.",
+ "settings.agents.command": "Command",
+ "settings.agents.commandRequired": "Command is required.",
+ "settings.agents.configure": "Configure {agent}",
+ "settings.agents.custom": "Custom",
+ "settings.agents.delete": "Delete Agent",
+ "settings.agents.deleteConfirm": "Delete {agent}?",
+ "settings.agents.description": "Description",
+ "settings.agents.duplicateId": "{id} already exists.",
+ "settings.agents.disabled": "Disabled",
+ "settings.agents.disabledToast": "Agent disabled.",
+ "settings.agents.enabled": "Enabled",
+ "settings.agents.enabledAria": "Toggle {agent} enabled state",
+ "settings.agents.enabledToast": "Agent enabled.",
+ "settings.agents.id": "ID",
+ "settings.agents.idRequired": "Agent ID is required.",
+ "settings.agents.invalidId": "{id} is not a valid Agent ID.",
+ "settings.agents.invalidTimeout": "Timeout must be a positive number of milliseconds.",
+ "settings.agents.installCommand": "Install command",
+ "settings.agents.label": "Name",
+ "settings.agents.logo": "Logo",
+ "settings.agents.logoDescription": "PNG, JPG, WebP, GIF, or SVG. Max 1 MB.",
+ "settings.agents.logoInvalid": "Choose a valid image file.",
+ "settings.agents.logoRemove": "Remove",
+ "settings.agents.logoTooLarge": "Logo must be smaller than 1 MB.",
+ "settings.agents.logoUpload": "Upload Logo",
+ "settings.agents.models": "Models",
+ "settings.agents.modelsCount": "{count} models",
+ "settings.agents.newAgent": "New Agent",
+ "settings.agents.save": "Save Agent",
+ "settings.agents.saveBeforeEnv": "Save the agent before configuring environment variables.",
+ "settings.agents.saveFailed": "Failed to save agent settings.",
+ "settings.agents.savedToast": "Agent settings saved.",
+ "settings.agents.remoteCommand": "Remote command",
+ "settings.agents.sshTarget": "SSH target",
+ "settings.agents.sshUrlRequired": "SSH mode requires an ssh://, host, or user@host target.",
+ "settings.agents.timeout": "Timeout ms",
+ "settings.agents.toastTitle": "Agents",
+ "settings.agents.transport": "Transport",
+ "settings.agents.transportSsh": "SSH mode",
+ "settings.agents.transportStdio": "Local mode",
+ "settings.agents.transportWebsocket": "Remote mode",
+ "settings.agents.unsavedInline": "Saved changes refresh the agent list.",
+ "settings.agents.url": "URL",
+ "settings.agents.urlRequired": "The selected transport requires a URL.",
+ "settings.agents.websocketUrlRequired": "Remote mode requires a ws:// or wss:// URL.",
+ "settings.subagents.add": "Add Subagent",
+ "settings.subagents.addTool": "Add Tool MCP",
+ "settings.subagents.apiUnavailable": "The current runtime does not expose the settings API.",
+ "settings.subagents.configure": "Configure {agent}",
+ "settings.subagents.defaultDescription": "Attach this subagent to a task so the main agent can call it when needed.",
+ "settings.subagents.delete": "Delete Subagent",
+ "settings.subagents.deleteConfirm": "Delete {agent}?",
+ "settings.subagents.description": "Description",
+ "settings.subagents.descriptionPlaceholder": "Describe the tasks and boundaries this subagent is suited for.",
+ "settings.subagents.descriptionRequired": "Subagent description is required.",
+ "settings.subagents.duplicateId": "{id} already exists.",
+ "settings.subagents.empty": "No subagents yet. Add a specialized agent backed by Codex or Claude Code.",
+ "settings.subagents.editTool": "Edit Tool MCP",
+ "settings.subagents.id": "ID",
+ "settings.subagents.idRequired": "Subagent ID is required.",
+ "settings.subagents.invalidId": "{id} is not a valid Subagent ID.",
+ "settings.subagents.label": "Name",
+ "settings.subagents.labelRequired": "Subagent name is required.",
+ "settings.subagents.model": "Model",
+ "settings.subagents.newSubagent": "New Subagent",
+ "settings.subagents.noTools": "No tool MCPs yet.",
+ "settings.subagents.provider": "Base Agent",
+ "settings.subagents.providerRequired": "Choose a base agent.",
+ "settings.subagents.save": "Save Subagent",
+ "settings.subagents.saveFailed": "Failed to save subagent settings.",
+ "settings.subagents.savedToast": "Subagent settings saved.",
+ "settings.subagents.systemPrompt": "System prompt",
+ "settings.subagents.systemPromptPlaceholder": "Describe this subagent's role, boundaries, and output style.",
+ "settings.subagents.systemPromptRequired": "Subagent system prompt is required.",
+ "settings.subagents.title": "Subagents",
+ "settings.subagents.toastTitle": "Subagents",
+ "settings.subagents.tools": "Tool MCPs",
+ "settings.subagents.toolsDialogDescription": "Available only to this subagent.",
+ "settings.subagents.toolsEmpty": "The tools JSON does not contain any valid MCP server.",
+ "settings.subagents.toolsInvalid": "Tools JSON is invalid.",
+ "settings.subagents.toolsRequired": "Tools MCP JSON is required.",
+ "settings.subagents.unsavedInline": "Saved subagents can be attached when sending a task.",
+ "settings.agentEnvironment.add": "Add variable",
+ "settings.agentEnvironment.apiUnavailable": "The current runtime does not expose the settings API.",
+ "settings.agentEnvironment.duplicateName": "{name} is duplicated.",
+ "settings.agentEnvironment.importButton": "Import",
+ "settings.agentEnvironment.importDescription": "Choose a built-in source template, or enter a third-party template link. Links take priority when provided.",
+ "settings.agentEnvironment.importFromTemplate": "Import environment variables from template",
+ "settings.agentEnvironment.importTemplate": "Import variables",
+ "settings.agentEnvironment.importTitle": "Import Environment Variables",
+ "settings.agentEnvironment.invalidName": "{name} is not a valid environment variable name.",
+ "settings.agentEnvironment.name": "Name",
+ "settings.agentEnvironment.nameRequired": "Variable name is required.",
+ "settings.agentEnvironment.provider.description": "Choose the agent these environment variables apply to.",
+ "settings.agentEnvironment.provider.label": "Agent",
+ "settings.agentEnvironment.remove": "Remove environment variable {name}",
+ "settings.agentEnvironment.save": "Save",
+ "settings.agentEnvironment.saveFailed": "Failed to save environment variables.",
+ "settings.agentEnvironment.savedInline": "Saved.",
+ "settings.agentEnvironment.savedToast": "Saved environment variables for {agent}.",
+ "settings.agentEnvironment.template": "Built-in Source Template",
+ "settings.agentEnvironment.templateEmpty": "Template content cannot be empty.",
+ "settings.agentEnvironment.templateFetchFailed": "Failed to load the template.",
+ "settings.agentEnvironment.templateInvalid": "No valid environment variable configuration was found in the template.",
+ "settings.agentEnvironment.templateInvalidJson": "Paste a valid environment-variable JSON or .env template.",
+ "settings.agentEnvironment.templateInvalidLine": "Line {line} is not valid KEY=value syntax.",
+ "settings.agentEnvironment.templateLink": "Third-party template link",
+ "settings.agentEnvironment.templateLinkFetchFailed": "Failed to load the third-party template link.",
+ "settings.agentEnvironment.templateMissing": "Choose a built-in source template, or enter a third-party template link.",
+ "settings.agentEnvironment.templateNoResults": "No matching templates.",
+ "settings.agentEnvironment.templateSearchPlaceholder": "Search templates",
+ "settings.agentEnvironment.templateSourceEmpty": "No built-in source templates are available.",
+ "settings.agentEnvironment.templateSourceFetchFailed": "Failed to load the built-in template source.",
+ "settings.agentEnvironment.templateSourceInvalid": "The built-in template source has an invalid format.",
+ "settings.agentEnvironment.templateSourceLoading": "Loading built-in template source...",
+ "settings.agentEnvironment.templateText": "Template content",
+ "settings.agentEnvironment.toastTitle": "Agent environment",
+ "settings.agentEnvironment.unnamed": "unnamed variable",
+ "settings.agentEnvironment.unsavedInline": "Saved variables apply to new agent messages.",
+ "settings.agentEnvironment.value": "Value",
+ "settings.agentEnvironment.valuePlaceholder": "Variable value",
+ "settings.agentEnvironment.variables.label": "Environment variables",
+ "settings.group.appearance": "Interface",
+ "settings.group.agentEnvironment": "Agent Environment",
+ "settings.group.commands": "Command Approvals",
+ "settings.group.integrations": "Plugins and Connections",
+ "settings.group.shortcuts": "Shortcuts",
+ "settings.group.startup": "Startup and Sessions",
+ "settings.group.voiceApi": "Voice Transcription API",
+ "settings.botGateway.addChannel": "Add Bot",
+ "settings.botGateway.actionFailed": "Bot action failed.",
+ "settings.botGateway.apiUnavailable": "The current runtime does not expose the Bot API.",
+ "settings.botGateway.configureIntegration": "Use this configuration in the form",
+ "settings.botGateway.credentialsRequiredForOverwrite": "This integration already has sensitive credentials; enter credentials again before replacing it.",
+ "settings.botGateway.description": "Use the npm-installed Bot Gateway stdio CLI to receive IM messages and send agent replies back to the matching conversation.",
+ "settings.botGateway.enable": "Enable Bot",
+ "settings.botGateway.enableDescription": "Enable this to add Bots and view or manage added Bots.",
+ "settings.botGateway.integrationActionFailed": "Integration action failed.",
+ "settings.botGateway.integrationSaveFailed": "Failed to save bot integration.",
+ "settings.botGateway.integrationSaved": "Bot integration saved.",
+ "settings.botGateway.integrationStarted": "Integration started.",
+ "settings.botGateway.integrationStopped": "Integration stopped.",
+ "settings.botGateway.hideToken": "Hide token",
+ "settings.botGateway.noIntegrations": "No Bots added.",
+ "settings.botGateway.platform": "Platform",
+ "settings.botGateway.qrStatus.alreadyBound": "Already bound",
+ "settings.botGateway.qrStatus.confirmed": "Connected",
+ "settings.botGateway.qrStatus.expired": "Expired",
+ "settings.botGateway.qrStatus.failed": "Failed",
+ "settings.botGateway.qrStatus.idle": "Waiting",
+ "settings.botGateway.qrStatus.needsVerification": "Needs verification",
+ "settings.botGateway.qrStatus.pending": "Waiting for scan",
+ "settings.botGateway.qrStatus.scanned": "Scanned",
+ "settings.botGateway.qrStatus.starting": "Generating QR code",
+ "settings.botGateway.refreshFailed": "Failed to refresh Bot status.",
+ "settings.botGateway.saveIntegration": "Save Configuration",
+ "settings.botGateway.showToken": "Show token",
+ "settings.botGateway.startIntegration": "Start integration",
+ "settings.botGateway.started": "Bot enabled.",
+ "settings.botGateway.stopIntegration": "Stop integration",
+ "settings.botGateway.stopped": "Bot disabled.",
+ "settings.botGateway.title": "Bot",
+ "settings.botGateway.weixinQrConfirmed": "Weixin iLink connected.",
+ "settings.botGateway.weixinQrEmpty": "QR code is unavailable. Regenerate it.",
+ "settings.botGateway.weixinQrRefresh": "Regenerate",
+ "settings.botGateway.weixinQrStarting": "Generating Weixin iLink QR code...",
+ "settings.botGateway.weixinQrTitle": "Scan with Weixin to connect iLink",
+ "settings.integration.addPlugin": "Add plugin",
+ "settings.integration.addMarketplacePlugin": "Add",
+ "settings.integration.availableStatus": "Available",
+ "settings.integration.descriptionLabel": "Description",
+ "settings.integration.installPlugin": "Install plugin",
+ "settings.integration.installUrlLabel": "Install URL",
+ "settings.integration.installUnavailable": "Local package unavailable",
+ "settings.integration.installedPlugins": "Plugins",
+ "settings.integration.installedStatus": "Installed",
+ "settings.integration.marketplaceOnlyStatus": "Marketplace",
+ "settings.integration.marketplaceTitle": "Plugins",
+ "settings.integration.noDescription": "No description provided.",
+ "settings.integration.noInstalledPlugins": "No plugins are installed.",
+ "settings.integration.noMarketplaceEntries": "No marketplace entries are available.",
+ "settings.integration.noMarketplaceSearchResults": "No plugins match your search.",
+ "settings.integration.searchPlugins": "Search plugins",
+ "settings.integration.sourceFilter": "Plugin source",
+ "settings.integration.sourceLabel": "Source",
+ "settings.integration.source.all": "all",
+ "settings.integration.source.bundled": "Bundled",
+ "settings.integration.source.claude": "Claude App",
+ "settings.integration.source.codex": "Codex App",
+ "settings.integration.source.development": "Development",
+ "settings.integration.source.marketplace": "Agent App",
+ "settings.integration.source.user": "Local",
+ "settings.integration.pluginsDescription": "Codex App, Claude App, and this app's plugins can be installed as plugin sources. After enabling and granting permissions, Claude Code, Codex, and other agents can use their MCP tools.",
+ "settings.integration.updatePlugin": "Update",
+ "settings.integration.versionLabel": "Version",
+ "settings.menuAria": "Settings menu",
+ "slash.category.plugins": "Plugins",
+ "slash.category.prompts": "Prompts",
+ "slash.explain.description": "Ask the agent to explain the current problem, code, or context.",
+ "slash.explain.prompt": "Explain the key points in this context, the relevant risks, and what should happen next.",
+ "slash.explain.title": "Explain Context",
+ "slash.fix.description": "Ask the agent to find and fix the current issue.",
+ "slash.fix.prompt": "Find the root cause of the current issue, implement the fix directly, and explain how it was verified.",
+ "slash.fix.title": "Fix Issue",
+ "slash.menuAria": "Slash commands",
+ "slash.review.description": "Review risks, regressions, and missing tests.",
+ "slash.review.prompt": "Review the current changes like a code review. Prioritize bugs, regression risks, and missing tests, with file and line references.",
+ "slash.review.title": "Code Review",
+ "slash.tests.description": "Ask the agent to add or run relevant tests.",
+ "slash.tests.prompt": "Add or run the most relevant tests for the current changes, prioritize high-risk paths, and summarize the results.",
+ "slash.tests.title": "Test Changes",
+ "slash.unavailable.description": "This plugin command is declared but does not have an executable handler yet.",
+ "settings.permissions.approvals.description": "Require user confirmation before running shell commands. Useful for sensitive workspaces.",
+ "settings.permissions.approvals.label": "Command approvals",
+ "settings.permissions.dangerous.description": "Always confirm destructive actions such as deleting files or resetting branches.",
+ "settings.permissions.dangerous.label": "Dangerous action confirmation",
+ "settings.permissions.network.description": "Allow the agent to request external network access.",
+ "settings.permissions.network.label": "Network access",
+ "settings.returnTitle": "Back to {label}",
+ "settings.section.agents.label": "Agents",
+ "settings.section.appearance.label": "Appearance",
+ "settings.section.general.label": "General",
+ "settings.section.integrations.label": "Integrations",
+ "settings.section.permissions.label": "Permissions and Approvals",
+ "settings.section.toolhub.label": "ToolHub",
+ "settings.shortcut.apiUnavailable": "The current runtime does not expose the settings API.",
+ "settings.shortcut.fallbackToast": "The system did not accept {shortcut}; {registered} is active.",
+ "settings.shortcut.invalidCombination": "Press a shortcut with at least one modifier key.",
+ "settings.shortcut.recording": "Press keys...",
+ "settings.shortcut.reset": "Reset default shortcut",
+ "settings.shortcut.resetToast": "Reset to {shortcut}.",
+ "settings.shortcut.saveFailed": "Failed to save shortcut.",
+ "settings.shortcut.savedToast": "Saved {shortcut}.",
+ "settings.shortcut.spotlight.description": "Shows or hides the quick input window. Click the current shortcut, then press a new key combination.",
+ "settings.shortcut.spotlight.label": "Quick input window",
+ "settings.shortcut.toastTitle": "Shortcut settings",
+ "settings.toolhub.apiUnavailable": "The current runtime does not expose the ToolHub API.",
+ "settings.toolhub.addEnv": "Add environment variable",
+ "settings.toolhub.args": "Arguments, one per line",
+ "settings.toolhub.authentication": "Authentication",
+ "settings.toolhub.authApiKey": "API Key",
+ "settings.toolhub.authBasic": "Basic Auth",
+ "settings.toolhub.authBearer": "Bearer Token",
+ "settings.toolhub.authHeaderName": "Header name",
+ "settings.toolhub.authNone": "None",
+ "settings.toolhub.authPassword": "Password",
+ "settings.toolhub.authToken": "Token",
+ "settings.toolhub.authUsername": "Username",
+ "settings.toolhub.authValue": "Auth value",
+ "settings.toolhub.builtin.automations.description": "Load into ToolHub to manage and run scheduled automations through ToolHub.",
+ "settings.toolhub.builtin.automations.label": "Scheduled automations",
+ "settings.toolhub.builtin.browser.description": "Load into ToolHub to use the built-in browser automation tools through ToolHub.",
+ "settings.toolhub.builtin.browser.label": "Browser automation",
+ "settings.toolhub.builtin.location.description": "Load into ToolHub to get the current latitude, longitude, and accuracy through the computer's system location permission.",
+ "settings.toolhub.builtin.location.label": "Location",
+ "settings.toolhub.builtin.userInteraction.description": "Load into ToolHub so agents can show form-based interactions and wait for the user's answer when required details are missing.",
+ "settings.toolhub.builtin.userInteraction.label": "User interaction",
+ "settings.toolhub.builtinServers": "Built-in MCP",
+ "settings.toolhub.builtinTag": "Built-in",
+ "settings.toolhub.builtinUpdatedToast": "Updated built-in MCP {label}.",
+ "settings.toolhub.cacheClearedToast": "ToolHub cache cleared.",
+ "settings.toolhub.command": "Launch command",
+ "settings.toolhub.clearCache": "Clear cache",
+ "settings.toolhub.clearCacheConfirm": "Clear the ToolHub cache?",
+ "settings.toolhub.clearCacheDescription": "Clear in-memory resolve and MCP client state, and delete the local MCP tool-list cache.",
+ "settings.toolhub.clearCacheFailed": "Failed to clear ToolHub cache.",
+ "settings.toolhub.connectionDirect": "Direct",
+ "settings.toolhub.connectionProxy": "Via proxy",
+ "settings.toolhub.connectionType": "Connection Type",
+ "settings.toolhub.disabledToast": "ToolHub is disabled.",
+ "settings.toolhub.disableServer": "Disable MCP Server {id}",
+ "settings.toolhub.editServer": "Edit MCP Server",
+ "settings.toolhub.enableDescription": "Configure the LLM Base URL, API Key, and Model before enabling ToolHub.",
+ "settings.toolhub.enableLabel": "Enable ToolHub",
+ "settings.toolhub.enableServer": "Enable MCP Server {id}",
+ "settings.toolhub.enabledToast": "ToolHub is enabled.",
+ "settings.toolhub.env": "Environment variables",
+ "settings.toolhub.envKey": "Key",
+ "settings.toolhub.envValue": "Value",
+ "settings.toolhub.formConfig": "Form config",
+ "settings.toolhub.install": "Install",
+ "settings.toolhub.installedServers": "MCP Servers",
+ "settings.toolhub.installServer": "Install MCP Server",
+ "settings.toolhub.importJsonConfig": "JSON config",
+ "settings.toolhub.invalidAuthentication": "Complete the Authentication configuration.",
+ "settings.toolhub.invalidCommand": "stdio MCP Server requires a launch command.",
+ "settings.toolhub.invalidEnvKey": "Environment variable name on line {line} is invalid.",
+ "settings.toolhub.invalidHttpUrl": "HTTP MCP Server requires an http:// or https:// URL.",
+ "settings.toolhub.invalidId": "MCP Server ID must start with a letter or number and contain only letters, numbers, underscores, or dashes.",
+ "settings.toolhub.invalidImportJson": "Paste valid JSON that contains mcpServers.",
+ "settings.toolhub.invalidImportServer": "No valid MCP Server configuration was found in the JSON.",
+ "settings.toolhub.invalidLlmBaseUrl": "LLM Base URL must be an http:// or https:// URL.",
+ "settings.toolhub.invalidServer": "Enter a valid MCP Server configuration.",
+ "settings.toolhub.llmApiKey": "API Key",
+ "settings.toolhub.llmApiKeyRequired": "API Key is required.",
+ "settings.toolhub.llmBaseUrl": "Base URL",
+ "settings.toolhub.llmBaseUrlRequired": "Base URL is required.",
+ "settings.toolhub.llmConfigDescription": "OpenAI-compatible endpoint used by ToolHub resolve. Base URL, API Key, and Model are all required before ToolHub can be enabled.",
+ "settings.toolhub.llmModel": "Model",
+ "settings.toolhub.llmModelRequired": "Model is required.",
+ "settings.toolhub.llmSavedToast": "ToolHub LLM settings saved.",
+ "settings.toolhub.llmSettings": "LLM Settings",
+ "settings.toolhub.llmSettingsButton": "Configure LLM settings",
+ "settings.toolhub.noServers": "No third-party MCP Server is installed yet.",
+ "settings.toolhub.removeEnv": "Remove environment variable",
+ "settings.toolhub.removeConfirm": "Remove MCP Server \"{id}\"?",
+ "settings.toolhub.saveFailed": "Failed to save ToolHub settings.",
+ "settings.toolhub.serverInstalledToast": "Installed MCP Server {id}.",
+ "settings.toolhub.serverDisabled": "Disabled",
+ "settings.toolhub.serverEnabled": "Enabled",
+ "settings.toolhub.serverLabel": "Display name",
+ "settings.toolhub.serverRemovedToast": "Removed MCP Server {id}.",
+ "settings.toolhub.serverUpdatedToast": "Updated MCP Server {id}.",
+ "settings.toolhub.toastTitle": "ToolHub",
+ "settings.toolhub.transport": "Transport",
+ "settings.toolhub.url": "HTTP URL",
+ "searchDialog.empty": "No matching conversations",
+ "searchDialog.placeholder": "Search conversations",
+ "searchDialog.recent": "Recent conversations",
+ "searchDialog.title": "Search conversations",
+ "thread.assistantRole": "Assistant",
+ "thread.copy": "Copy",
+ "thread.copyFailed": "Copy failed.",
+ "thread.copyMarkdown": "Copy Markdown",
+ "thread.copyMarkdownSuccess": "Copied Markdown.",
+ "thread.copySessionId": "Copy session ID",
+ "thread.copySessionIdSuccess": "Copied session ID.",
+ "thread.branchFailed": "Failed to branch the conversation.",
+ "thread.branchSuccess": "Created a branched conversation.",
+ "thread.branchUnavailable": "There is no conversation to branch yet.",
+ "thread.delete": "Delete Session",
+ "thread.deleteConfirm": "Delete session \"{title}\"?",
+ "thread.deleteFailed": "Failed to delete session.",
+ "thread.menu": "Conversation menu",
+ "thread.menuFailed": "Failed to open conversation menu.",
+ "thread.openSmallWindow": "Open in small window",
+ "thread.rename": "Rename conversation",
+ "thread.renameFailed": "Failed to rename conversation.",
+ "thread.renamePrompt": "Enter a new conversation name",
+ "thread.toastTitle": "Conversation",
+ "thread.userRole": "User",
+ "settings.theme.dark": "Dark",
+ "settings.theme.light": "Light",
+ "settings.theme.system": "System",
+ "smallWindow.apiUnavailable": "The current runtime does not expose the small window API.",
+ "smallWindow.close": "Close small window",
+ "smallWindow.openFailed": "Failed to open the small window.",
+ "smallWindow.pin": "Pin and lock window",
+ "smallWindow.pinFailed": "Failed to change pin state.",
+ "smallWindow.title": "Small chat window",
+ "smallWindow.toastTitle": "Small window",
+ "smallWindow.unpin": "Unpin and unlock window",
+ "settings.voice.apiKeyMissing": "Fill in an API key before using voice input.",
+ "settings.voice.apiKey": "API Key",
+ "settings.voice.configDescription": "Configure the OpenAI-compatible audio transcription endpoint used by voice input. The /audio/transcriptions path is added automatically.",
+ "settings.voice.configReady": "Voice input can use the current configuration for transcription requests.",
+ "settings.voice.configStatus": "Configuration status",
+ "settings.voice.configureButton": "Configure voice transcription settings",
+ "settings.voice.configured": "Configured",
+ "settings.voice.endpoint": "API Endpoint",
+ "settings.voice.language": "Language",
+ "settings.voice.model": "Model",
+ "settings.voice.notConfigured": "Not configured",
+ "settings.voice.prompt": "Prompt",
+ "settings.voice.settingsTitle": "Voice Transcription Settings",
+ "sidebar.automations": "Automations",
+ "sidebar.botOperation": "Bot settings",
+ "sidebar.collapseLeft": "Collapse left sidebar",
+ "sidebar.collapseRight": "Collapse right sidebar",
+ "sidebar.expandLeft": "Expand left sidebar",
+ "sidebar.expandRight": "Expand right sidebar",
+ "sidebar.mobileOperation": "Phone operation",
+ "sidebar.newSession": "New session",
+ "sidebar.noSessions": "No sessions",
+ "sidebar.plugins": "Plugins",
+ "sidebar.projectsSessions": "Projects / Sessions",
+ "sidebar.repositories": "Repositories",
+ "sidebar.resizeLeft": "Resize left sidebar",
+ "sidebar.resizeRight": "Resize right sidebar",
+ "sidebar.search": "Search",
+ "sidebar.settings": "Settings",
+ "terminal.closeSession": "Close {title}",
+ "terminal.newTerminal": "New terminal",
+ "terminal.unavailable": "Terminal is available in the Electron shell.",
+ "voice.emptyAudio": "No transcribable audio was recorded.",
+ "voice.missingApiKey": "Fill in the voice transcription API key first.",
+ "voice.noApi": "The current runtime does not expose the voice transcription API.",
+ "voice.noMicrophone": "Microphone recording is not supported in this environment.",
+ "voice.recordingFailed": "Recording failed.",
+ "voice.toastTitle": "Voice input",
+ "voice.transcriptionCompleted": "The transcript was inserted into the composer.",
+ "voice.transcriptionEmpty": "Transcription result is empty.",
+ "voice.transcriptionFailed": "Voice transcription failed.",
+ "voice.start": "Start voice input"
+} satisfies Record;
+
+const messages = { en, zh };
+
+export type TranslationKey = keyof typeof zh;
+export type TFunction = (key: TranslationKey, params?: TranslationParams) => string;
+
+type I18nContextValue = {
+ locale: Locale;
+ setLocale: (locale: Locale) => void;
+ t: TFunction;
+};
+
+const I18nContext = createContext(null);
+
+export function I18nProvider({ children }: { children: ReactNode }) {
+ const [locale, setLocaleState] = useState(() => loadLocale());
+
+ const setLocale = useCallback((nextLocale: Locale) => {
+ setLocaleState(resolveLocale(nextLocale));
+ }, []);
+
+ const t = useCallback(
+ (key, params) => {
+ const template = messages[locale][key] ?? messages.zh[key] ?? key;
+ if (!params) return template;
+ return template.replace(/\{(\w+)\}/g, (match, paramKey) => String(params[paramKey] ?? match));
+ },
+ [locale]
+ );
+
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(localeStorageKey, locale);
+ }
+ if (typeof document !== "undefined") {
+ document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
+ document.documentElement.dataset.locale = locale;
+ }
+ }, [locale]);
+
+ const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
+
+ return {children};
+}
+
+export function useI18n() {
+ const context = useContext(I18nContext);
+ if (!context) {
+ throw new Error("useI18n must be used inside I18nProvider");
+ }
+ return context;
+}
+
+export function getIntlLocale(locale: Locale) {
+ return locale === "zh" ? "zh-CN" : "en-US";
+}
+
+function loadLocale(): Locale {
+ if (typeof window === "undefined") return "zh";
+
+ const storedLocale = window.localStorage.getItem(localeStorageKey);
+ if (storedLocale) return resolveLocale(storedLocale);
+
+ const navigatorLanguage = window.navigator.language.toLowerCase();
+ return navigatorLanguage.startsWith("zh") ? "zh" : "en";
+}
+
+function resolveLocale(value: unknown): Locale {
+ return value === "en" || value === "zh" ? value : "zh";
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/lib/utils.ts b/marketplace/plugins/agent-console/src/renderer/lib/utils.ts
new file mode 100644
index 00000000..a5ef1935
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/pages/home/App.tsx b/marketplace/plugins/agent-console/src/renderer/pages/home/App.tsx
new file mode 100644
index 00000000..062f3ea8
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/pages/home/App.tsx
@@ -0,0 +1,3233 @@
+import { useToast } from "@/components/ui/toast";
+import { useI18n } from "@/lib/i18n";
+import { AnimatePresence, MotionConfig } from "motion/react";
+import type { PointerEvent as ReactPointerEvent } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { AgentConsolePluginState } from "../../../shared/plugin-types";
+import { hasSidebarThread, type SidebarProject, type SidebarThread } from "../../../shared/sidebar-data";
+import type { ToolHubBuiltinMcpServerId, ToolHubLlmSettings, ToolHubUserMcpServerConfig } from "../../../shared/toolhub-types";
+import { ChatbotPage } from "./components/chat";
+import {
+ ActiveStream,
+ AgentApprovalPrompt,
+ AgentQuestionPrompt,
+ AgentQuestionResponse,
+ AgentProviderOption,
+ appendTextMessagePart,
+ applyThemePreference,
+ AppPage,
+ AppSettingsState,
+ ChatAgentApprovalDecision,
+ ChatAgentApprovalMode,
+ ChatAgentEffort,
+ ChatAgentProviderId,
+ ChatAgentSpeed,
+ ChatAgentRunEvent,
+ ChatAttachment,
+ ChatMessage,
+ ConfiguredAgentProviderSettings,
+ ConfiguredSubagentSettings,
+ createBlankSubagentSettingsForm,
+ createMessageId,
+ createSlashCommands,
+ defaultAppSettingsState,
+ defaultPluginState,
+ defaultProjectBranchState,
+ defaultRightSidebarTabId,
+ defaultSmallWindowState,
+ findThreadForId,
+ formatConversationMarkdown,
+ formatShortcutAccelerator,
+ getAgentContextWindowInfo,
+ getAgentEffortForRequest,
+ getAgentEffortOptionsForModel,
+ getAgentModelOptions,
+ getAgentProviderByLabel,
+ getConfiguredSubagentFromForm,
+ getAgentProviderLabel,
+ getAgentProviderLogoDataUrl,
+ getAgentSpeedForRequest,
+ getAgentSpeedOptionsForModel,
+ getAppWindowMode,
+ getContextWindowMetrics,
+ getDefaultAgentModel,
+ getDefaultProject,
+ getEnabledAgentProviders,
+ getFallbackAgentProviderOptions,
+ getInitialSelectedThread,
+ getProjectDisplayName,
+ getSmallWindowOpeningTransitionRequested,
+ getValidAgentEffort,
+ getValidAgentModel,
+ getValidAgentSpeed,
+ leftSidebarBounds,
+ loadSettingsPreferences,
+ loadTranscriptionConfig,
+ mergeAttachments,
+ newSessionThreadId,
+ normalizeAgentEffort,
+ normalizeAgentSpeed,
+ normalizeAgentProviderCapabilities,
+ normalizeAgentProviderInfo,
+ normalizeAppSettingsState,
+ normalizeMessagePart,
+ normalizeMessageParts,
+ normalizePluginState,
+ normalizeSmallWindowOpeningGeometry,
+ normalizeTrailingToolMessageParts,
+ normalizeToolEvent,
+ normalizeToolEvents,
+ normalizeTranscriptionConfig,
+ ProjectBranchState,
+ ResizeSide,
+ rightSidebarBounds,
+ RightSidebarState,
+ RightSidebarTab,
+ saveSettingsPreferences,
+ saveTranscriptionConfig,
+ SettingsPreferences,
+ SettingsPreferenceValue,
+ SettingsSectionId,
+ SlashCommand,
+ SmallWindowOpeningGeometry,
+ SmallWindowOpeningPhase,
+ smallWindowOpeningTransitionDurationMs,
+ SmallWindowState,
+ SubagentSettingsForm,
+ TranscriptionConfig,
+ updateSubagentSettingsFormValue,
+ upsertMessagePartOnMessage,
+ upsertToolEvent,
+ upsertToolMessagePart,
+ UsageTokenMetrics,
+ writeClipboardText
+} from "./utils/core";
+import {
+ ConversationSearchDialog,
+ FloatingSidebarToggles,
+ ProjectSidebar,
+ RightSidebar,
+ SmallChatWindowLayout,
+ SmallChatWindowOpeningTransition,
+ ThreadHeader
+} from "./components/layout";
+import { AutomationsPage } from "./components/automations";
+import {
+ AgentSettingsDialog,
+ BotGatewayPage,
+ SettingsPage,
+ SubagentConfigurationEditor
+} from "./components/settings";
+import {
+ resolveHomeThemeConfig,
+ toHomeThemeRootStyle
+} from "./utils/theme";
+import {
+ defaultRightSidebarPluginId,
+ useRightSidebarPlugins,
+ type RightSidebarPluginId
+} from "./right-sidebar-plugins";
+
+type LocalRunMessageIds = {
+ assistantMessageId: string;
+ threadId: string;
+ userMessageId: string;
+};
+
+type PendingRunLocalMessageIds = LocalRunMessageIds & {
+ runId?: string;
+};
+
+type PendingRunSnapshot = {
+ activeStream?: ActiveStream;
+ localRunMessages?: PendingRunLocalMessageIds;
+ messages: ChatMessage[];
+ threadId: string;
+ updatedAt: number;
+ version: 1;
+};
+
+const pendingRunSnapshotsStorageKey = "agentConsole.pendingRunSnapshots.v1";
+const pendingRunSnapshotMaxAgeMs = 7 * 24 * 60 * 60 * 1000;
+
+function mergeMessagesPreservingInFlight(baseMessages: ChatMessage[], inFlightMessages: ChatMessage[] | undefined): ChatMessage[] {
+ if (!inFlightMessages?.length) return baseMessages;
+
+ const merged = [...baseMessages];
+ for (const inFlightMessage of inFlightMessages) {
+ const existingIndex = merged.findIndex((message) => message.id === inFlightMessage.id);
+ if (existingIndex >= 0) {
+ merged[existingIndex] = {
+ ...merged[existingIndex],
+ ...inFlightMessage,
+ parts: inFlightMessage.parts ?? merged[existingIndex].parts,
+ toolEvents: inFlightMessage.toolEvents ?? merged[existingIndex].toolEvents
+ };
+ continue;
+ }
+
+ if (inFlightMessage.role === "user" && merged.some((message) => message.role === "user" && message.content === inFlightMessage.content)) {
+ continue;
+ }
+
+ if (inFlightMessage.role === "assistant" && hasPersistedAssistantReplacement(baseMessages, inFlightMessages, inFlightMessage)) {
+ continue;
+ }
+
+ merged.push(inFlightMessage);
+ }
+
+ return merged.sort((left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0));
+}
+
+function shouldDiscardRecoveredInFlightMessages(baseMessages: ChatMessage[], inFlightMessages: ChatMessage[] | undefined): boolean {
+ return Boolean(inFlightMessages?.some((message) => (
+ message.role === "assistant" &&
+ hasPersistedAssistantReplacement(baseMessages, inFlightMessages, message)
+ )));
+}
+
+function hasPersistedAssistantReplacement(baseMessages: ChatMessage[], inFlightMessages: ChatMessage[], assistantMessage: ChatMessage): boolean {
+ const assistantIndex = inFlightMessages.findIndex((message) => message.id === assistantMessage.id);
+ if (assistantIndex < 0) return false;
+
+ const localUserMessage = [...inFlightMessages.slice(0, assistantIndex)]
+ .reverse()
+ .find((message) => message.role === "user" && message.content.trim());
+ if (!localUserMessage) return false;
+
+ const baseUserIndex = baseMessages.findIndex((message) => (
+ message.role === "user" &&
+ message.content === localUserMessage.content
+ ));
+ if (baseUserIndex < 0) return false;
+
+ return baseMessages.slice(baseUserIndex + 1).some((message) => message.role === "assistant" && messageHasVisibleContent(message));
+}
+
+function messageHasVisibleContent(message: ChatMessage): boolean {
+ return Boolean(message.content.trim() || message.parts?.length || message.toolEvents?.length);
+}
+
+function removeLocalRunMessages(messages: ChatMessage[], runMessages: LocalRunMessageIds): ChatMessage[] {
+ return messages.filter((message) => message.id !== runMessages.userMessageId && message.id !== runMessages.assistantMessageId);
+}
+
+function normalizeAgentMessageRecord(message: {
+ content: string;
+ createdAt?: number;
+ id: string;
+ parts?: unknown;
+ role: "assistant" | "user";
+ toolEvents?: unknown;
+}): ChatMessage {
+ return {
+ content: message.content,
+ createdAt: message.createdAt,
+ id: message.id,
+ parts: normalizeMessageParts(message.parts, message.content, message.toolEvents),
+ role: message.role,
+ toolEvents: normalizeToolEvents(message.toolEvents)
+ };
+}
+
+function loadPendingRunSnapshots(): Map {
+ const snapshots = new Map();
+ if (typeof window === "undefined") return snapshots;
+
+ try {
+ const rawValue = window.localStorage.getItem(pendingRunSnapshotsStorageKey);
+ if (!rawValue) return snapshots;
+
+ const parsed = JSON.parse(rawValue);
+ const rawSnapshots = Array.isArray(parsed)
+ ? parsed
+ : isRecord(parsed) && Array.isArray(parsed.snapshots)
+ ? parsed.snapshots
+ : [];
+ const now = Date.now();
+ let pruned = false;
+ for (const rawSnapshot of rawSnapshots) {
+ const snapshot = normalizePendingRunSnapshot(rawSnapshot);
+ if (!snapshot) {
+ pruned = true;
+ continue;
+ }
+ if (now - snapshot.updatedAt > pendingRunSnapshotMaxAgeMs) {
+ pruned = true;
+ continue;
+ }
+ snapshots.set(snapshot.threadId, snapshot);
+ }
+
+ if (pruned) {
+ savePendingRunSnapshots(snapshots);
+ }
+ } catch {
+ try {
+ window.localStorage.removeItem(pendingRunSnapshotsStorageKey);
+ } catch {
+ // Ignore storage cleanup failures.
+ }
+ }
+
+ return snapshots;
+}
+
+function savePendingRunSnapshots(snapshots: Map) {
+ if (typeof window === "undefined") return;
+
+ try {
+ if (snapshots.size === 0) {
+ window.localStorage.removeItem(pendingRunSnapshotsStorageKey);
+ return;
+ }
+ window.localStorage.setItem(pendingRunSnapshotsStorageKey, JSON.stringify({
+ snapshots: [...snapshots.values()]
+ }));
+ } catch (error) {
+ console.warn("[agent] Failed to persist pending run snapshots.", error);
+ }
+}
+
+function normalizePendingRunSnapshot(value: unknown): PendingRunSnapshot | null {
+ const record = isRecord(value) ? value : {};
+ const threadId = typeof record.threadId === "string" ? record.threadId.trim() : "";
+ const messages = Array.isArray(record.messages)
+ ? record.messages.map(normalizePendingChatMessage).filter((message): message is ChatMessage => Boolean(message))
+ : [];
+ if (!threadId || messages.length === 0) return null;
+
+ const updatedAt = typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt)
+ ? record.updatedAt
+ : Date.now();
+
+ return {
+ activeStream: normalizePendingActiveStream(record.activeStream),
+ localRunMessages: normalizePendingRunLocalMessages(record.localRunMessages),
+ messages,
+ threadId,
+ updatedAt,
+ version: 1
+ };
+}
+
+function normalizePendingChatMessage(value: unknown): ChatMessage | null {
+ const record = isRecord(value) ? value : {};
+ const role = record.role === "assistant" || record.role === "user" ? record.role : null;
+ const id = typeof record.id === "string" && record.id.trim() ? record.id : "";
+ if (!role || !id) return null;
+
+ const content = typeof record.content === "string" ? record.content : "";
+ return {
+ content,
+ createdAt: typeof record.createdAt === "number" && Number.isFinite(record.createdAt) ? record.createdAt : undefined,
+ id,
+ parts: normalizeMessageParts(record.parts, content, record.toolEvents),
+ role,
+ streaming: typeof record.streaming === "boolean" ? record.streaming : undefined,
+ toolEvents: normalizeToolEvents(record.toolEvents)
+ };
+}
+
+function normalizePendingActiveStream(value: unknown): ActiveStream | undefined {
+ const record = isRecord(value) ? value : {};
+ const id = typeof record.id === "string" && record.id.trim() ? record.id : "";
+ if (!id) return undefined;
+
+ return {
+ id,
+ running: record.running !== false,
+ streamKey: typeof record.streamKey === "number" && Number.isFinite(record.streamKey) ? record.streamKey : Date.now()
+ };
+}
+
+function normalizePendingRunLocalMessages(value: unknown): PendingRunLocalMessageIds | undefined {
+ const record = isRecord(value) ? value : {};
+ const assistantMessageId = typeof record.assistantMessageId === "string" ? record.assistantMessageId : "";
+ const threadId = typeof record.threadId === "string" ? record.threadId : "";
+ const userMessageId = typeof record.userMessageId === "string" ? record.userMessageId : "";
+ if (!assistantMessageId || !threadId || !userMessageId) return undefined;
+
+ return {
+ assistantMessageId,
+ runId: typeof record.runId === "string" && record.runId ? record.runId : undefined,
+ threadId,
+ userMessageId
+ };
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+function createPendingMessagesMap(snapshots: Map): Map {
+ return new Map([...snapshots].map(([threadId, snapshot]) => [threadId, snapshot.messages]));
+}
+
+function createPendingStreamsMap(snapshots: Map): Map {
+ return new Map([...snapshots]
+ .filter((entry): entry is [string, PendingRunSnapshot & { activeStream: ActiveStream }] => Boolean(entry[1].activeStream))
+ .map(([threadId, snapshot]) => [threadId, snapshot.activeStream]));
+}
+
+function createPendingLocalRunMessagesMap(snapshots: Map): Map {
+ const entries: Array<[string, LocalRunMessageIds]> = [];
+ for (const snapshot of snapshots.values()) {
+ if (!snapshot.localRunMessages?.runId) continue;
+ entries.push([snapshot.localRunMessages.runId, {
+ assistantMessageId: snapshot.localRunMessages.assistantMessageId,
+ threadId: snapshot.localRunMessages.threadId,
+ userMessageId: snapshot.localRunMessages.userMessageId
+ }]);
+ }
+ return new Map(entries);
+}
+
+function findLocalRunMessagesForThread(runMessagesByRunId: Map, threadId: string): PendingRunLocalMessageIds | undefined {
+ for (const [runId, runMessages] of runMessagesByRunId) {
+ if (runMessages.threadId === threadId) {
+ return { ...runMessages, runId };
+ }
+ }
+ return undefined;
+}
+
+function App() {
+ const { t } = useI18n();
+ const toast = useToast();
+ const appWindowMode = useMemo(() => getAppWindowMode(), []);
+ const smallWindowOpeningTransitionRequested = useMemo(() => getSmallWindowOpeningTransitionRequested(), []);
+ const initialSelectedThread = useMemo(() => getInitialSelectedThread(), []);
+ const initialPendingRunSnapshots = useMemo(() => loadPendingRunSnapshots(), []);
+ const isSmallChatWindow = appWindowMode === "small-chat";
+ const [smallWindowOpeningPhase, setSmallWindowOpeningPhase] = useState(
+ smallWindowOpeningTransitionRequested ? "compact" : "done"
+ );
+ const [smallWindowOpeningGeometry, setSmallWindowOpeningGeometry] = useState(null);
+ const [selectedThread, setSelectedThread] = useState(initialSelectedThread);
+ const [activePage, setActivePage] = useState("chat");
+ const [activeSettingsSection, setActiveSettingsSection] = useState("general");
+ const [leftOpen, setLeftOpen] = useState(true);
+ const [rightOpen, setRightOpen] = useState(false);
+ const [leftWidth, setLeftWidth] = useState(300);
+ const [rightWidth, setRightWidth] = useState(360);
+ const [searchDialogOpen, setSearchDialogOpen] = useState(false);
+ const [resizingSide, setResizingSide] = useState(null);
+ const nextRightPanelTabIndexRef = useRef(1);
+ const [rightSidebarState, setRightSidebarState] = useState(() => ({
+ activeTabId: defaultRightSidebarTabId,
+ tabs: [{ id: defaultRightSidebarTabId, pluginId: defaultRightSidebarPluginId }]
+ }));
+ const [messages, setMessages] = useState([]);
+ const [activeStream, setActiveStream] = useState(null);
+ const [contextUsageByThread, setContextUsageByThread] = useState>({});
+ const [agentProviderId, setAgentProviderId] = useState("codex");
+ const [agentProviders, setAgentProviders] = useState([]);
+ const [agentModel, setAgentModel] = useState("");
+ const [agentEffort, setAgentEffort] = useState("medium");
+ const [agentSpeed, setAgentSpeed] = useState("default");
+ const [agentApprovalMode, setAgentApprovalMode] = useState("request");
+ const [approvalPrompt, setApprovalPrompt] = useState(null);
+ const [questionPrompt, setQuestionPrompt] = useState(null);
+ const [composerValue, setComposerValue] = useState("");
+ const [composerAttachments, setComposerAttachments] = useState([]);
+ const [selectedSubagentIds, setSelectedSubagentIds] = useState([]);
+ const [subagentCreateDialogOpen, setSubagentCreateDialogOpen] = useState(false);
+ const [subagentCreateForm, setSubagentCreateForm] = useState(null);
+ const [subagentCreateError, setSubagentCreateError] = useState(null);
+ const [savingCreatedSubagent, setSavingCreatedSubagent] = useState(false);
+ const [projects, setProjects] = useState([]);
+ const [renamingSidebarThreadId, setRenamingSidebarThreadId] = useState(null);
+ const [renamingHeaderThreadId, setRenamingHeaderThreadId] = useState(null);
+ const [projectBranchState, setProjectBranchState] = useState(defaultProjectBranchState);
+ const [newSessionProjectId, setNewSessionProjectId] = useState("");
+ const [transcriptionConfig, setTranscriptionConfig] = useState(() => loadTranscriptionConfig());
+ const [settingsPreferences, setSettingsPreferences] = useState(() => loadSettingsPreferences());
+ const [pluginState, setPluginState] = useState(defaultPluginState);
+ const enabledAgentProviders = useMemo(() => getEnabledAgentProviders(agentProviders), [agentProviders]);
+ const subagentProviderOptions = useMemo(
+ () => enabledAgentProviders.length ? enabledAgentProviders : getFallbackAgentProviderOptions(),
+ [enabledAgentProviders]
+ );
+ const pluginThemeConfigs = useMemo(() => pluginState.plugins.map((plugin) => plugin.theme).filter(Boolean), [pluginState.plugins]);
+ const homeTheme = useMemo(
+ () => resolveHomeThemeConfig(settingsPreferences.homeThemeConfig, pluginThemeConfigs),
+ [pluginThemeConfigs, settingsPreferences.homeThemeConfig]
+ );
+ const availableRightSidebarPlugins = useRightSidebarPlugins(pluginState.rightSidebarPanels);
+ useEffect(() => {
+ const availablePanelIds = new Set(availableRightSidebarPlugins.map((plugin) => plugin.id));
+ setRightSidebarState((currentState) => {
+ const nextTabs = currentState.tabs.filter((tab) => availablePanelIds.has(tab.pluginId));
+ const normalizedTabs = nextTabs.length
+ ? nextTabs
+ : [{ id: defaultRightSidebarTabId, pluginId: defaultRightSidebarPluginId }];
+ const activeTabId = normalizedTabs.some((tab) => tab.id === currentState.activeTabId)
+ ? currentState.activeTabId
+ : normalizedTabs[0].id;
+ if (activeTabId === currentState.activeTabId && normalizedTabs.length === currentState.tabs.length) {
+ return currentState;
+ }
+ return { activeTabId, tabs: normalizedTabs };
+ });
+ }, [availableRightSidebarPlugins]);
+ const [appSettings, setAppSettings] = useState(defaultAppSettingsState);
+ const [smallWindowState, setSmallWindowState] = useState(defaultSmallWindowState);
+ useEffect(() => {
+ const availableSubagentIds = new Set(appSettings.subagents.map((subagent) => subagent.id));
+ setSelectedSubagentIds((currentIds) => currentIds.filter((subagentId) => availableSubagentIds.has(subagentId)));
+ }, [appSettings.subagents]);
+
+ const pendingAssistantMessageIdRef = useRef(null);
+ const pendingUserMessageIdRef = useRef(null);
+ const pendingAssistantThreadIdRef = useRef(null);
+ const selectedThreadRef = useRef(selectedThread);
+ const inFlightMessagesByThreadRef = useRef(createPendingMessagesMap(initialPendingRunSnapshots));
+ const activeStreamsByThreadRef = useRef(createPendingStreamsMap(initialPendingRunSnapshots));
+ const runMessageIdsRef = useRef(new Map());
+ const runThreadIdsRef = useRef(new Map());
+ const runLocalMessageIdsRef = useRef(createPendingLocalRunMessagesMap(initialPendingRunSnapshots));
+ const pendingRunSnapshotsRef = useRef(initialPendingRunSnapshots);
+ const approvalQueueRef = useRef([]);
+ const questionQueueRef = useRef([]);
+ const ignoredApprovalPromptIdsRef = useRef(new Set());
+ const ignoredApprovalPromptKeysRef = useRef(new Set());
+ const ignoredQuestionPromptIdsRef = useRef(new Set());
+ const ignoredQuestionPromptKeysRef = useRef(new Set());
+ const resolvedInitialThreadRef = useRef(false);
+ const suppressThreadHistoryLoadRef = useRef(null);
+ const selectedSidebarThread = useMemo(
+ () => selectedThread === newSessionThreadId ? null : findThreadForId(projects, selectedThread),
+ [projects, selectedThread]
+ );
+ const activeRightSidebarProject = useMemo(() => {
+ if (selectedThread === newSessionThreadId) {
+ return projects.find((project) => project.id === newSessionProjectId) ?? getDefaultProject(projects) ?? null;
+ }
+
+ return projects.find((project) => project.threads.some((thread) => thread.id === selectedThread)) ?? null;
+ }, [newSessionProjectId, projects, selectedThread]);
+ const activeAgentProviderId = selectedSidebarThread?.providerId ?? agentProviderId;
+ const activeAgentProvider = agentProviders.find((provider) => provider.id === activeAgentProviderId) ?? null;
+ const activeAgentProviderKind = activeAgentProvider?.kind;
+ const activeAgentProviderLabel = getAgentProviderLabel(agentProviders, activeAgentProviderId);
+ const activeAgentLogoDataUrl = getAgentProviderLogoDataUrl(agentProviders, activeAgentProviderId);
+ const activeModelOptions = getAgentModelOptions(enabledAgentProviders, activeAgentProviderId);
+ const activeAgentModel = getValidAgentModel(agentModel, enabledAgentProviders, activeAgentProviderId);
+ const activeAgentEffortOptions = getAgentEffortOptionsForModel(activeAgentModel, activeModelOptions, activeAgentProvider);
+ const activeAgentEffort = getValidAgentEffort(agentEffort, activeAgentModel, activeModelOptions, activeAgentProvider);
+ const activeAgentSpeedOptions = getAgentSpeedOptionsForModel(activeAgentModel, activeModelOptions, activeAgentProvider);
+ const activeAgentSpeed = getValidAgentSpeed(agentSpeed, activeAgentModel, activeModelOptions, activeAgentProvider);
+ const activeContextUsage = contextUsageByThread[selectedThread] ?? null;
+ const activePromptThreadId = selectedThread === newSessionThreadId ? null : selectedThread;
+ const visibleApprovalPrompt = activePromptThreadId && approvalPrompt?.threadId === activePromptThreadId ? approvalPrompt : null;
+ const visibleQuestionPrompt = activePromptThreadId && questionPrompt?.threadId === activePromptThreadId ? questionPrompt : null;
+ const slashCommands = useMemo(() => createSlashCommands(pluginState, t, activeAgentProvider), [activeAgentProvider, pluginState, t]);
+ const rightSidebarAgentContext = useMemo(() => ({
+ activeModel: activeAgentModel,
+ agentApprovalMode,
+ agentEffort: activeAgentEffort,
+ agentProviderId: activeAgentProviderId,
+ agentProviders: enabledAgentProviders,
+ project: activeRightSidebarProject,
+ selectedThread: selectedSidebarThread
+ }), [
+ activeAgentEffort,
+ activeAgentModel,
+ activeAgentProviderId,
+ activeRightSidebarProject,
+ agentApprovalMode,
+ enabledAgentProviders,
+ selectedSidebarThread
+ ]);
+
+ useEffect(() => {
+ void window.agentConsole?.workspace?.setActiveProject({
+ projectId: activeRightSidebarProject?.id,
+ projectPath: activeRightSidebarProject?.path
+ }).catch((error) => {
+ console.warn("[workspace] Failed to set active project.", error);
+ });
+ }, [activeRightSidebarProject?.id, activeRightSidebarProject?.path]);
+
+ useEffect(() => {
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi || !activeAgentProviderId || !activeAgentProviderKind) return;
+
+ let cancelled = false;
+ void agentApi.getProviderCapabilities({ providerId: activeAgentProviderId })
+ .then((result) => {
+ if (cancelled || !result?.success) return;
+ const capabilities = normalizeAgentProviderCapabilities(result.capabilities);
+ setAgentProviders((currentProviders) => currentProviders.map((provider) => (
+ provider.id === activeAgentProviderId
+ ? { ...provider, capabilities }
+ : provider
+ )));
+ })
+ .catch(() => undefined);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [activeAgentProviderId, activeAgentProviderKind]);
+
+ useEffect(() => {
+ selectedThreadRef.current = selectedThread;
+ }, [selectedThread]);
+
+ const deletePendingRunSnapshot = useCallback((threadId: string) => {
+ if (!threadId) return;
+ pendingRunSnapshotsRef.current.delete(threadId);
+ savePendingRunSnapshots(pendingRunSnapshotsRef.current);
+ }, []);
+
+ const persistPendingRunSnapshotForThread = useCallback((threadId: string) => {
+ if (!threadId) return;
+
+ const messagesForThread = inFlightMessagesByThreadRef.current.get(threadId) ?? [];
+ if (!messagesForThread.length) {
+ deletePendingRunSnapshot(threadId);
+ return;
+ }
+
+ pendingRunSnapshotsRef.current.set(threadId, {
+ activeStream: activeStreamsByThreadRef.current.get(threadId),
+ localRunMessages: findLocalRunMessagesForThread(runLocalMessageIdsRef.current, threadId),
+ messages: messagesForThread,
+ threadId,
+ updatedAt: Date.now(),
+ version: 1
+ });
+ savePendingRunSnapshots(pendingRunSnapshotsRef.current);
+ }, [deletePendingRunSnapshot]);
+
+ const clearPendingRunStateForThread = useCallback((threadId: string) => {
+ if (!threadId) return;
+
+ inFlightMessagesByThreadRef.current.delete(threadId);
+ activeStreamsByThreadRef.current.delete(threadId);
+ for (const [runId, runMessages] of runLocalMessageIdsRef.current) {
+ if (runMessages.threadId === threadId) {
+ runLocalMessageIdsRef.current.delete(runId);
+ }
+ }
+ deletePendingRunSnapshot(threadId);
+ if (selectedThreadRef.current === threadId) {
+ setActiveStream(null);
+ }
+ }, [deletePendingRunSnapshot]);
+
+ const setVisibleActiveStream = useCallback((stream: ActiveStream | null, threadId = selectedThreadRef.current) => {
+ if (stream) {
+ activeStreamsByThreadRef.current.set(threadId, stream);
+ } else {
+ activeStreamsByThreadRef.current.delete(threadId);
+ }
+ if (selectedThreadRef.current === threadId) {
+ setActiveStream(stream);
+ }
+ persistPendingRunSnapshotForThread(threadId);
+ }, [persistPendingRunSnapshotForThread]);
+
+ const setVisibleMessagesForThread = useCallback((threadId: string, nextMessages: ChatMessage[]) => {
+ if (selectedThreadRef.current === threadId) {
+ setMessages(nextMessages);
+ setActiveStream(activeStreamsByThreadRef.current.get(threadId) ?? null);
+ }
+ }, []);
+
+ const setInFlightMessagesForThread = useCallback((threadId: string, nextMessages: ChatMessage[]) => {
+ if (nextMessages.length) {
+ inFlightMessagesByThreadRef.current.set(threadId, nextMessages);
+ } else {
+ inFlightMessagesByThreadRef.current.delete(threadId);
+ }
+ persistPendingRunSnapshotForThread(threadId);
+ }, [persistPendingRunSnapshotForThread]);
+
+ const moveInFlightThreadState = useCallback((fromThreadId: string, toThreadId: string) => {
+ if (!fromThreadId || !toThreadId || fromThreadId === toThreadId) return;
+
+ const messagesForThread = inFlightMessagesByThreadRef.current.get(fromThreadId);
+ if (messagesForThread) {
+ inFlightMessagesByThreadRef.current.delete(fromThreadId);
+ inFlightMessagesByThreadRef.current.set(toThreadId, messagesForThread);
+ }
+
+ const streamForThread = activeStreamsByThreadRef.current.get(fromThreadId);
+ if (streamForThread) {
+ activeStreamsByThreadRef.current.delete(fromThreadId);
+ activeStreamsByThreadRef.current.set(toThreadId, streamForThread);
+ }
+
+ for (const [runId, runMessages] of runLocalMessageIdsRef.current) {
+ if (runMessages.threadId === fromThreadId) {
+ runLocalMessageIdsRef.current.set(runId, {
+ ...runMessages,
+ threadId: toThreadId
+ });
+ }
+ }
+
+ deletePendingRunSnapshot(fromThreadId);
+ persistPendingRunSnapshotForThread(toThreadId);
+ }, [deletePendingRunSnapshot, persistPendingRunSnapshotForThread]);
+
+ const updateInFlightMessage = useCallback((threadId: string, messageId: string, updateMessage: (message: ChatMessage) => ChatMessage) => {
+ const cachedMessages = inFlightMessagesByThreadRef.current.get(threadId) ?? [];
+ const nextCachedMessages = cachedMessages.map((message) => (message.id === messageId ? updateMessage(message) : message));
+ if (nextCachedMessages.length) {
+ inFlightMessagesByThreadRef.current.set(threadId, nextCachedMessages);
+ persistPendingRunSnapshotForThread(threadId);
+ }
+
+ if (selectedThreadRef.current !== threadId) return;
+
+ setMessages((currentMessages) => {
+ const sourceMessages = currentMessages.some((message) => message.id === messageId)
+ ? currentMessages
+ : mergeMessagesPreservingInFlight(currentMessages, nextCachedMessages);
+ return sourceMessages.map((message) => (message.id === messageId ? updateMessage(message) : message));
+ });
+ }, [persistPendingRunSnapshotForThread]);
+
+ const clearLocalRunMessages = useCallback((runId: string) => {
+ const runMessages = runLocalMessageIdsRef.current.get(runId);
+ if (!runMessages) return;
+
+ runLocalMessageIdsRef.current.delete(runId);
+ const cachedMessages = inFlightMessagesByThreadRef.current.get(runMessages.threadId);
+ if (cachedMessages) {
+ const nextCachedMessages = removeLocalRunMessages(cachedMessages, runMessages);
+ setInFlightMessagesForThread(runMessages.threadId, nextCachedMessages);
+ }
+
+ activeStreamsByThreadRef.current.delete(runMessages.threadId);
+ if (selectedThreadRef.current === runMessages.threadId) {
+ setActiveStream(null);
+ }
+
+ }, [setInFlightMessagesForThread]);
+
+ useEffect(() => {
+ if (!activePromptThreadId) return;
+
+ setApprovalPrompt((currentPrompt) => promoteApprovalPromptForThread(
+ currentPrompt,
+ approvalQueueRef.current,
+ activePromptThreadId,
+ ignoredApprovalPromptIdsRef.current,
+ ignoredApprovalPromptKeysRef.current
+ ));
+ setQuestionPrompt((currentPrompt) => promoteQuestionPromptForThread(
+ currentPrompt,
+ questionQueueRef.current,
+ activePromptThreadId,
+ ignoredQuestionPromptIdsRef.current,
+ ignoredQuestionPromptKeysRef.current
+ ));
+ }, [activePromptThreadId]);
+
+ const reloadProjects = useCallback(async () => {
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi) return;
+
+ const result = await agentApi.listProjects();
+ setProjects(result.projects);
+ setNewSessionProjectId((currentProjectId) => {
+ if (currentProjectId && result.projects.some((project) => project.id === currentProjectId)) {
+ return currentProjectId;
+ }
+
+ return result.projects[0]?.id ?? "";
+ });
+ }, []);
+
+ const reloadAgentProviders = useCallback(async () => {
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi) return [];
+
+ const providers = await agentApi.listProviders();
+ const nextProviders = normalizeAgentProviderInfo(providers);
+ const nextEnabledProviders = getEnabledAgentProviders(nextProviders);
+ setAgentProviders(nextProviders);
+ setAgentProviderId((currentProviderId) => {
+ const selectedProvider = nextEnabledProviders.find((provider) => provider.id === currentProviderId) ?? nextEnabledProviders[0];
+ setAgentModel((currentModel) => {
+ if (!selectedProvider) return currentModel;
+ const nextModel = selectedProvider.models.some((model) => model.value === currentModel)
+ ? currentModel
+ : getDefaultAgentModel(selectedProvider);
+ setAgentEffort((currentEffort) => getValidAgentEffort(currentEffort, nextModel, selectedProvider.models, selectedProvider));
+ setAgentSpeed((currentSpeed) => getValidAgentSpeed(currentSpeed, nextModel, selectedProvider.models, selectedProvider));
+ return nextModel;
+ });
+ return selectedProvider?.id ?? currentProviderId;
+ });
+
+ return nextProviders;
+ }, []);
+
+ const createRightPanelTab = useCallback((panelId: RightSidebarPluginId): RightSidebarTab => {
+ const index = nextRightPanelTabIndexRef.current;
+ nextRightPanelTabIndexRef.current += 1;
+ return { id: `right-panel-tab-${panelId}-${index}`, pluginId: panelId };
+ }, []);
+
+ const openRightPanelTab = useCallback((panelId: RightSidebarPluginId) => {
+ if (!availableRightSidebarPlugins.some((plugin) => plugin.id === panelId)) return;
+
+ setRightSidebarState((currentState) => {
+ const existingTab = currentState.tabs.find((tab) => tab.pluginId === panelId);
+ if (existingTab) {
+ return existingTab.id === currentState.activeTabId ? currentState : { ...currentState, activeTabId: existingTab.id };
+ }
+
+ const nextTab = createRightPanelTab(panelId);
+ return {
+ activeTabId: nextTab.id,
+ tabs: [...currentState.tabs, nextTab]
+ };
+ });
+ setRightOpen(true);
+ }, [availableRightSidebarPlugins, createRightPanelTab]);
+
+ const runSlashCommand = useCallback((command: SlashCommand) => {
+ if (command.disabled) return;
+
+ if (command.action.type === "insert") {
+ setComposerValue(command.action.text);
+ return;
+ }
+
+ if (command.action.type === "open-panel") {
+ openRightPanelTab(command.action.panelId);
+ setComposerValue("");
+ return;
+ }
+
+ toast.warning({
+ content: t("slash.unavailable.description"),
+ title: command.title
+ });
+ }, [openRightPanelTab, t, toast]);
+
+ useEffect(() => {
+ const pluginsApi = window.agentConsole?.plugins;
+ const runPluginCommandById = (commandId: string) => {
+ if (!commandId) return;
+ const command = slashCommands.find((candidate) => candidate.id === commandId);
+ if (command) runSlashCommand(command);
+ };
+
+ const onLocalPluginCommand = (event: Event) => {
+ const commandId = (event as CustomEvent<{ commandId?: string }>).detail?.commandId ?? "";
+ runPluginCommandById(commandId);
+ };
+ window.addEventListener("agent-console:plugins:command", onLocalPluginCommand);
+
+ const disposePluginCommand = pluginsApi?.onCommand?.((payload) => {
+ const commandId = payload && typeof payload === "object" && "commandId" in payload
+ ? String((payload as { commandId?: unknown }).commandId ?? "")
+ : "";
+ runPluginCommandById(commandId);
+ });
+
+ return () => {
+ window.removeEventListener("agent-console:plugins:command", onLocalPluginCommand);
+ disposePluginCommand?.();
+ };
+ }, [runSlashCommand, slashCommands]);
+
+ const setActiveRightPanelTab = useCallback((tabId: string) => {
+ setRightSidebarState((currentState) => {
+ if (currentState.activeTabId === tabId || !currentState.tabs.some((tab) => tab.id === tabId)) {
+ return currentState;
+ }
+
+ return { ...currentState, activeTabId: tabId };
+ });
+ }, []);
+
+ useEffect(() => {
+ const onSelectRightPanel = (event: Event) => {
+ openRightPanelTab((event as CustomEvent).detail);
+ };
+
+ window.addEventListener("agent-console:right-panel:select", onSelectRightPanel);
+ return () => window.removeEventListener("agent-console:right-panel:select", onSelectRightPanel);
+ }, [openRightPanelTab]);
+
+ useEffect(() => {
+ saveTranscriptionConfig(transcriptionConfig);
+ }, [transcriptionConfig]);
+
+ useEffect(() => {
+ saveSettingsPreferences(settingsPreferences);
+ }, [settingsPreferences]);
+
+ useEffect(() => {
+ let canceled = false;
+ reloadProjects().catch((error) => {
+ if (!canceled) {
+ console.warn("[agent] Failed to load projects.", error);
+ }
+ });
+
+ return () => {
+ canceled = true;
+ };
+ }, [reloadProjects]);
+
+ useEffect(() => {
+ let canceled = false;
+
+ reloadAgentProviders()
+ .then(() => undefined)
+ .catch((error) => {
+ if (!canceled) {
+ console.warn("[agent] Failed to load providers.", error);
+ }
+ });
+
+ return () => {
+ canceled = true;
+ };
+ }, [reloadAgentProviders]);
+
+ useEffect(() => {
+ if (!projects.length) return;
+
+ if (!resolvedInitialThreadRef.current) {
+ resolvedInitialThreadRef.current = true;
+ const requestedThreadId = initialSelectedThread;
+ const nextThreadId = requestedThreadId !== newSessionThreadId && hasSidebarThread(projects, requestedThreadId)
+ ? requestedThreadId
+ : newSessionThreadId;
+
+ if (nextThreadId && nextThreadId !== selectedThread) {
+ setSelectedThread(nextThreadId);
+ return;
+ }
+
+ if (!nextThreadId && selectedThread !== newSessionThreadId) {
+ setSelectedThread(newSessionThreadId);
+ return;
+ }
+ }
+
+ if (
+ selectedThread !== newSessionThreadId &&
+ !hasSidebarThread(projects, selectedThread) &&
+ suppressThreadHistoryLoadRef.current !== selectedThread
+ ) {
+ setSelectedThread(newSessionThreadId);
+ setMessages([]);
+ setActiveStream(null);
+ return;
+ }
+
+ }, [initialSelectedThread, projects, selectedThread]);
+
+ useEffect(() => {
+ if (selectedThread !== newSessionThreadId) return undefined;
+
+ const selectedProject = projects.find((project) => project.id === newSessionProjectId) ?? getDefaultProject(projects);
+ if (!selectedProject) {
+ setProjectBranchState(defaultProjectBranchState);
+ return undefined;
+ }
+
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi) {
+ setProjectBranchState(defaultProjectBranchState);
+ return undefined;
+ }
+
+ let canceled = false;
+ setProjectBranchState((currentState) => ({ ...currentState, loading: true }));
+ agentApi.listProjectBranches({
+ projectId: selectedProject.id,
+ projectPath: selectedProject.path
+ })
+ .then((result) => {
+ if (canceled) return;
+ const branchNames = result.branches.map((branch) => branch.name);
+ setProjectBranchState({
+ branches: result.branches,
+ currentBranch: result.currentBranch,
+ detached: result.detached,
+ isGitRepository: result.isGitRepository,
+ loading: false,
+ selectedBranch: result.currentBranch || branchNames[0] || ""
+ });
+ })
+ .catch((error) => {
+ if (canceled) return;
+ console.warn("[agent] Failed to load project branches.", error);
+ setProjectBranchState(defaultProjectBranchState);
+ });
+
+ return () => {
+ canceled = true;
+ };
+ }, [newSessionProjectId, projects, selectedThread]);
+
+ useEffect(() => {
+ if (selectedThread === newSessionThreadId) {
+ setMessages(inFlightMessagesByThreadRef.current.get(newSessionThreadId) ?? []);
+ setActiveStream(activeStreamsByThreadRef.current.get(newSessionThreadId) ?? null);
+ return undefined;
+ }
+
+ if (suppressThreadHistoryLoadRef.current === selectedThread) {
+ suppressThreadHistoryLoadRef.current = null;
+ return undefined;
+ }
+
+ let canceled = false;
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi) return undefined;
+
+ agentApi.getThreadMessages({ threadId: selectedThread })
+ .then((result) => {
+ if (canceled) return;
+ const historyMessages = result.messages.map(normalizeAgentMessageRecord);
+ const inFlightMessages = inFlightMessagesByThreadRef.current.get(selectedThread);
+ if (shouldDiscardRecoveredInFlightMessages(historyMessages, inFlightMessages)) {
+ clearPendingRunStateForThread(selectedThread);
+ setVisibleMessagesForThread(selectedThread, historyMessages);
+ } else {
+ setVisibleMessagesForThread(
+ selectedThread,
+ mergeMessagesPreservingInFlight(historyMessages, inFlightMessages)
+ );
+ }
+ const usage = result.usage;
+ if (usage) {
+ setContextUsageByThread((currentUsageByThread) => ({
+ ...currentUsageByThread,
+ [selectedThread]: usage
+ }));
+ }
+ })
+ .catch((error) => {
+ if (!canceled) {
+ console.warn("[agent] Failed to load thread messages.", error);
+ setVisibleMessagesForThread(selectedThread, inFlightMessagesByThreadRef.current.get(selectedThread) ?? []);
+ }
+ });
+
+ return () => {
+ canceled = true;
+ };
+ }, [clearPendingRunStateForThread, selectedThread, setVisibleMessagesForThread]);
+
+ useEffect(() => {
+ let canceled = false;
+ const settingsApi = window.agentConsole?.settings;
+
+ if (!settingsApi) {
+ return () => {
+ canceled = true;
+ };
+ }
+
+ settingsApi.get()
+ .then((settings) => {
+ if (!canceled) setAppSettings(normalizeAppSettingsState(settings));
+ })
+ .catch((error) => {
+ console.warn("[settings] Failed to load app settings.", error);
+ });
+
+ return () => {
+ canceled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ let canceled = false;
+ const pluginsApi = window.agentConsole?.plugins;
+
+ if (!pluginsApi) return undefined;
+
+ pluginsApi.get()
+ .then((state) => {
+ if (!canceled) setPluginState(normalizePluginState(state));
+ })
+ .catch((error) => {
+ console.warn("[plugins] Failed to load plugin state.", error);
+ });
+
+ return () => {
+ canceled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ window.dispatchEvent(new CustomEvent("agent-console:plugins:state-changed", { detail: pluginState }));
+ }, [pluginState]);
+
+ useEffect(() => {
+ document.documentElement.dataset.windowMode = appWindowMode;
+ }, [appWindowMode]);
+
+ useEffect(() => {
+ document.documentElement.dataset.formFactor = "desktop";
+ }, []);
+
+ useEffect(() => {
+ document.documentElement.dataset.density = settingsPreferences.compactDensity ? "compact" : "comfortable";
+ document.documentElement.dataset.reduceMotion = settingsPreferences.reduceMotion ? "true" : "false";
+ }, [settingsPreferences.compactDensity, settingsPreferences.reduceMotion]);
+
+ useEffect(() => {
+ if (!isSmallChatWindow) return undefined;
+
+ let canceled = false;
+ const smallWindowApi = window.agentConsole?.smallWindow;
+ if (!smallWindowApi) return undefined;
+
+ smallWindowApi.getState()
+ .then((state) => {
+ if (!canceled) setSmallWindowState(state);
+ })
+ .catch((error) => {
+ console.warn("[small-window] Failed to load window state.", error);
+ });
+
+ return () => {
+ canceled = true;
+ };
+ }, [isSmallChatWindow]);
+
+ useEffect(() => {
+ if (!isSmallChatWindow || !smallWindowOpeningTransitionRequested) return undefined;
+
+ const smallWindowApi = window.agentConsole?.smallWindow;
+ let started = false;
+ let doneTimer: number | null = null;
+
+ const clearTimers = () => {
+ if (doneTimer !== null) {
+ window.clearTimeout(doneTimer);
+ doneTimer = null;
+ }
+ };
+
+ const startTransition = (payload?: { durationMs?: number; from?: { height?: number; width?: number }; to?: { height?: number; width?: number } }) => {
+ if (started) return;
+ started = true;
+
+ const durationMs = typeof payload?.durationMs === "number"
+ ? payload.durationMs
+ : smallWindowOpeningTransitionDurationMs;
+ setSmallWindowOpeningGeometry(normalizeSmallWindowOpeningGeometry(payload));
+ setSmallWindowOpeningPhase("compact");
+ doneTimer = window.setTimeout(() => {
+ setSmallWindowOpeningPhase("done");
+ doneTimer = null;
+ }, durationMs + 160);
+ };
+
+ const dispose = smallWindowApi?.onOpeningTransitionStart(startTransition);
+ void smallWindowApi?.notifyOpeningTransitionReady().catch((error) => {
+ console.warn("[small-window] Failed to start opening transition.", error);
+ });
+
+ return () => {
+ dispose?.();
+ clearTimers();
+ };
+ }, [isSmallChatWindow, smallWindowOpeningTransitionRequested]);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return undefined;
+
+ const syncTheme = () => applyThemePreference(settingsPreferences.theme);
+ const colorSchemeMedia = window.matchMedia("(prefers-color-scheme: dark)");
+
+ syncTheme();
+ colorSchemeMedia.addEventListener("change", syncTheme);
+
+ return () => {
+ colorSchemeMedia.removeEventListener("change", syncTheme);
+ };
+ }, [settingsPreferences.theme]);
+
+ const activeTitle = useMemo(() => {
+ if (selectedThread === newSessionThreadId) return t("newSession.title");
+
+ for (const project of projects) {
+ const thread = project.threads.find((item) => item.id === selectedThread);
+ if (thread) return thread.title;
+ }
+ return t("app.defaultTitle");
+ }, [projects, selectedThread, t]);
+
+ const saveThreadTitle = useCallback(async (thread: SidebarThread, nextTitle: string) => {
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi?.renameThread) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("thread.toastTitle") });
+ return;
+ }
+
+ const normalizedTitle = nextTitle.replace(/\s+/g, " ").trim();
+ if (!normalizedTitle || normalizedTitle === thread.title) {
+ setRenamingSidebarThreadId(null);
+ setRenamingHeaderThreadId(null);
+ return;
+ }
+
+ try {
+ const result = await agentApi.renameThread({
+ threadId: thread.id,
+ title: normalizedTitle
+ });
+ setProjects(result.projects);
+ setRenamingSidebarThreadId(null);
+ setRenamingHeaderThreadId(null);
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("thread.renameFailed"),
+ title: t("thread.toastTitle")
+ });
+ }
+ }, [t, toast]);
+
+ const startThreadRename = useCallback((thread: SidebarThread) => {
+ setRenamingSidebarThreadId(thread.id);
+ setRenamingHeaderThreadId(null);
+ }, []);
+
+ const renameActiveThread = useCallback(() => {
+ if (!selectedSidebarThread) return;
+ setRenamingSidebarThreadId(null);
+ setRenamingHeaderThreadId(selectedSidebarThread.id);
+ }, [selectedSidebarThread]);
+
+ const deleteThread = useCallback(async (thread: SidebarThread) => {
+ if (thread.working) return;
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi?.deleteThread) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("thread.toastTitle") });
+ return;
+ }
+
+ if (!window.confirm(t("thread.deleteConfirm", { title: thread.title }))) return;
+
+ try {
+ const result = await agentApi.deleteThread({ threadId: thread.id });
+ setProjects(result.projects);
+ clearPendingRunStateForThread(thread.id);
+ if (selectedThread === thread.id) {
+ setSelectedThread(newSessionThreadId);
+ setActivePage("chat");
+ setMessages([]);
+ setActiveStream(null);
+ setComposerValue("");
+ setComposerAttachments([]);
+ }
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("thread.deleteFailed"),
+ title: t("thread.toastTitle")
+ });
+ }
+ }, [clearPendingRunStateForThread, selectedThread, t, toast]);
+
+ const showProjectInFinder = useCallback(async (project: SidebarProject) => {
+ if (!project.path) return;
+ const shellApi = window.agentConsole?.shell;
+ if (!shellApi?.showItemInFolder) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("project.toastTitle") });
+ return;
+ }
+
+ try {
+ await shellApi.showItemInFolder({ path: project.path });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("project.showInFinderFailed"),
+ title: t("project.toastTitle")
+ });
+ }
+ }, [t, toast]);
+
+ const removeProject = useCallback(async (project: SidebarProject) => {
+ if (project.threads.some((thread) => thread.working)) return;
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi?.removeProject) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("project.toastTitle") });
+ return;
+ }
+
+ if (!window.confirm(t("project.removeConfirm", { name: getProjectDisplayName(project) }))) return;
+
+ try {
+ const result = await agentApi.removeProject({
+ projectId: project.id,
+ projectPath: project.path
+ });
+ setProjects(result.projects);
+ for (const thread of project.threads) {
+ clearPendingRunStateForThread(thread.id);
+ }
+
+ if (project.threads.some((thread) => thread.id === selectedThread)) {
+ setSelectedThread(newSessionThreadId);
+ setActivePage("chat");
+ setMessages([]);
+ setActiveStream(null);
+ setComposerValue("");
+ setComposerAttachments([]);
+ }
+
+ if (newSessionProjectId === project.id) {
+ const nextProject = result.projects.find((item) => item.id !== project.id) ?? result.projects[0] ?? null;
+ setNewSessionProjectId(nextProject?.id ?? "");
+ setComposerAttachments([]);
+ setProjectBranchState(defaultProjectBranchState);
+ }
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("project.removeFailed"),
+ title: t("project.toastTitle")
+ });
+ }
+ }, [clearPendingRunStateForThread, newSessionProjectId, selectedThread, t, toast]);
+
+ const openProjectContextMenu = useCallback(async (project: SidebarProject) => {
+ const nativeMenu = window.agentConsole?.nativeMenu;
+ if (!nativeMenu?.popup) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("project.toastTitle") });
+ return;
+ }
+
+ try {
+ const hasRunningSession = project.threads.some((thread) => thread.working);
+ const result = await nativeMenu.popup({
+ items: [
+ { enabled: Boolean(project.path), id: "show-in-finder", label: t("project.showInFinder") },
+ { type: "separator" },
+ { enabled: Boolean(project.path) && !hasRunningSession, id: "remove-project", label: t("project.remove") }
+ ]
+ });
+
+ if (result.actionId === "show-in-finder") {
+ await showProjectInFinder(project);
+ } else if (result.actionId === "remove-project") {
+ await removeProject(project);
+ }
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("project.removeFailed"),
+ title: t("project.toastTitle")
+ });
+ }
+ }, [removeProject, showProjectInFinder, t, toast]);
+
+ const openThreadContextMenu = useCallback(async (thread: SidebarThread) => {
+ const nativeMenu = window.agentConsole?.nativeMenu;
+ if (!nativeMenu?.popup) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("thread.toastTitle") });
+ return;
+ }
+
+ try {
+ const result = await nativeMenu.popup({
+ items: [
+ { id: "rename-thread", label: t("thread.rename") },
+ { type: "separator" },
+ { enabled: !thread.working, id: "delete-thread", label: t("thread.delete") }
+ ]
+ });
+
+ if (result.actionId === "rename-thread") {
+ startThreadRename(thread);
+ } else if (result.actionId === "delete-thread") {
+ await deleteThread(thread);
+ }
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("thread.deleteFailed"),
+ title: t("thread.toastTitle")
+ });
+ }
+ }, [deleteThread, startThreadRename, t, toast]);
+
+ const copyActiveThreadId = useCallback(async () => {
+ if (!selectedSidebarThread) return;
+
+ try {
+ await writeClipboardText(selectedSidebarThread.id);
+ toast.success({ content: t("thread.copySessionIdSuccess"), title: t("thread.toastTitle") });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("thread.copyFailed"),
+ title: t("thread.toastTitle")
+ });
+ }
+ }, [selectedSidebarThread, t, toast]);
+
+ const copyActiveThreadMarkdown = useCallback(async () => {
+ if (!messages.length) return;
+
+ try {
+ await writeClipboardText(formatConversationMarkdown(activeTitle, messages, t));
+ toast.success({ content: t("thread.copyMarkdownSuccess"), title: t("thread.toastTitle") });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("thread.copyFailed"),
+ title: t("thread.toastTitle")
+ });
+ }
+ }, [activeTitle, messages, t, toast]);
+
+ const openActiveThreadSmallWindow = useCallback(async () => {
+ const smallWindowApi = window.agentConsole?.smallWindow;
+ if (!smallWindowApi) {
+ toast.error({ content: t("smallWindow.apiUnavailable"), title: t("smallWindow.toastTitle") });
+ return;
+ }
+
+ try {
+ await smallWindowApi.create({
+ threadId: selectedThread === newSessionThreadId ? undefined : selectedThread
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("smallWindow.openFailed"),
+ title: t("smallWindow.toastTitle")
+ });
+ }
+ }, [selectedThread, t, toast]);
+
+ const openSettings = useCallback((section: SettingsSectionId = "general") => {
+ setActiveSettingsSection(section);
+ setActivePage("settings");
+ setRightOpen(false);
+ }, []);
+
+ const openBotPage = useCallback(() => {
+ setActivePage("bot");
+ setRightOpen(false);
+ }, []);
+
+ const openAutomationsPage = useCallback(() => {
+ setActivePage("automations");
+ setRightOpen(false);
+ }, []);
+
+ const toggleSmallWindowPinned = useCallback(async () => {
+ const smallWindowApi = window.agentConsole?.smallWindow;
+ if (!smallWindowApi) return;
+
+ const pinned = !smallWindowState.pinned;
+ setSmallWindowState((currentState) => ({ ...currentState, pinned }));
+ try {
+ setSmallWindowState(await smallWindowApi.setPinned({ pinned }));
+ } catch (error) {
+ setSmallWindowState((currentState) => ({ ...currentState, pinned: !pinned }));
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("smallWindow.pinFailed"),
+ title: t("smallWindow.toastTitle")
+ });
+ }
+ }, [smallWindowState.pinned, t, toast]);
+
+ const closeSmallWindow = useCallback(() => {
+ void window.agentConsole?.smallWindow?.close().catch((error) => {
+ console.warn("[small-window] Failed to close small window.", error);
+ });
+ }, []);
+
+ const startNewSession = useCallback(() => {
+ clearPendingRunStateForThread(newSessionThreadId);
+ selectedThreadRef.current = newSessionThreadId;
+ setSelectedThread(newSessionThreadId);
+ setActivePage("chat");
+ setMessages([]);
+ setActiveStream(null);
+ setComposerValue("");
+ setComposerAttachments([]);
+ }, [clearPendingRunStateForThread]);
+
+ const editUserMessage = useCallback((message: ChatMessage) => {
+ if (message.role !== "user") return;
+ setComposerValue(message.content);
+ setComposerAttachments([]);
+ }, []);
+
+ const forkMessage = useCallback(async (message: ChatMessage) => {
+ const sourceThreadId = selectedThreadRef.current;
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi?.forkThread) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("thread.toastTitle") });
+ return;
+ }
+ if (sourceThreadId === newSessionThreadId) {
+ toast.error({ content: t("thread.branchUnavailable"), title: t("thread.toastTitle") });
+ return;
+ }
+
+ try {
+ const result = await agentApi.forkThread({
+ messageId: message.id,
+ threadId: sourceThreadId
+ });
+ const forkedMessages = result.messages.map(normalizeAgentMessageRecord);
+ clearPendingRunStateForThread(result.thread.id);
+ suppressThreadHistoryLoadRef.current = result.thread.id;
+ selectedThreadRef.current = result.thread.id;
+ setProjects(result.projects);
+ setSelectedThread(result.thread.id);
+ setActivePage("chat");
+ setRenamingHeaderThreadId(null);
+ setMessages(forkedMessages);
+ setActiveStream(null);
+ setComposerValue("");
+ setComposerAttachments([]);
+ setContextUsageByThread((currentUsageByThread) => {
+ const nextUsageByThread = { ...currentUsageByThread };
+ delete nextUsageByThread[result.thread.id];
+ return nextUsageByThread;
+ });
+ toast.success({ content: t("thread.branchSuccess"), title: t("thread.toastTitle") });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("thread.branchFailed"),
+ title: t("thread.toastTitle")
+ });
+ }
+ }, [clearPendingRunStateForThread, t, toast]);
+
+ const changeNewSessionProject = useCallback((projectId: string) => {
+ setNewSessionProjectId(projectId);
+ setComposerAttachments([]);
+ setProjectBranchState(defaultProjectBranchState);
+ }, []);
+
+ const createBlankProject = useCallback(async () => {
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("agent.toastTitle") });
+ return;
+ }
+
+ try {
+ const result = await agentApi.createBlankProject();
+ setProjects(result.projects);
+ if (result.project) {
+ changeNewSessionProject(result.project.id);
+ }
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("newSession.projectCreateFailed"),
+ title: t("agent.toastTitle")
+ });
+ }
+ }, [changeNewSessionProject, t, toast]);
+
+ const addExistingProject = useCallback(async () => {
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("agent.toastTitle") });
+ return;
+ }
+
+ try {
+ const result = await agentApi.addExistingProject();
+ if (result.canceled) return;
+
+ setProjects(result.projects);
+ if (result.project) {
+ changeNewSessionProject(result.project.id);
+ }
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("newSession.projectAddFailed"),
+ title: t("agent.toastTitle")
+ });
+ }
+ }, [changeNewSessionProject, t, toast]);
+
+ const addComposerAttachments = useCallback(async () => {
+ const filesApi = window.agentConsole?.files;
+ if (!filesApi) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("agent.toastTitle") });
+ return;
+ }
+
+ const selectedProject = projects.find((project) => project.id === newSessionProjectId) ?? getDefaultProject(projects);
+ try {
+ const result = await filesApi.chooseAttachments({ defaultPath: selectedProject?.path });
+ if (result.canceled) return;
+
+ setComposerAttachments((currentAttachments) => mergeAttachments(currentAttachments, result.attachments));
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("newSession.attachFilesFailed"),
+ title: t("agent.toastTitle")
+ });
+ }
+ }, [newSessionProjectId, projects, t, toast]);
+
+ const removeComposerAttachment = useCallback((attachmentPath: string) => {
+ setComposerAttachments((currentAttachments) => currentAttachments.filter((attachment) => attachment.path !== attachmentPath));
+ }, []);
+
+ const changeNewSessionBranch = useCallback(async (branchName: string) => {
+ if (!branchName || branchName === projectBranchState.selectedBranch) return;
+
+ const selectedProject = projects.find((project) => project.id === newSessionProjectId) ?? getDefaultProject(projects);
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi || !selectedProject) return;
+
+ setProjectBranchState((currentState) => ({ ...currentState, loading: true, selectedBranch: branchName }));
+ try {
+ const result = await agentApi.checkoutProjectBranch({
+ branchName,
+ projectId: selectedProject.id,
+ projectPath: selectedProject.path
+ });
+ const branchNames = result.branches.map((branch) => branch.name);
+ setProjectBranchState({
+ branches: result.branches,
+ currentBranch: result.currentBranch,
+ detached: result.detached,
+ isGitRepository: result.isGitRepository,
+ loading: false,
+ selectedBranch: result.currentBranch || branchNames[0] || branchName
+ });
+ } catch (error) {
+ setProjectBranchState((currentState) => ({ ...currentState, loading: false, selectedBranch: currentState.currentBranch || currentState.selectedBranch }));
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("newSession.branchSwitchFailed"),
+ title: t("agent.toastTitle")
+ });
+ }
+ }, [newSessionProjectId, projectBranchState.selectedBranch, projects, t, toast]);
+
+ const selectThread = useCallback((thread: string) => {
+ if (thread === newSessionThreadId) {
+ startNewSession();
+ return;
+ }
+
+ const alreadySelected = selectedThreadRef.current === thread;
+ selectedThreadRef.current = thread;
+ setSelectedThread(thread);
+ setRenamingHeaderThreadId(null);
+ setActivePage("chat");
+
+ if (!alreadySelected) {
+ setMessages(inFlightMessagesByThreadRef.current.get(thread) ?? []);
+ setActiveStream(activeStreamsByThreadRef.current.get(thread) ?? null);
+ }
+
+ setComposerValue("");
+ setComposerAttachments([]);
+ }, [startNewSession]);
+
+ const selectSearchResult = useCallback((thread: string) => {
+ selectThread(thread);
+ setSearchDialogOpen(false);
+ }, [selectThread]);
+
+ useEffect(() => {
+ return window.agentConsole?.ipc.on("agent-console:sidebar:select-thread", (payload) => {
+ if (typeof payload === "string" && hasSidebarThread(projects, payload)) {
+ selectThread(payload);
+ }
+ });
+ }, [projects, selectThread]);
+
+ useEffect(() => {
+ window.agentConsole?.ipc.send("agent-console:sidebar:selected-thread-changed", selectedThread);
+ }, [selectedThread]);
+
+ const updateTranscriptionConfig = useCallback((key: keyof TranscriptionConfig, nextValue: string) => {
+ setTranscriptionConfig((currentConfig) => normalizeTranscriptionConfig({ ...currentConfig, [key]: nextValue }));
+ }, []);
+
+ const updateSettingsPreference = useCallback((key: keyof SettingsPreferences, value: SettingsPreferenceValue) => {
+ setSettingsPreferences((currentPreferences) => ({ ...currentPreferences, [key]: value }) as SettingsPreferences);
+ }, []);
+
+ const saveAgentEnvironment = useCallback(
+ async (providerId: ChatAgentProviderId, env: Record) => {
+ if (!window.agentConsole?.settings) {
+ toast.error({ content: t("settings.agentEnvironment.apiUnavailable"), title: t("settings.agentEnvironment.toastTitle") });
+ throw new Error(t("settings.agentEnvironment.apiUnavailable"));
+ }
+
+ try {
+ const nextSettings = await window.agentConsole.settings.setAgentEnvironment({ env, providerId });
+ setAppSettings(normalizeAppSettingsState(nextSettings));
+ await reloadAgentProviders();
+ toast.success({
+ content: t("settings.agentEnvironment.savedToast", { agent: getAgentProviderLabel(agentProviders, providerId) }),
+ title: t("settings.agentEnvironment.toastTitle")
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.agentEnvironment.saveFailed"),
+ title: t("settings.agentEnvironment.toastTitle")
+ });
+ throw error;
+ }
+ },
+ [agentProviders, reloadAgentProviders, t, toast]
+ );
+
+ const saveAgentProviders = useCallback(
+ async (providers: ConfiguredAgentProviderSettings[]) => {
+ if (!window.agentConsole?.settings) {
+ toast.error({ content: t("settings.agents.apiUnavailable"), title: t("settings.agents.toastTitle") });
+ throw new Error(t("settings.agents.apiUnavailable"));
+ }
+
+ try {
+ const nextSettings = await window.agentConsole.settings.setAgentProviders({ providers });
+ setAppSettings(normalizeAppSettingsState(nextSettings));
+ await reloadAgentProviders();
+ toast.success({
+ content: t("settings.agents.savedToast"),
+ title: t("settings.agents.toastTitle")
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.agents.saveFailed"),
+ title: t("settings.agents.toastTitle")
+ });
+ throw error;
+ }
+ },
+ [reloadAgentProviders, t, toast]
+ );
+
+ const saveSubagents = useCallback(
+ async (subagents: ConfiguredSubagentSettings[]) => {
+ if (!window.agentConsole?.settings?.setSubagents) {
+ toast.error({ content: t("settings.subagents.apiUnavailable"), title: t("settings.subagents.toastTitle") });
+ throw new Error(t("settings.subagents.apiUnavailable"));
+ }
+
+ try {
+ const nextSettings = await window.agentConsole.settings.setSubagents({ subagents });
+ setAppSettings(normalizeAppSettingsState(nextSettings));
+ toast.success({
+ content: t("settings.subagents.savedToast"),
+ title: t("settings.subagents.toastTitle")
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.subagents.saveFailed"),
+ title: t("settings.subagents.toastTitle")
+ });
+ throw error;
+ }
+ },
+ [t, toast]
+ );
+
+ const openSubagentCreateDialog = useCallback(() => {
+ setSubagentCreateForm(createBlankSubagentSettingsForm(appSettings.subagents, subagentProviderOptions));
+ setSubagentCreateError(null);
+ setSubagentCreateDialogOpen(true);
+ }, [appSettings.subagents, subagentProviderOptions]);
+
+ const closeSubagentCreateDialog = useCallback(() => {
+ if (savingCreatedSubagent) return;
+ setSubagentCreateDialogOpen(false);
+ setSubagentCreateForm(null);
+ setSubagentCreateError(null);
+ }, [savingCreatedSubagent]);
+
+ const updateSubagentCreateForm = useCallback((key: keyof SubagentSettingsForm, value: string) => {
+ setSubagentCreateForm((currentForm) => currentForm ? updateSubagentSettingsFormValue(currentForm, key, value, subagentProviderOptions) : currentForm);
+ setSubagentCreateError(null);
+ }, [subagentProviderOptions]);
+
+ const saveCreatedSubagent = useCallback(async () => {
+ if (!subagentCreateForm) return;
+
+ const { error, subagent } = getConfiguredSubagentFromForm(subagentCreateForm, appSettings.subagents, subagentProviderOptions, t);
+ if (error || !subagent) {
+ setSubagentCreateError(error);
+ return;
+ }
+
+ setSavingCreatedSubagent(true);
+ try {
+ await saveSubagents([...appSettings.subagents, subagent]);
+ setSelectedSubagentIds((currentIds) => currentIds.includes(subagent.id) ? currentIds : [...currentIds, subagent.id]);
+ setSubagentCreateDialogOpen(false);
+ setSubagentCreateForm(null);
+ setSubagentCreateError(null);
+ } finally {
+ setSavingCreatedSubagent(false);
+ }
+ }, [appSettings.subagents, saveSubagents, subagentCreateForm, subagentProviderOptions, t]);
+
+ const createAndSelectAgentProvider = useCallback(
+ async (provider: ConfiguredAgentProviderSettings) => {
+ if (!window.agentConsole?.settings) {
+ toast.error({ content: t("settings.agents.apiUnavailable"), title: t("settings.agents.toastTitle") });
+ throw new Error(t("settings.agents.apiUnavailable"));
+ }
+
+ try {
+ const currentSettings = normalizeAppSettingsState(await window.agentConsole.settings.get());
+ const nextSettings = await window.agentConsole.settings.setAgentProviders({
+ providers: [...currentSettings.agentProviders, provider]
+ });
+ setAppSettings(normalizeAppSettingsState(nextSettings));
+ const nextProviders = await reloadAgentProviders();
+ const selectedProvider = getEnabledAgentProviders(nextProviders).find((candidate) => candidate.id === provider.id) ?? null;
+
+ if (selectedProvider) {
+ const nextModel = getDefaultAgentModel(selectedProvider);
+ setAgentProviderId(selectedProvider.id);
+ setAgentModel(nextModel);
+ setAgentEffort((currentEffort) => getValidAgentEffort(currentEffort, nextModel, selectedProvider.models, selectedProvider));
+ setAgentSpeed((currentSpeed) => getValidAgentSpeed(currentSpeed, nextModel, selectedProvider.models, selectedProvider));
+ }
+
+ toast.success({
+ content: t("settings.agents.savedToast"),
+ title: t("settings.agents.toastTitle")
+ });
+ return selectedProvider;
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.agents.saveFailed"),
+ title: t("settings.agents.toastTitle")
+ });
+ throw error;
+ }
+ },
+ [reloadAgentProviders, t, toast]
+ );
+
+ const saveAgentProviderEnabled = useCallback(
+ async (providerId: ChatAgentProviderId, enabled: boolean) => {
+ if (!window.agentConsole?.settings) {
+ toast.error({ content: t("settings.agents.apiUnavailable"), title: t("settings.agents.toastTitle") });
+ throw new Error(t("settings.agents.apiUnavailable"));
+ }
+
+ try {
+ const nextSettings = await window.agentConsole.settings.setAgentProviderEnabled({ enabled, providerId });
+ setAppSettings(normalizeAppSettingsState(nextSettings));
+ await reloadAgentProviders();
+ await reloadProjects();
+ toast.success({
+ content: enabled ? t("settings.agents.enabledToast") : t("settings.agents.disabledToast"),
+ title: t("settings.agents.toastTitle")
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.agents.saveFailed"),
+ title: t("settings.agents.toastTitle")
+ });
+ throw error;
+ }
+ },
+ [reloadAgentProviders, reloadProjects, t, toast]
+ );
+
+ const updateSpotlightShortcut = useCallback(
+ async (accelerator: string) => {
+ if (!window.agentConsole?.settings) {
+ toast.error({ content: t("settings.shortcut.apiUnavailable"), title: t("settings.shortcut.toastTitle") });
+ return;
+ }
+
+ try {
+ const nextSettings = await window.agentConsole.settings.setSpotlightShortcut({ accelerator });
+ setAppSettings(normalizeAppSettingsState(nextSettings));
+
+ if (nextSettings.registeredSpotlightShortcut && nextSettings.registeredSpotlightShortcut !== nextSettings.spotlightShortcut) {
+ toast.warning({
+ content: t("settings.shortcut.fallbackToast", {
+ registered: formatShortcutAccelerator(nextSettings.registeredSpotlightShortcut),
+ shortcut: formatShortcutAccelerator(nextSettings.spotlightShortcut)
+ }),
+ title: t("settings.shortcut.toastTitle")
+ });
+ return;
+ }
+
+ toast.success({
+ content: t("settings.shortcut.savedToast", { shortcut: formatShortcutAccelerator(nextSettings.spotlightShortcut) }),
+ title: t("settings.shortcut.toastTitle")
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.shortcut.saveFailed"),
+ title: t("settings.shortcut.toastTitle")
+ });
+ }
+ },
+ [t, toast]
+ );
+
+ const resetSpotlightShortcut = useCallback(async () => {
+ if (!window.agentConsole?.settings) {
+ toast.error({ content: t("settings.shortcut.apiUnavailable"), title: t("settings.shortcut.toastTitle") });
+ return;
+ }
+
+ try {
+ const nextSettings = await window.agentConsole.settings.resetSpotlightShortcut();
+ setAppSettings(normalizeAppSettingsState(nextSettings));
+
+ if (nextSettings.registeredSpotlightShortcut && nextSettings.registeredSpotlightShortcut !== nextSettings.spotlightShortcut) {
+ toast.warning({
+ content: t("settings.shortcut.fallbackToast", {
+ registered: formatShortcutAccelerator(nextSettings.registeredSpotlightShortcut),
+ shortcut: formatShortcutAccelerator(nextSettings.spotlightShortcut)
+ }),
+ title: t("settings.shortcut.toastTitle")
+ });
+ return;
+ }
+
+ toast.success({
+ content: t("settings.shortcut.resetToast", { shortcut: formatShortcutAccelerator(nextSettings.spotlightShortcut) }),
+ title: t("settings.shortcut.toastTitle")
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.shortcut.saveFailed"),
+ title: t("settings.shortcut.toastTitle")
+ });
+ }
+ }, [t, toast]);
+
+ const reloadAppSettings = useCallback(async () => {
+ const settingsApi = window.agentConsole?.settings;
+ if (!settingsApi) return;
+ const nextSettings = await settingsApi.get();
+ setAppSettings(normalizeAppSettingsState(nextSettings));
+ }, []);
+
+ const saveToolHubEnabled = useCallback(async (enabled: boolean) => {
+ const toolHubApi = window.agentConsole?.toolhub;
+ if (!toolHubApi) {
+ toast.error({ content: t("settings.toolhub.apiUnavailable"), title: t("settings.toolhub.toastTitle") });
+ throw new Error(t("settings.toolhub.apiUnavailable"));
+ }
+
+ try {
+ await toolHubApi.setEnabled({ enabled });
+ await reloadAppSettings();
+ toast.success({
+ content: enabled ? t("settings.toolhub.enabledToast") : t("settings.toolhub.disabledToast"),
+ title: t("settings.toolhub.toastTitle")
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.toolhub.saveFailed"),
+ title: t("settings.toolhub.toastTitle")
+ });
+ throw error;
+ }
+ }, [reloadAppSettings, t, toast]);
+
+ const saveToolHubLlmConfig = useCallback(async (llm: ToolHubLlmSettings) => {
+ const toolHubApi = window.agentConsole?.toolhub;
+ if (!toolHubApi) {
+ toast.error({ content: t("settings.toolhub.apiUnavailable"), title: t("settings.toolhub.toastTitle") });
+ throw new Error(t("settings.toolhub.apiUnavailable"));
+ }
+
+ try {
+ await toolHubApi.setLlmConfig(llm);
+ await reloadAppSettings();
+ toast.success({ content: t("settings.toolhub.llmSavedToast"), title: t("settings.toolhub.toastTitle") });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.toolhub.saveFailed"),
+ title: t("settings.toolhub.toastTitle")
+ });
+ throw error;
+ }
+ }, [reloadAppSettings, t, toast]);
+
+ const installToolHubServer = useCallback(async (server: ToolHubUserMcpServerConfig) => {
+ const toolHubApi = window.agentConsole?.toolhub;
+ if (!toolHubApi) {
+ toast.error({ content: t("settings.toolhub.apiUnavailable"), title: t("settings.toolhub.toastTitle") });
+ throw new Error(t("settings.toolhub.apiUnavailable"));
+ }
+
+ try {
+ await toolHubApi.installServer(server);
+ await reloadAppSettings();
+ toast.success({ content: t("settings.toolhub.serverInstalledToast", { id: server.id }), title: t("settings.toolhub.toastTitle") });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.toolhub.saveFailed"),
+ title: t("settings.toolhub.toastTitle")
+ });
+ throw error;
+ }
+ }, [reloadAppSettings, t, toast]);
+
+ const updateToolHubServer = useCallback(async (server: ToolHubUserMcpServerConfig) => {
+ const toolHubApi = window.agentConsole?.toolhub;
+ if (!toolHubApi) {
+ toast.error({ content: t("settings.toolhub.apiUnavailable"), title: t("settings.toolhub.toastTitle") });
+ throw new Error(t("settings.toolhub.apiUnavailable"));
+ }
+
+ try {
+ await toolHubApi.updateServer(server);
+ await reloadAppSettings();
+ toast.success({ content: t("settings.toolhub.serverUpdatedToast", { id: server.id }), title: t("settings.toolhub.toastTitle") });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.toolhub.saveFailed"),
+ title: t("settings.toolhub.toastTitle")
+ });
+ throw error;
+ }
+ }, [reloadAppSettings, t, toast]);
+
+ const removeToolHubServer = useCallback(async (serverId: string) => {
+ const toolHubApi = window.agentConsole?.toolhub;
+ if (!toolHubApi) {
+ toast.error({ content: t("settings.toolhub.apiUnavailable"), title: t("settings.toolhub.toastTitle") });
+ throw new Error(t("settings.toolhub.apiUnavailable"));
+ }
+
+ try {
+ await toolHubApi.removeServer({ id: serverId });
+ await reloadAppSettings();
+ toast.success({ content: t("settings.toolhub.serverRemovedToast", { id: serverId }), title: t("settings.toolhub.toastTitle") });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.toolhub.saveFailed"),
+ title: t("settings.toolhub.toastTitle")
+ });
+ throw error;
+ }
+ }, [reloadAppSettings, t, toast]);
+
+ const updateToolHubBuiltinMcpServer = useCallback(async (serverId: ToolHubBuiltinMcpServerId, enabled: boolean) => {
+ const toolHubApi = window.agentConsole?.toolhub;
+ if (!toolHubApi) {
+ toast.error({ content: t("settings.toolhub.apiUnavailable"), title: t("settings.toolhub.toastTitle") });
+ throw new Error(t("settings.toolhub.apiUnavailable"));
+ }
+
+ try {
+ await toolHubApi.setBuiltinMcpServerEnabled({ enabled, id: serverId });
+ await reloadAppSettings();
+ toast.success({
+ content: t("settings.toolhub.builtinUpdatedToast", { label: t(`settings.toolhub.builtin.${serverId}.label`) }),
+ title: t("settings.toolhub.toastTitle")
+ });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.toolhub.saveFailed"),
+ title: t("settings.toolhub.toastTitle")
+ });
+ throw error;
+ }
+ }, [reloadAppSettings, t, toast]);
+
+ const clearToolHubCache = useCallback(async () => {
+ const toolHubApi = window.agentConsole?.toolhub;
+ if (!toolHubApi) {
+ toast.error({ content: t("settings.toolhub.apiUnavailable"), title: t("settings.toolhub.toastTitle") });
+ throw new Error(t("settings.toolhub.apiUnavailable"));
+ }
+
+ try {
+ await toolHubApi.clearCache();
+ toast.success({ content: t("settings.toolhub.cacheClearedToast"), title: t("settings.toolhub.toastTitle") });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("settings.toolhub.clearCacheFailed"),
+ title: t("settings.toolhub.toastTitle")
+ });
+ throw error;
+ }
+ }, [t, toast]);
+
+ const runPluginAction = useCallback(
+ async (
+ action: "disable" | "enable" | "grant-permissions" | "install" | "reload" | "revoke-permissions" | "set-configuration" | "uninstall" | "update",
+ payload?: unknown
+ ) => {
+ const pluginsApi = window.agentConsole?.plugins;
+ if (!pluginsApi) {
+ toast.error({ content: "Plugin API is unavailable.", title: "Plugins" });
+ return;
+ }
+
+ try {
+ let nextState: AgentConsolePluginState;
+ if (action === "disable") {
+ nextState = await pluginsApi.disable(payload as { id?: string; pluginId?: string } | string);
+ } else if (action === "enable") {
+ nextState = await pluginsApi.enable(payload as { id?: string; permissionIds?: string[]; pluginId?: string } | string);
+ } else if (action === "grant-permissions") {
+ nextState = await pluginsApi.grantPermissions(payload as { id?: string; permissionIds: string[]; pluginId?: string });
+ } else if (action === "revoke-permissions") {
+ nextState = await pluginsApi.revokePermissions(payload as { id?: string; permissionIds: string[]; pluginId?: string });
+ } else if (action === "install") {
+ nextState = await pluginsApi.install(payload as Parameters[0]);
+ } else if (action === "set-configuration") {
+ nextState = await pluginsApi.setConfiguration(payload as { id?: string; pluginId?: string; values: Record });
+ } else if (action === "uninstall") {
+ nextState = await pluginsApi.uninstall(payload as { id?: string; pluginId?: string } | string);
+ } else if (action === "update") {
+ nextState = await pluginsApi.update(payload as Parameters[0]);
+ } else {
+ nextState = await pluginsApi.reload();
+ }
+ setPluginState(normalizePluginState(nextState));
+ toast.success({ content: "Plugin state updated.", title: "Plugins" });
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : "Plugin operation failed.",
+ title: "Plugins"
+ });
+ }
+ },
+ [toast]
+ );
+
+ const toggleStreaming = useCallback(() => {
+ setActiveStream((currentStream) => {
+ if (!currentStream) return currentStream;
+ const nextStream = { ...currentStream, running: !currentStream.running };
+ activeStreamsByThreadRef.current.set(selectedThreadRef.current, nextStream);
+ return nextStream;
+ });
+ }, []);
+
+ const changeAgentModel = useCallback((nextModel: string) => {
+ const modelOptions = getAgentModelOptions(enabledAgentProviders, activeAgentProviderId);
+ const provider = enabledAgentProviders.find((candidate) => candidate.id === activeAgentProviderId) ?? null;
+ setAgentModel(nextModel);
+ setAgentEffort((currentEffort) => getValidAgentEffort(currentEffort, nextModel, modelOptions, provider));
+ setAgentSpeed((currentSpeed) => getValidAgentSpeed(currentSpeed, nextModel, modelOptions, provider));
+ }, [activeAgentProviderId, enabledAgentProviders]);
+
+ const changeAgentProvider = useCallback((value: string) => {
+ const provider = getAgentProviderByLabel(enabledAgentProviders, value, false);
+ if (!provider) return;
+
+ const nextModel = getDefaultAgentModel(provider);
+ setAgentProviderId(provider.id);
+ setAgentModel(nextModel);
+ setAgentEffort((currentEffort) => getValidAgentEffort(currentEffort, nextModel, provider.models, provider));
+ setAgentSpeed((currentSpeed) => getValidAgentSpeed(currentSpeed, nextModel, provider.models, provider));
+ }, [enabledAgentProviders]);
+
+ const enqueueApprovalPrompt = useCallback((event: ChatAgentRunEvent) => {
+ if (!event.approvalId) return;
+
+ const prompt: AgentApprovalPrompt = {
+ approvalId: event.approvalId,
+ approvalOptions: event.approvalOptions as ChatAgentApprovalDecision[] | undefined,
+ approvalScope: event.approvalScope,
+ method: event.method,
+ params: event.params,
+ providerId: event.providerId,
+ runId: event.runId,
+ threadId: event.threadId,
+ title: event.title
+ };
+ const promptKey = getApprovalPromptDedupeKey(prompt);
+
+ setApprovalPrompt((currentPrompt) => {
+ if (
+ ignoredApprovalPromptIdsRef.current.has(prompt.approvalId) ||
+ ignoredApprovalPromptKeysRef.current.has(promptKey) ||
+ currentPrompt?.approvalId === prompt.approvalId ||
+ (currentPrompt && getApprovalPromptDedupeKey(currentPrompt) === promptKey) ||
+ approvalQueueRef.current.some((queuedPrompt) => queuedPrompt.approvalId === prompt.approvalId || getApprovalPromptDedupeKey(queuedPrompt) === promptKey)
+ ) {
+ return currentPrompt;
+ }
+
+ if (currentPrompt && activePromptThreadId === prompt.threadId && currentPrompt.threadId !== prompt.threadId) {
+ approvalQueueRef.current.push(currentPrompt);
+ return prompt;
+ }
+
+ if (currentPrompt) {
+ approvalQueueRef.current.push(prompt);
+ return currentPrompt;
+ }
+
+ return prompt;
+ });
+ }, [activePromptThreadId]);
+
+ const resolveApprovalPrompt = useCallback((decision: ChatAgentApprovalDecision, message?: string) => {
+ const currentPrompt = visibleApprovalPrompt;
+ if (!currentPrompt) return;
+
+ ignoredApprovalPromptIdsRef.current.add(currentPrompt.approvalId);
+ const promptKey = getApprovalPromptDedupeKey(currentPrompt);
+ ignoredApprovalPromptKeysRef.current.add(promptKey);
+ approvalQueueRef.current = approvalQueueRef.current.filter((queuedPrompt) => queuedPrompt.approvalId !== currentPrompt.approvalId && getApprovalPromptDedupeKey(queuedPrompt) !== promptKey);
+
+ void window.agentConsole?.agent.resolveApproval({
+ approvalId: currentPrompt.approvalId,
+ decision,
+ ...(message?.trim() ? { message: message.trim() } : {})
+ }).catch((error) => {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("agent.approvalResolveFailed"),
+ title: t("agent.toastTitle")
+ });
+ ignoredApprovalPromptIdsRef.current.delete(currentPrompt.approvalId);
+ ignoredApprovalPromptKeysRef.current.delete(promptKey);
+ });
+
+ setApprovalPrompt(shiftNextApprovalPrompt(approvalQueueRef.current, ignoredApprovalPromptIdsRef.current, ignoredApprovalPromptKeysRef.current, activePromptThreadId));
+ }, [activePromptThreadId, t, toast, visibleApprovalPrompt]);
+
+ const enqueueQuestionPrompt = useCallback((event: ChatAgentRunEvent) => {
+ if (!event.questionId || !event.questions?.length) return;
+
+ const prompt: AgentQuestionPrompt = {
+ method: event.method,
+ params: event.params,
+ providerId: event.providerId,
+ questionId: event.questionId,
+ questions: event.questions,
+ runId: event.runId,
+ threadId: event.threadId,
+ title: event.title
+ };
+ const promptKey = getQuestionPromptDedupeKey(prompt);
+
+ setQuestionPrompt((currentPrompt) => {
+ if (
+ ignoredQuestionPromptIdsRef.current.has(prompt.questionId) ||
+ ignoredQuestionPromptKeysRef.current.has(promptKey) ||
+ currentPrompt?.questionId === prompt.questionId ||
+ (currentPrompt && getQuestionPromptDedupeKey(currentPrompt) === promptKey) ||
+ questionQueueRef.current.some((queuedPrompt) => (
+ queuedPrompt.questionId === prompt.questionId ||
+ getQuestionPromptDedupeKey(queuedPrompt) === promptKey
+ ))
+ ) {
+ return currentPrompt;
+ }
+
+ if (currentPrompt && activePromptThreadId === prompt.threadId && currentPrompt.threadId !== prompt.threadId) {
+ questionQueueRef.current.push(currentPrompt);
+ return prompt;
+ }
+
+ if (currentPrompt) {
+ questionQueueRef.current.push(prompt);
+ return currentPrompt;
+ }
+
+ return prompt;
+ });
+ }, [activePromptThreadId]);
+
+ const resolveQuestionPrompt = useCallback((response: AgentQuestionResponse) => {
+ const currentPrompt = visibleQuestionPrompt;
+ if (!currentPrompt) return;
+
+ const currentPromptKey = getQuestionPromptDedupeKey(currentPrompt);
+ ignoredQuestionPromptIdsRef.current.add(currentPrompt.questionId);
+ ignoredQuestionPromptKeysRef.current.add(currentPromptKey);
+ questionQueueRef.current = questionQueueRef.current.filter((queuedPrompt) => (
+ queuedPrompt.questionId !== currentPrompt.questionId &&
+ getQuestionPromptDedupeKey(queuedPrompt) !== currentPromptKey
+ ));
+
+ void window.agentConsole?.agent.resolveQuestion({
+ ...response,
+ questionId: currentPrompt.questionId
+ }).catch((error) => {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("agent.questionResolveFailed"),
+ title: t("agent.toastTitle")
+ });
+ ignoredQuestionPromptIdsRef.current.delete(currentPrompt.questionId);
+ ignoredQuestionPromptKeysRef.current.delete(currentPromptKey);
+ });
+
+ setQuestionPrompt(shiftNextQuestionPrompt(
+ questionQueueRef.current,
+ ignoredQuestionPromptIdsRef.current,
+ ignoredQuestionPromptKeysRef.current,
+ activePromptThreadId
+ ));
+ }, [activePromptThreadId, t, toast, visibleQuestionPrompt]);
+
+ const updateContextUsage = useCallback((threadId: string, usage: UsageTokenMetrics | undefined) => {
+ if (!threadId || !usage) return;
+ setContextUsageByThread((currentUsageByThread) => ({
+ ...currentUsageByThread,
+ [threadId]: usage
+ }));
+ }, []);
+
+ useEffect(() => {
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi) return undefined;
+
+ let canceled = false;
+ const dispose = agentApi.onEvent((event) => {
+ if (!event || !event.runId) return;
+
+ if (event.type === "run_started") {
+ const pendingMessageId = pendingAssistantMessageIdRef.current;
+ if (pendingMessageId) {
+ runMessageIdsRef.current.set(event.runId, pendingMessageId);
+ runThreadIdsRef.current.set(event.runId, event.threadId);
+ runLocalMessageIdsRef.current.set(event.runId, {
+ assistantMessageId: pendingMessageId,
+ threadId: event.threadId,
+ userMessageId: pendingUserMessageIdRef.current ?? ""
+ });
+ persistPendingRunSnapshotForThread(event.threadId);
+ pendingAssistantMessageIdRef.current = null;
+ pendingUserMessageIdRef.current = null;
+ pendingAssistantThreadIdRef.current = null;
+ }
+ return;
+ }
+
+ if (event.type === "approval_request") {
+ enqueueApprovalPrompt(event);
+ return;
+ }
+
+ if (event.type === "question_request") {
+ enqueueQuestionPrompt(event);
+ return;
+ }
+
+ if (event.type === "usage") {
+ updateContextUsage(event.threadId, event.usage);
+ return;
+ }
+
+ const messageId = runMessageIdsRef.current.get(event.runId) ?? pendingAssistantMessageIdRef.current;
+ if (!messageId) return;
+ const threadId = event.threadId || runThreadIdsRef.current.get(event.runId) || pendingAssistantThreadIdRef.current || selectedThreadRef.current;
+
+ if (event.type === "message_delta" && event.data) {
+ updateInFlightMessage(threadId, messageId, (message) => ({
+ ...message,
+ content: `${message.content}${event.data ?? ""}`,
+ parts: appendTextMessagePart(message.parts, event.data ?? ""),
+ streaming: true
+ }));
+ return;
+ }
+
+ if (event.type === "message_part" && event.part) {
+ const part = normalizeMessagePart(event.part);
+ if (!part) return;
+
+ updateInFlightMessage(threadId, messageId, (message) => upsertMessagePartOnMessage(message, part));
+ return;
+ }
+
+ if (event.type === "tool_event" && event.toolEvent) {
+ const toolEvent = normalizeToolEvent(event.toolEvent);
+ if (!toolEvent) return;
+
+ updateInFlightMessage(threadId, messageId, (message) => ({
+ ...message,
+ parts: upsertToolMessagePart(message.parts, toolEvent),
+ streaming: true,
+ toolEvents: upsertToolEvent(message.toolEvents, toolEvent)
+ }));
+ return;
+ }
+
+ if (event.type === "error") {
+ const errorMessage = event.message || t("agent.runFailed");
+ updateInFlightMessage(threadId, messageId, (message) => ({
+ ...message,
+ content: `${message.content}${message.content ? "\n\n" : ""}> ${errorMessage}`,
+ streaming: false
+ }));
+ setVisibleActiveStream(null, threadId);
+ runMessageIdsRef.current.delete(event.runId);
+ runThreadIdsRef.current.delete(event.runId);
+ clearLocalRunMessages(event.runId);
+ return;
+ }
+
+ if (event.type === "run_finished") {
+ updateContextUsage(event.threadId, event.usage);
+ updateInFlightMessage(threadId, messageId, (message) => ({
+ ...message,
+ parts: message.parts?.length ? normalizeTrailingToolMessageParts(message.parts) : message.parts,
+ streaming: false
+ }));
+ setVisibleActiveStream(null, threadId);
+ runMessageIdsRef.current.delete(event.runId);
+ runThreadIdsRef.current.delete(event.runId);
+ clearLocalRunMessages(event.runId);
+ void reloadProjects().catch((error) => {
+ console.warn("[agent] Failed to refresh projects after run.", error);
+ });
+ }
+ });
+
+ void agentApi.listPendingInteractions?.().then((result) => {
+ if (canceled) return;
+ result.approvals.forEach(enqueueApprovalPrompt);
+ result.questions.forEach(enqueueQuestionPrompt);
+ }).catch((error) => {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("agent.runFailed"),
+ title: t("agent.toastTitle")
+ });
+ });
+
+ return () => {
+ canceled = true;
+ dispose();
+ };
+ }, [clearLocalRunMessages, enqueueApprovalPrompt, enqueueQuestionPrompt, persistPendingRunSnapshotForThread, reloadProjects, setVisibleActiveStream, t, toast, updateContextUsage, updateInFlightMessage]);
+
+ const enqueuePrompt = useCallback((rawPrompt: string) => {
+ const userPrompt = rawPrompt.trim();
+ if (!userPrompt || activeStream) return false;
+ const selectedProject = projects.find((project) => project.id === newSessionProjectId) ?? getDefaultProject(projects);
+ const runProviderId = selectedThread === newSessionThreadId
+ ? agentProviderId
+ : findThreadForId(projects, selectedThread)?.providerId ?? agentProviderId;
+ const runProvider = enabledAgentProviders.find((provider) => provider.id === runProviderId) ?? null;
+ const runModelOptions = getAgentModelOptions(enabledAgentProviders, runProviderId);
+ const runModel = getValidAgentModel(agentModel, enabledAgentProviders, runProviderId);
+ const runEffort = getAgentEffortForRequest(agentEffort, runModel, runModelOptions, runProvider);
+ const runSpeedOptions = getAgentSpeedOptionsForModel(runModel, runModelOptions, runProvider);
+ const runSpeed = getAgentSpeedForRequest(agentSpeed, runSpeedOptions);
+ const prompt = userPrompt;
+ const contextWindow = getAgentContextWindowInfo(getContextWindowMetrics({
+ attachments: composerAttachments,
+ composerValue: prompt,
+ messages,
+ model: runModel,
+ modelOptions: runModelOptions,
+ providerId: runProviderId,
+ usage: contextUsageByThread[selectedThread] ?? null
+ }));
+
+ const createdAt = Date.now();
+ const userMessage: ChatMessage = {
+ createdAt,
+ id: createMessageId("user"),
+ role: "user",
+ content: prompt
+ };
+ const assistantMessage: ChatMessage = {
+ createdAt,
+ id: createMessageId("assistant"),
+ role: "assistant",
+ content: "",
+ parts: [],
+ streaming: true
+ };
+
+ const agentApi = window.agentConsole?.agent;
+ if (!agentApi) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("agent.toastTitle") });
+ return false;
+ }
+
+ if (selectedThread === newSessionThreadId && !selectedProject) {
+ toast.error({ content: t("newSession.projectUnavailable"), title: t("agent.toastTitle") });
+ return false;
+ }
+
+ const provisionalThreadId = selectedThread;
+ const nextStream = {
+ id: assistantMessage.id,
+ running: true,
+ streamKey: Date.now()
+ };
+ pendingAssistantMessageIdRef.current = assistantMessage.id;
+ pendingUserMessageIdRef.current = userMessage.id;
+ pendingAssistantThreadIdRef.current = provisionalThreadId;
+ setInFlightMessagesForThread(provisionalThreadId, [
+ ...(inFlightMessagesByThreadRef.current.get(provisionalThreadId) ?? []),
+ userMessage,
+ assistantMessage
+ ]);
+ activeStreamsByThreadRef.current.set(provisionalThreadId, nextStream);
+ persistPendingRunSnapshotForThread(provisionalThreadId);
+ setMessages((currentMessages) => [...currentMessages, userMessage, assistantMessage]);
+ setActiveStream(nextStream);
+
+ void (async () => {
+ let threadId = selectedThread;
+ if (selectedThread === newSessionThreadId) {
+ const threadResult = await agentApi.startThread({
+ cwd: selectedProject?.path,
+ projectId: selectedProject?.id,
+ projectName: selectedProject ? getProjectDisplayName(selectedProject) : undefined,
+ projectPath: selectedProject?.path,
+ prompt,
+ providerId: agentProviderId,
+ title: userPrompt.replace(/\s+/g, " ").slice(0, 80)
+ });
+ threadId = threadResult.thread.id;
+ suppressThreadHistoryLoadRef.current = threadId;
+ pendingAssistantThreadIdRef.current = threadId;
+ moveInFlightThreadState(provisionalThreadId, threadId);
+ selectedThreadRef.current = threadId;
+ setSelectedThread(threadId);
+ void reloadProjects().catch((error) => {
+ console.warn("[agent] Failed to refresh projects after creating a thread.", error);
+ });
+ }
+
+ await agentApi.sendMessage({
+ approvalMode: agentApprovalMode,
+ attachments: composerAttachments,
+ contextWindow,
+ effort: runEffort,
+ model: runModel || undefined,
+ prompt,
+ providerId: runProviderId,
+ speed: runSpeed,
+ subagentIds: selectedSubagentIds,
+ threadId
+ });
+ })().catch((error) => {
+ const errorMessage = error instanceof Error && error.message ? error.message : t("agent.sendFailed");
+ const failureThreadId = pendingAssistantThreadIdRef.current || provisionalThreadId;
+ updateInFlightMessage(failureThreadId, assistantMessage.id, (message) => ({
+ ...message,
+ content: `${message.content}${message.content ? "\n\n" : ""}> ${errorMessage}`,
+ streaming: false
+ }));
+ setVisibleActiveStream(null, failureThreadId);
+ pendingAssistantMessageIdRef.current = null;
+ pendingUserMessageIdRef.current = null;
+ pendingAssistantThreadIdRef.current = null;
+ });
+
+ return true;
+ }, [activeStream, agentApprovalMode, agentEffort, agentModel, agentProviderId, agentSpeed, composerAttachments, contextUsageByThread, enabledAgentProviders, messages, moveInFlightThreadState, newSessionProjectId, persistPendingRunSnapshotForThread, projects, reloadProjects, selectedSubagentIds, selectedThread, setInFlightMessagesForThread, setVisibleActiveStream, t, toast, updateInFlightMessage]);
+
+ const submitMessage = useCallback(() => {
+ if (enqueuePrompt(composerValue)) {
+ setComposerValue("");
+ setComposerAttachments([]);
+ }
+ }, [composerValue, enqueuePrompt]);
+
+ useEffect(() => {
+ return window.agentConsole?.ipc.on("agent-console:spotlight:prompt", (payload) => {
+ if (typeof payload !== "string") return;
+ if (!enqueuePrompt(payload)) {
+ setComposerValue(payload.trim());
+ }
+ });
+ }, [enqueuePrompt]);
+
+ const startSidebarResize = (side: ResizeSide, event: ReactPointerEvent) => {
+ event.preventDefault();
+ const startX = event.clientX;
+ const startWidth = side === "left" ? leftWidth : rightWidth;
+ const bounds = side === "left" ? leftSidebarBounds : rightSidebarBounds;
+ const maxWidth = Math.max(bounds.min, Math.min(bounds.max, window.innerWidth - 520));
+
+ setResizingSide(side);
+
+ const onPointerMove = (moveEvent: PointerEvent) => {
+ const delta = moveEvent.clientX - startX;
+ const nextWidth = side === "left" ? startWidth + delta : startWidth - delta;
+ const clampedWidth = Math.min(maxWidth, Math.max(bounds.min, nextWidth));
+
+ if (side === "left") {
+ setLeftWidth(clampedWidth);
+ } else {
+ setRightWidth(clampedWidth);
+ }
+ };
+
+ const stopResize = () => {
+ setResizingSide(null);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ window.removeEventListener("pointermove", onPointerMove);
+ window.removeEventListener("pointerup", stopResize);
+ window.removeEventListener("pointercancel", stopResize);
+ };
+
+ document.body.style.cursor = "col-resize";
+ document.body.style.userSelect = "none";
+ window.addEventListener("pointermove", onPointerMove);
+ window.addEventListener("pointerup", stopResize);
+ window.addEventListener("pointercancel", stopResize);
+ };
+
+ const subagentCreateDialog = (
+
+ );
+
+ if (isSmallChatWindow) {
+ const smallShell = (
+
+ setAgentEffort(normalizeAgentEffort(value))}
+ onAgentModelChange={changeAgentModel}
+ onAgentProviderCreate={createAndSelectAgentProvider}
+ onAgentProviderChange={changeAgentProvider}
+ onAgentSpeedChange={(value) => setAgentSpeed(normalizeAgentSpeed(value))}
+ onApprovalResolve={resolveApprovalPrompt}
+ onAttachFiles={addComposerAttachments}
+ onBranchChange={changeNewSessionBranch}
+ onComposerChange={setComposerValue}
+ onCreateBlankProject={createBlankProject}
+ onCreateSubagent={openSubagentCreateDialog}
+ onMessageBranch={forkMessage}
+ onUserMessageEdit={editUserMessage}
+ onNewSessionProjectChange={changeNewSessionProject}
+ onOpenVoiceSettings={() => openSettings("general")}
+ onQuestionResolve={resolveQuestionPrompt}
+ onRemoveAttachment={removeComposerAttachment}
+ onSlashCommandSelect={runSlashCommand}
+ onSubmit={submitMessage}
+ onToggleStreaming={toggleStreaming}
+ projects={projects}
+ questionPrompt={visibleQuestionPrompt}
+ runtimeAgentProviders={agentProviders}
+ selectedProjectId={newSessionProjectId}
+ selectedSubagentIds={selectedSubagentIds}
+ slashCommands={slashCommands}
+ subagents={appSettings.subagents}
+ transcriptionConfig={transcriptionConfig}
+ onSubagentSelectionChange={setSelectedSubagentIds}
+ />
+
+ );
+ const fullShell = (
+
+
startSidebarResize("left", event)}
+ onOpenSettings={openSettings}
+ onOpenSearch={() => setSearchDialogOpen(true)}
+ onStartNewSession={startNewSession}
+ onCancelThreadRename={() => setRenamingSidebarThreadId(null)}
+ onRenameThread={saveThreadTitle}
+ onStartThreadRename={startThreadRename}
+ onThreadContextMenu={openThreadContextMenu}
+ open={leftOpen}
+ projects={projects}
+ renamingThreadId={renamingSidebarThreadId}
+ resizing={resizingSide === "left"}
+ selectedThread={selectedThread}
+ setSelectedThread={selectThread}
+ width={leftWidth}
+ />
+
+
+ 0}
+ canCopyThreadId={Boolean(selectedSidebarThread)}
+ canRename={Boolean(selectedSidebarThread)}
+ editingThread={renamingHeaderThreadId === selectedSidebarThread?.id ? selectedSidebarThread : null}
+ leftOpen={leftOpen}
+ onCancelRename={() => setRenamingHeaderThreadId(null)}
+ onCopyMarkdown={copyActiveThreadMarkdown}
+ onCopyThreadId={copyActiveThreadId}
+ onOpenSmallWindow={openActiveThreadSmallWindow}
+ onRename={renameActiveThread}
+ onSubmitRename={saveThreadTitle}
+ title={!messages.length && selectedThread === newSessionThreadId ? "" : activeTitle}
+ />
+
+ setAgentEffort(normalizeAgentEffort(value))}
+ onAgentModelChange={changeAgentModel}
+ onAgentProviderCreate={createAndSelectAgentProvider}
+ onAgentProviderChange={changeAgentProvider}
+ onAgentSpeedChange={(value) => setAgentSpeed(normalizeAgentSpeed(value))}
+ onApprovalResolve={resolveApprovalPrompt}
+ onAttachFiles={addComposerAttachments}
+ onBranchChange={changeNewSessionBranch}
+ onComposerChange={setComposerValue}
+ onCreateBlankProject={createBlankProject}
+ onCreateSubagent={openSubagentCreateDialog}
+ onMessageBranch={forkMessage}
+ onUserMessageEdit={editUserMessage}
+ onNewSessionProjectChange={changeNewSessionProject}
+ onOpenVoiceSettings={() => openSettings("general")}
+ onQuestionResolve={resolveQuestionPrompt}
+ onRemoveAttachment={removeComposerAttachment}
+ onSlashCommandSelect={runSlashCommand}
+ onSubmit={submitMessage}
+ onToggleStreaming={toggleStreaming}
+ projects={projects}
+ questionPrompt={visibleQuestionPrompt}
+ runtimeAgentProviders={agentProviders}
+ selectedProjectId={newSessionProjectId}
+ selectedSubagentIds={selectedSubagentIds}
+ slashCommands={slashCommands}
+ subagents={appSettings.subagents}
+ transcriptionConfig={transcriptionConfig}
+ onSubagentSelectionChange={setSelectedSubagentIds}
+ />
+
+
+
+ startSidebarResize("right", event)}
+ onSelectThread={setSelectedThread}
+ onThreadsChanged={reloadProjects}
+ open={rightOpen}
+ openTabs={rightSidebarState.tabs}
+ plugins={availableRightSidebarPlugins}
+ resizing={resizingSide === "right"}
+ setActiveTab={setActiveRightPanelTab}
+ width={rightWidth}
+ />
+
+ setLeftOpen((open) => !open)}
+ toggleRight={() => setRightOpen((open) => !open)}
+ />
+
+ );
+
+ return (
+
+
+
+ {subagentCreateDialog}
+
+
+ );
+ }
+
+ return (
+
+
+ {activePage === "settings" ? (
+
setActivePage("chat")}
+ onAgentEnvironmentSave={saveAgentEnvironment}
+ onAgentProviderEnabledChange={saveAgentProviderEnabled}
+ onAgentProvidersSave={saveAgentProviders}
+ onSubagentsSave={saveSubagents}
+ onPreferenceChange={updateSettingsPreference}
+ onPluginAction={runPluginAction}
+ onSectionChange={setActiveSettingsSection}
+ onSpotlightShortcutChange={updateSpotlightShortcut}
+ onSpotlightShortcutReset={resetSpotlightShortcut}
+ onTranscriptionConfigChange={updateTranscriptionConfig}
+ onToolHubEnabledChange={saveToolHubEnabled}
+ onToolHubBuiltinMcpServerChange={updateToolHubBuiltinMcpServer}
+ onToolHubCacheClear={clearToolHubCache}
+ onToolHubLlmConfigSave={saveToolHubLlmConfig}
+ onToolHubServerInstall={installToolHubServer}
+ onToolHubServerRemove={removeToolHubServer}
+ onToolHubServerUpdate={updateToolHubServer}
+ preferences={settingsPreferences}
+ pluginState={pluginState}
+ transcriptionConfig={transcriptionConfig}
+ />
+ ) : (
+
+
startSidebarResize("left", event)}
+ onOpenSettings={openSettings}
+ onOpenSearch={() => setSearchDialogOpen(true)}
+ onStartNewSession={startNewSession}
+ onCancelThreadRename={() => setRenamingSidebarThreadId(null)}
+ onRenameThread={saveThreadTitle}
+ onStartThreadRename={startThreadRename}
+ onThreadContextMenu={openThreadContextMenu}
+ open={leftOpen}
+ projects={projects}
+ renamingThreadId={renamingSidebarThreadId}
+ resizing={resizingSide === "left"}
+ selectedThread={selectedThread}
+ setSelectedThread={selectThread}
+ width={leftWidth}
+ />
+
+
+ {activePage === "bot" ? (
+ setActivePage("chat")}
+ />
+ ) : activePage === "automations" ? (
+ setActivePage("chat")}
+ projects={projects}
+ />
+ ) : (
+ <>
+ 0}
+ canCopyThreadId={Boolean(selectedSidebarThread)}
+ canRename={Boolean(selectedSidebarThread)}
+ editingThread={renamingHeaderThreadId === selectedSidebarThread?.id ? selectedSidebarThread : null}
+ leftOpen={leftOpen}
+ onCancelRename={() => setRenamingHeaderThreadId(null)}
+ onCopyMarkdown={copyActiveThreadMarkdown}
+ onCopyThreadId={copyActiveThreadId}
+ onOpenSmallWindow={openActiveThreadSmallWindow}
+ onRename={renameActiveThread}
+ onSubmitRename={saveThreadTitle}
+ title={!messages.length && selectedThread === newSessionThreadId ? "" : activeTitle}
+ />
+
+ setAgentEffort(normalizeAgentEffort(value))}
+ onAgentModelChange={changeAgentModel}
+ onAgentProviderCreate={createAndSelectAgentProvider}
+ onAgentProviderChange={changeAgentProvider}
+ onAgentSpeedChange={(value) => setAgentSpeed(normalizeAgentSpeed(value))}
+ onApprovalResolve={resolveApprovalPrompt}
+ onAttachFiles={addComposerAttachments}
+ onBranchChange={changeNewSessionBranch}
+ onComposerChange={setComposerValue}
+ onCreateBlankProject={createBlankProject}
+ onCreateSubagent={openSubagentCreateDialog}
+ onMessageBranch={forkMessage}
+ onUserMessageEdit={editUserMessage}
+ onNewSessionProjectChange={changeNewSessionProject}
+ onOpenVoiceSettings={() => openSettings("general")}
+ onQuestionResolve={resolveQuestionPrompt}
+ onRemoveAttachment={removeComposerAttachment}
+ onSlashCommandSelect={runSlashCommand}
+ onSubmit={submitMessage}
+ onToggleStreaming={toggleStreaming}
+ projects={projects}
+ questionPrompt={visibleQuestionPrompt}
+ runtimeAgentProviders={agentProviders}
+ selectedProjectId={newSessionProjectId}
+ selectedSubagentIds={selectedSubagentIds}
+ slashCommands={slashCommands}
+ subagents={appSettings.subagents}
+ transcriptionConfig={transcriptionConfig}
+ onSubagentSelectionChange={setSelectedSubagentIds}
+ />
+
+ >
+ )}
+
+
+ startSidebarResize("right", event)}
+ onSelectThread={setSelectedThread}
+ onThreadsChanged={reloadProjects}
+ open={rightOpen}
+ openTabs={rightSidebarState.tabs}
+ plugins={availableRightSidebarPlugins}
+ resizing={resizingSide === "right"}
+ setActiveTab={setActiveRightPanelTab}
+ width={rightWidth}
+ />
+
+ setLeftOpen((open) => !open)}
+ toggleRight={() => setRightOpen((open) => !open)}
+ />
+
+ setSearchDialogOpen(false)}
+ onSelectThread={selectSearchResult}
+ open={searchDialogOpen}
+ projects={projects}
+ selectedThread={selectedThread}
+ />
+
+ )}
+ {subagentCreateDialog}
+
+
+ );
+}
+
+function SubagentCreateDialog({
+ agentProviders,
+ error,
+ form,
+ onChange,
+ onClose,
+ onSave,
+ open,
+ saving
+}: {
+ agentProviders: AgentProviderOption[];
+ error: string | null;
+ form: SubagentSettingsForm | null;
+ onChange: (key: keyof SubagentSettingsForm, value: string) => void;
+ onClose: () => void;
+ onSave: () => Promise;
+ open: boolean;
+ saving: boolean;
+}) {
+ const { t } = useI18n();
+
+ return (
+
+ {open ? (
+
+
+ {error ?? t("settings.subagents.unsavedInline")}
+
+
+
+
+
+
+ }
+ key="composer-new-subagent"
+ onClose={onClose}
+ title={t("settings.subagents.newSubagent")}
+ >
+
+
+ ) : null}
+
+ );
+}
+
+function getApprovalPromptDedupeKey(prompt: Pick): string {
+ return stringifyStableValue({
+ method: prompt.method || "",
+ params: prompt.params,
+ providerId: prompt.providerId,
+ runId: prompt.runId,
+ scope: prompt.approvalScope || "",
+ threadId: prompt.threadId,
+ title: prompt.title || ""
+ });
+}
+
+function stringifyStableValue(value: unknown): string {
+ try {
+ return JSON.stringify(sortStableValue(value));
+ } catch {
+ return String(value);
+ }
+}
+
+function sortStableValue(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map(sortStableValue);
+ }
+
+ if (value && typeof value === "object") {
+ return Object.fromEntries(
+ Object.entries(value as Record)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, item]) => [key, sortStableValue(item)])
+ );
+ }
+
+ return value;
+}
+
+function shiftNextApprovalPrompt(
+ queue: AgentApprovalPrompt[],
+ ignoredIds: Set,
+ ignoredKeys: Set,
+ preferredThreadId: string | null
+): AgentApprovalPrompt | null {
+ const preferredPrompt = takeApprovalPromptForThread(queue, preferredThreadId, ignoredIds, ignoredKeys);
+ if (preferredPrompt) return preferredPrompt;
+
+ let nextPrompt = queue.shift() ?? null;
+ while (nextPrompt && isIgnoredApprovalPrompt(nextPrompt, ignoredIds, ignoredKeys)) {
+ nextPrompt = queue.shift() ?? null;
+ }
+ return nextPrompt;
+}
+
+function shiftNextQuestionPrompt(
+ queue: AgentQuestionPrompt[],
+ ignoredIds: Set,
+ ignoredKeys: Set,
+ preferredThreadId: string | null
+): AgentQuestionPrompt | null {
+ const preferredPrompt = takeQuestionPromptForThread(queue, preferredThreadId, ignoredIds, ignoredKeys);
+ if (preferredPrompt) return preferredPrompt;
+
+ let nextPrompt = queue.shift() ?? null;
+ while (nextPrompt && isIgnoredQuestionPrompt(nextPrompt, ignoredIds, ignoredKeys)) {
+ nextPrompt = queue.shift() ?? null;
+ }
+ return nextPrompt;
+}
+
+function promoteApprovalPromptForThread(
+ currentPrompt: AgentApprovalPrompt | null,
+ queue: AgentApprovalPrompt[],
+ threadId: string,
+ ignoredIds: Set,
+ ignoredKeys: Set
+): AgentApprovalPrompt | null {
+ if (currentPrompt?.threadId === threadId && !isIgnoredApprovalPrompt(currentPrompt, ignoredIds, ignoredKeys)) {
+ return currentPrompt;
+ }
+
+ const nextPrompt = takeApprovalPromptForThread(queue, threadId, ignoredIds, ignoredKeys);
+ if (!nextPrompt) return currentPrompt;
+
+ if (currentPrompt && !isIgnoredApprovalPrompt(currentPrompt, ignoredIds, ignoredKeys)) {
+ queue.push(currentPrompt);
+ }
+ return nextPrompt;
+}
+
+function promoteQuestionPromptForThread(
+ currentPrompt: AgentQuestionPrompt | null,
+ queue: AgentQuestionPrompt[],
+ threadId: string,
+ ignoredIds: Set,
+ ignoredKeys: Set
+): AgentQuestionPrompt | null {
+ if (currentPrompt?.threadId === threadId && !isIgnoredQuestionPrompt(currentPrompt, ignoredIds, ignoredKeys)) {
+ return currentPrompt;
+ }
+
+ const nextPrompt = takeQuestionPromptForThread(queue, threadId, ignoredIds, ignoredKeys);
+ if (!nextPrompt) return currentPrompt;
+
+ if (currentPrompt && !isIgnoredQuestionPrompt(currentPrompt, ignoredIds, ignoredKeys)) {
+ queue.push(currentPrompt);
+ }
+ return nextPrompt;
+}
+
+function takeApprovalPromptForThread(
+ queue: AgentApprovalPrompt[],
+ threadId: string | null,
+ ignoredIds: Set,
+ ignoredKeys: Set
+): AgentApprovalPrompt | null {
+ if (!threadId) return null;
+
+ const promptIndex = queue.findIndex((prompt) => prompt.threadId === threadId && !isIgnoredApprovalPrompt(prompt, ignoredIds, ignoredKeys));
+ if (promptIndex === -1) return null;
+
+ const [prompt] = queue.splice(promptIndex, 1);
+ return prompt ?? null;
+}
+
+function isIgnoredApprovalPrompt(
+ prompt: AgentApprovalPrompt,
+ ignoredIds: Set,
+ ignoredKeys: Set
+): boolean {
+ return ignoredIds.has(prompt.approvalId) || ignoredKeys.has(getApprovalPromptDedupeKey(prompt));
+}
+
+function takeQuestionPromptForThread(
+ queue: AgentQuestionPrompt[],
+ threadId: string | null,
+ ignoredIds: Set,
+ ignoredKeys: Set
+): AgentQuestionPrompt | null {
+ if (!threadId) return null;
+
+ const promptIndex = queue.findIndex((prompt) => (
+ prompt.threadId === threadId &&
+ !isIgnoredQuestionPrompt(prompt, ignoredIds, ignoredKeys)
+ ));
+ if (promptIndex === -1) return null;
+
+ const [prompt] = queue.splice(promptIndex, 1);
+ return prompt ?? null;
+}
+
+function isIgnoredQuestionPrompt(
+ prompt: AgentQuestionPrompt,
+ ignoredIds: Set,
+ ignoredKeys: Set
+): boolean {
+ return ignoredIds.has(prompt.questionId) || ignoredKeys.has(getQuestionPromptDedupeKey(prompt));
+}
+
+function getQuestionPromptDedupeKey(prompt: AgentQuestionPrompt): string {
+ const questions = prompt.questions.map((question) => ({
+ allowCustomAnswer: question.allowCustomAnswer === true,
+ control: question.control || "",
+ header: question.header || "",
+ multiSelect: question.multiSelect === true,
+ options: (question.options ?? []).map((option) => ({
+ description: option.description || "",
+ label: option.label,
+ preview: option.preview || ""
+ })),
+ placeholder: question.placeholder || "",
+ preview: question.preview || "",
+ question: question.question
+ }));
+ return JSON.stringify({
+ providerId: prompt.providerId,
+ questions,
+ runId: prompt.runId,
+ threadId: prompt.threadId
+ });
+}
+
+export default App;
diff --git a/marketplace/plugins/agent-console/src/renderer/pages/home/components/automations.tsx b/marketplace/plugins/agent-console/src/renderer/pages/home/components/automations.tsx
new file mode 100644
index 00000000..8f1879fb
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/pages/home/components/automations.tsx
@@ -0,0 +1,860 @@
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select } from "@/components/ui/select";
+import { Textarea } from "@/components/ui/textarea";
+import { useToast } from "@/components/ui/toast";
+import { cn } from "@/lib/utils";
+import {
+ Activity,
+ Clock3,
+ Globe2,
+ ListChecks,
+ Pencil,
+ Play,
+ Plus,
+ RefreshCw,
+ Save,
+ Trash2,
+ Webhook,
+ X
+} from "lucide-react";
+import type { KeyboardEvent, ReactNode } from "react";
+import { useCallback, useEffect, useState } from "react";
+import type {
+ AutomationDefinition,
+ AutomationListResult,
+ AutomationPollCondition,
+ AutomationTrigger,
+ AutomationTriggerType
+} from "../../../../shared/automation-types";
+import { getSidebarProjectLabel, type SidebarProject } from "../../../../shared/sidebar-data";
+import type { AgentProviderOption } from "../utils/core";
+
+type AutomationFormState = {
+ approvalMode: "auto" | "full" | "request";
+ concurrencyPolicy: "queue" | "skip";
+ cronExpression: string;
+ enabled: boolean;
+ every: string;
+ id: string;
+ model: string;
+ name: string;
+ pollBody: string;
+ pollConditionType: "json-equals" | "json-exists" | "text-contains";
+ pollDedupeKeyPath: string;
+ pollMethod: "GET" | "POST";
+ pollPath: string;
+ pollTriggerOnRepeatedMatch: boolean;
+ pollUrl: string;
+ pollValue: string;
+ projectId: string;
+ prompt: string;
+ providerId: string;
+ threadPolicy: "new" | "reuse";
+ timezone: string;
+ triggerType: AutomationTriggerType;
+ webhookSecret: string;
+ webhookSlug: string;
+};
+
+const defaultCronExpression = "0 * * * * *";
+const defaultEvery = "1m";
+const approvalModeOptions = [
+ { label: "Auto", value: "auto" },
+ { label: "Full", value: "full" },
+ { label: "Request", value: "request" }
+];
+const concurrencyPolicyOptions = [
+ { label: "Skip overlap", value: "skip" },
+ { label: "Queue overlap", value: "queue" }
+];
+const intervalOptions = [
+ { label: "1 second", value: "1s" },
+ { label: "5 seconds", value: "5s" },
+ { label: "10 seconds", value: "10s" },
+ { label: "30 seconds", value: "30s" },
+ { label: "1 minute", value: "1m" },
+ { label: "5 minutes", value: "5m" },
+ { label: "15 minutes", value: "15m" },
+ { label: "1 hour", value: "1h" },
+ { label: "1 day", value: "1d" }
+];
+const pollConditionOptions = [
+ { label: "Text contains", value: "text-contains" },
+ { label: "JSON exists", value: "json-exists" },
+ { label: "JSON equals", value: "json-equals" }
+];
+const pollMethodOptions = [
+ { label: "GET", value: "GET" },
+ { label: "POST", value: "POST" }
+];
+const threadPolicyOptions = [
+ { label: "Reuse", value: "reuse" },
+ { label: "New each run", value: "new" }
+];
+
+export function AutomationsPage({
+ agentProviders,
+ leftOpen,
+ projects
+}: {
+ agentProviders: AgentProviderOption[];
+ leftOpen: boolean;
+ onBack: () => void;
+ projects: SidebarProject[];
+}) {
+ const toast = useToast();
+ const [automations, setAutomations] = useState([]);
+ const [submitting, setSubmitting] = useState(false);
+ const [webhookBaseUrl, setWebhookBaseUrl] = useState(null);
+ const [draft, setDraft] = useState(() => createDefaultDraft(agentProviders, projects));
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const selectedProvider = agentProviders.find((provider) => provider.id === draft.providerId) ?? agentProviders[0];
+ const webhookUrl = draft.triggerType === "webhook" && webhookBaseUrl && draft.webhookSlug
+ ? `${webhookBaseUrl}/${draft.webhookSlug}`
+ : "";
+
+ const applyListResult = useCallback((result: AutomationListResult) => {
+ setAutomations(result.automations);
+ setWebhookBaseUrl(result.webhookBaseUrl);
+ }, []);
+
+ const reloadAutomations = useCallback(async () => {
+ const api = window.agentConsole?.automations;
+ if (!api) return;
+ applyListResult(await api.list());
+ }, [applyListResult]);
+
+ useEffect(() => {
+ reloadAutomations().catch((error) => {
+ console.warn("[automation] Failed to load automations.", error);
+ });
+ }, [reloadAutomations]);
+
+ useEffect(() => {
+ const api = window.agentConsole?.automations;
+ if (!api?.onEvent) return undefined;
+ return api.onEvent(() => {
+ reloadAutomations().catch((error) => {
+ console.warn("[automation] Failed to reload after event.", error);
+ });
+ });
+ }, [reloadAutomations]);
+
+ const startNewAutomation = () => {
+ setDraft(createDefaultDraft(agentProviders, projects));
+ setDialogOpen(true);
+ };
+
+ const editAutomation = (automation: AutomationDefinition) => {
+ setDraft(formStateFromAutomation(automation, agentProviders, projects));
+ setDialogOpen(true);
+ };
+
+ const saveAutomation = async () => {
+ const api = window.agentConsole?.automations;
+ if (!api) return;
+ setSubmitting(true);
+ try {
+ const payload = automationPayloadFromDraft(draft, agentProviders, projects);
+ const result = draft.id ? await api.update(payload as AutomationDefinition & { id: string }) : await api.create(payload);
+ applyListResult(result);
+ setDialogOpen(false);
+ toast.success({ content: "Automation saved.", title: "Automations" });
+ } catch (error) {
+ toast.error({ content: error instanceof Error ? error.message : String(error), title: "Automations" });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const deleteAutomation = async (id: string) => {
+ if (!id) return;
+ const api = window.agentConsole?.automations;
+ if (!api) return;
+ setSubmitting(true);
+ try {
+ applyListResult(await api.delete({ id }));
+ setDraft(createDefaultDraft(agentProviders, projects));
+ setDialogOpen(false);
+ toast.success({ content: "Automation deleted.", title: "Automations" });
+ } catch (error) {
+ toast.error({ content: error instanceof Error ? error.message : String(error), title: "Automations" });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const setEnabled = async (id: string, enabled: boolean) => {
+ const api = window.agentConsole?.automations;
+ if (!api || !id) return;
+ setSubmitting(true);
+ try {
+ applyListResult(await api.setEnabled({ enabled, id }));
+ } catch (error) {
+ toast.error({ content: error instanceof Error ? error.message : String(error), title: "Automations" });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const runNow = async (id: string) => {
+ const api = window.agentConsole?.automations;
+ if (!api || !id) return;
+ setSubmitting(true);
+ try {
+ applyListResult(await api.runNow({ id }));
+ toast.success({ content: "Run started.", title: "Automations" });
+ } catch (error) {
+ toast.error({ content: error instanceof Error ? error.message : String(error), title: "Automations" });
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {automations.length ? automations.map((automation) => (
+
+
+
+
+ {automation.name}
+
+
+
+ Trigger
+ {formatTrigger(automation.trigger)}
+
+
+
+
+
+
+
+
+
+ )) : (
+
No automation tasks
+ )}
+
+
+
+
+
setDialogOpen(false)}
+ onDelete={draft.id ? () => void deleteAutomation(draft.id) : undefined}
+ onRun={draft.id ? () => void runNow(draft.id) : undefined}
+ onSave={() => void saveAutomation()}
+ open={dialogOpen}
+ projects={projects}
+ selectedProvider={selectedProvider}
+ setDraft={setDraft}
+ submitting={submitting}
+ webhookUrl={webhookUrl}
+ />
+
+ );
+}
+
+function AutomationTaskDialog({
+ agentProviders,
+ draft,
+ onClose,
+ onDelete,
+ onRun,
+ onSave,
+ open,
+ projects,
+ selectedProvider,
+ setDraft,
+ submitting,
+ webhookUrl
+}: {
+ agentProviders: AgentProviderOption[];
+ draft: AutomationFormState;
+ onClose: () => void;
+ onDelete?: () => void;
+ onRun?: () => void;
+ onSave: () => void;
+ open: boolean;
+ projects: SidebarProject[];
+ selectedProvider?: AgentProviderOption;
+ setDraft: (draft: AutomationFormState) => void;
+ submitting: boolean;
+ webhookUrl: string;
+}) {
+ if (!open) return null;
+
+ const title = draft.id ? "Edit automation task" : "New automation task";
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ onClose();
+ }
+ };
+
+ return (
+
+ );
+}
+
+function TriggerFields({
+ draft,
+ setDraft,
+ webhookUrl
+}: {
+ draft: AutomationFormState;
+ setDraft: (draft: AutomationFormState) => void;
+ webhookUrl: string;
+}) {
+ if (draft.triggerType === "webhook") {
+ return (
+
+
+ setDraft({ ...draft, webhookSlug: event.target.value })} />
+
+
+ setDraft({ ...draft, webhookSecret: event.target.value })} />
+
+
+
+
+
+ );
+ }
+
+ if (draft.triggerType === "poll") {
+ return (
+
+
+ setDraft({ ...draft, cronExpression: event.target.value })} />
+
+
+
+
+ setDraft({ ...draft, pollUrl: event.target.value })} />
+
+
+
+
+ setDraft({ ...draft, pollPath: event.target.value })} />
+
+
+ setDraft({ ...draft, pollValue: event.target.value })} />
+
+
+ setDraft({ ...draft, pollDedupeKeyPath: event.target.value })} />
+
+
+
+
+ );
+ }
+
+ if (draft.triggerType === "cron") {
+ return (
+
+
+ setDraft({ ...draft, cronExpression: event.target.value })} />
+
+
+ setDraft({ ...draft, timezone: event.target.value })} />
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ setDraft({ ...draft, cronExpression: event.target.value })} />
+
+
+ );
+}
+
+function Field({ children, className, label }: { children: ReactNode; className?: string; label: string }) {
+ return (
+
+ );
+}
+
+function SectionTitle({ icon: Icon, title }: { icon: typeof Activity; title: string }) {
+ return (
+
+
+ {title}
+
+ );
+}
+
+function TriggerIcon({ triggerType }: { triggerType: AutomationTriggerType }) {
+ if (triggerType === "webhook") return ;
+ if (triggerType === "poll") return ;
+ return ;
+}
+
+function createDefaultDraft(agentProviders: AgentProviderOption[], projects: SidebarProject[]): AutomationFormState {
+ return {
+ approvalMode: "auto",
+ concurrencyPolicy: "skip",
+ cronExpression: defaultCronExpression,
+ enabled: false,
+ every: defaultEvery,
+ id: "",
+ model: "",
+ name: "New automation",
+ pollBody: "",
+ pollConditionType: "text-contains",
+ pollDedupeKeyPath: "",
+ pollMethod: "GET",
+ pollPath: "",
+ pollTriggerOnRepeatedMatch: false,
+ pollUrl: "",
+ pollValue: "",
+ projectId: projects[0]?.id ?? "",
+ prompt: "",
+ providerId: agentProviders[0]?.id ?? "codex",
+ threadPolicy: "reuse",
+ timezone: "",
+ triggerType: "interval",
+ webhookSecret: "",
+ webhookSlug: "new-automation"
+ };
+}
+
+function formStateFromAutomation(automation: AutomationDefinition, agentProviders: AgentProviderOption[], projects: SidebarProject[]): AutomationFormState {
+ const base = createDefaultDraft(agentProviders, projects);
+ const trigger = automation.trigger;
+ const pollCondition = trigger.type === "poll" ? trigger.condition : null;
+ return {
+ ...base,
+ approvalMode: automation.task.approvalMode,
+ concurrencyPolicy: automation.concurrencyPolicy,
+ cronExpression: trigger.type === "cron" || trigger.type === "interval" || trigger.type === "poll" ? trigger.cronExpression : base.cronExpression,
+ enabled: automation.enabled,
+ every: trigger.type === "interval" ? trigger.every : base.every,
+ id: automation.id,
+ model: automation.task.model ?? "",
+ name: automation.name,
+ pollBody: trigger.type === "poll" ? trigger.request.body ?? "" : "",
+ pollConditionType: pollCondition?.type ?? "text-contains",
+ pollDedupeKeyPath: pollCondition?.dedupeKeyPath ?? "",
+ pollMethod: trigger.type === "poll" ? trigger.request.method ?? "GET" : "GET",
+ pollPath: pollCondition?.type === "json-equals" || pollCondition?.type === "json-exists" ? pollCondition.path ?? "" : "",
+ pollTriggerOnRepeatedMatch: pollCondition?.triggerOnRepeatedMatch ?? false,
+ pollUrl: trigger.type === "poll" ? trigger.request.url : "",
+ pollValue: pollCondition?.type === "json-equals" || pollCondition?.type === "text-contains" ? pollCondition.value : "",
+ projectId: automation.task.projectId ?? "",
+ prompt: automation.task.prompt,
+ providerId: automation.task.providerId,
+ threadPolicy: automation.task.threadPolicy,
+ timezone: trigger.type === "cron" || trigger.type === "interval" || trigger.type === "poll" ? trigger.timezone ?? "" : "",
+ triggerType: trigger.type,
+ webhookSecret: trigger.type === "webhook" ? trigger.secret ?? "" : "",
+ webhookSlug: trigger.type === "webhook" ? trigger.slug : base.webhookSlug
+ };
+}
+
+function automationPayloadFromDraft(draft: AutomationFormState, agentProviders: AgentProviderOption[], projects: SidebarProject[]): Partial {
+ const project = projects.find((candidate) => candidate.id === draft.projectId);
+ const trigger = triggerFromDraft(draft);
+ return {
+ concurrencyPolicy: draft.concurrencyPolicy,
+ enabled: draft.enabled,
+ id: draft.id || undefined,
+ name: draft.name,
+ task: {
+ approvalMode: draft.approvalMode,
+ model: draft.model || undefined,
+ projectId: project?.id,
+ projectName: project ? getSidebarProjectLabel(project) || project.name : undefined,
+ projectPath: project?.path,
+ prompt: draft.prompt,
+ providerId: agentProviders.some((provider) => provider.id === draft.providerId) ? draft.providerId : "codex",
+ threadPolicy: draft.threadPolicy
+ },
+ trigger
+ };
+}
+
+function triggerFromDraft(draft: AutomationFormState): AutomationTrigger {
+ if (draft.triggerType === "webhook") {
+ return {
+ secret: draft.webhookSecret || undefined,
+ slug: draft.webhookSlug,
+ type: "webhook"
+ };
+ }
+
+ if (draft.triggerType === "poll") {
+ return {
+ condition: pollConditionFromDraft(draft),
+ cronExpression: draft.cronExpression,
+ request: {
+ body: draft.pollMethod === "POST" ? draft.pollBody || undefined : undefined,
+ method: draft.pollMethod,
+ url: draft.pollUrl
+ },
+ timezone: draft.timezone || undefined,
+ type: "poll"
+ };
+ }
+
+ if (draft.triggerType === "cron") {
+ return {
+ cronExpression: draft.cronExpression,
+ timezone: draft.timezone || undefined,
+ type: "cron"
+ };
+ }
+
+ return {
+ cronExpression: draft.cronExpression,
+ every: draft.every,
+ timezone: draft.timezone || undefined,
+ type: "interval"
+ };
+}
+
+function pollConditionFromDraft(draft: AutomationFormState): AutomationPollCondition {
+ if (draft.pollConditionType === "json-exists") {
+ return {
+ dedupeKeyPath: draft.pollDedupeKeyPath || undefined,
+ path: draft.pollPath || undefined,
+ triggerOnRepeatedMatch: draft.pollTriggerOnRepeatedMatch,
+ type: "json-exists"
+ };
+ }
+ if (draft.pollConditionType === "json-equals") {
+ return {
+ dedupeKeyPath: draft.pollDedupeKeyPath || undefined,
+ path: draft.pollPath,
+ triggerOnRepeatedMatch: draft.pollTriggerOnRepeatedMatch,
+ type: "json-equals",
+ value: draft.pollValue
+ };
+ }
+ return {
+ dedupeKeyPath: draft.pollDedupeKeyPath || undefined,
+ triggerOnRepeatedMatch: draft.pollTriggerOnRepeatedMatch,
+ type: "text-contains",
+ value: draft.pollValue
+ };
+}
+
+function formatTrigger(trigger: AutomationTrigger): string {
+ if (trigger.type === "webhook") return `webhook /${trigger.slug}`;
+ if (trigger.type === "interval") return `interval ${trigger.every} (${trigger.cronExpression})`;
+ if (trigger.type === "poll") return `poll ${trigger.cronExpression}`;
+ return `cron ${trigger.cronExpression}`;
+}
+
+function cronExpressionForEvery(every: string): string {
+ switch (every) {
+ case "1s":
+ return "* * * * * *";
+ case "5s":
+ return "*/5 * * * * *";
+ case "10s":
+ return "*/10 * * * * *";
+ case "30s":
+ return "*/30 * * * * *";
+ case "5m":
+ return "0 */5 * * * *";
+ case "15m":
+ return "0 */15 * * * *";
+ case "1h":
+ return "0 0 * * * *";
+ case "1d":
+ return "0 0 0 * * *";
+ case "1m":
+ default:
+ return defaultCronExpression;
+ }
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/pages/home/components/chat.tsx b/marketplace/plugins/agent-console/src/renderer/pages/home/components/chat.tsx
new file mode 100644
index 00000000..f4131b54
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/pages/home/components/chat.tsx
@@ -0,0 +1,4116 @@
+import { useToast } from "@/components/ui/toast";
+import { type TFunction, useI18n } from "@/lib/i18n";
+import { cn } from "@/lib/utils";
+import {
+ ArrowDown,
+ ArrowUp,
+ Bot,
+ CheckCircle2,
+ ChevronDown,
+ ChevronLeft,
+ ChevronRight,
+ CornerDownLeft,
+ Copy,
+ FileText,
+ Folder,
+ FolderOpen,
+ GitBranch,
+ Globe2,
+ HardDriveUpload,
+ Loader2,
+ Mic,
+ Pause,
+ Play,
+ Plus,
+ SquarePen,
+ Square,
+ Terminal,
+ Zap,
+ X,
+ type LucideIcon
+} from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import type { KeyboardEvent, ReactNode } from "react";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { MarkdownRenderer, StreamingMarkdownRenderer } from "../../../../shared/markdown-renderer";
+import { type SidebarProject } from "../../../../shared/sidebar-data";
+import { HeaderSelect } from "./ui-controls";
+import {
+ ActiveStream,
+ AgentApprovalPrompt,
+ AgentQuestion,
+ AgentQuestionAnswer,
+ AgentQuestionControl,
+ AgentQuestionPrompt,
+ AgentQuestionResponse,
+ AgentModelOption,
+ AgentProviderOption,
+ AgentProviderSettingsForm,
+ appendTranscription,
+ appendWaveformLevel,
+ ChatAgentApprovalDecision,
+ ChatAgentApprovalMode,
+ ChatAgentConnectionMode,
+ ChatAgentEffort,
+ ChatAgentSpeed,
+ ChatAgentProviderId,
+ ChatAttachment,
+ ChatMessage,
+ ChatMessagePart,
+ ChatToolEvent,
+ ConfiguredAgentProviderSettings,
+ ConfiguredSubagentSettings,
+ ContextWindowMetrics,
+ createIdleWaveform,
+ DictationStatus,
+ filterSlashCommands,
+ formatApprovalDetails,
+ formatRecordingTime,
+ formatTokenCount,
+ getAgentApprovalModeFromLabel,
+ getAgentApprovalModeLabel,
+ getAgentApprovalModeOptions,
+ getAgentEffortLabel,
+ getAgentModelLabel,
+ getAgentModelOptions,
+ getAgentProviderByLabel,
+ getAgentProviderConnectionMode,
+ getAgentProviderLabel,
+ getAgentProviderOptions,
+ getAgentSpeedLabel,
+ getConfiguredAgentProviderFromForm,
+ getContextWindowMetrics,
+ getDefaultProject,
+ getDictationErrorMessage,
+ getFirstEnabledSlashCommandIndex,
+ getUniqueAgentProviderId,
+ getMessageMarkdownContent,
+ getNextEnabledSlashCommandIndex,
+ getPreferredAudioMimeType,
+ getProjectDisplayName,
+ getProjectOptionLabel,
+ getSlashCommandQuery,
+ getWaveformLevel,
+ iconSpringTransition,
+ normalizeTranscriptionConfig,
+ popoverSpringTransition,
+ ProjectBranchState,
+ SlashCommand,
+ smoothWaveformLevel,
+ stringifyApprovalValue,
+ TranscriptionConfig,
+ truncateText,
+ UsageTokenMetrics,
+ waveformBarIntervalMs,
+ writeClipboardText
+} from "../utils/core";
+import { AgentProviderSelectOption } from "./layout";
+import { AutoHeightMotion } from "./primitives";
+import {
+ toHomeThemeStyle,
+ type HomeThemeSectionConfig,
+ type ResolvedHomeTheme
+} from "../utils/theme";
+
+export function ChatbotPage({
+ activeStream,
+ agentApprovalMode,
+ approvalPrompt,
+ agentEffort,
+ agentEffortOptions,
+ agentModel,
+ agentProviderId,
+ agentProviders,
+ agentSpeed,
+ agentSpeedOptions,
+ attachments,
+ branchState,
+ compact = false,
+ composerValue,
+ configuredAgentProviders,
+ contextUsage,
+ homeTheme,
+ isNewSession,
+ messages,
+ mobile = false,
+ onAddExistingProject,
+ onAgentApprovalModeChange,
+ onAgentEffortChange,
+ onAgentModelChange,
+ onAgentProviderCreate,
+ onAgentProviderChange,
+ onAgentSpeedChange,
+ onApprovalResolve,
+ onAttachFiles,
+ onBranchChange,
+ onComposerChange,
+ onCreateBlankProject,
+ onCreateSubagent,
+ onMessageBranch,
+ onUserMessageEdit,
+ onOpenVoiceSettings,
+ onQuestionResolve,
+ onRemoveAttachment,
+ onSlashCommandSelect,
+ onSubagentSelectionChange,
+ onSubmit,
+ onToggleStreaming,
+ onNewSessionProjectChange,
+ projects,
+ questionPrompt,
+ runtimeAgentProviders,
+ selectedProjectId,
+ selectedSubagentIds,
+ slashCommands,
+ subagents,
+ transcriptionConfig
+}: {
+ activeStream: ActiveStream | null;
+ agentApprovalMode: ChatAgentApprovalMode;
+ approvalPrompt: AgentApprovalPrompt | null;
+ agentEffort: ChatAgentEffort;
+ agentEffortOptions: ChatAgentEffort[];
+ agentModel: string;
+ agentProviderId: ChatAgentProviderId;
+ agentProviders: AgentProviderOption[];
+ agentSpeed: ChatAgentSpeed;
+ agentSpeedOptions: ChatAgentSpeed[];
+ attachments: ChatAttachment[];
+ branchState: ProjectBranchState;
+ compact?: boolean;
+ composerValue: string;
+ configuredAgentProviders: ConfiguredAgentProviderSettings[];
+ contextUsage: UsageTokenMetrics | null;
+ homeTheme: ResolvedHomeTheme;
+ isNewSession: boolean;
+ messages: ChatMessage[];
+ mobile?: boolean;
+ onAddExistingProject: () => Promise;
+ onAgentApprovalModeChange: (value: ChatAgentApprovalMode) => void;
+ onAgentEffortChange: (value: string) => void;
+ onAgentModelChange: (value: string) => void;
+ onAgentProviderCreate: (provider: ConfiguredAgentProviderSettings) => Promise;
+ onAgentProviderChange: (value: string) => void;
+ onAgentSpeedChange: (value: ChatAgentSpeed) => void;
+ onApprovalResolve: (decision: ChatAgentApprovalDecision, message?: string) => void;
+ onAttachFiles: () => Promise;
+ onBranchChange: (branchName: string) => Promise;
+ onComposerChange: (value: string) => void;
+ onCreateBlankProject: () => Promise;
+ onCreateSubagent: () => void;
+ onMessageBranch: (message: ChatMessage) => Promise;
+ onUserMessageEdit: (message: ChatMessage) => void;
+ onOpenVoiceSettings: () => void;
+ onQuestionResolve: (response: AgentQuestionResponse) => void;
+ onRemoveAttachment: (attachmentPath: string) => void;
+ onSlashCommandSelect: (command: SlashCommand) => void;
+ onSubmit: () => void;
+ onToggleStreaming: () => void;
+ onNewSessionProjectChange: (projectId: string) => void;
+ projects: SidebarProject[];
+ questionPrompt: AgentQuestionPrompt | null;
+ runtimeAgentProviders: AgentProviderOption[];
+ selectedProjectId: string;
+ selectedSubagentIds: string[];
+ slashCommands: SlashCommand[];
+ subagents: ConfiguredSubagentSettings[];
+ transcriptionConfig: TranscriptionConfig;
+ onSubagentSelectionChange: (subagentIds: string[]) => void;
+}) {
+ const scrollRef = useRef(null);
+ const stickToBottomRef = useRef(true);
+ const scrollFrameRef = useRef(null);
+ const emptySession = (!compact || mobile) && isNewSession && messages.length === 0 && !activeStream;
+ const assistantMessageTheme = homeTheme.sections.assistantMessage;
+ const chatbotTheme = homeTheme.sections.chatbot;
+ const chatbotScrollTheme = homeTheme.sections.chatbotScroll;
+ const markdownTheme = homeTheme.sections.markdown;
+ const userMessageTheme = homeTheme.sections.userMessage;
+ const contextWindowMetrics = useMemo(
+ () => getContextWindowMetrics({
+ attachments,
+ composerValue,
+ messages,
+ model: agentModel,
+ modelOptions: getAgentModelOptions(agentProviders, agentProviderId),
+ providerId: agentProviderId,
+ usage: contextUsage
+ }),
+ [agentModel, agentProviderId, agentProviders, attachments, composerValue, contextUsage, messages]
+ );
+
+ const requestScrollToBottom = useCallback(() => {
+ if (!stickToBottomRef.current || scrollFrameRef.current !== null) return;
+
+ scrollFrameRef.current = window.requestAnimationFrame(() => {
+ scrollFrameRef.current = null;
+ const scrollElement = scrollRef.current;
+ if (scrollElement) scrollElement.scrollTop = scrollElement.scrollHeight;
+ });
+ }, []);
+
+ useEffect(() => {
+ requestScrollToBottom();
+ return () => {
+ if (scrollFrameRef.current !== null) {
+ window.cancelAnimationFrame(scrollFrameRef.current);
+ scrollFrameRef.current = null;
+ }
+ };
+ }, [messages, requestScrollToBottom]);
+
+ const handleScroll = () => {
+ const scrollElement = scrollRef.current;
+ if (!scrollElement) return;
+ const distanceFromBottom = scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight;
+ stickToBottomRef.current = distanceFromBottom < 240;
+ };
+
+ if (emptySession) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {messages.map((message) => (
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
+
+export function NewSessionPage({
+ agentApprovalMode,
+ approvalPrompt,
+ agentEffort,
+ agentEffortOptions,
+ agentModel,
+ agentProviderId,
+ agentProviders,
+ agentSpeed,
+ agentSpeedOptions,
+ attachments,
+ branchState,
+ composerValue,
+ configuredAgentProviders,
+ homeTheme,
+ onComposerChange,
+ onAddExistingProject,
+ onAgentApprovalModeChange,
+ onAgentEffortChange,
+ onAgentModelChange,
+ onAgentProviderCreate,
+ onAgentProviderChange,
+ onAgentSpeedChange,
+ onApprovalResolve,
+ onAttachFiles,
+ onBranchChange,
+ onCreateBlankProject,
+ onCreateSubagent,
+ onProjectChange,
+ onOpenVoiceSettings,
+ onQuestionResolve,
+ onRemoveAttachment,
+ onSlashCommandSelect,
+ onSubagentSelectionChange,
+ onSubmit,
+ projects,
+ questionPrompt,
+ runtimeAgentProviders,
+ selectedProjectId,
+ selectedSubagentIds,
+ slashCommands,
+ subagents,
+ mobile = false
+}: {
+ agentApprovalMode: ChatAgentApprovalMode;
+ approvalPrompt: AgentApprovalPrompt | null;
+ agentEffort: ChatAgentEffort;
+ agentEffortOptions: ChatAgentEffort[];
+ agentModel: string;
+ agentProviderId: ChatAgentProviderId;
+ agentProviders: AgentProviderOption[];
+ agentSpeed: ChatAgentSpeed;
+ agentSpeedOptions: ChatAgentSpeed[];
+ attachments: ChatAttachment[];
+ branchState: ProjectBranchState;
+ composerValue: string;
+ configuredAgentProviders: ConfiguredAgentProviderSettings[];
+ homeTheme: ResolvedHomeTheme;
+ onComposerChange: (value: string) => void;
+ onAddExistingProject: () => Promise;
+ onAgentApprovalModeChange: (value: ChatAgentApprovalMode) => void;
+ onAgentEffortChange: (value: string) => void;
+ onAgentModelChange: (value: string) => void;
+ onAgentProviderCreate: (provider: ConfiguredAgentProviderSettings) => Promise;
+ onAgentProviderChange: (value: string) => void;
+ onAgentSpeedChange: (value: ChatAgentSpeed) => void;
+ onApprovalResolve: (decision: ChatAgentApprovalDecision, message?: string) => void;
+ onAttachFiles: () => Promise;
+ onBranchChange: (branchName: string) => Promise;
+ onCreateBlankProject: () => Promise;
+ onCreateSubagent: () => void;
+ onProjectChange: (projectId: string) => void;
+ onOpenVoiceSettings: () => void;
+ onQuestionResolve: (response: AgentQuestionResponse) => void;
+ onRemoveAttachment: (attachmentPath: string) => void;
+ onSlashCommandSelect: (command: SlashCommand) => void;
+ onSubagentSelectionChange: (subagentIds: string[]) => void;
+ onSubmit: () => void;
+ projects: SidebarProject[];
+ questionPrompt: AgentQuestionPrompt | null;
+ runtimeAgentProviders: AgentProviderOption[];
+ selectedProjectId: string;
+ selectedSubagentIds: string[];
+ slashCommands: SlashCommand[];
+ subagents: ConfiguredSubagentSettings[];
+ mobile?: boolean;
+}) {
+ const { t } = useI18n();
+ const homeSectionTheme = homeTheme.sections.home;
+ const heroTheme = homeTheme.sections.hero;
+
+ return (
+
+
+
+ {t("newSession.questionPrefix")}
+
+ {t("newSession.questionSuffix")}
+
+
+
+
+
+ );
+}
+
+export function useSlashCommandController({
+ commands,
+ onSelect,
+ value
+}: {
+ commands: SlashCommand[];
+ onSelect: (command: SlashCommand) => void;
+ value: string;
+}) {
+ const [dismissedValue, setDismissedValue] = useState("");
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const query = getSlashCommandQuery(value);
+ const visibleCommands = useMemo(
+ () => query === null || dismissedValue === value ? [] : filterSlashCommands(commands, query),
+ [commands, dismissedValue, query, value]
+ );
+ const open = visibleCommands.length > 0;
+
+ useEffect(() => {
+ setSelectedIndex(getFirstEnabledSlashCommandIndex(visibleCommands));
+ }, [visibleCommands]);
+
+ const selectCommand = useCallback((command: SlashCommand) => {
+ if (command.disabled) return;
+ setDismissedValue("");
+ onSelect(command);
+ }, [onSelect]);
+
+ const onKeyDown = useCallback((event: KeyboardEvent) => {
+ if (!open) return false;
+
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setSelectedIndex((currentIndex) => getNextEnabledSlashCommandIndex(visibleCommands, currentIndex, 1));
+ return true;
+ }
+
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setSelectedIndex((currentIndex) => getNextEnabledSlashCommandIndex(visibleCommands, currentIndex, -1));
+ return true;
+ }
+
+ if (event.key === "Enter" || event.key === "Tab") {
+ event.preventDefault();
+ const command = visibleCommands[selectedIndex];
+ if (command && !command.disabled) {
+ selectCommand(command);
+ }
+ return true;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setDismissedValue(value);
+ return true;
+ }
+
+ return false;
+ }, [open, selectCommand, selectedIndex, value, visibleCommands]);
+
+ return {
+ commands: visibleCommands,
+ onKeyDown,
+ open,
+ selectCommand,
+ selectedIndex,
+ setSelectedIndex
+ };
+}
+
+export function SlashCommandMenu({
+ commands,
+ onHover,
+ onSelect,
+ open,
+ selectedIndex
+}: {
+ commands: SlashCommand[];
+ onHover: (index: number) => void;
+ onSelect: (command: SlashCommand) => void;
+ open: boolean;
+ selectedIndex: number;
+}) {
+ const { t } = useI18n();
+
+ return (
+
+ {open ? (
+
+
+ {commands.map((command, index) => {
+ const Icon = command.icon;
+ const selected = index === selectedIndex;
+
+ return (
+
+ );
+ })}
+
+
+ ) : null}
+
+ );
+}
+
+type AgentRunSettingsView = "model" | "root" | "speed";
+
+function AgentRunSettingsSelect({
+ agentEffort,
+ agentEffortOptions,
+ agentModel,
+ agentSpeed,
+ agentSpeedOptions,
+ buttonClassName,
+ modelFallbackLabel,
+ modelOptions,
+ onAgentEffortChange,
+ onAgentModelChange,
+ onAgentSpeedChange,
+ placement = "bottom"
+}: {
+ agentEffort: ChatAgentEffort;
+ agentEffortOptions: ChatAgentEffort[];
+ agentModel: string;
+ agentSpeed: ChatAgentSpeed;
+ agentSpeedOptions: ChatAgentSpeed[];
+ buttonClassName?: string;
+ modelFallbackLabel: string;
+ modelOptions: AgentModelOption[];
+ onAgentEffortChange: (value: string) => void;
+ onAgentModelChange: (value: string) => void;
+ onAgentSpeedChange: (value: ChatAgentSpeed) => void;
+ placement?: "bottom" | "top";
+}) {
+ const { t } = useI18n();
+ const [open, setOpen] = useState(false);
+ const [view, setView] = useState("root");
+ const modelValue = getAgentModelLabel(agentModel, modelOptions, modelFallbackLabel);
+ const supportsEffort = agentEffortOptions.length > 0;
+ const effortLabel = supportsEffort ? getAgentEffortLabel(agentEffort, t) : "";
+ const speedLabel = getAgentSpeedLabel(agentSpeed, t);
+ const summaryLabel = supportsEffort ? `${modelValue} · ${effortLabel}` : modelValue;
+ const supportsSpeed = agentSpeedOptions.length > 0;
+ const showFastIcon = supportsSpeed && agentSpeed === "fast";
+
+ useEffect(() => {
+ if (!supportsSpeed && view === "speed") setView("root");
+ }, [supportsSpeed, view]);
+
+ const close = () => {
+ setOpen(false);
+ setView("root");
+ };
+
+ return (
+
+
+
+ {open ? (
+ <>
+
+
+ {view === "root" ? (
+ <>
+ {supportsEffort ? (
+ <>
+
{t("agent.effort")}
+
+ {agentEffortOptions.map((effort) => {
+ const selected = effort === agentEffort;
+ return (
+
+ );
+ })}
+
+
+
+ >
+ ) : null}
+
+ {supportsSpeed ? (
+
+ ) : null}
+ >
+ ) : null}
+
+ {view === "model" ? (
+ <>
+
+
+ {modelOptions.length ? modelOptions.map((model) => {
+ const selected = model.value === agentModel;
+ return (
+
+ );
+ }) : (
+
{modelFallbackLabel}
+ )}
+
+ >
+ ) : null}
+
+ {supportsSpeed && view === "speed" ? (
+ <>
+
+
+ {agentSpeedOptions.map((speed) => {
+ const selected = speed === agentSpeed;
+ return (
+
+ );
+ })}
+
+ >
+ ) : null}
+
+ >
+ ) : null}
+
+ );
+}
+
+type ComposerAttachmentSubmenuSide = "left" | "right";
+
+function ComposerAttachmentMenu({
+ buttonClassName,
+ onAttachFiles,
+ onCreateSubagent,
+ onSubagentSelectionChange,
+ placement = "bottom",
+ selectedSubagentIds,
+ subagents
+}: {
+ buttonClassName?: string;
+ onAttachFiles: () => Promise;
+ onCreateSubagent: () => void;
+ onSubagentSelectionChange: (subagentIds: string[]) => void;
+ placement?: "bottom" | "top";
+ selectedSubagentIds: string[];
+ subagents: ConfiguredSubagentSettings[];
+}) {
+ const { t } = useI18n();
+ const [open, setOpen] = useState(false);
+ const [subagentMenuOpen, setSubagentMenuOpen] = useState(false);
+ const [subagentMenuSide, setSubagentMenuSide] = useState("right");
+ const menuRef = useRef(null);
+ const selectedIdSet = useMemo(() => new Set(selectedSubagentIds), [selectedSubagentIds]);
+ const selectedCount = subagents.filter((subagent) => selectedIdSet.has(subagent.id)).length;
+ const subagentsSummary = selectedCount > 0 ? t("agent.subagentsCount", { count: selectedCount }) : t("agent.subagents");
+
+ const toggleSubagent = (subagentId: string) => {
+ const nextIds = selectedIdSet.has(subagentId)
+ ? selectedSubagentIds.filter((id) => id !== subagentId)
+ : [...selectedSubagentIds, subagentId];
+ onSubagentSelectionChange(nextIds);
+ };
+
+ const close = () => {
+ setOpen(false);
+ setSubagentMenuOpen(false);
+ };
+
+ const attachFiles = () => {
+ close();
+ void onAttachFiles();
+ };
+
+ const createSubagent = () => {
+ close();
+ onCreateSubagent();
+ };
+
+ const openSubagentMenu = () => {
+ const menuRect = menuRef.current?.getBoundingClientRect();
+ if (menuRect) {
+ const submenuWidth = Math.min(320, Math.max(0, window.innerWidth - 32));
+ const gap = 4;
+ const spaceRight = window.innerWidth - menuRect.right;
+ const spaceLeft = menuRect.left;
+ setSubagentMenuSide(spaceRight >= submenuWidth + gap || spaceRight >= spaceLeft ? "right" : "left");
+ }
+ setSubagentMenuOpen(true);
+ };
+
+ return (
+
+
+
+ {open ? (
+ <>
+
+
+
+
+
+ {subagentMenuOpen ? (
+
+
+
+ {subagents.length ? subagents.map((subagent) => {
+ const selected = selectedIdSet.has(subagent.id);
+ return (
+
+ );
+ }) : (
+
{t("agent.subagentsEmpty")}
+ )}
+
+
+ ) : null}
+
+ >
+ ) : null}
+
+ );
+}
+
+export function NewSessionComposer({
+ agentApprovalMode,
+ approvalPrompt,
+ agentEffort,
+ agentEffortOptions,
+ agentModel,
+ agentProviderId,
+ agentProviders,
+ agentSpeed,
+ agentSpeedOptions,
+ attachments,
+ branchState,
+ configuredAgentProviders,
+ homeTheme,
+ onAddExistingProject,
+ onAgentApprovalModeChange,
+ onAgentEffortChange,
+ onAgentModelChange,
+ onAgentProviderCreate,
+ onAgentProviderChange,
+ onAgentSpeedChange,
+ onApprovalResolve,
+ onAttachFiles,
+ onBranchChange,
+ onChange,
+ onCreateBlankProject,
+ onCreateSubagent,
+ onOpenVoiceSettings,
+ onProjectChange,
+ onQuestionResolve,
+ onRemoveAttachment,
+ onSlashCommandSelect,
+ onSubagentSelectionChange,
+ onSubmit,
+ slashCommands,
+ selectedSubagentIds,
+ subagents,
+ mobile = false,
+ value,
+ projects,
+ questionPrompt,
+ runtimeAgentProviders,
+ selectedProjectId
+}: {
+ agentApprovalMode: ChatAgentApprovalMode;
+ approvalPrompt: AgentApprovalPrompt | null;
+ agentEffort: ChatAgentEffort;
+ agentEffortOptions: ChatAgentEffort[];
+ agentModel: string;
+ agentProviderId: ChatAgentProviderId;
+ agentProviders: AgentProviderOption[];
+ agentSpeed: ChatAgentSpeed;
+ agentSpeedOptions: ChatAgentSpeed[];
+ attachments: ChatAttachment[];
+ branchState: ProjectBranchState;
+ configuredAgentProviders: ConfiguredAgentProviderSettings[];
+ homeTheme: ResolvedHomeTheme;
+ onAddExistingProject: () => Promise;
+ onAgentApprovalModeChange: (value: ChatAgentApprovalMode) => void;
+ onAgentEffortChange: (value: string) => void;
+ onAgentModelChange: (value: string) => void;
+ onAgentProviderCreate: (provider: ConfiguredAgentProviderSettings) => Promise;
+ onAgentProviderChange: (value: string) => void;
+ onAgentSpeedChange: (value: ChatAgentSpeed) => void;
+ onApprovalResolve: (decision: ChatAgentApprovalDecision, message?: string) => void;
+ onAttachFiles: () => Promise;
+ onBranchChange: (branchName: string) => Promise;
+ onChange: (value: string) => void;
+ onCreateBlankProject: () => Promise;
+ onCreateSubagent: () => void;
+ onOpenVoiceSettings: () => void;
+ onProjectChange: (projectId: string) => void;
+ onQuestionResolve: (response: AgentQuestionResponse) => void;
+ onRemoveAttachment: (attachmentPath: string) => void;
+ onSlashCommandSelect: (command: SlashCommand) => void;
+ onSubagentSelectionChange: (subagentIds: string[]) => void;
+ onSubmit: () => void;
+ projects: SidebarProject[];
+ questionPrompt: AgentQuestionPrompt | null;
+ runtimeAgentProviders: AgentProviderOption[];
+ selectedProjectId: string;
+ selectedSubagentIds: string[];
+ slashCommands: SlashCommand[];
+ subagents: ConfiguredSubagentSettings[];
+ mobile?: boolean;
+ value: string;
+}) {
+ const { t } = useI18n();
+ const composerTheme = homeTheme.sections.composer;
+ const toolbarTheme = homeTheme.sections.composerToolbar;
+ const canSend = value.trim().length > 0;
+ const providerOptions = getAgentProviderOptions(agentProviders, false);
+ const providerLabel = getAgentProviderLabel(agentProviders, agentProviderId);
+ const renderAgentProviderSelectOption = useCallback((option: string) => {
+ const provider = getAgentProviderByLabel(agentProviders, option, false);
+ return ;
+ }, [agentProviders]);
+ const modelOptions = getAgentModelOptions(agentProviders, agentProviderId);
+ const modelFallbackLabel = t("agent.defaultModel");
+ const approvalModeOptions = getAgentApprovalModeOptions(t);
+ const approvalModeLabel = getAgentApprovalModeLabel(agentApprovalMode, t);
+ const slashCommandController = useSlashCommandController({
+ commands: slashCommands,
+ onSelect: onSlashCommandSelect,
+ value
+ });
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (slashCommandController.onKeyDown(event)) return;
+ if (event.key !== "Enter" || event.shiftKey) return;
+ event.preventDefault();
+ if (canSend) onSubmit();
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function NewSessionProjectPicker({
+ onAddExistingProject,
+ onCreateBlankProject,
+ onProjectChange,
+ placement = "top",
+ projects,
+ selectedProjectId,
+ variant = "toolbar"
+}: {
+ onAddExistingProject: () => Promise;
+ onCreateBlankProject: () => Promise;
+ onProjectChange: (projectId: string) => void;
+ placement?: "bottom" | "top";
+ projects: SidebarProject[];
+ selectedProjectId: string;
+ variant?: "title" | "toolbar";
+}) {
+ const { t } = useI18n();
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef(null);
+ const selectedProject = projects.find((project) => project.id === selectedProjectId) ?? getDefaultProject(projects);
+ const selectedLabel = selectedProject ? getProjectDisplayName(selectedProject) : t("newSession.noProjects");
+ const selectedTitle = selectedProject ? getProjectOptionLabel(selectedProject) : t("newSession.noProjects");
+ const titleVariant = variant === "title";
+
+ useEffect(() => {
+ if (!open) return undefined;
+
+ const closeOnOutsidePointer = (event: PointerEvent) => {
+ if (containerRef.current?.contains(event.target as Node)) return;
+ setOpen(false);
+ };
+
+ window.addEventListener("pointerdown", closeOnOutsidePointer);
+ return () => window.removeEventListener("pointerdown", closeOnOutsidePointer);
+ }, [open]);
+
+ const selectProject = (project: SidebarProject) => {
+ onProjectChange(project.id);
+ setOpen(false);
+ };
+
+ const runProjectAction = (action: () => Promise) => {
+ setOpen(false);
+ void action();
+ };
+
+ return (
+
+
+
+
+ {open ? (
+
+
+ {projects.length ? (
+ projects.map((project) => {
+ const active = project.id === selectedProject?.id;
+ return (
+
+ );
+ })
+ ) : (
+
{t("newSession.noProjects")}
+ )}
+
+
+
+
+
+
+ ) : null}
+
+
+ );
+}
+
+export function NewSessionBranchPicker({
+ branchState,
+ onBranchChange
+}: {
+ branchState: ProjectBranchState;
+ onBranchChange: (branchName: string) => Promise;
+}) {
+ const { t } = useI18n();
+ const [open, setOpen] = useState(false);
+ const containerRef = useRef(null);
+ const branchLabel = branchState.loading
+ ? t("common.loading")
+ : branchState.selectedBranch || branchState.currentBranch || t("newSession.noGitBranch");
+ const disabled = branchState.loading || !branchState.isGitRepository || branchState.branches.length === 0;
+
+ useEffect(() => {
+ if (!open) return undefined;
+
+ const closeOnOutsidePointer = (event: PointerEvent) => {
+ if (containerRef.current?.contains(event.target as Node)) return;
+ setOpen(false);
+ };
+
+ window.addEventListener("pointerdown", closeOnOutsidePointer);
+ return () => window.removeEventListener("pointerdown", closeOnOutsidePointer);
+ }, [open]);
+
+ const selectBranch = (branchName: string) => {
+ setOpen(false);
+ void onBranchChange(branchName);
+ };
+
+ return (
+
+
+
+
+ {open ? (
+
+
+ {branchState.branches.map((branch) => {
+ const active = branch.name === branchState.selectedBranch;
+ return (
+
+ );
+ })}
+
+
+ ) : null}
+
+
+ );
+}
+
+export function NewSessionConnectionModePicker({
+ agentProviderId,
+ agentProviders,
+ configuredAgentProviders,
+ runtimeAgentProviders,
+ onAgentProviderCreate,
+ onAgentProviderChange
+}: {
+ agentProviderId: ChatAgentProviderId;
+ agentProviders: AgentProviderOption[];
+ configuredAgentProviders: ConfiguredAgentProviderSettings[];
+ runtimeAgentProviders: AgentProviderOption[];
+ onAgentProviderCreate: (provider: ConfiguredAgentProviderSettings) => Promise;
+ onAgentProviderChange: (value: string) => void;
+}) {
+ const { t } = useI18n();
+ const [open, setOpen] = useState(false);
+ const [configurationMode, setConfigurationMode] = useState(null);
+ const [configurationForm, setConfigurationForm] = useState(null);
+ const [configurationError, setConfigurationError] = useState(null);
+ const [savingConfiguration, setSavingConfiguration] = useState(false);
+ const containerRef = useRef(null);
+ const currentProvider = agentProviders.find((provider) => provider.id === agentProviderId) ?? agentProviders[0];
+ const currentMode = getAgentProviderConnectionMode(currentProvider);
+ const providerByMode = useMemo(() => {
+ const providers = new Map();
+ for (const provider of agentProviders) {
+ const mode = getAgentProviderConnectionMode(provider);
+ if (!providers.has(mode)) providers.set(mode, provider);
+ }
+ return providers;
+ }, [agentProviders]);
+ const options: Array<{ icon: LucideIcon; label: string; mode: ChatAgentConnectionMode }> = [
+ { icon: HardDriveUpload, label: t("newSession.localMode"), mode: "local" },
+ { icon: Globe2, label: t("newSession.remoteMode"), mode: "remote" },
+ { icon: Terminal, label: t("newSession.sshMode"), mode: "ssh" }
+ ];
+ const currentOption = options.find((option) => option.mode === currentMode) ?? options[0];
+ const CurrentIcon = currentOption.icon;
+
+ useEffect(() => {
+ if (!open) return undefined;
+
+ const closeOnOutsidePointer = (event: PointerEvent) => {
+ if (containerRef.current?.contains(event.target as Node)) return;
+ setOpen(false);
+ };
+
+ window.addEventListener("pointerdown", closeOnOutsidePointer);
+ return () => window.removeEventListener("pointerdown", closeOnOutsidePointer);
+ }, [open]);
+
+ const selectMode = (mode: ChatAgentConnectionMode) => {
+ const provider = providerByMode.get(mode);
+ if (!provider) {
+ setOpen(false);
+ setConfigurationError(null);
+ setConfigurationMode(mode);
+ setConfigurationForm(createConnectionModeProviderForm(mode, configuredAgentProviders, runtimeAgentProviders, t));
+ return;
+ }
+ onAgentProviderChange(provider.label);
+ setOpen(false);
+ };
+
+ const updateConfigurationForm = (key: keyof AgentProviderSettingsForm, value: string) => {
+ setConfigurationError(null);
+ setConfigurationForm((currentForm) => currentForm ? { ...currentForm, [key]: value } : currentForm);
+ };
+
+ const closeConfiguration = () => {
+ if (savingConfiguration) return;
+ setConfigurationMode(null);
+ setConfigurationForm(null);
+ setConfigurationError(null);
+ };
+
+ const saveConfiguration = async () => {
+ if (!configurationForm) return;
+
+ const result = getConfiguredAgentProviderFromForm(configurationForm, configuredAgentProviders, runtimeAgentProviders, t);
+ if (result.error || !result.provider) {
+ setConfigurationError(result.error ?? t("settings.agents.saveFailed"));
+ return;
+ }
+
+ setSavingConfiguration(true);
+ setConfigurationError(null);
+ try {
+ await onAgentProviderCreate(result.provider);
+ setConfigurationMode(null);
+ setConfigurationForm(null);
+ } catch (error) {
+ setConfigurationError(error instanceof Error && error.message ? error.message : t("settings.agents.saveFailed"));
+ } finally {
+ setSavingConfiguration(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {open ? (
+
+ {options.map((option) => {
+ const Icon = option.icon;
+ const active = option.mode === currentMode;
+ const provider = providerByMode.get(option.mode);
+ const unconfigured = !provider;
+ return (
+
+ );
+ })}
+
+ ) : null}
+
+
+
void saveConfiguration()}
+ saving={savingConfiguration}
+ />
+
+ );
+}
+
+function createConnectionModeProviderForm(
+ mode: ChatAgentConnectionMode,
+ configuredProviders: ConfiguredAgentProviderSettings[],
+ runtimeProviders: AgentProviderOption[],
+ t: TFunction
+): AgentProviderSettingsForm {
+ const usedIds = [...configuredProviders.map((provider) => provider.id), ...runtimeProviders.map((provider) => provider.id)];
+ const baseId = mode === "ssh" ? "ssh-agent" : mode === "remote" ? "remote-agent" : "local-agent";
+ const label = mode === "ssh"
+ ? t("newSession.sshMode")
+ : mode === "remote"
+ ? t("newSession.remoteMode")
+ : t("newSession.localMode");
+
+ return {
+ argsText: "",
+ command: "",
+ description: "",
+ id: getUniqueAgentProviderId(baseId, usedIds),
+ installCommand: "",
+ label,
+ logoDataUrl: "",
+ modelsText: "",
+ timeoutMs: "",
+ transport: mode === "ssh" ? "ssh" : mode === "remote" ? "websocket" : "stdio",
+ url: ""
+ };
+}
+
+function ConnectionModeConfigurationDialog({
+ error,
+ form,
+ mode,
+ onChange,
+ onClose,
+ onSave,
+ saving
+}: {
+ error: string | null;
+ form: AgentProviderSettingsForm | null;
+ mode: ChatAgentConnectionMode | null;
+ onChange: (key: keyof AgentProviderSettingsForm, value: string) => void;
+ onClose: () => void;
+ onSave: () => void;
+ saving: boolean;
+}) {
+ const { t } = useI18n();
+ if (!mode || !form) return null;
+
+ const isSsh = mode === "ssh";
+ const isRemote = mode === "remote";
+ const title = isSsh ? t("newSession.sshMode") : isRemote ? t("newSession.remoteMode") : t("newSession.localMode");
+ const requiresCommand = mode === "local" || isSsh;
+ const requiresUrl = isRemote || isSsh;
+
+ return (
+
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}
+ >
+ {
+ event.preventDefault();
+ onSave();
+ }}
+ role="dialog"
+ transition={popoverSpringTransition}
+ >
+
+
+ {isSsh ? : isRemote ? : }
+
+
+
{t("settings.agents.configure", { agent: title })}
+
{t("settings.agents.unsavedInline")}
+
+
+
+
+ onChange("label", value)}
+ placeholder={title}
+ value={form.label}
+ />
+ {requiresUrl ? (
+ onChange("url", value)}
+ placeholder={isSsh ? "ssh://user@example.com:22" : "ws://127.0.0.1:8787/asp"}
+ value={form.url}
+ />
+ ) : null}
+ {requiresCommand ? (
+ onChange("command", value)}
+ placeholder="my-agent-asp"
+ value={form.command}
+ />
+ ) : null}
+ {requiresCommand ? (
+ onChange("argsText", value)}
+ placeholder="--stdio"
+ value={form.argsText}
+ />
+ ) : null}
+
+
+ {error ? {error}
: null}
+
+
+
+
+
+
+
+
+ );
+}
+
+function ConnectionModeField({
+ label,
+ multiline = false,
+ onChange,
+ placeholder,
+ value
+}: {
+ label: string;
+ multiline?: boolean;
+ onChange: (value: string) => void;
+ placeholder: string;
+ value: string;
+}) {
+ return (
+
+ );
+}
+
+export function ContextWindowIndicator({ metrics }: { metrics: ContextWindowMetrics }) {
+ const { t } = useI18n();
+ const [open, setOpen] = useState(false);
+ const radius = 7;
+ const circumference = 2 * Math.PI * radius;
+ const runtimeUsedTokens = metrics.usedTokens;
+ const hasRuntimeUsage = runtimeUsedTokens !== null;
+ const rawProgress = hasRuntimeUsage && metrics.limitTokens ? runtimeUsedTokens / metrics.limitTokens : hasRuntimeUsage && runtimeUsedTokens > 0 ? 0.72 : 0.18;
+ const progress = Math.min(1, Math.max(0.04, rawProgress));
+ const usedLabel = hasRuntimeUsage ? formatTokenCount(runtimeUsedTokens) : t("contextWindow.unknown");
+ const limitLabel = metrics.limitTokens ? formatTokenCount(metrics.limitTokens) : t("contextWindow.unknown");
+ const percentLabel = hasRuntimeUsage && metrics.limitTokens ? formatContextWindowPercent(runtimeUsedTokens / metrics.limitTokens) : "-";
+ const progressTone = hasRuntimeUsage && metrics.limitTokens && progress >= 0.9
+ ? "text-destructive"
+ : hasRuntimeUsage && metrics.limitTokens && progress >= 0.75
+ ? "text-amber-500"
+ : "text-foreground";
+
+ return (
+ {
+ if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
+ }}
+ onFocus={() => setOpen(true)}
+ onMouseEnter={() => setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ >
+
+
+
+ {open ? (
+
+
+ {t("contextWindow.used")}
+ {usedLabel} / {limitLabel}
+
+
+ {t("contextWindow.percent")}
+ {percentLabel}
+
+
+ ) : null}
+
+
+ );
+}
+
+export function formatContextWindowPercent(value: number): string {
+ const percent = Math.min(100, Math.max(0, value * 100));
+ if (percent > 0 && percent < 1) return "<1%";
+ return `${Math.round(percent)}%`;
+}
+
+function AgentInteractionSheets({
+ agentProviders,
+ approvalPrompt,
+ compact = false,
+ mobile = false,
+ onApprovalResolve,
+ onQuestionResolve,
+ questionPrompt
+}: {
+ agentProviders: AgentProviderOption[];
+ approvalPrompt: AgentApprovalPrompt | null;
+ compact?: boolean;
+ mobile?: boolean;
+ onApprovalResolve: (decision: ChatAgentApprovalDecision, message?: string) => void;
+ onQuestionResolve: (response: AgentQuestionResponse) => void;
+ questionPrompt: AgentQuestionPrompt | null;
+}) {
+ return (
+ <>
+
+
+ >
+ );
+}
+
+export function AgentApprovalSheet({
+ agentProviders,
+ compact = false,
+ mobile = false,
+ onResolve,
+ prompt
+}: {
+ agentProviders: AgentProviderOption[];
+ compact?: boolean;
+ mobile?: boolean;
+ onResolve: (decision: ChatAgentApprovalDecision, message?: string) => void;
+ prompt: AgentApprovalPrompt | null;
+}) {
+ const { t } = useI18n();
+ const approvalAgentLabel = prompt ? getAgentProviderLabel(agentProviders, prompt.providerId) : t("agent.provider");
+ const approvalChoices = useMemo(() => prompt ? getApprovalSheetChoices(prompt, t, approvalAgentLabel) : [], [approvalAgentLabel, prompt, t]);
+ const [selectedDecision, setSelectedDecision] = useState("allow");
+ const [denyMessage, setDenyMessage] = useState("");
+
+ useEffect(() => {
+ if (!prompt) return;
+ const defaultChoice = getDefaultApprovalChoice(prompt);
+ setSelectedDecision(defaultChoice);
+ setDenyMessage("");
+ }, [prompt?.approvalId]);
+
+ const activeDecision = approvalChoices.some((choice) => choice.decision === selectedDecision)
+ ? selectedDecision
+ : approvalChoices[0]?.decision;
+ const commandPreview = prompt ? getApprovalCommandPreview(prompt) : "";
+ const detailsPreview = prompt ? getApprovalDetailsPreview(prompt, commandPreview) : "";
+ const toolParametersPreview = prompt ? getApprovalToolParametersPreview(prompt) : "";
+ const trimmedDenyMessage = denyMessage.trim();
+ const denyMessageRequired = activeDecision === "deny";
+
+ return (
+
+ {prompt ? (
+
+
+
+ {prompt.title || t("agent.approvalTitle")}
+
+ {detailsPreview ? (
+
+ {detailsPreview}
+
+ ) : null}
+ {toolParametersPreview ? (
+
+
{t("agent.approvalToolParameters")}
+
+ {toolParametersPreview}
+
+
+ ) : null}
+
+
+
+ {approvalChoices.map((choice, index) => {
+ const selected = choice.decision === activeDecision;
+
+ return (
+
setSelectedDecision(choice.decision)}
+ preview={choice.preview}
+ selected={selected}
+ trailing={selected ? (
+
+
+
+
+ ) : null}
+ />
+ );
+ })}
+
+
+ {denyMessageRequired ? (
+
+
+
+ ) : null}
+
+ onResolve("cancel")}
+ onSubmit={() => {
+ if (activeDecision) onResolve(activeDecision, activeDecision === "deny" ? trimmedDenyMessage : undefined);
+ }}
+ skipLabel={t("agent.sheetSkip")}
+ submitLabel={t("agent.sheetSubmit")}
+ />
+
+ ) : null}
+
+ );
+}
+
+type AgentQuestionDraft = {
+ customAnswer: string;
+ selectedLabels: string[];
+};
+
+function getAgentQuestionControl(question: AgentQuestion): AgentQuestionControl {
+ if (question.control) return question.control;
+ if (question.multiSelect) return "multi_select";
+ if (question.options?.length) return "single_select";
+ return "text";
+}
+
+function questionAllowsCustomAnswer(question: AgentQuestion, control = getAgentQuestionControl(question)): boolean {
+ if (control === "text") return true;
+ if (question.allowCustomAnswer === true) return true;
+ return !question.control;
+}
+
+function isAgentQuestionAnswered(question: AgentQuestion, draft: AgentQuestionDraft): boolean {
+ const control = getAgentQuestionControl(question);
+ const customAnswer = questionAllowsCustomAnswer(question, control) ? draft.customAnswer.trim() : "";
+ if (control === "text") return customAnswer.length > 0;
+ return draft.selectedLabels.length > 0 || customAnswer.length > 0;
+}
+
+function buildAgentQuestionAnswer(question: AgentQuestion, draft: AgentQuestionDraft): AgentQuestionAnswer {
+ const control = getAgentQuestionControl(question);
+ const customAnswer = questionAllowsCustomAnswer(question, control) ? draft.customAnswer.trim() : "";
+ const selectedOptions = (question.options ?? []).filter((option) => draft.selectedLabels.includes(option.label));
+ const answer = control === "text"
+ ? customAnswer
+ : control === "multi_select"
+ ? customAnswer
+ ? [...draft.selectedLabels, customAnswer]
+ : draft.selectedLabels
+ : customAnswer || draft.selectedLabels[0] || "";
+
+ return {
+ answer,
+ customAnswer: customAnswer || undefined,
+ header: question.header,
+ question: question.question,
+ questionId: question.id,
+ selectedOptions
+ };
+}
+
+export function AgentQuestionSheet({
+ compact = false,
+ mobile = false,
+ onResolve,
+ prompt
+}: {
+ compact?: boolean;
+ mobile?: boolean;
+ onResolve: (response: AgentQuestionResponse) => void;
+ prompt: AgentQuestionPrompt | null;
+}) {
+ const { t } = useI18n();
+ const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
+ const [drafts, setDrafts] = useState>({});
+
+ useEffect(() => {
+ setActiveQuestionIndex(0);
+ setDrafts({});
+ }, [prompt?.questionId]);
+
+ const questions = prompt?.questions.filter((question) => question.question.trim()) ?? [];
+ const currentQuestionIndex = questions.length ? Math.min(activeQuestionIndex, questions.length - 1) : 0;
+ const currentQuestion = questions[currentQuestionIndex];
+ const currentQuestionKey = currentQuestion ? getQuestionKey(currentQuestion.id, currentQuestionIndex) : "";
+ const getDraft = (questionKey: string): AgentQuestionDraft => drafts[questionKey] ?? {
+ customAnswer: "",
+ selectedLabels: []
+ };
+ const updateDraft = (questionKey: string, updater: (draft: AgentQuestionDraft) => AgentQuestionDraft) => {
+ setDrafts((currentDrafts) => ({
+ ...currentDrafts,
+ [questionKey]: updater(getDraftFromMap(currentDrafts, questionKey))
+ }));
+ };
+ const answered = questions.length > 0 && questions.every((question, index) => {
+ const draft = getDraft(getQuestionKey(question.id, index));
+ return isAgentQuestionAnswered(question, draft);
+ });
+ const currentDraft = currentQuestion ? getDraft(currentQuestionKey) : { customAnswer: "", selectedLabels: [] };
+ const currentQuestionControl = currentQuestion ? getAgentQuestionControl(currentQuestion) : "text";
+ const currentCustomAllowed = currentQuestion ? questionAllowsCustomAnswer(currentQuestion, currentQuestionControl) : false;
+ const currentOptions = currentQuestion?.options ?? [];
+ const currentAnswered = currentQuestion ? isAgentQuestionAnswered(currentQuestion, currentDraft) : false;
+ const lastQuestion = currentQuestionIndex >= questions.length - 1;
+ const sheetTitle = currentQuestion?.question || prompt?.title || t("agent.questionTitle");
+ const sheetPreview = currentQuestion?.preview ?? "";
+ const progressLabel = questions.length > 1 ? `${currentQuestionIndex + 1} / ${questions.length}` : "";
+
+ const submitAnswer = () => {
+ if (!prompt || !currentAnswered) return;
+ if (!lastQuestion) {
+ setActiveQuestionIndex((index) => Math.min(index + 1, questions.length - 1));
+ return;
+ }
+ if (!answered) return;
+
+ const answers = questions.map((question, index): AgentQuestionAnswer => {
+ const questionKey = getQuestionKey(question.id, index);
+ const draft = getDraft(questionKey);
+ return buildAgentQuestionAnswer(question, draft);
+ });
+
+ onResolve({ answers });
+ };
+
+ return (
+
+ {prompt ? (
+
+
+
+
{sheetTitle}
+ {progressLabel ? (
+
+ {progressLabel}
+
+ ) : null}
+
+ {sheetPreview ? (
+
+ {sheetPreview}
+
+ ) : null}
+
+
+
+ {currentQuestion ? (
+
+ {currentQuestion.header ? (
+
+
+ {currentQuestion.header}
+
+
+ ) : null}
+
+ {currentQuestionControl === "dropdown" && currentOptions.length ? (
+
+ ) : null}
+
+ {(currentQuestionControl === "single_select" || currentQuestionControl === "multi_select") && currentOptions.length ? (
+
+ {currentOptions.map((option, optionIndex) => {
+ const selected = currentDraft.selectedLabels.includes(option.label);
+ return (
+
updateDraft(currentQuestionKey, (draft) => {
+ if (currentQuestionControl === "multi_select") {
+ const selectedLabels = draft.selectedLabels.includes(option.label)
+ ? draft.selectedLabels.filter((label) => label !== option.label)
+ : [...draft.selectedLabels, option.label];
+ return { ...draft, selectedLabels };
+ }
+
+ return {
+ ...draft,
+ customAnswer: "",
+ selectedLabels: [option.label]
+ };
+ })}
+ preview={option.preview}
+ selected={selected}
+ />
+ );
+ })}
+
+ ) : null}
+
+ {currentCustomAllowed ? (
+
updateDraft(currentQuestionKey, (draft) => ({
+ ...draft,
+ customAnswer: value,
+ selectedLabels: currentQuestionControl !== "multi_select" && value.trim() ? [] : draft.selectedLabels
+ }))}
+ placeholder={currentQuestion.placeholder || t("agent.questionCustomPlaceholder")}
+ selected={currentDraft.customAnswer.trim().length > 0}
+ value={currentDraft.customAnswer}
+ />
+ ) : null}
+
+ ) : null}
+
+
+ 0 ? () => setActiveQuestionIndex((index) => Math.max(0, index - 1)) : undefined}
+ onSkip={() => onResolve({ unanswered: true })}
+ onSubmit={submitAnswer}
+ skipLabel={t("agent.sheetSkip")}
+ submitLabel={lastQuestion ? t("agent.sheetSubmit") : t("agent.sheetNext")}
+ />
+
+ ) : null}
+
+ );
+}
+
+type AgentSheetChoiceRowProps = {
+ description?: string;
+ icon?: LucideIcon;
+ index?: number;
+ label: string;
+ onSelect: () => void;
+ preview?: string;
+ selected: boolean;
+ trailing?: ReactNode;
+};
+
+function AgentSheetChoiceRow({
+ description,
+ icon: Icon,
+ index,
+ label,
+ onSelect,
+ preview,
+ selected,
+ trailing
+}: AgentSheetChoiceRowProps) {
+ return (
+
+ );
+}
+
+function AgentSheetChoiceMarker({
+ icon: Icon,
+ index,
+ selected
+}: {
+ icon?: LucideIcon;
+ index?: number;
+ selected: boolean;
+}) {
+ return (
+
+ {Icon ? : index}
+
+ );
+}
+
+function AgentSheetCustomAnswer({
+ onChange,
+ placeholder,
+ selected,
+ value
+}: {
+ onChange: (value: string) => void;
+ placeholder: string;
+ selected: boolean;
+ value: string;
+}) {
+ return (
+
+ );
+}
+
+function AgentSheetActions({
+ backLabel,
+ disabled,
+ onBack,
+ onSkip,
+ onSubmit,
+ skipLabel,
+ submitLabel
+}: {
+ backLabel?: string;
+ disabled?: boolean;
+ onBack?: () => void;
+ onSkip: () => void;
+ onSubmit: () => void;
+ skipLabel: string;
+ submitLabel: string;
+}) {
+ return (
+
+ {onBack ? (
+
+ ) : null}
+
+
+
+ );
+}
+
+type AgentApprovalSheetChoice = {
+ decision: ChatAgentApprovalDecision;
+ description?: string;
+ icon?: LucideIcon;
+ label: string;
+ preview?: string;
+};
+
+function getApprovalSheetChoices(prompt: AgentApprovalPrompt, t: TFunction, agentLabel: string): AgentApprovalSheetChoice[] {
+ const commandPreview = getApprovalCommandPreview(prompt);
+ const availableDecisions = getApprovalAvailableDecisionSet(prompt);
+ const choices: AgentApprovalSheetChoice[] = [];
+
+ if (availableDecisions.has("allow")) {
+ choices.push({
+ decision: "allow",
+ label: t("agent.approvalChoiceAllow")
+ });
+ }
+
+ if (availableDecisions.has("allow-session")) {
+ choices.push({
+ decision: "allow-session",
+ label: t("agent.approvalChoiceAllowSession"),
+ preview: commandPreview
+ });
+ }
+
+ if (availableDecisions.has("deny")) {
+ choices.push({
+ decision: "deny",
+ icon: SquarePen,
+ label: t("agent.approvalChoiceDeny", { agent: agentLabel })
+ });
+ }
+
+ if (!choices.length && availableDecisions.has("cancel")) {
+ choices.push({
+ decision: "cancel",
+ label: t("agent.sheetSkip")
+ });
+ }
+
+ return choices;
+}
+
+function getDefaultApprovalChoice(prompt: AgentApprovalPrompt): ChatAgentApprovalDecision {
+ const availableDecisions = getApprovalAvailableDecisionSet(prompt);
+ if (availableDecisions.has("allow")) return "allow";
+ if (availableDecisions.has("allow-session")) return "allow-session";
+ if (availableDecisions.has("deny")) return "deny";
+ return "cancel";
+}
+
+function getApprovalAvailableDecisionSet(prompt: AgentApprovalPrompt): Set {
+ const decisions: ChatAgentApprovalDecision[] = prompt.approvalOptions?.length ? prompt.approvalOptions : ["allow", "allow-session", "deny", "cancel"];
+ return new Set(decisions);
+}
+
+function getApprovalCommandPreview(prompt: AgentApprovalPrompt): string {
+ return getApprovalParamString(prompt.params, ["command", "toolName", "tool_name"]) || prompt.method || prompt.approvalScope || "";
+}
+
+function getApprovalDetailsPreview(prompt: AgentApprovalPrompt, commandPreview: string): string {
+ if (commandPreview) return commandPreview;
+ return formatApprovalDetails(prompt).trim();
+}
+
+function getApprovalToolParametersPreview(prompt: AgentApprovalPrompt): string {
+ if (!isToolApprovalPrompt(prompt)) return "";
+
+ const toolParameters = getApprovalToolParameters(prompt.params);
+ return stringifyApprovalValue(toolParameters, 2400);
+}
+
+function isToolApprovalPrompt(prompt: AgentApprovalPrompt): boolean {
+ if (prompt.approvalScope?.toLowerCase() === "tool") return true;
+ if (prompt.method?.toLowerCase().includes("tool")) return true;
+ return Boolean(getApprovalParamString(prompt.params, ["tool", "toolName", "tool_name", "name"]));
+}
+
+function getApprovalToolParameters(params: unknown): unknown {
+ const extractedParameters = extractApprovalToolParameters(params);
+ if (extractedParameters !== undefined) return extractedParameters;
+ return params ?? {};
+}
+
+function extractApprovalToolParameters(value: unknown): unknown {
+ if (!isAgentSheetRecord(value)) return undefined;
+
+ for (const key of ["input", "arguments", "args", "parameters"]) {
+ if (value[key] !== undefined) return value[key];
+ }
+
+ for (const key of ["toolCall", "tool_call", "call", "request"]) {
+ const nestedParameters = extractApprovalToolParameters(value[key]);
+ if (nestedParameters !== undefined) return nestedParameters;
+ }
+
+ return undefined;
+}
+
+function getApprovalParamString(value: unknown, keys: string[]): string {
+ if (!isAgentSheetRecord(value)) return "";
+
+ for (const key of keys) {
+ const candidate = value[key];
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
+ }
+
+ const input = value.input;
+ if (isAgentSheetRecord(input)) {
+ for (const key of keys) {
+ const candidate = input[key];
+ if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
+ }
+ }
+
+ return "";
+}
+
+function isAgentSheetRecord(value: unknown): value is Record {
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
+}
+
+function getQuestionKey(questionId: string | undefined, index: number): string {
+ return questionId || `question-${index}`;
+}
+
+function getDraftFromMap(drafts: Record, questionKey: string): AgentQuestionDraft {
+ return drafts[questionKey] ?? {
+ customAnswer: "",
+ selectedLabels: []
+ };
+}
+
+export const ChatMessageRow = memo(function ChatMessageRow({
+ activeStream,
+ assistantMessageTheme,
+ compact = false,
+ markdownTheme,
+ message,
+ onMessageBranch,
+ onStreamFrame,
+ onUserMessageEdit,
+ userMessageTheme
+}: {
+ activeStream: ActiveStream | null;
+ assistantMessageTheme: HomeThemeSectionConfig;
+ compact?: boolean;
+ markdownTheme: HomeThemeSectionConfig;
+ message: ChatMessage;
+ onMessageBranch: (message: ChatMessage) => Promise;
+ onStreamFrame: () => void;
+ onUserMessageEdit: (message: ChatMessage) => void;
+ userMessageTheme: HomeThemeSectionConfig;
+}) {
+ const { t } = useI18n();
+ const isAssistant = message.role === "assistant";
+ const isStreamingMessage = Boolean(message.streaming && activeStream?.id === message.id);
+ const hasContent = Boolean(message.content.trim());
+ const hasToolEvents = Boolean(message.toolEvents?.length);
+ const hasParts = Boolean(message.parts?.length);
+ const showThinking = isStreamingMessage && !hasContent && !hasToolEvents && !hasParts;
+ const showPersistentThinking = isStreamingMessage && Boolean(activeStream?.running) && !showThinking;
+ const showAssistantActions = !showThinking && !showPersistentThinking;
+ const actionContent = getMessageMarkdownContent(message);
+ const canCopy = actionContent.length > 0;
+ const [copySucceeded, setCopySucceeded] = useState(false);
+ const [branchLoading, setBranchLoading] = useState(false);
+ const copyTimerRef = useRef(null);
+
+ useEffect(() => {
+ return () => {
+ if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current);
+ };
+ }, []);
+
+ const copyMessage = useCallback(() => {
+ if (!canCopy) return;
+
+ void writeClipboardText(actionContent)
+ .then(() => {
+ setCopySucceeded(true);
+ if (copyTimerRef.current !== null) window.clearTimeout(copyTimerRef.current);
+ copyTimerRef.current = window.setTimeout(() => {
+ setCopySucceeded(false);
+ copyTimerRef.current = null;
+ }, 1200);
+ })
+ .catch(() => {
+ setCopySucceeded(false);
+ });
+ }, [actionContent, canCopy]);
+
+ const branchMessage = useCallback(() => {
+ if (branchLoading) return;
+ setBranchLoading(true);
+ void onMessageBranch(message).finally(() => {
+ setBranchLoading(false);
+ });
+ }, [branchLoading, message, onMessageBranch]);
+
+ const editUserMessage = useCallback(() => {
+ if (isAssistant) return;
+ onUserMessageEdit(message);
+ }, [isAssistant, message, onUserMessageEdit]);
+
+ return (
+
+ {isAssistant ? (
+
+ {showThinking ? (
+
+ ) : hasParts ? (
+
+ ) : isStreamingMessage && activeStream && hasContent ? (
+
undefined}
+ onFrame={onStreamFrame}
+ running={activeStream.running}
+ style={toHomeThemeStyle(markdownTheme.style)}
+ streamKey={activeStream.streamKey}
+ />
+ ) : hasContent ? (
+
+ ) : null}
+ {!hasParts && hasToolEvents ? (
+
+ ) : null}
+ {showPersistentThinking ? (
+
+
+
+ ) : null}
+ {showAssistantActions ? (
+
+ ) : null}
+
+ ) : (
+
+
+ {message.content}
+
+
+
+ )}
+
+ );
+});
+
+export function MessageActionToolbar({
+ align,
+ branchLoading,
+ canCopy,
+ copySucceeded,
+ isUserMessage,
+ onBranch,
+ onCopy,
+ onEdit,
+ timestamp
+}: {
+ align: "left" | "right";
+ branchLoading: boolean;
+ canCopy: boolean;
+ copySucceeded: boolean;
+ isUserMessage: boolean;
+ onBranch: () => void;
+ onCopy: () => void;
+ onEdit?: () => void;
+ timestamp?: number;
+}) {
+ const { t } = useI18n();
+ const timeLabel = formatMessageTime(timestamp);
+
+ return (
+
+
+ {timeLabel}
+
+
+
+
+ {isUserMessage && onEdit ? (
+
+ ) : null}
+
+
+ );
+}
+
+export function MessageActionButton({
+ disabled = false,
+ icon: Icon,
+ iconClassName,
+ iconKey,
+ label,
+ onClick
+}: {
+ disabled?: boolean;
+ icon: LucideIcon;
+ iconClassName?: string;
+ iconKey?: string;
+ label: string;
+ onClick: () => void;
+}) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function formatMessageTime(timestamp?: number): string {
+ if (!timestamp) return "";
+
+ return new Intl.DateTimeFormat(undefined, {
+ hour: "2-digit",
+ minute: "2-digit"
+ }).format(new Date(timestamp));
+}
+
+export function AssistantMessageParts({
+ activeStream,
+ compact = false,
+ isStreamingMessage,
+ markdownTheme,
+ onLayoutChange,
+ onStreamFrame,
+ parts
+}: {
+ activeStream: ActiveStream | null;
+ compact?: boolean;
+ isStreamingMessage: boolean;
+ markdownTheme: HomeThemeSectionConfig;
+ onLayoutChange: () => void;
+ onStreamFrame: () => void;
+ parts: ChatMessagePart[];
+}) {
+ const partKeys = new Map();
+
+ return (
+
+ {parts.map((part, index) => {
+ const partKey = getMessagePartRenderKey(part, partKeys);
+ const activePart = Boolean(isStreamingMessage && activeStream?.running && index === parts.length - 1 && part.type !== "text");
+ if (part.type === "tool") {
+ return ;
+ }
+
+ if (part.type !== "text") {
+ return ;
+ }
+
+ const isLastPart = index === parts.length - 1;
+ if (isStreamingMessage && activeStream && isLastPart) {
+ return (
+ undefined}
+ onFrame={onStreamFrame}
+ running={activeStream.running}
+ style={toHomeThemeStyle(markdownTheme.style)}
+ streamKey={activeStream.streamKey}
+ />
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function getMessagePartRenderKey(part: ChatMessagePart, seenKeys: Map): string {
+ const baseKey = `${part.type}-${part.id}`;
+ const occurrence = seenKeys.get(baseKey) ?? 0;
+ seenKeys.set(baseKey, occurrence + 1);
+ return occurrence === 0 ? baseKey : `${baseKey}-${occurrence}`;
+}
+
+export function StructuredMessagePart({
+ active = false,
+ compact = false,
+ onLayoutChange,
+ part
+}: {
+ active?: boolean;
+ compact?: boolean;
+ onLayoutChange?: () => void;
+ part: Exclude;
+}) {
+ const [expanded, setExpanded] = useState(part.type !== "raw" && part.type !== "reasoning");
+
+ if (part.type === "reasoning") {
+ return ;
+ }
+
+ const title = getStructuredPartTitle(part);
+ const detail = getStructuredPartDetail(part);
+ const content = getStructuredPartContent(part);
+ const rawText = part.type === "raw" ? stringifyApprovalValue(part.value, 8_000) : "";
+
+ return (
+
+
+
+
+ {expanded ? (
+
+ {part.type === "diff" ? (
+
+ ) : rawText ? (
+
+ {rawText}
+
+ ) : content ? (
+
+ ) : null}
+ {part.type === "plan" && part.items?.length ? (
+
+ {part.items.map((item, index) => - {item}
)}
+
+ ) : null}
+ {part.type !== "raw" && part.metadata && Object.keys(part.metadata).length ? (
+
+ {stringifyApprovalValue(part.metadata, 4_000)}
+
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
+
+function DiffContent({ content }: { content: string }) {
+ const lines = truncateText(content, 20_000).split("\n");
+
+ return (
+
+
+ {lines.map((line, index) => (
+
+ {line}
+
+ ))}
+
+
+ );
+}
+
+function getUnifiedDiffLineClassName(line: string): string {
+ if (line.startsWith("@@")) {
+ return "bg-[#edf4ff] text-[#1d4ed8]";
+ }
+ if (isUnifiedDiffMetadataLine(line)) {
+ return "bg-[#f6f7f9] text-muted-foreground";
+ }
+ if (line.startsWith("+")) {
+ return "bg-[#e8f6ee] text-[#166534]";
+ }
+ if (line.startsWith("-")) {
+ return "bg-[#fdecec] text-[#991b1b]";
+ }
+ return "text-foreground";
+}
+
+function isUnifiedDiffMetadataLine(line: string): boolean {
+ return (
+ line.startsWith("diff --git ") ||
+ line.startsWith("index ") ||
+ line.startsWith("--- ") ||
+ line.startsWith("+++ ") ||
+ line.startsWith("rename from ") ||
+ line.startsWith("rename to ") ||
+ line.startsWith("new file mode ") ||
+ line.startsWith("deleted file mode ") ||
+ line.startsWith("similarity index ") ||
+ line.startsWith("dissimilarity index ") ||
+ line.startsWith("\\ No newline at end of file")
+ );
+}
+
+export function ReasoningPartCard({
+ active = false,
+ compact = false,
+ onLayoutChange,
+ part
+}: {
+ active?: boolean;
+ compact?: boolean;
+ onLayoutChange?: () => void;
+ part: Extract;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const title = getStructuredPartTitle(part);
+ const content = part.content || "";
+
+ return (
+
+
+
+
+ {expanded ? (
+
+ {content ? : null}
+ {part.metadata && Object.keys(part.metadata).length ? (
+
+ {stringifyApprovalValue(part.metadata, 4_000)}
+
+ ) : null}
+
+ ) : null}
+
+
+ );
+}
+
+export function getStructuredPartTitle(part: Exclude): string {
+ if (part.type === "resource") return part.title || part.name || part.path || part.uri || "Resource";
+ if (part.type === "artifact") return part.title || part.artifactId || part.path || part.uri || "Artifact";
+ if (part.type === "diff") return getDiffPartTitle(part);
+ if (part.type === "citation") return part.title || part.uri || "Citation";
+ if (part.type === "raw") return part.label || "Raw event";
+ if (part.type === "reasoning") return part.title || "Reasoning";
+ if (part.type === "plan") return part.title || "Plan";
+ return part.title || part.status || "Status";
+}
+
+export function getStructuredPartDetail(part: Exclude): string {
+ if (part.type === "resource" || part.type === "artifact") return part.mimeType || part.path || part.uri || "";
+ if (part.type === "diff") return getDiffPartDetail(part);
+ if (part.type === "citation") return part.uri || "";
+ if (part.type === "status") return part.status || "";
+ return "";
+}
+
+function getDiffPartTitle(part: Extract): string {
+ const title = part.title?.trim();
+ if (!title) return "Diff";
+
+ const path = part.path?.trim();
+ const oldPath = part.oldPath?.trim();
+ const detail = getDiffPartDetail(part);
+ const duplicateTitles = new Set([
+ path,
+ oldPath,
+ detail,
+ path ? `Diff ${path}` : undefined,
+ oldPath ? `Diff ${oldPath}` : undefined,
+ detail ? `Diff ${detail}` : undefined
+ ].filter((value): value is string => Boolean(value)));
+
+ return duplicateTitles.has(title) ? "Diff" : title;
+}
+
+function getDiffPartDetail(part: Extract): string {
+ const oldPath = part.oldPath?.trim();
+ const path = part.path?.trim();
+ if (oldPath && path && oldPath !== path) {
+ return `${oldPath} -> ${path}`;
+ }
+ return path || oldPath || "";
+}
+
+export function getStructuredPartContent(part: Exclude): string {
+ if (part.type === "plan") {
+ return part.content || "";
+ }
+ if (part.type === "reasoning" || part.type === "status" || part.type === "resource" || part.type === "artifact" || part.type === "citation") {
+ return part.content || "";
+ }
+ return "";
+}
+
+export function ToolEventsList({
+ active = false,
+ compact = false,
+ onLayoutChange,
+ toolEvents
+}: {
+ active?: boolean;
+ compact?: boolean;
+ onLayoutChange?: () => void;
+ toolEvents: ChatToolEvent[];
+}) {
+ return (
+
+ {toolEvents.map((toolEvent, index) => (
+
+ ))}
+
+ );
+}
+
+export function ToolEventCard({
+ active = false,
+ compact = false,
+ depth = 0,
+ onLayoutChange,
+ toolEvent
+}: {
+ active?: boolean;
+ compact?: boolean;
+ depth?: number;
+ onLayoutChange?: () => void;
+ toolEvent: ChatToolEvent;
+}) {
+ const { t } = useI18n();
+ const [expanded, setExpanded] = useState(false);
+ const inputText = toolEvent.input === undefined ? "" : stringifyApprovalValue(toolEvent.input, 4_000);
+ const outputText = toolEvent.output ? truncateText(toolEvent.output, 8_000) : "";
+ const errorText = toolEvent.error ? truncateText(toolEvent.error, 4_000) : "";
+ const children = toolEvent.children ?? [];
+
+ return (
+ 0 && "border-l border-border/60 pl-3"
+ )}
+ >
+
+
+
+ {expanded ? (
+
+ {inputText ? : null}
+ {children.length ? null : outputText ? : null}
+ {errorText ? : null}
+
+ {children.length ? (
+
+ {children.map((childEvent, index) => (
+
+ ))}
+
+ ) : null}
+ {children.length && outputText ? : null}
+
+ ) : null}
+
+
+ );
+}
+
+export function ToolEventPayload({ danger = false, label, value }: { danger?: boolean; label: string; value: string }) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+export function ThinkingIndicator({ label }: { label: string }) {
+ return (
+
+ {label}
+
+ );
+}
+
+export function FollowUpComposer({
+ activeStream,
+ agentApprovalMode,
+ approvalPrompt,
+ agentEffort,
+ agentEffortOptions,
+ agentModel,
+ agentProviderId,
+ agentProviders,
+ agentSpeed,
+ agentSpeedOptions,
+ attachments,
+ compact = false,
+ contextWindowMetrics,
+ mobile = false,
+ onAgentApprovalModeChange,
+ onAgentEffortChange,
+ onAgentModelChange,
+ onAgentSpeedChange,
+ onApprovalResolve,
+ onAttachFiles,
+ onChange,
+ onCreateSubagent,
+ onOpenVoiceSettings,
+ onQuestionResolve,
+ onRemoveAttachment,
+ onSlashCommandSelect,
+ onSubagentSelectionChange,
+ onSubmit,
+ onToggleStreaming,
+ questionPrompt,
+ selectedSubagentIds,
+ slashCommands,
+ subagents,
+ transcriptionConfig,
+ value
+}: {
+ activeStream: ActiveStream | null;
+ agentApprovalMode: ChatAgentApprovalMode;
+ approvalPrompt: AgentApprovalPrompt | null;
+ agentEffort: ChatAgentEffort;
+ agentEffortOptions: ChatAgentEffort[];
+ agentModel: string;
+ agentProviderId: ChatAgentProviderId;
+ agentProviders: AgentProviderOption[];
+ agentSpeed: ChatAgentSpeed;
+ agentSpeedOptions: ChatAgentSpeed[];
+ attachments: ChatAttachment[];
+ compact?: boolean;
+ contextWindowMetrics: ContextWindowMetrics;
+ mobile?: boolean;
+ onAgentApprovalModeChange: (value: ChatAgentApprovalMode) => void;
+ onAgentEffortChange: (value: string) => void;
+ onAgentModelChange: (value: string) => void;
+ onAgentSpeedChange: (value: ChatAgentSpeed) => void;
+ onApprovalResolve: (decision: ChatAgentApprovalDecision, message?: string) => void;
+ onAttachFiles: () => Promise;
+ onChange: (value: string) => void;
+ onCreateSubagent: () => void;
+ onOpenVoiceSettings: () => void;
+ onQuestionResolve: (response: AgentQuestionResponse) => void;
+ onRemoveAttachment: (attachmentPath: string) => void;
+ onSlashCommandSelect: (command: SlashCommand) => void;
+ onSubagentSelectionChange: (subagentIds: string[]) => void;
+ onSubmit: () => void;
+ onToggleStreaming: () => void;
+ questionPrompt: AgentQuestionPrompt | null;
+ selectedSubagentIds: string[];
+ slashCommands: SlashCommand[];
+ subagents: ConfiguredSubagentSettings[];
+ transcriptionConfig: TranscriptionConfig;
+ value: string;
+}) {
+ const { t } = useI18n();
+ const toast = useToast();
+ const [dictationStatus, setDictationStatus] = useState("idle");
+ const [dictationError, setDictationError] = useState(null);
+ const [recordingSeconds, setRecordingSeconds] = useState(0);
+ const [waveformBars, setWaveformBars] = useState(() => createIdleWaveform());
+ const analyserRef = useRef(null);
+ const audioContextRef = useRef(null);
+ const mediaRecorderRef = useRef(null);
+ const mediaStreamSourceRef = useRef(null);
+ const recordingChunksRef = useRef([]);
+ const recordingStartRef = useRef(0);
+ const streamRef = useRef(null);
+ const valueRef = useRef(value);
+ const waveformFrameRef = useRef(null);
+ const canSend = value.trim().length > 0 && !activeStream && dictationStatus === "idle";
+ const canRecord = !activeStream && dictationStatus === "idle";
+ const modelOptions = getAgentModelOptions(agentProviders, agentProviderId);
+ const modelFallbackLabel = t("agent.defaultModel");
+ const approvalModeOptions = getAgentApprovalModeOptions(t);
+ const approvalModeLabel = getAgentApprovalModeLabel(agentApprovalMode, t);
+ const slashCommandController = useSlashCommandController({
+ commands: slashCommands,
+ onSelect: onSlashCommandSelect,
+ value
+ });
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (slashCommandController.onKeyDown(event)) return;
+ if (event.key !== "Enter" || event.shiftKey) return;
+ event.preventDefault();
+ if (canSend) onSubmit();
+ };
+
+ const showDictationError = useCallback(
+ (message: string) => {
+ setDictationError(message);
+ toast.error({ content: message, title: t("voice.toastTitle") });
+ },
+ [t, toast]
+ );
+
+ const showDictationWarning = useCallback(
+ (message: string) => {
+ setDictationError(message);
+ toast.warning({ content: message, title: t("voice.toastTitle") });
+ },
+ [t, toast]
+ );
+
+ const stopWaveform = useCallback(() => {
+ if (waveformFrameRef.current !== null) {
+ window.cancelAnimationFrame(waveformFrameRef.current);
+ waveformFrameRef.current = null;
+ }
+ }, []);
+
+ const cleanupRecordingResources = useCallback(() => {
+ stopWaveform();
+
+ streamRef.current?.getTracks().forEach((track) => track.stop());
+ streamRef.current = null;
+ analyserRef.current = null;
+ mediaStreamSourceRef.current = null;
+
+ const audioContext = audioContextRef.current;
+ audioContextRef.current = null;
+ if (audioContext && audioContext.state !== "closed") {
+ void audioContext.close().catch(() => undefined);
+ }
+ }, [stopWaveform]);
+
+ const startWaveform = useCallback(() => {
+ stopWaveform();
+ const analyser = analyserRef.current;
+ if (!analyser) return;
+
+ const sampleData = new Uint8Array(analyser.fftSize);
+ let smoothedLevel = 0;
+ let sampleWindowPeak = 0;
+ let previousBarTimestamp = 0;
+ const draw = (timestamp: number) => {
+ analyser.getByteTimeDomainData(sampleData);
+ sampleWindowPeak = Math.max(sampleWindowPeak, getWaveformLevel(sampleData));
+
+ if (timestamp - previousBarTimestamp >= waveformBarIntervalMs) {
+ previousBarTimestamp = timestamp;
+ smoothedLevel = smoothWaveformLevel(smoothedLevel, sampleWindowPeak);
+ sampleWindowPeak = 0;
+ setWaveformBars((currentBars) => appendWaveformLevel(currentBars, smoothedLevel));
+ }
+
+ waveformFrameRef.current = window.requestAnimationFrame(draw);
+ };
+
+ waveformFrameRef.current = window.requestAnimationFrame(draw);
+ }, [stopWaveform]);
+
+ const transcribeBlob = useCallback(
+ async (audioBlob: Blob) => {
+ if (!audioBlob.size) {
+ showDictationError(t("voice.emptyAudio"));
+ setDictationStatus("idle");
+ return;
+ }
+
+ const currentConfig = normalizeTranscriptionConfig(transcriptionConfig);
+ if (!currentConfig.apiKey.trim()) {
+ showDictationWarning(t("voice.missingApiKey"));
+ setDictationStatus("idle");
+ onOpenVoiceSettings();
+ return;
+ }
+
+ const voiceApi = window.agentConsole?.voice;
+ if (!voiceApi) {
+ showDictationError(t("voice.noApi"));
+ setDictationStatus("idle");
+ return;
+ }
+
+ setDictationStatus("transcribing");
+ setWaveformBars(createIdleWaveform());
+
+ try {
+ const audioBuffer = await audioBlob.arrayBuffer();
+ const result = await voiceApi.transcribeAudio({
+ audioBuffer,
+ config: currentConfig,
+ mimeType: audioBlob.type || "audio/webm"
+ });
+ const transcript = result.text.trim();
+
+ if (!transcript) {
+ throw new Error(t("voice.transcriptionEmpty"));
+ }
+
+ onChange(appendTranscription(valueRef.current, transcript));
+ setDictationError(null);
+ toast.success({ content: t("voice.transcriptionCompleted"), title: t("voice.toastTitle") });
+ } catch (error) {
+ showDictationError(getDictationErrorMessage(error, t("voice.transcriptionFailed")));
+ } finally {
+ setDictationStatus("idle");
+ setRecordingSeconds(0);
+ setWaveformBars(createIdleWaveform());
+ }
+ },
+ [onChange, onOpenVoiceSettings, showDictationError, showDictationWarning, t, toast, transcriptionConfig]
+ );
+
+ const stopDictation = useCallback(() => {
+ const recorder = mediaRecorderRef.current;
+ if (!recorder || recorder.state === "inactive") {
+ cleanupRecordingResources();
+ setDictationStatus("idle");
+ return;
+ }
+
+ setDictationStatus("transcribing");
+ recorder.stop();
+ }, [cleanupRecordingResources]);
+
+ const startDictation = useCallback(async () => {
+ if (!canRecord) return;
+
+ const currentConfig = normalizeTranscriptionConfig(transcriptionConfig);
+ if (!currentConfig.apiKey.trim()) {
+ showDictationWarning(t("voice.missingApiKey"));
+ onOpenVoiceSettings();
+ return;
+ }
+
+ if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
+ showDictationError(t("voice.noMicrophone"));
+ return;
+ }
+
+ try {
+ setDictationError(null);
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ autoGainControl: true,
+ echoCancellation: true,
+ noiseSuppression: true
+ }
+ });
+ const AudioContextConstructor =
+ window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
+ streamRef.current = stream;
+
+ if (AudioContextConstructor) {
+ const audioContext = new AudioContextConstructor();
+ const analyser = audioContext.createAnalyser();
+ analyser.fftSize = 128;
+ analyser.smoothingTimeConstant = 0.78;
+ const mediaStreamSource = audioContext.createMediaStreamSource(stream);
+ mediaStreamSource.connect(analyser);
+ audioContextRef.current = audioContext;
+ analyserRef.current = analyser;
+ mediaStreamSourceRef.current = mediaStreamSource;
+ void audioContext.resume().catch(() => undefined);
+ }
+
+ const mimeType = getPreferredAudioMimeType();
+ const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
+ recordingChunksRef.current = [];
+ mediaRecorderRef.current = recorder;
+
+ recorder.ondataavailable = (event) => {
+ if (event.data.size > 0) recordingChunksRef.current.push(event.data);
+ };
+ recorder.onerror = (event) => {
+ const message = (event as Event & { error?: { message?: string } }).error?.message;
+ showDictationError(message || t("voice.recordingFailed"));
+ };
+ recorder.onstop = () => {
+ const recordedMimeType = recorder.mimeType || mimeType || "audio/webm";
+ const audioBlob = new Blob(recordingChunksRef.current, { type: recordedMimeType });
+ recordingChunksRef.current = [];
+ mediaRecorderRef.current = null;
+ cleanupRecordingResources();
+ void transcribeBlob(audioBlob);
+ };
+
+ recordingStartRef.current = Date.now();
+ setRecordingSeconds(0);
+ setDictationStatus("recording");
+ setWaveformBars(createIdleWaveform());
+ startWaveform();
+ recorder.start(250);
+ } catch (error) {
+ cleanupRecordingResources();
+ mediaRecorderRef.current = null;
+ setDictationStatus("idle");
+ showDictationError(getDictationErrorMessage(error, t("voice.transcriptionFailed")));
+ }
+ }, [canRecord, cleanupRecordingResources, onOpenVoiceSettings, showDictationError, showDictationWarning, startWaveform, t, transcriptionConfig, transcribeBlob]);
+
+ useEffect(() => {
+ valueRef.current = value;
+ }, [value]);
+
+ useEffect(() => {
+ if (dictationStatus !== "recording") return undefined;
+
+ const updateElapsed = () => {
+ setRecordingSeconds(Math.floor((Date.now() - recordingStartRef.current) / 1000));
+ };
+ updateElapsed();
+ const intervalId = window.setInterval(updateElapsed, 250);
+ return () => window.clearInterval(intervalId);
+ }, [dictationStatus]);
+
+ useEffect(() => {
+ return () => {
+ const recorder = mediaRecorderRef.current;
+ if (recorder && recorder.state !== "inactive") recorder.stop();
+ cleanupRecordingResources();
+ };
+ }, [cleanupRecordingResources]);
+
+ return (
+
+
+
+
+ {dictationStatus === "recording" ? (
+
+ ) : dictationStatus === "transcribing" ? (
+
+ ) : (
+ <>
+
+
+ );
+}
+
+export function RecordingComposerSurface({
+ compact = false,
+ onStop,
+ recordingSeconds,
+ waveformBars
+}: {
+ compact?: boolean;
+ onStop: () => void;
+ recordingSeconds: number;
+ waveformBars: number[];
+}) {
+ const { t } = useI18n();
+ return (
+
+
+
+
+
{formatRecordingTime(recordingSeconds)}
+
+
+
+
+ );
+}
+
+export function TranscribingComposerSurface({ compact = false, waveformBars }: { compact?: boolean; waveformBars: number[] }) {
+ const { t } = useI18n();
+ return (
+
+
+
+ {t("chat.transcribingVoice")}
+
+
+
+
+
+ );
+}
+
+export function VoiceWaveform({
+ bars,
+ muted,
+ scrolling
+}: {
+ bars: number[];
+ muted?: boolean;
+ scrolling?: boolean;
+}) {
+ const canvasRef = useRef(null);
+ const barsRef = useRef(bars);
+ const lastBarsUpdateRef = useRef(performance.now());
+ const scrollingRef = useRef(Boolean(scrolling));
+ const mutedRef = useRef(Boolean(muted));
+
+ useEffect(() => {
+ barsRef.current = bars;
+ lastBarsUpdateRef.current = performance.now();
+ }, [bars]);
+
+ useEffect(() => {
+ scrollingRef.current = Boolean(scrolling);
+ mutedRef.current = Boolean(muted);
+ }, [muted, scrolling]);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return undefined;
+
+ const context = canvas.getContext("2d");
+ if (!context) return undefined;
+
+ let animationFrameId = 0;
+
+ const draw = () => {
+ const rect = canvas.getBoundingClientRect();
+ const pixelRatio = window.devicePixelRatio || 1;
+ const nextWidth = Math.max(1, Math.round(rect.width * pixelRatio));
+ const nextHeight = Math.max(1, Math.round(rect.height * pixelRatio));
+
+ if (canvas.width !== nextWidth || canvas.height !== nextHeight) {
+ canvas.width = nextWidth;
+ canvas.height = nextHeight;
+ }
+
+ context.clearRect(0, 0, nextWidth, nextHeight);
+
+ const barsSnapshot = barsRef.current;
+ const cellWidth = nextWidth / Math.max(1, barsSnapshot.length);
+ const centerY = nextHeight / 2;
+ const scrollProgress = scrollingRef.current
+ ? Math.min(1, (performance.now() - lastBarsUpdateRef.current) / waveformBarIntervalMs)
+ : 0;
+ const offsetX = scrollProgress * cellWidth;
+ const rootStyles = getComputedStyle(document.documentElement);
+ const dashColor = rootStyles.getPropertyValue("--muted-foreground").trim() || "rgba(143, 148, 155, .75)";
+ const barColor = mutedRef.current ? dashColor : rootStyles.getPropertyValue("--primary").trim() || "#0f766e";
+ const dashWidth = Math.max(2, Math.round(3 * pixelRatio));
+ const barWidth = Math.max(2, Math.round(3 * pixelRatio));
+
+ context.fillStyle = dashColor;
+ for (let index = -1; index <= barsSnapshot.length + 1; index += 1) {
+ const dashX = index * cellWidth - offsetX + cellWidth / 2 - dashWidth / 2;
+ context.fillRect(dashX, centerY - 0.5 * pixelRatio, dashWidth, pixelRatio);
+ }
+
+ context.fillStyle = barColor;
+ barsSnapshot.forEach((level, index) => {
+ if (level <= 0) return;
+
+ const height = (2 + level * 32) * pixelRatio;
+ const barX = index * cellWidth - offsetX + cellWidth / 2 - barWidth / 2;
+ const barY = centerY - height / 2;
+ const radius = barWidth / 2;
+
+ context.beginPath();
+ context.roundRect(barX, barY, barWidth, height, radius);
+ context.fill();
+ });
+
+ animationFrameId = window.requestAnimationFrame(draw);
+ };
+
+ draw();
+ return () => window.cancelAnimationFrame(animationFrameId);
+ }, []);
+
+ return (
+
+
+
+ );
+}
+
+export function VoiceSettingsInput({
+ label,
+ onChange,
+ placeholder,
+ type = "text",
+ value
+}: {
+ label: string;
+ onChange: (value: string) => void;
+ placeholder: string;
+ type?: string;
+ value: string;
+}) {
+ return (
+
+ );
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/pages/home/components/layout.tsx b/marketplace/plugins/agent-console/src/renderer/pages/home/components/layout.tsx
new file mode 100644
index 00000000..2eb16b7a
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/pages/home/components/layout.tsx
@@ -0,0 +1,1317 @@
+import { useToast } from "@/components/ui/toast";
+import { useI18n } from "@/lib/i18n";
+import { cn } from "@/lib/utils";
+import {
+ Bot,
+ Clock4,
+ Ellipsis,
+ Folder,
+ FolderOpen,
+ LayoutGrid,
+ Loader2,
+ PanelLeft,
+ PanelRight,
+ Pin,
+ PinOff,
+ Plus,
+ Search,
+ Settings,
+ Smartphone,
+ SquarePen,
+ X,
+ type LucideIcon
+} from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import type { KeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, PointerEvent as ReactPointerEvent } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { getSidebarProjectLabel, type SidebarProject, type SidebarThread } from "../../../../shared/sidebar-data";
+import {
+ AgentProviderOption,
+ getAgentProviderLabel,
+ getAgentProviderLogoDataUrl,
+ isMacPlatform,
+ newSessionThreadId,
+ popoverSpringTransition,
+ ResizeSide,
+ RightSidebarTab,
+ SettingsSectionId,
+ SmallWindowOpeningGeometry,
+ SmallWindowOpeningPhase,
+ smallWindowOpeningTransitionDurationMs
+} from "../utils/core";
+import { AnimatedSelectionBackground } from "./primitives";
+import {
+ defaultRightSidebarPluginId,
+ getRightSidebarPlugin,
+ type RightSidebarPlugin,
+ type RightSidebarPluginId,
+ type RightSidebarAgentContext,
+ type RightSidebarPluginPanelProps
+} from "../right-sidebar-plugins";
+
+export function SmallChatWindowOpeningTransition({
+ fullShell,
+ geometry,
+ phase,
+ smallShell
+}: {
+ fullShell: ReactNode;
+ geometry: SmallWindowOpeningGeometry | null;
+ phase: SmallWindowOpeningPhase;
+ smallShell: ReactNode;
+}) {
+ if (phase === "done" && !geometry) {
+ return <>{smallShell}>;
+ }
+
+ const compactVisible = phase !== "full";
+ const renderFullLayer = phase !== "done";
+ const duration = smallWindowOpeningTransitionDurationMs / 1000;
+ const followEase = [0.22, 1, 0.36, 1] as const;
+ const fullLayerScaleX = geometry ? geometry.toWidth / geometry.fromWidth : 0.985;
+ const fullLayerScaleY = geometry ? geometry.toHeight / geometry.fromHeight : 0.985;
+ const fullLayerStyle = geometry
+ ? {
+ height: geometry.fromHeight,
+ transformOrigin: "top left",
+ width: geometry.fromWidth
+ }
+ : {
+ transformOrigin: "top left"
+ };
+ const fullLayerTransition = {
+ duration,
+ ease: followEase,
+ opacity: { delay: compactVisible ? duration * 0.62 : 0, duration: duration * 0.22, ease: "easeOut" }
+ } as const;
+ const compactLayerTransition = {
+ duration: smallWindowOpeningTransitionDurationMs / 1000,
+ ease: followEase,
+ opacity: { delay: compactVisible ? duration * 0.48 : 0, duration: duration * 0.28, ease: "easeOut" },
+ scale: { delay: compactVisible ? duration * 0.36 : 0, duration: duration * 0.5, ease: followEase }
+ } as const;
+
+ return (
+
+
+ {smallShell}
+
+ {renderFullLayer ? (
+
+ {fullShell}
+
+ ) : null}
+
+ );
+}
+
+export function SmallChatWindowLayout({
+ agentLogoDataUrl,
+ agentProviderLabel,
+ children,
+ onClose,
+ onTogglePinned,
+ pinned,
+ title
+}: {
+ agentLogoDataUrl?: string;
+ agentProviderLabel: string;
+ children: ReactNode;
+ onClose: () => void;
+ onTogglePinned: () => void;
+ pinned: boolean;
+ title: string;
+}) {
+ const { t } = useI18n();
+ const PinIcon = pinned ? PinOff : Pin;
+
+ return (
+
+
+
+
+
{title || t("smallWindow.title")}
+
+
+
+
{children}
+
+ );
+}
+
+export function AgentLogo({
+ className,
+ label,
+ logoDataUrl
+}: {
+ className?: string;
+ label: string;
+ logoDataUrl?: string;
+}) {
+ return (
+
+ {logoDataUrl ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export function AgentProviderSelectOption({ label, logoDataUrl }: { label: string; logoDataUrl?: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+export function ProjectSidebar({
+ activeSettingsSection,
+ agentProviders,
+ automationPageActive,
+ botPageActive,
+ chatActive,
+ onOpenAutomationsPage,
+ onOpenBotPage,
+ onProjectContextMenu,
+ onResizeStart,
+ onOpenSettings,
+ onOpenSearch,
+ onStartNewSession,
+ onCancelThreadRename,
+ onRenameThread,
+ onStartThreadRename,
+ onThreadContextMenu,
+ open,
+ projects,
+ renamingThreadId,
+ resizing,
+ selectedThread,
+ setSelectedThread,
+ width
+}: {
+ activeSettingsSection: SettingsSectionId;
+ agentProviders: AgentProviderOption[];
+ automationPageActive: boolean;
+ botPageActive: boolean;
+ chatActive: boolean;
+ onOpenAutomationsPage: () => void;
+ onOpenBotPage: () => void;
+ onProjectContextMenu: (project: SidebarProject) => void | Promise;
+ onResizeStart: (event: ReactPointerEvent) => void;
+ onOpenSettings: (section?: SettingsSectionId) => void;
+ onOpenSearch: () => void;
+ onStartNewSession: () => void;
+ onCancelThreadRename: () => void;
+ onRenameThread: (thread: SidebarThread, title: string) => void | Promise;
+ onStartThreadRename: (thread: SidebarThread) => void;
+ onThreadContextMenu: (thread: SidebarThread) => void | Promise;
+ open: boolean;
+ projects: SidebarProject[];
+ renamingThreadId: string | null;
+ resizing: boolean;
+ selectedThread: string;
+ setSelectedThread: (thread: string) => void;
+ width: number;
+}) {
+ const { t } = useI18n();
+ const [expandedProjectIds, setExpandedProjectIds] = useState>(() => new Set(projects.map((project) => project.id)));
+
+ useEffect(() => {
+ setExpandedProjectIds((current) => {
+ const next = new Set(current);
+ for (const project of projects) {
+ next.add(project.id);
+ }
+ return next;
+ });
+ }, [projects]);
+
+ const toggleProject = (projectId: string) => {
+ setExpandedProjectIds((current) => {
+ const next = new Set(current);
+ if (next.has(projectId)) {
+ next.delete(projectId);
+ } else {
+ next.add(projectId);
+ }
+ return next;
+ });
+ };
+
+ return (
+
+ );
+}
+
+export function ProjectGroup({
+ agentProviders,
+ expanded,
+ onCancelThreadRename,
+ onProjectContextMenu,
+ onRenameThread,
+ onStartThreadRename,
+ onThreadContextMenu,
+ onToggle,
+ project,
+ renamingThreadId,
+ selectedThread,
+ setSelectedThread
+}: {
+ agentProviders: AgentProviderOption[];
+ expanded: boolean;
+ onCancelThreadRename: () => void;
+ onProjectContextMenu: (project: SidebarProject) => void | Promise;
+ onRenameThread: (thread: SidebarThread, title: string) => void | Promise;
+ onStartThreadRename: (thread: SidebarThread) => void;
+ onThreadContextMenu: (thread: SidebarThread) => void | Promise;
+ onToggle: () => void;
+ project: SidebarProject;
+ renamingThreadId: string | null;
+ selectedThread: string;
+ setSelectedThread: (thread: string) => void;
+}) {
+ const { t } = useI18n();
+ const containsSelectedThread = project.threads.some((thread) => thread.id === selectedThread);
+ const selectedCollapsedProject = containsSelectedThread && !expanded;
+ const label = project.name || t("sidebar.repositories");
+ const handleProjectContextMenu = (event: ReactMouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ void onProjectContextMenu(project);
+ };
+
+ return (
+
+
+
+
+ {project.threads.length ? (
+
+ {project.threads.map((thread) => {
+ const selected = selectedThread === thread.id;
+ const logoDataUrl = getAgentProviderLogoDataUrl(agentProviders, thread.providerId);
+ const renaming = renamingThreadId === thread.id;
+ const handleThreadContextMenu = (event: ReactMouseEvent
) => {
+ event.preventDefault();
+ event.stopPropagation();
+ void onThreadContextMenu(thread);
+ };
+ const handleThreadDoubleClick = (event: ReactMouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ onStartThreadRename(thread);
+ };
+
+ if (renaming) {
+ return (
+ onRenameThread(thread, title)}
+ selected={selected}
+ thread={thread}
+ />
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ ) : (
+ {t("sidebar.noSessions")}
+ )}
+
+
+ );
+}
+
+export function ProjectThreadRenameForm({
+ logoDataUrl,
+ onCancel,
+ onSubmit,
+ selected,
+ thread
+}: {
+ logoDataUrl?: string;
+ onCancel: () => void;
+ onSubmit: (title: string) => void | Promise;
+ selected: boolean;
+ thread: SidebarThread;
+}) {
+ const inputRef = useRef(null);
+ const canceledRef = useRef(false);
+ const [draft, setDraft] = useState(thread.title);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ const input = inputRef.current;
+ if (!input) return;
+ input.focus();
+ input.select();
+ }, []);
+
+ const submit = () => {
+ if (canceledRef.current || submitting) return;
+ setSubmitting(true);
+ void Promise.resolve(onSubmit(draft)).finally(() => setSubmitting(false));
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ canceledRef.current = true;
+ onCancel();
+ return;
+ }
+
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submit();
+ }
+ };
+
+ return (
+
+ );
+}
+
+export function AnimatedTreeChildren({ children, expanded }: { children: ReactNode; expanded: boolean }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function IconButton({
+ ariaLabel,
+ disabled,
+ icon: Icon,
+ onClick
+}: {
+ ariaLabel: string;
+ disabled?: boolean;
+ icon: LucideIcon;
+ onClick?: () => void;
+}) {
+ return (
+
+ );
+}
+
+export function SidebarResizeHandle({
+ onPointerDown,
+ side
+}: {
+ onPointerDown: (event: ReactPointerEvent) => void;
+ side: ResizeSide;
+}) {
+ const { t } = useI18n();
+ return (
+
+ );
+}
+
+export function SidebarAction({ active = false, icon: Icon, label, onClick }: { active?: boolean; icon: LucideIcon; label: string; onClick?: () => void }) {
+ return (
+
+ );
+}
+
+export type ConversationSearchResult = {
+ id: string;
+ providerLabel: string;
+ providerLogoDataUrl?: string;
+ projectLabel: string;
+ title: string;
+ working: boolean;
+};
+
+export function ConversationSearchDialog({
+ agentProviders,
+ onClose,
+ onSelectThread,
+ open,
+ projects,
+ selectedThread
+}: {
+ agentProviders: AgentProviderOption[];
+ onClose: () => void;
+ onSelectThread: (thread: string) => void;
+ open: boolean;
+ projects: SidebarProject[];
+ selectedThread: string;
+}) {
+ const { t } = useI18n();
+ const inputRef = useRef(null);
+ const [query, setQuery] = useState("");
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ const searchResults = useMemo(() => {
+ return projects.flatMap((project) => {
+ const projectLabel = getSidebarProjectLabel(project) || t("sidebar.repositories");
+ return project.threads.map((thread) => {
+ const providerLabel = thread.providerId ? getAgentProviderLabel(agentProviders, thread.providerId) : thread.title;
+ return {
+ id: thread.id,
+ providerLabel,
+ providerLogoDataUrl: getAgentProviderLogoDataUrl(agentProviders, thread.providerId),
+ projectLabel,
+ title: thread.title,
+ working: Boolean(thread.working)
+ };
+ });
+ });
+ }, [agentProviders, projects, t]);
+
+ const visibleResults = useMemo(() => {
+ const normalizedQuery = query.trim().toLocaleLowerCase();
+ const filteredResults = normalizedQuery
+ ? searchResults.filter((result) =>
+ `${result.title} ${result.projectLabel} ${result.providerLabel}`.toLocaleLowerCase().includes(normalizedQuery)
+ )
+ : searchResults;
+
+ return filteredResults.slice(0, 9);
+ }, [query, searchResults]);
+
+ useEffect(() => {
+ if (!open) return;
+
+ setQuery("");
+ setActiveIndex(0);
+ const frame = window.requestAnimationFrame(() => {
+ inputRef.current?.focus();
+ });
+
+ return () => window.cancelAnimationFrame(frame);
+ }, [open]);
+
+ useEffect(() => {
+ setActiveIndex(0);
+ }, [query]);
+
+ const selectVisibleResult = useCallback((result: ConversationSearchResult | undefined) => {
+ if (!result) return;
+ onSelectThread(result.id);
+ }, [onSelectThread]);
+
+ const onDialogKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onClose();
+ return;
+ }
+
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setActiveIndex((index) => Math.min(visibleResults.length - 1, index + 1));
+ return;
+ }
+
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setActiveIndex((index) => Math.max(0, index - 1));
+ return;
+ }
+
+ if (event.key === "Enter") {
+ event.preventDefault();
+ selectVisibleResult(visibleResults[activeIndex]);
+ return;
+ }
+
+ if ((event.metaKey || event.ctrlKey) && /^[1-9]$/.test(event.key)) {
+ event.preventDefault();
+ selectVisibleResult(visibleResults[Number(event.key) - 1]);
+ }
+ };
+
+ const shortcutPrefix = isMacPlatform() ? "⌘" : "Ctrl+";
+
+ return (
+
+ {open ? (
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}
+ transition={{ duration: 0.14, ease: "easeOut" }}
+ >
+
+
+
+ setQuery(event.target.value)}
+ placeholder={t("searchDialog.placeholder")}
+ ref={inputRef}
+ value={query}
+ />
+
+
+
+ {t("searchDialog.recent")}
+
+
+ {visibleResults.length ? (
+
+ {visibleResults.map((result, index) => {
+ const active = activeIndex === index;
+ const selected = selectedThread === result.id;
+ return (
+
+ );
+ })}
+
+ ) : (
+
{t("searchDialog.empty")}
+ )}
+
+
+
+ ) : null}
+
+ );
+}
+
+
+export function ThreadHeader({
+ agentLogoDataUrl,
+ agentProviderLabel,
+ canCopyMarkdown,
+ canCopyThreadId,
+ canRename,
+ editingThread,
+ leftOpen,
+ onCancelRename,
+ onCopyMarkdown,
+ onCopyThreadId,
+ onOpenSmallWindow,
+ onRename,
+ onSubmitRename,
+ title
+}: {
+ agentLogoDataUrl?: string;
+ agentProviderLabel: string;
+ canCopyMarkdown: boolean;
+ canCopyThreadId: boolean;
+ canRename: boolean;
+ editingThread: SidebarThread | null;
+ leftOpen: boolean;
+ onCancelRename: () => void;
+ onCopyMarkdown: () => void | Promise;
+ onCopyThreadId: () => void | Promise;
+ onOpenSmallWindow: () => void | Promise;
+ onRename: () => void | Promise;
+ onSubmitRename: (thread: SidebarThread, title: string) => void | Promise;
+ title: string;
+}) {
+ const showHeaderTitle = Boolean(title || editingThread);
+
+ return (
+
+ );
+}
+
+export function ThreadHeaderRenameForm({
+ onCancel,
+ onSubmit,
+ thread
+}: {
+ onCancel: () => void;
+ onSubmit: (title: string) => void | Promise;
+ thread: SidebarThread;
+}) {
+ const { t } = useI18n();
+ const inputRef = useRef(null);
+ const canceledRef = useRef(false);
+ const [draft, setDraft] = useState(thread.title);
+ const [submitting, setSubmitting] = useState(false);
+
+ useEffect(() => {
+ const input = inputRef.current;
+ if (!input) return;
+ input.focus();
+ input.select();
+ }, []);
+
+ const submit = () => {
+ if (canceledRef.current || submitting) return;
+ setSubmitting(true);
+ void Promise.resolve(onSubmit(draft)).finally(() => setSubmitting(false));
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ canceledRef.current = true;
+ onCancel();
+ return;
+ }
+
+ if (event.key === "Enter") {
+ event.preventDefault();
+ submit();
+ }
+ };
+
+ return (
+
+ );
+}
+
+export function createNativeMenuSvgIcon(children: string): string {
+ const svg = [
+ '"
+ ].join("");
+
+ if (typeof window !== "undefined" && typeof window.btoa === "function") {
+ return `data:image/svg+xml;base64,${window.btoa(svg)}`;
+ }
+
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
+}
+
+export const threadHeaderNativeMenuIcons = {
+ copy: createNativeMenuSvgIcon(''),
+ markdown: createNativeMenuSvgIcon(''),
+ rename: createNativeMenuSvgIcon(''),
+ smallWindow: createNativeMenuSvgIcon('')
+};
+
+export function ThreadHeaderMenu({
+ canCopyMarkdown,
+ canCopyThreadId,
+ canRename,
+ onCopyMarkdown,
+ onCopyThreadId,
+ onOpenSmallWindow,
+ onRename
+}: {
+ canCopyMarkdown: boolean;
+ canCopyThreadId: boolean;
+ canRename: boolean;
+ onCopyMarkdown: () => void | Promise;
+ onCopyThreadId: () => void | Promise;
+ onOpenSmallWindow: () => void | Promise;
+ onRename: () => void | Promise;
+}) {
+ const { t } = useI18n();
+ const toast = useToast();
+
+ const openMenu = async (event: ReactMouseEvent) => {
+ const nativeMenu = window.agentConsole?.nativeMenu;
+ if (!nativeMenu?.popup) {
+ toast.error({ content: t("agent.apiUnavailable"), title: t("thread.toastTitle") });
+ return;
+ }
+
+ try {
+ const triggerRect = event.currentTarget.getBoundingClientRect();
+ const result = await nativeMenu.popup({
+ items: [
+ { enabled: canRename, icon: threadHeaderNativeMenuIcons.rename, id: "rename-thread", label: t("thread.rename") },
+ { type: "separator" },
+ {
+ enabled: canCopyThreadId || canCopyMarkdown,
+ icon: threadHeaderNativeMenuIcons.copy,
+ label: t("thread.copy"),
+ submenu: [
+ { enabled: canCopyThreadId, icon: threadHeaderNativeMenuIcons.copy, id: "copy-thread-id", label: t("thread.copySessionId") },
+ { enabled: canCopyMarkdown, icon: threadHeaderNativeMenuIcons.markdown, id: "copy-markdown", label: t("thread.copyMarkdown") }
+ ]
+ },
+ { type: "separator" },
+ { icon: threadHeaderNativeMenuIcons.smallWindow, id: "open-small-window", label: t("thread.openSmallWindow") }
+ ],
+ x: Math.round(triggerRect.left),
+ y: Math.round(triggerRect.bottom + 4)
+ });
+
+ if (result.actionId === "rename-thread") {
+ await onRename();
+ } else if (result.actionId === "copy-thread-id") {
+ await onCopyThreadId();
+ } else if (result.actionId === "copy-markdown") {
+ await onCopyMarkdown();
+ } else if (result.actionId === "open-small-window") {
+ await onOpenSmallWindow();
+ }
+ } catch (error) {
+ toast.error({
+ content: error instanceof Error && error.message ? error.message : t("thread.menuFailed"),
+ title: t("thread.toastTitle")
+ });
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+export function FloatingSidebarToggles({
+ leftOpen,
+ rightOpen,
+ toggleLeft,
+ toggleRight
+}: {
+ leftOpen: boolean;
+ rightOpen: boolean;
+ toggleLeft: () => void;
+ toggleRight: () => void;
+}) {
+ const { t } = useI18n();
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+}
+
+
+export function RightSidebar({
+ activeTabId,
+ agentContext,
+ onAddPanel,
+ onResizeStart,
+ onSelectThread,
+ onThreadsChanged,
+ open,
+ openTabs,
+ plugins,
+ resizing,
+ setActiveTab,
+ width
+}: {
+ activeTabId: string;
+ agentContext?: RightSidebarAgentContext;
+ onAddPanel: (panel: RightSidebarPluginId) => void;
+ onResizeStart: (event: ReactPointerEvent) => void;
+ onSelectThread?: (threadId: string) => void;
+ onThreadsChanged?: () => void | Promise;
+ open: boolean;
+ openTabs: RightSidebarTab[];
+ plugins: RightSidebarPlugin[];
+ resizing: boolean;
+ setActiveTab: (tabId: string) => void;
+ width: number;
+}) {
+ const { t } = useI18n();
+ const [addMenuOpen, setAddMenuOpen] = useState(false);
+ const addMenuRef = useRef(null);
+ const visibleTabs = useMemo(
+ () =>
+ openTabs.map((tab) => {
+ const plugin = getRightSidebarPlugin(tab.pluginId, plugins);
+ return {
+ ...tab,
+ label: plugin.label,
+ plugin,
+ title: plugin.title
+ };
+ }),
+ [openTabs, plugins]
+ );
+ const activeTab = visibleTabs.find((tab) => tab.id === activeTabId) ?? visibleTabs[0];
+ const activePlugin = activeTab?.plugin ?? getRightSidebarPlugin(defaultRightSidebarPluginId, plugins);
+ const ActivePluginPanel = activePlugin.component;
+ const availablePlugins = useMemo(
+ () => plugins.filter((plugin) => !openTabs.some((tab) => tab.pluginId === plugin.id)),
+ [openTabs, plugins]
+ );
+ const activePluginPanelProps = useMemo(
+ () => ({
+ agentContext,
+ onSelectThread,
+ onThreadsChanged,
+ nativeViewOccluded: addMenuOpen
+ }),
+ [addMenuOpen, agentContext, onSelectThread, onThreadsChanged]
+ );
+
+ useEffect(() => {
+ if (!addMenuOpen) return undefined;
+
+ const closeOnOutsidePointer = (event: PointerEvent) => {
+ if (!addMenuRef.current?.contains(event.target as Node)) {
+ setAddMenuOpen(false);
+ }
+ };
+
+ const closeOnEscape = (event: globalThis.KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setAddMenuOpen(false);
+ }
+ };
+
+ document.addEventListener("pointerdown", closeOnOutsidePointer);
+ document.addEventListener("keydown", closeOnEscape);
+
+ return () => {
+ document.removeEventListener("pointerdown", closeOnOutsidePointer);
+ document.removeEventListener("keydown", closeOnEscape);
+ };
+ }, [addMenuOpen]);
+
+ return (
+
+ );
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/pages/home/components/primitives.tsx b/marketplace/plugins/agent-console/src/renderer/pages/home/components/primitives.tsx
new file mode 100644
index 00000000..582ca674
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/pages/home/components/primitives.tsx
@@ -0,0 +1,66 @@
+import { cn } from "@/lib/utils";
+import { motion } from "motion/react";
+import type { ReactNode } from "react";
+import { useEffect, useRef, useState } from "react";
+import { autoHeightSpringTransition, selectionSpringTransition } from "../utils/core";
+
+export function AutoHeightMotion({
+ children,
+ className,
+ contentClassName,
+ onHeightChange
+}: {
+ children: ReactNode;
+ className?: string;
+ contentClassName?: string;
+ onHeightChange?: () => void;
+}) {
+ const contentRef = useRef(null);
+ const [height, setHeight] = useState(0);
+
+ useEffect(() => {
+ const element = contentRef.current;
+ if (!element) return;
+
+ const updateHeight = () => {
+ setHeight(element.scrollHeight);
+ onHeightChange?.();
+ };
+ updateHeight();
+
+ const resizeObserver = new ResizeObserver(updateHeight);
+ resizeObserver.observe(element);
+ return () => resizeObserver.disconnect();
+ }, [onHeightChange]);
+
+ return (
+ onHeightChange() : undefined}
+ transition={autoHeightSpringTransition}
+ >
+ {children}
+
+ );
+}
+
+export function AnimatedSelectionBackground({
+ className,
+ layoutId
+}: {
+ className?: string;
+ layoutId: string;
+}) {
+ return (
+
+ );
+}
diff --git a/marketplace/plugins/agent-console/src/renderer/pages/home/components/settings.tsx b/marketplace/plugins/agent-console/src/renderer/pages/home/components/settings.tsx
new file mode 100644
index 00000000..158a16f4
--- /dev/null
+++ b/marketplace/plugins/agent-console/src/renderer/pages/home/components/settings.tsx
@@ -0,0 +1,5945 @@
+import { Select } from "@/components/ui/select";
+import { useToast } from "@/components/ui/toast";
+import { useI18n, type Locale, type TFunction } from "@/lib/i18n";
+import { cn } from "@/lib/utils";
+import {
+ AlertCircle,
+ Bot,
+ CheckCircle2,
+ ChevronLeft,
+ Circle,
+ Eye,
+ EyeOff,
+ HardDriveUpload,
+ KeyRound,
+ Loader2,
+ Pause,
+ Play,
+ Plus,
+ RotateCcw,
+ Save,
+ Search,
+ Settings,
+ Smartphone,
+ SquarePen,
+ Trash2,
+ X
+} from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import type { ChangeEvent, KeyboardEvent, ReactNode } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { AgentConsolePluginInfo, AgentConsolePluginMarketplaceEntry, AgentConsolePluginSourceType, AgentConsolePluginState, AgentMcpServerConfig, AgentMcpServerMap } from "../../../../shared/plugin-types";
+import { toolHubBuiltinMcpServerIds, type ToolHubBuiltinMcpServerId, type ToolHubLlmSettings, type ToolHubSettings, type ToolHubUserMcpServerConfig } from "../../../../shared/toolhub-types";
+import {
+ acceleratorFromKeyboardEvent,
+ AgentConsoleBridge,
+ AgentEnvironmentRow,
+ AgentEnvironmentSettings,
+ AgentProviderOption,
+ AgentProviderSettingsForm,
+ agentApprovalModes,
+ AppSettingsState,
+ BotGatewayChannelManifest,
+ botGatewayDefaultAuthType,
+ botGatewayDefaultTransport,
+ BotGatewayFieldDefinition,
+ BotGatewayIntegration,
+ BotGatewayIntegrationDraft,
+ botGatewayManagedTenantId,
+ botGatewayPlatformNames,
+ botGatewayPlatformOrder,
+ BotGatewayQrDisplay,
+ BotGatewayQrLoginState,
+ botGatewayQrWebviewZoomFactor,
+ botGatewayStartablePlatforms,
+ BotGatewayStatus,
+ ChatAgentProviderId,
+ ConfiguredAgentProviderSettings,
+ ConfiguredSubagentSettings,
+ createAgentEnvironmentRow,
+ createAgentProviderSettingsForm,
+ createBlankAgentProviderSettingsForm,
+ createBlankSubagentSettingsForm,
+ createSubagentSettingsForm,
+ formatShortcutAccelerator,
+ getAgentEffortOptionsForModel,
+ getAgentSpeedLabel,
+ getAgentSpeedOptionsForModel,
+ getAgentEnvironmentRows,
+ getValidAgentModel,
+ getConfiguredAgentProviderDescription,
+ getConfiguredAgentProviderFromForm,
+ getConfiguredSubagentFromForm,
+ getEnvironmentFromRows,
+ getFallbackAgentProviderOptions,
+ getSettingsSections,
+ isToolHubLlmConfigured,
+ isValidEnvironmentVariableName,
+ maxAgentLogoBytes,
+ normalizeAgentLogoDataUrl,
+ normalizeAgentModelOptions,
+ normalizeAgentMcpServerMap,
+ popoverSpringTransition,
+ SettingsPreferences,
+ SettingsPreferenceValue,
+ SettingsSectionId,
+ SubagentSettingsForm,
+ ThemePreference,
+ TranscriptionConfig,
+ updateSubagentSettingsFormValue
+} from "../utils/core";
+import { AgentLogo } from "./layout";
+import {
+ defaultHomeThemeConfigText,
+ getHomeThemeConfigError
+} from "../utils/theme";
+
+export function BotGatewayPage({
+ leftOpen,
+ onBack
+}: {
+ leftOpen: boolean;
+ onBack: () => void;
+}) {
+ const { t } = useI18n();
+
+ return (
+
+ );
+}
+
+
+export function SettingsPage({
+ activeSection,
+ agentEnvironments,
+ agentProviders,
+ appSettings,
+ backLabel,
+ onAgentEnvironmentSave,
+ onAgentProviderEnabledChange,
+ onAgentProvidersSave,
+ onBack,
+ onPreferenceChange,
+ onPluginAction,
+ onSectionChange,
+ onSpotlightShortcutChange,
+ onSpotlightShortcutReset,
+ onTranscriptionConfigChange,
+ onToolHubEnabledChange,
+ onToolHubBuiltinMcpServerChange,
+ onToolHubCacheClear,
+ onToolHubLlmConfigSave,
+ onToolHubServerInstall,
+ onToolHubServerRemove,
+ onToolHubServerUpdate,
+ onSubagentsSave,
+ preferences,
+ pluginState,
+ transcriptionConfig
+}: {
+ activeSection: SettingsSectionId;
+ agentEnvironments: AgentEnvironmentSettings;
+ agentProviders: AgentProviderOption[];
+ appSettings: AppSettingsState;
+ backLabel: string;
+ onAgentEnvironmentSave: (providerId: ChatAgentProviderId, env: Record) => Promise;
+ onAgentProviderEnabledChange: (providerId: ChatAgentProviderId, enabled: boolean) => Promise;
+ onAgentProvidersSave: (providers: ConfiguredAgentProviderSettings[]) => Promise;
+ onSubagentsSave: (subagents: ConfiguredSubagentSettings[]) => Promise;
+ onBack: () => void;
+ onPreferenceChange: (key: keyof SettingsPreferences, value: SettingsPreferenceValue) => void;
+ onPluginAction: (
+ action: "disable" | "enable" | "grant-permissions" | "install" | "reload" | "revoke-permissions" | "set-configuration" | "uninstall" | "update",
+ payload?: unknown
+ ) => Promise;
+ onSectionChange: (section: SettingsSectionId) => void;
+ onSpotlightShortcutChange: (accelerator: string) => Promise;
+ onSpotlightShortcutReset: () => Promise;
+ onTranscriptionConfigChange: (key: keyof TranscriptionConfig, value: string) => void;
+ onToolHubEnabledChange: (enabled: boolean) => Promise;
+ onToolHubBuiltinMcpServerChange: (serverId: ToolHubBuiltinMcpServerId, enabled: boolean) => Promise;
+ onToolHubCacheClear: () => Promise;
+ onToolHubLlmConfigSave: (llm: ToolHubLlmSettings) => Promise;
+ onToolHubServerInstall: (server: ToolHubUserMcpServerConfig) => Promise;
+ onToolHubServerRemove: (serverId: string) => Promise;
+ onToolHubServerUpdate: (server: ToolHubUserMcpServerConfig) => Promise;
+ preferences: SettingsPreferences;
+ pluginState: AgentConsolePluginState;
+ transcriptionConfig: TranscriptionConfig;
+}) {
+ const { t } = useI18n();
+ const settingsSections = useMemo(() => getSettingsSections(t), [t]);
+ const activeSectionConfig = settingsSections.find((section) => section.id === activeSection) ?? settingsSections[0];
+
+ return (
+
+
+
+
+
+ {activeSection !== "agents" ? (
+
+
{activeSectionConfig.label}
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+export function SettingsSectionContent({
+ activeSection,
+ activeSectionLabel,
+ agentEnvironments,
+ agentProviders,
+ appSettings,
+ onAgentEnvironmentSave,
+ onAgentProviderEnabledChange,
+ onAgentProvidersSave,
+ onSubagentsSave,
+ onPreferenceChange,
+ onPluginAction,
+ onSpotlightShortcutChange,
+ onSpotlightShortcutReset,
+ onTranscriptionConfigChange,
+ onToolHubEnabledChange,
+ onToolHubBuiltinMcpServerChange,
+ onToolHubCacheClear,
+ onToolHubLlmConfigSave,
+ onToolHubServerInstall,
+ onToolHubServerRemove,
+ onToolHubServerUpdate,
+ preferences,
+ pluginState,
+ transcriptionConfig
+}: {
+ activeSection: SettingsSectionId;
+ activeSectionLabel: string;
+ agentEnvironments: AgentEnvironmentSettings;
+ agentProviders: AgentProviderOption[];
+ appSettings: AppSettingsState;
+ onAgentEnvironmentSave: (providerId: ChatAgentProviderId, env: Record) => Promise;
+ onAgentProviderEnabledChange: (providerId: ChatAgentProviderId, enabled: boolean) => Promise;
+ onAgentProvidersSave: (providers: ConfiguredAgentProviderSettings[]) => Promise;
+ onSubagentsSave: (subagents: ConfiguredSubagentSettings[]) => Promise;
+ onPreferenceChange: (key: keyof SettingsPreferences, value: SettingsPreferenceValue) => void;
+ onPluginAction: (
+ action: "disable" | "enable" | "grant-permissions" | "install" | "reload" | "revoke-permissions" | "set-configuration" | "uninstall" | "update",
+ payload?: unknown
+ ) => Promise;
+ onSpotlightShortcutChange: (accelerator: string) => Promise;
+ onSpotlightShortcutReset: () => Promise;
+ onTranscriptionConfigChange: (key: keyof TranscriptionConfig, value: string) => void;
+ onToolHubEnabledChange: (enabled: boolean) => Promise;
+ onToolHubBuiltinMcpServerChange: (serverId: ToolHubBuiltinMcpServerId, enabled: boolean) => Promise;
+ onToolHubCacheClear: () => Promise;
+ onToolHubLlmConfigSave: (llm: ToolHubLlmSettings) => Promise;
+ onToolHubServerInstall: (server: ToolHubUserMcpServerConfig) => Promise;
+ onToolHubServerRemove: (serverId: string) => Promise;
+ onToolHubServerUpdate: (server: ToolHubUserMcpServerConfig) => Promise;
+ preferences: SettingsPreferences;
+ pluginState: AgentConsolePluginState;
+ transcriptionConfig: TranscriptionConfig;
+}) {
+ const { locale, setLocale, t } = useI18n();
+
+ if (activeSection === "agents") {
+ return (
+
+ );
+ }
+
+ if (activeSection === "permissions") {
+ return (
+ <>
+
+
+ onPreferenceChange("commandApprovals", checked)} />
+
+
+ onPreferenceChange("networkAccess", checked)} />
+
+
+ onPreferenceChange("confirmDangerousActions", checked)} />
+
+
+ >
+ );
+ }
+
+ if (activeSection === "integrations") {
+ return (
+
+ );
+ }
+
+ if (activeSection === "toolhub") {
+ return (
+
+ );
+ }
+
+ if (activeSection === "appearance") {
+ const languageOptions = [
+ { label: t("language.zh"), value: "zh" },
+ { label: t("language.en"), value: "en" }
+ ];
+ const themeOptions = [
+ { label: t("settings.theme.system"), value: "system" },
+ { label: t("settings.theme.light"), value: "light" },
+ { label: t("settings.theme.dark"), value: "dark" }
+ ];
+
+ return (
+ <>
+
+
+
+
+
+
+ onPreferenceChange("compactDensity", checked)} />
+
+
+ onPreferenceChange("reduceMotion", checked)} />
+
+
+ onPreferenceChange("homeThemeConfig", value)} />
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ onPreferenceChange("restoreLastThread", checked)} />
+
+
+ onPreferenceChange("autoSaveDrafts", checked)} />
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+export function VoiceTranscriptionSettingsPanel({
+ onTranscriptionConfigChange,
+ transcriptionConfig
+}: {
+ onTranscriptionConfigChange: (key: keyof TranscriptionConfig, value: string) => void;
+ transcriptionConfig: TranscriptionConfig;
+}) {
+ const { t } = useI18n();
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [form, setForm] = useState(() => transcriptionConfig);
+ const hasApiKey = Boolean(transcriptionConfig.apiKey.trim());
+
+ useEffect(() => {
+ if (!dialogOpen) {
+ setForm(transcriptionConfig);
+ }
+ }, [dialogOpen, transcriptionConfig]);
+
+ const openDialog = useCallback(() => {
+ setForm(transcriptionConfig);
+ setDialogOpen(true);
+ }, [transcriptionConfig]);
+
+ const closeDialog = useCallback(() => {
+ setForm(transcriptionConfig);
+ setDialogOpen(false);
+ }, [transcriptionConfig]);
+
+ const updateForm = useCallback((key: keyof TranscriptionConfig, value: string) => {
+ setForm((currentForm) => ({ ...currentForm, [key]: value }));
+ }, []);
+
+ const saveConfig = useCallback(() => {
+ const keys: Array = ["endpoint", "apiKey", "model", "language", "prompt"];
+ keys.forEach((key) => onTranscriptionConfigChange(key, form[key]));
+ setDialogOpen(false);
+ }, [form, onTranscriptionConfigChange]);
+
+ return (
+ <>
+
+
+
+
+
+ {hasApiKey ? t("settings.voice.configured") : t("settings.voice.notConfigured")}
+
+
+
+
+
+
+
+ {dialogOpen ? (
+
+
+
+
+ )}
+ key="voice-transcription-settings-dialog"
+ onClose={closeDialog}
+ title={t("settings.voice.settingsTitle")}
+ >
+
+
{t("settings.voice.configDescription")}
+
+ updateForm("endpoint", nextValue)}
+ placeholder="https://api.openai.com/v1"
+ value={form.endpoint}
+ />
+ updateForm("apiKey", nextValue)}
+ placeholder="sk-..."
+ value={form.apiKey}
+ />
+ updateForm("model", nextValue)}
+ placeholder="gpt-4o-transcribe"
+ value={form.model}
+ />
+ updateForm("language", nextValue)}
+ placeholder="auto"
+ value={form.language}
+ />
+ updateForm("prompt", nextValue)}
+ placeholder={t("common.optional")}
+ value={form.prompt}
+ />
+
+
+
+ ) : null}
+
+ >
+ );
+}
+
+export type ToolHubMcpServerFormAuthType = "api-key" | "basic" | "bearer" | "none";
+export type ToolHubMcpServerInputMode = "form" | "json";
+export type ToolHubMcpServerFormTransport = "http" | "sse" | "stdio";
+
+export type ToolHubKeyValueRow = {
+ id: string;
+ key: string;
+ value: string;
+};
+
+export type ToolHubMcpServerForm = {
+ argsText: string;
+ authHeaderName: string;
+ authPassword: string;
+ authToken: string;
+ authType: ToolHubMcpServerFormAuthType;
+ authUsername: string;
+ authValue: string;
+ command: string;
+ connectionType: "direct" | "proxy";
+ enabled: boolean;
+ envRows: ToolHubKeyValueRow[];
+ headers: Record;
+ id: string;
+ label: string;
+ type: ToolHubMcpServerFormTransport;
+ url: string;
+};
+
+export type ToolHubMcpServerFormUpdate = (key: K, value: ToolHubMcpServerForm[K]) => void;
+export type ToolHubLlmFormErrors = Partial>;
+
+export function ToolHubSettingsPanel({
+ onBuiltinMcpServerChange,
+ onCacheClear,
+ onEnabledChange,
+ onLlmConfigSave,
+ onServerInstall,
+ onServerRemove,
+ onServerUpdate,
+ settings
+}: {
+ onBuiltinMcpServerChange: (serverId: ToolHubBuiltinMcpServerId, enabled: boolean) => Promise;
+ onCacheClear: () => Promise;
+ onEnabledChange: (enabled: boolean) => Promise;
+ onLlmConfigSave: (llm: ToolHubLlmSettings) => Promise;
+ onServerInstall: (server: ToolHubUserMcpServerConfig) => Promise;
+ onServerRemove: (serverId: string) => Promise;
+ onServerUpdate: (server: ToolHubUserMcpServerConfig) => Promise