diff --git a/apps/vscode/.vscode/launch.json b/apps/vscode/.vscode/launch.json new file mode 100644 index 0000000000000000000000000000000000000000..6a674b7ea99b0ed8d5212214295ef0bc8c8b00c7 --- /dev/null +++ b/apps/vscode/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Kimi Code: Extension Development Host (isolated)", + "type": "extensionHost", + "request": "launch", + "preLaunchTask": "vscode: dev", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--user-data-dir=${workspaceFolder}/../../.tmp/vscode-extension-dev/user-data", + "--extensions-dir=${workspaceFolder}/../../.tmp/vscode-extension-dev/extensions", + "--disable-workspace-trust", + "--skip-welcome", + "--skip-release-notes", + "${workspaceFolder}/../../.tmp/vscode-extension-dev/workspace" + ], + "env": { + "KIMI_CODE_HOME": "${workspaceFolder}/../../.tmp/vscode-extension-dev/kimi-home" + }, + "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/dist/**/*.mjs"], + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ] + } + ] +} diff --git a/apps/vscode/.vscode/tasks.json b/apps/vscode/.vscode/tasks.json new file mode 100644 index 0000000000000000000000000000000000000000..948c22a2b84a7ed3365aee0a638e0680aad32e5c --- /dev/null +++ b/apps/vscode/.vscode/tasks.json @@ -0,0 +1,77 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "vscode: prepare isolated dev", + "type": "process", + "command": "pnpm", + "args": ["run", "dev:prepare"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] + }, + { + "label": "vscode: watch extension", + "type": "process", + "command": "pnpm", + "args": ["run", "dev:extension"], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "fileLocation": "absolute", + "pattern": { + "regexp": "^(.*?):(\\d+):(\\d+):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*", + "endsPattern": "Extension dev build complete; watching for changes" + } + } + }, + { + "label": "vscode: watch webview", + "type": "process", + "command": "pnpm", + "args": ["run", "dev:webview"], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "problemMatcher": { + "owner": "vite", + "fileLocation": "absolute", + "pattern": { + "regexp": "^(.*?):(\\d+):(\\d+):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*", + "endsPattern": "built in \\d+.*ms" + } + } + }, + { + "label": "vscode: dev", + "dependsOrder": "parallel", + "dependsOn": [ + "vscode: prepare isolated dev", + "vscode: watch extension", + "vscode: watch webview" + ], + "problemMatcher": [] + } + ] +} diff --git a/apps/vscode/scripts/extension-host-smoke.mjs b/apps/vscode/scripts/extension-host-smoke.mjs new file mode 100644 index 0000000000000000000000000000000000000000..03eeb2090d2dbe788c212d9d0a17b67636d7bc15 --- /dev/null +++ b/apps/vscode/scripts/extension-host-smoke.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runTests, runVSCodeCommand } from "@vscode/test-electron"; + +import { isMainModule } from "./vsix-targets.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(scriptDir, ".."); +const defaultCachePath = join(tmpdir(), "kimi-vscode-test-cache"); + +export async function runExtensionHostSmoke(options = {}) { + const version = options.version ?? "stable"; + const cacheRoot = resolve(options.cachePath ?? process.env.VSCODE_TEST_CACHE ?? defaultCachePath); + const vsixPath = resolve(options.vsixPath ?? defaultVsixPath()); + await access(vsixPath); + await mkdir(cacheRoot, { recursive: true }); + + // A stable request must never reuse an older stable download. @vscode/test-electron + // falls back to a cached build when version discovery fails, which would turn an + // offline run into a false green. Exact versions remain cached by version. + const disposableCache = version === "stable"; + const cachePath = disposableCache + ? await mkdtemp(join(cacheRoot, "stable-")) + : cacheRoot; + + const root = await mkdtemp(join(tmpdir(), "kvh-")); + const paths = { + root, + extensions: join(root, "ext"), + installUserData: join(root, "install"), + userData: join(root, "user"), + kimiHome: join(root, "home"), + osHome: join(root, "os-home"), + workspace: join(root, "ws"), + harness: join(root, "harness"), + report: join(root, "extension-host-report.json"), + }; + + try { + await Promise.all([ + paths.extensions, + paths.installUserData, + paths.userData, + paths.kimiHome, + paths.osHome, + paths.workspace, + paths.harness, + ].map((path) => mkdir(path, { recursive: true }))); + await writeFile(join(paths.workspace, "README.md"), "# Kimi VSIX Extension Host smoke\n", "utf8"); + await writeHarnessManifest(paths.harness); + + const installProfileArgs = [ + `--extensions-dir=${paths.extensions}`, + `--user-data-dir=${paths.installUserData}`, + ]; + const profileArgs = [ + `--extensions-dir=${paths.extensions}`, + `--user-data-dir=${paths.userData}`, + ]; + const downloadOptions = { version, cachePath }; + const install = await runVSCodeCommand( + ["--install-extension", vsixPath, "--force", ...installProfileArgs], + downloadOptions, + ); + const installOutput = `${install.stdout}\n${install.stderr}`; + if (!/successfully installed|was successfully installed/i.test(installOutput)) { + throw new Error(`VSIX installation did not report success:\n${installOutput.trim()}`); + } + + await runTests({ + ...downloadOptions, + extensionDevelopmentPath: paths.harness, + extensionTestsPath: join(appDir, "test", "extension-host", "index.cjs"), + launchArgs: [ + paths.workspace, + ...profileArgs, + "--disable-workspace-trust", + "--skip-welcome", + "--skip-release-notes", + ], + extensionTestsEnv: { + KIMI_CODE_HOME: paths.kimiHome, + KIMI_VSCODE_SMOKE_OS_HOME: paths.osHome, + KIMI_VSCODE_SMOKE_REPORT: paths.report, + KIMI_VSCODE_SMOKE_VSIX: basename(vsixPath), + }, + }); + + const report = JSON.parse(await readFile(paths.report, "utf8")); + if (typeof report.vscode !== "string" || report.vscode.length === 0) { + throw new Error("Extension Host smoke did not report its actual VS Code version"); + } + if (version !== "stable" && report.vscode !== version) { + throw new Error( + `Extension Host ran VS Code ${report.vscode}, expected requested version ${version}`, + ); + } + + return { version, vscodeVersion: report.vscode, vsixPath, cachePath }; + } finally { + await Promise.all([ + rm(root, { recursive: true, force: true }), + disposableCache ? rm(cachePath, { recursive: true, force: true }) : Promise.resolve(), + ]); + } +} + +function defaultVsixPath() { + const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : process.arch; + return join(appDir, "artifacts", "vsix", `kimi-code-${process.platform}-${arch}.vsix`); +} + +async function writeHarnessManifest(directory) { + await writeFile( + join(directory, "package.json"), + JSON.stringify({ + name: "kimi-vscode-extension-host-smoke", + displayName: "Kimi VSCode Extension Host Smoke", + publisher: "local-test", + version: "0.0.0", + engines: { vscode: "^1.70.0" }, + main: "./extension.cjs", + activationEvents: ["*"], + }), + "utf8", + ); + await writeFile( + join(directory, "extension.cjs"), + "exports.activate = function activate() {}; exports.deactivate = function deactivate() {};\n", + "utf8", + ); +} + +function parseArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--") continue; + if (argument === "--version") { + options.version = requiredValue(argv[++index], argument); + } else if (argument === "--vsix") { + options.vsixPath = requiredValue(argv[++index], argument); + } else if (argument === "--cache-path") { + options.cachePath = requiredValue(argv[++index], argument); + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else { + throw new Error(`Unknown option: ${argument}`); + } + } + return options; +} + +function requiredValue(value, flag) { + if (value === undefined || value.startsWith("-")) throw new Error(`${flag} requires a value`); + return value; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log("Usage: node scripts/extension-host-smoke.mjs [--version ] [--vsix ] [--cache-path ]"); + return; + } + const result = await runExtensionHostSmoke(options); + console.log( + `VSIX Extension Host smoke passed: ${result.vsixPath} on VS Code ${result.vscodeVersion} (requested ${result.version})`, + ); +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`VSIX Extension Host smoke failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/src/KimiWebviewProvider.ts b/apps/vscode/src/KimiWebviewProvider.ts new file mode 100644 index 0000000000000000000000000000000000000000..2fed3fa09d0cba0de5df77a26e017eb260326c8d --- /dev/null +++ b/apps/vscode/src/KimiWebviewProvider.ts @@ -0,0 +1,182 @@ +import * as vscode from "vscode"; +import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk"; +import { Events } from "../shared/bridge"; +import { BridgeHandler } from "./bridge-handler"; + +function getNonce(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let i = 0; i < 32; i++) { + nonce += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return nonce; +} + +/** + * Manages webview instances (sidebar and panels). + * Each webview gets a unique viewId for session isolation. + */ +export class KimiWebviewProvider implements vscode.WebviewViewProvider { + private webviews = new Map(); + private bridgeHandler: BridgeHandler; + + constructor( + private readonly extensionUri: vscode.Uri, + context: vscode.ExtensionContext, + showLogs: () => void, + writeLog: (message: string) => void, + ) { + this.bridgeHandler = new BridgeHandler( + this.broadcastInternal.bind(this), + context.workspaceState, + context.globalStorageUri.fsPath, + this.reloadWebview.bind(this), + showLogs, + writeLog, + ); + } + + dispose(): void { + void this.bridgeHandler.dispose(); + } + + shutdown(): Promise { + return this.bridgeHandler.dispose(); + } + + get harness(): KimiHarness { + return this.bridgeHandler.runtime.harness; + } + + resolveWebviewView(webviewView: vscode.WebviewView): void { + const webviewId = `sidebar_${crypto.randomUUID()}`; + this.setupWebview(webviewId, webviewView.webview); + + webviewView.onDidDispose(() => { + void this.bridgeHandler.disposeView(webviewId); + this.webviews.delete(webviewId); + }); + } + + createPanel(): vscode.WebviewPanel { + const webviewId = `panel_${crypto.randomUUID()}`; + + const panel = vscode.window.createWebviewPanel("kimiPanel", "Kimi Code", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }); + + this.setupWebview(webviewId, panel.webview); + + panel.onDidDispose(() => { + void this.bridgeHandler.disposeView(webviewId); + this.webviews.delete(webviewId); + }); + + return panel; + } + + broadcast(event: string, data: unknown): void { + this.broadcastInternal(event, data); + } + + async insertEditorMention(documentUri: vscode.Uri, selection: vscode.Selection): Promise { + let inserted = false; + await Promise.all( + [...this.webviews.keys()].map(async (webviewId) => { + const mention = await this.bridgeHandler.getEditorMention(webviewId, documentUri, selection); + if (mention === null) return; + inserted = true; + this.broadcastInternal(Events.InsertMention, { mention }, webviewId); + }), + ); + return inserted; + } + + private setupWebview(webviewId: string, webview: vscode.Webview): void { + webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + + webview.html = this.getHtml(webviewId, webview); + this.webviews.set(webviewId, webview); + + webview.onDidReceiveMessage(async (msg: unknown) => { + const result = await this.bridgeHandler.handle(msg, webviewId); + webview.postMessage(result); + }); + } + + private broadcastInternal(event: string, data: unknown, targetWebviewId?: string): void { + const msg = { event, data }; + + if (targetWebviewId) { + void this.webviews.get(targetWebviewId)?.postMessage(msg); + } else { + this.webviews.forEach((webview) => { + void webview.postMessage(msg); + }); + } + } + + private reloadWebview(webviewId: string): void { + const webview = this.webviews.get(webviewId); + if (webview) { + webview.html = this.getHtml(webviewId, webview); + } + } + + reloadAllWebviews(): void { + this.webviews.forEach((webview, webviewId) => { + webview.html = this.getHtml(webviewId, webview); + }); + } + + async resetAllWebviews(): Promise { + await Promise.all( + [...this.webviews.keys()].map((webviewId) => this.bridgeHandler.disposeView(webviewId)), + ); + this.reloadAllWebviews(); + } + + getBaselineContent(sessionId: string, filePath: string): Promise { + return this.bridgeHandler.getBaselineContent(sessionId, filePath); + } + + async setYoloModeForActiveSessions(enabled: boolean): Promise { + await this.bridgeHandler.runtime.setYoloModeForActiveSessions(enabled); + } + + private getHtml(webviewId: string, webview: vscode.Webview): string { + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")); + const baseUri = webview.asWebviewUri(this.extensionUri).toString(); + const nonce = getNonce(); + + const csp = [ + `default-src 'none'`, + `style-src ${webview.cspSource} 'unsafe-inline'`, + `img-src ${webview.cspSource} data: blob:`, + `font-src ${webview.cspSource}`, + `media-src ${webview.cspSource} data: blob:`, + `connect-src ${webview.cspSource}`, + `worker-src ${webview.cspSource} blob:`, + `script-src 'nonce-${nonce}' ${webview.cspSource}`, + ].join("; "); + + return ` + + + + + + Kimi Code + + +
+ + +`; + } +} diff --git a/apps/vscode/src/bridge-handler.ts b/apps/vscode/src/bridge-handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..d16d65debf232818415ca0343cecf129907737d7 --- /dev/null +++ b/apps/vscode/src/bridge-handler.ts @@ -0,0 +1,350 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { + validateRpcMessage, + type RpcMethod, + type RpcResult, +} from "../shared/bridge"; +import { VSCodeSettings } from "./config/vscode-settings"; +import { handlers, type BroadcastFn, type HandlerContext, type ReloadWebviewFn, type ShowLogsFn } from "./handlers"; +import { BaselineManager, type BaselineSession } from "./managers/baseline.manager"; +import { FileManager } from "./managers/file.manager"; +import { KimiRuntime } from "./runtime/kimi-runtime"; +import type { SessionRuntime } from "./runtime/session-runtime"; +import { areSameFsPath } from "./utils/fs-path"; +import { + isWorkspacePathContained, + isWorkspacePathContainedSync, + relativeWorkspacePath, + resolveWorkspacePath, + type WorkspacePath, + workDirUriFromPath, +} from "./utils/workspace-path"; + +export class BridgeHandler { + readonly baselineManager: BaselineManager; + readonly runtime: KimiRuntime; + + private readonly customWorkDirs = new Map(); + private readonly fileManager: FileManager; + + constructor( + private readonly broadcast: BroadcastFn, + private readonly workspaceState: vscode.Memento, + globalStoragePath: string, + private readonly reloadWebview: ReloadWebviewFn, + private readonly showLogs: ShowLogsFn, + private readonly writeLog: (message: string) => void, + ) { + try { + this.runtime = new KimiRuntime({ + version: VSCodeSettings.getExtensionConfig().version, + broadcast, + captureBaseline: (session, filePath, webviewIds) => { + this.captureFileBaseline(session, filePath, webviewIds); + }, + log: (message, error) => this.logRuntimeError(message, error), + }); + } catch (error) { + throw new Error( + `Failed to start the Kimi engine: ${error instanceof Error ? error.message : String(error)}.`, + { cause: error }, + ); + } + this.baselineManager = new BaselineManager(globalStoragePath, this.runtime.harness.homeDir); + this.fileManager = new FileManager(this.baselineManager, broadcast); + } + + async handle(value: unknown, webviewId: string): Promise { + const startedAt = Date.now(); + const validation = validateRpcMessage(value); + if (!validation.ok) { + this.trace(validation.id, validation.method, Date.now() - startedAt, false); + this.logRuntimeError(`Bridge request rejected: ${validation.method}`, validation.error); + return { id: validation.id, error: validation.error }; + } + + const msg = validation.message; + try { + const result = await this.dispatch(msg.method, msg.params, webviewId); + this.trace(msg.id, msg.method, Date.now() - startedAt, true); + return { id: msg.id, result }; + } catch (error) { + this.trace(msg.id, msg.method, Date.now() - startedAt, false); + this.logRuntimeError(`Bridge request failed: ${msg.method}`, error); + return { + id: msg.id, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private get workspaceRoot(): string | null { + return this.workspaceRootUri?.fsPath ?? null; + } + + private get workspaceRootUri(): vscode.Uri | null { + return vscode.workspace.workspaceFolders?.[0]?.uri ?? null; + } + + private getWorkDir(webviewId: string): string | null { + return this.customWorkDirs.get(webviewId) ?? this.workspaceRoot; + } + + private getWorkDirUri(webviewId: string): vscode.Uri | null { + const workspaceRoot = this.workspaceRoot; + const workspaceRootUri = this.workspaceRootUri; + const workDir = this.getWorkDir(webviewId); + if (workspaceRoot === null || workspaceRootUri === null || workDir === null) return null; + return workDirUriFromPath(workspaceRootUri, workspaceRoot, workDir) ?? null; + } + + private async setCustomWorkDir(webviewId: string, workDir: string | null): Promise { + const workspaceRoot = this.workspaceRoot; + const workspaceRootUri = this.workspaceRootUri; + if (workspaceRoot === null || workspaceRootUri === null) throw new Error("No workspace folder open"); + if (workDir !== null) { + const workDirUri = workDirUriFromPath(workspaceRootUri, workspaceRoot, workDir); + if (workDirUri === undefined || !(await isWorkspacePathContained(workspaceRootUri, workDirUri))) { + throw new Error("Working directory must be within the workspace"); + } + } + if (workDir && workDir !== this.workspaceRoot) { + this.customWorkDirs.set(webviewId, workDir); + } else { + this.customWorkDirs.delete(webviewId); + } + await this.runtime.detachView(webviewId); + this.fileManager.clearSession(webviewId); + } + + private requireWorkDir(webviewId: string): string { + const workDir = this.getWorkDir(webviewId); + if (!workDir) throw new Error("No workspace folder open"); + return workDir; + } + + private requireWorkDirUri(webviewId: string): vscode.Uri { + const workDirUri = this.getWorkDirUri(webviewId); + if (!workDirUri) throw new Error("No workspace folder open"); + return workDirUri; + } + + private async dispatch(method: RpcMethod, params: unknown, webviewId: string): Promise { + if (!Object.hasOwn(handlers, method)) throw new Error(`Unknown method: ${method}`); + const handler = handlers[method]; + if (!handler) throw new Error(`Unknown method: ${method}`); + return handler(params, this.createContext(webviewId)); + } + + private createContext(webviewId: string): HandlerContext { + return { + webviewId, + workDir: this.getWorkDir(webviewId), + workDirUri: this.getWorkDirUri(webviewId), + workspaceRoot: this.workspaceRoot, + workspaceRootUri: this.workspaceRootUri, + workspaceState: this.workspaceState, + requireWorkDir: () => this.requireWorkDir(webviewId), + requireWorkDirUri: () => this.requireWorkDirUri(webviewId), + broadcast: this.broadcast, + fileManager: this.fileManager, + baselineManager: this.baselineManager, + runtime: this.runtime, + harness: this.runtime.harness, + reloadWebview: () => this.reloadWebview(webviewId), + showLogs: this.showLogs, + logError: (message, error) => this.logRuntimeError(message, error), + getSession: () => this.runtime.getSessionForView(webviewId), + getSessionId: () => this.fileManager.getSessionId(webviewId), + getOrCreateSession: async (model, effort, sessionId) => { + const runtime = await this.runtime.openSession({ + webviewId, + workDir: this.requireWorkDir(webviewId), + model, + effort, + yoloMode: VSCodeSettings.yoloMode, + ...(sessionId === undefined ? {} : { sessionId }), + }); + this.fileManager.setSession(webviewId, baselineSession(runtime)); + return runtime; + }, + resumeSession: async (sessionId) => { + const current = this.runtime.getSession(sessionId); + const session = + current?.session ?? + (await this.runtime.harness.resumeSession({ id: sessionId, includeSubagents: true })); + if (!areSameFsPath(session.workDir, this.requireWorkDir(webviewId))) { + if (current === undefined) { + await session.close().catch((error: unknown) => { + this.logRuntimeError("Unable to close a rejected session", error); + }); + } + throw new Error("The selected session belongs to a different working directory."); + } + const runtime = await this.runtime.attachResumedSession( + webviewId, + session, + VSCodeSettings.yoloMode, + ); + this.fileManager.setSession(webviewId, baselineSession(runtime)); + return runtime; + }, + closeSession: async () => { + await this.runtime.detachView(webviewId); + this.fileManager.clearSession(webviewId); + }, + saveAllDirty: () => this.saveAllDirty(), + setCustomWorkDir: (workDir) => this.setCustomWorkDir(webviewId, workDir), + }; + } + + private async saveAllDirty(): Promise { + const dirty = vscode.workspace.textDocuments.filter((document) => document.isDirty && !document.isUntitled); + await Promise.all(dirty.map((document) => document.save())); + } + + async disposeView(webviewId: string): Promise { + await this.runtime.detachView(webviewId); + this.customWorkDirs.delete(webviewId); + this.fileManager.disposeView(webviewId); + } + + async getEditorMention( + webviewId: string, + documentUri: vscode.Uri, + selection: vscode.Selection, + ): Promise { + const workDirUri = this.getWorkDirUri(webviewId); + // Mirror the CLI/TUI: no UI-level directory gate on mentions. Inside the + // working directory the mention is relative; outside it (for example a + // file under the session's additionalDirs) it falls back to the absolute + // path, and the session's tool layer decides readability. Virtual + // documents (untitled:, git:, ...) have no meaningful path to mention. + if (workDirUri === null || documentUri.scheme !== workDirUri.scheme) return null; + const filePath = relativeWorkspacePath(workDirUri, documentUri) ?? documentUri.fsPath; + // Quote paths containing spaces, as the CLI/TUI mention completers do, so + // whitespace cannot split the path; any line range goes after the quote. + const mentionTarget = filePath.includes(" ") ? `"${filePath}"` : filePath; + + if (selection.isEmpty) return `@${mentionTarget}`; + return selection.start.line === selection.end.line + ? `@${mentionTarget}:${selection.start.line + 1}` + : `@${mentionTarget}:${selection.start.line + 1}-${selection.end.line + 1}`; + } + + captureFileBaseline( + session: BaselineSession, + filePath: string, + webviewIds: readonly string[], + ): void { + const workspaceRoot = this.workspaceRoot; + const workspaceRootUri = this.workspaceRootUri; + if (workspaceRoot === null || workspaceRootUri === null) return; + + const workDirUri = workDirUriFromPath(workspaceRootUri, workspaceRoot, session.workDir); + if ( + workDirUri === undefined || + !isWorkspacePathContainedSync(workspaceRootUri, workDirUri) + ) { + this.logRuntimeError( + "Unable to capture a file baseline", + new Error("Session working directory is outside the workspace"), + ); + return; + } + + const resolved = resolveSessionFilePath(workDirUri, session.workDir, filePath); + if ( + resolved === undefined || + !isWorkspacePathContainedSync(workDirUri, resolved.uri, { allowMissing: true }) + ) { + this.logRuntimeError( + "Unable to capture a file baseline", + new Error("File is outside the session working directory"), + ); + return; + } + + const capture = this.baselineManager.capture(session, resolved.uri.fsPath); + void capture + .then(async () => { + await Promise.all( + webviewIds.map(async (webviewId) => { + this.fileManager.trackFile(webviewId, resolved.uri.fsPath); + await this.fileManager.refreshChanges(webviewId); + }), + ); + }) + .catch((error) => { + this.logRuntimeError("Unable to capture a file baseline", error); + }); + } + + async dispose(): Promise { + this.fileManager.dispose(); + await this.runtime.dispose(); + } + + async getBaselineContent(sessionId: string, filePath: string): Promise { + const active = this.runtime.getSession(sessionId)?.summary; + const summary = active ?? (await this.runtime.harness.listSessions({ sessionId }))[0]; + if (summary === undefined) throw new Error("Session was not found."); + return this.baselineManager.getContent(baselineSummary(summary), filePath); + } + + private trace(id: string, method: string, durationMs: number, ok: boolean): void { + // Deliberately exclude params, prompt text, file paths, and credentials. + const line = `[bridge] id=${id} method=${method} ok=${String(ok)} durationMs=${durationMs}`; + console.debug(`[kimi-vscode] ${line}`); + this.writeLog(line); + } + + private logRuntimeError(message: string, error?: unknown): void { + const detail = errorDetail(error); + const line = `${message}${detail ? `: ${detail}` : ""}`; + console.error(`[kimi-vscode] ${line}`); + this.writeLog(line); + } +} + +function errorDetail(error: unknown): string { + if (error === undefined) return ""; + if (error instanceof Error) return `${error.name}: ${error.message}`; + if (typeof error === "string") return error; + if (typeof error === "number" || typeof error === "bigint" || typeof error === "boolean") { + return String(error); + } + return "Unknown error"; +} + +function baselineSession(runtime: SessionRuntime): BaselineSession { + return baselineSummary({ + id: runtime.id, + workDir: runtime.session.workDir, + metadata: runtime.summary?.metadata, + }); +} + +function baselineSummary(summary: Pick): BaselineSession { + return { + id: summary.id, + workDir: summary.workDir, + ...(summary.metadata === undefined ? {} : { metadata: summary.metadata }), + }; +} + +function resolveSessionFilePath( + workDirUri: vscode.Uri, + workDir: string, + filePath: string, +): WorkspacePath | undefined { + if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath)) { + const uri = workDirUriFromPath(workDirUri, workDir, filePath); + if (uri === undefined) return undefined; + const relativePath = relativeWorkspacePath(workDirUri, uri); + return relativePath === undefined ? undefined : { uri, relativePath }; + } + return resolveWorkspacePath(workDirUri, filePath); +} diff --git a/apps/vscode/src/config/vscode-settings.ts b/apps/vscode/src/config/vscode-settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..9bca3b1267a69f91fabb2762eb948e1e2d0769e9 --- /dev/null +++ b/apps/vscode/src/config/vscode-settings.ts @@ -0,0 +1,64 @@ +import * as vscode from "vscode"; +import type { ExtensionConfig } from "../../shared/types"; + +declare const __EXTENSION_VERSION__: string; +const EXTENSION_VERSION = typeof __EXTENSION_VERSION__ !== "undefined" ? __EXTENSION_VERSION__ : "0.0.0"; + +function getConfig() { + return vscode.workspace.getConfiguration("kimi"); +} + +export const VSCodeSettings = { + get yoloMode(): boolean { + return getConfig().get("yoloMode", false); + }, + + get autosave(): boolean { + return getConfig().get("autosave", true); + }, + + get enableNewConversationShortcut(): boolean { + return getConfig().get("enableNewConversationShortcut", false); + }, + + get useCtrlEnterToSend(): boolean { + return getConfig().get("useCtrlEnterToSend", false); + }, + + get showThinkingContent(): boolean { + return getConfig().get("showThinkingContent", false); + }, + + get showThinkingExpanded(): boolean { + return getConfig().get("showThinkingExpanded", false); + }, + + get editorContext(): "never" | "onConversationStart" | "onFileChange" { + return getConfig().get<"never" | "onConversationStart" | "onFileChange">("editorContext", "never"); + }, + + getExtensionConfig(): ExtensionConfig { + return { + yoloMode: this.yoloMode, + autosave: this.autosave, + useCtrlEnterToSend: this.useCtrlEnterToSend, + enableNewConversationShortcut: this.enableNewConversationShortcut, + showThinkingContent: this.showThinkingContent, + showThinkingExpanded: this.showThinkingExpanded, + version: EXTENSION_VERSION, + }; + }, +}; + +export function onSettingsChange(callback: (changedKeys: string[]) => void): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration("kimi")) { + return; + } + const keys = ["yoloMode", "autosave", "enableNewConversationShortcut", "useCtrlEnterToSend", "showThinkingContent", "showThinkingExpanded", "editorContext"]; + const changedKeys = keys.filter((key) => e.affectsConfiguration(`kimi.${key}`)); + if (changedKeys.length > 0) { + callback(changedKeys); + } + }); +} diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..4418526fbd97475dfbb3ee379bde3ce27185ef19 --- /dev/null +++ b/apps/vscode/src/extension.ts @@ -0,0 +1,280 @@ +import * as vscode from "vscode"; + +import { Events } from "../shared/bridge"; +import { KimiWebviewProvider } from "./KimiWebviewProvider"; +import { onSettingsChange, VSCodeSettings } from "./config/vscode-settings"; +import { + LegacyMigrationManager, + type LegacyMigrationDiscovery, + type LegacyMigrationRunResult, +} from "./migration"; +import { updateLoginContext } from "./utils/context"; + +let outputChannel: vscode.OutputChannel | undefined; +let provider: KimiWebviewProvider | undefined; + +const LEGACY_REAUTH_NOTICE_KEY = "kimi.legacyMigration.reauthNotice.v1"; +const LEGACY_WARNING_NOTICE_KEY = "kimi.legacyMigration.warningNotice.v1"; + +export async function activate(context: vscode.ExtensionContext): Promise { + outputChannel = vscode.window.createOutputChannel("Kimi Code"); + const remoteInfo = vscode.env.remoteName ? ` (remote: ${vscode.env.remoteName})` : ""; + log(`Kimi Code ${VSCodeSettings.getExtensionConfig().version} activating${remoteInfo}`); + + provider = new KimiWebviewProvider( + context.extensionUri, + context, + () => outputChannel?.show(), + (message) => log(message), + ); + context.subscriptions.push(provider, outputChannel); + + let isLoggedIn = false; + try { + isLoggedIn = await updateLoginContext(provider.harness); + } catch (error) { + logError("Unable to determine login status", error); + } + + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider("kimi-baseline", { + provideTextDocumentContent: async (uri) => { + const sessionId = new URLSearchParams(uri.query).get("sessionId"); + if (!sessionId || !provider) return ""; + const relativePath = decodeURIComponent(uri.path.replace(/^\//, "")); + try { + return await provider.getBaselineContent(sessionId, relativePath); + } catch (error) { + logError("Unable to open baseline content", error); + return ""; + } + }, + }), + ); + + context.subscriptions.push( + onSettingsChange((changedKeys) => { + provider?.broadcast(Events.ExtensionConfigChanged, { + config: VSCodeSettings.getExtensionConfig(), + changedKeys, + }); + if (changedKeys.includes("yoloMode")) { + void provider + ?.setYoloModeForActiveSessions(VSCodeSettings.yoloMode) + .catch((error) => logError("Unable to update session permission", error)); + } + }), + vscode.window.registerWebviewViewProvider("kimi.webview", provider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + + const migrationManager = new LegacyMigrationManager({ + targetHome: provider.harness.homeDir, + workspaceRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + legacyEnvironmentVariables: vscode.workspace + .getConfiguration("kimi") + .get("environmentVariables"), + }); + let migrationInFlight: Promise | undefined; + const runMigration = (retry: boolean): Promise => { + if (migrationInFlight !== undefined) return migrationInFlight; + migrationInFlight = performMigration(migrationManager, retry).finally(() => { + migrationInFlight = undefined; + }); + return migrationInFlight; + }; + + const commands: Record void | Promise> = { + "kimi.clearAllState": async () => { + await context.globalState.update("kimi.config", undefined); + await context.globalState.update("kimi.mcpServers", undefined); + await context.workspaceState.update("kimi.mcpEnabled", undefined); + await vscode.window.showInformationMessage("Kimi: Extension UI state cleared."); + }, + "kimi.openInTab": () => { + provider?.createPanel(); + }, + "kimi.openInSideBar": async () => { + await vscode.commands.executeCommand("kimi.webview.focus"); + }, + "kimi.focusInput": async () => { + await vscode.commands.executeCommand("kimi.webview.focus"); + provider?.broadcast(Events.FocusInput, {}); + }, + "kimi.insertMention": async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + await vscode.window.showWarningMessage("No active editor"); + return; + } + await vscode.commands.executeCommand("kimi.webview.focus"); + if (!(await provider?.insertEditorMention(editor.document.uri, editor.selection))) { + await vscode.window.showWarningMessage("The active file is outside the selected working directory."); + } + }, + "kimi.newConversation": async () => { + await vscode.commands.executeCommand("kimi.webview.focus"); + provider?.broadcast(Events.NewConversation, {}); + }, + "kimi.showLogs": () => outputChannel?.show(), + "kimi.resetKimi": () => provider?.resetAllWebviews(), + "kimi.logout": async () => { + await vscode.commands.executeCommand("kimi.webview.focus"); + await vscode.window.showInformationMessage("Use the logout button in Kimi settings."); + }, + "kimi.migrateLegacyData": () => runMigration(true), + }; + + for (const [id, handler] of Object.entries(commands)) { + context.subscriptions.push(vscode.commands.registerCommand(id, handler)); + } + + void offerLegacyMigration( + migrationManager, + () => runMigration(false), + context.globalState, + isLoggedIn, + ).catch((error) => { + logError("Unable to check for legacy Kimi data", error); + }); + log("Kimi Code activated"); +} + +export async function deactivate(): Promise { + log("Kimi Code deactivating"); + await provider?.shutdown(); + provider = undefined; +} + +function log(message: string): void { + outputChannel?.appendLine(`[${new Date().toISOString()}] ${message}`); +} + +function logError(message: string, error: unknown): void { + const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + log(`${message}: ${detail}`); +} + +async function offerLegacyMigration( + manager: LegacyMigrationManager, + migrate: () => Promise, + globalState: vscode.Memento, + isLoggedIn: boolean, +): Promise { + const discovery = await manager.discover(); + logMigrationDiscovery(discovery); + const reauthNotice = legacyReauthNotice(discovery, isLoggedIn); + const warningNotice = + discovery.warnings.length === 0 + ? null + : "Some legacy Kimi data could not be inspected. Use “Kimi Code: Migrate Legacy Data” to retry."; + if (discovery.prompt === null) { + if (reauthNotice !== null && !globalState.get(LEGACY_REAUTH_NOTICE_KEY, false)) { + await vscode.window.showWarningMessage(reauthNotice); + await globalState.update(LEGACY_REAUTH_NOTICE_KEY, true); + } + if ( + discovery.warnings.length > 0 && + !globalState.get(LEGACY_WARNING_NOTICE_KEY, false) + ) { + const action = await vscode.window.showWarningMessage( + warningNotice ?? "Some legacy Kimi data could not be inspected.", + "Show Logs", + ); + await globalState.update(LEGACY_WARNING_NOTICE_KEY, true); + if (action === "Show Logs") outputChannel?.show(); + } + return; + } + + const action = await vscode.window.showInformationMessage( + [discovery.prompt.message, reauthNotice, warningNotice] + .filter((message) => message !== null) + .join(" "), + ...discovery.prompt.actions.map(({ label }) => label), + ); + if (reauthNotice !== null) await globalState.update(LEGACY_REAUTH_NOTICE_KEY, true); + if (warningNotice !== null) await globalState.update(LEGACY_WARNING_NOTICE_KEY, true); + if (action === "Migrate Now") await migrate(); +} + +function legacyReauthNotice( + discovery: LegacyMigrationDiscovery, + isLoggedIn: boolean, +): string | null { + const kimiLogins = isLoggedIn ? 0 : discovery.notices.oauthLoginsRequiringRelogin.length; + const mcpLogins = discovery.notices.mcpOauthServersRequiringReauth.length; + if (kimiLogins === 0 && mcpLogins === 0) return null; + if (kimiLogins > 0 && mcpLogins > 0) { + return "Legacy OAuth credentials are not copied. Sign in to Kimi Code and authorize your MCP servers again."; + } + return kimiLogins > 0 + ? "Legacy OAuth credentials are not copied. Sign in to Kimi Code again." + : "Legacy MCP OAuth credentials are not copied. Authorize those MCP servers again."; +} + +async function performMigration( + manager: LegacyMigrationManager, + retry: boolean, +): Promise { + log(`${retry ? "Retrying" : "Starting"} legacy Kimi data migration`); + const result = retry ? await manager.retry() : await manager.migrateNow(); + logMigrationResult(result); + + if (result.status === "completed" || result.status === "partial") { + try { + await provider?.harness.getConfig({ reload: true }); + await provider?.resetAllWebviews(); + } catch (error) { + logError("Migration finished, but the runtime config could not be reloaded", error); + } + } + + const reauthCount = + result.notices.oauthLoginsRequiringRelogin.length + + result.notices.mcpOauthServersRequiringReauth.length; + const reauthNotice = + reauthCount === 0 + ? "" + : ` ${reauthCount} OAuth connection(s) must be signed in again.`; + const message = `${result.message}${reauthNotice}`; + const needsLogs = + result.status === "partial" || + result.status === "failed" || + result.warnings.length > 0 || + result.manualActions.length > 0; + + if (result.status === "failed") { + const action = await vscode.window.showErrorMessage(message, "Show Logs"); + if (action === "Show Logs") outputChannel?.show(); + } else if (needsLogs) { + const action = await vscode.window.showWarningMessage(message, "Show Logs"); + if (action === "Show Logs") outputChannel?.show(); + } else { + await vscode.window.showInformationMessage(message); + } +} + +function logMigrationDiscovery(discovery: LegacyMigrationDiscovery): void { + for (const warning of discovery.warnings) log(`Legacy migration warning: ${warning.message}`); + for (const source of discovery.suppressedSources) { + log(`Legacy migration already completed for ${source.sourceHome}`); + } +} + +function logMigrationResult(result: LegacyMigrationRunResult): void { + const { totals } = result; + log( + `Legacy migration ${result.status}: config=${totals.configFiles} mcp=${totals.mcpServers} history=${totals.userHistoryEntries} skills=${totals.skills} sessions=${totals.sessions} alreadyMigrated=${totals.alreadyMigratedSessions} skipped=${totals.skippedItems} conflicts=${totals.conflicts} failures=${totals.failures}`, + ); + for (const warning of result.warnings) log(`Legacy migration warning: ${warning.message}`); + for (const source of result.sources) { + for (const failure of source.failures) { + log(`Legacy migration failure (${failure.sourceHome}): ${failure.message}`); + } + } + for (const action of result.manualActions) log(`Legacy migration action: ${action}`); +} + +export { log }; diff --git a/apps/vscode/src/handlers/auth.handler.ts b/apps/vscode/src/handlers/auth.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..eece7452012d7e6e8b499a061f284b7c4493301a --- /dev/null +++ b/apps/vscode/src/handlers/auth.handler.ts @@ -0,0 +1,56 @@ +import * as vscode from "vscode"; + +import { OAuthAccessDeniedError } from "@moonshot-ai/kimi-code-oauth"; + +import { Events, Methods } from "../../shared/bridge"; +import type { LoginResult } from "../../shared/legacy-sdk"; +import type { LoginStatus } from "../../shared/types"; +import { updateLoginContext } from "../utils/context"; +import type { Handler } from "./types"; + +export const authHandlers: Record> = { + [Methods.CheckLoginStatus]: async (_, ctx): Promise => { + return { loggedIn: await updateLoginContext(ctx.harness) }; + }, + + [Methods.Login]: async (_, ctx): Promise => { + try { + await ctx.harness.auth.login(undefined, { + onDeviceCode: async (authorization) => { + const url = authorization.verificationUriComplete || authorization.verificationUri; + ctx.broadcast(Events.LoginUrl, { url }, ctx.webviewId); + await vscode.env.openExternal(vscode.Uri.parse(url)); + }, + }); + await updateLoginContext(ctx.harness); + return { success: true }; + } catch (error) { + ctx.logError("Kimi login failed", error); + await updateLoginContext(ctx.harness).catch((statusError: unknown) => { + ctx.logError("Unable to refresh login status after a failed login", statusError); + }); + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: error instanceof OAuthAccessDeniedError ? `Login cancelled: ${message}` : message, + }; + } + }, + + [Methods.Logout]: async (_, ctx): Promise => { + try { + await ctx.harness.auth.logout(); + await updateLoginContext(ctx.harness); + return { success: true }; + } catch (error) { + ctx.logError("Kimi logout failed", error); + await updateLoginContext(ctx.harness).catch((statusError: unknown) => { + ctx.logError("Unable to refresh login status after a failed logout", statusError); + }); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}; diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5b21f89ad826801737d68ced3c253151346f7a2 --- /dev/null +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -0,0 +1,226 @@ +import * as vscode from "vscode"; +import { isKimiError } from "@moonshot-ai/kimi-code-sdk"; + +import { Events, Methods } from "../../shared/bridge"; +import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk"; +import { getUserMessage } from "../../shared/errors"; +import type { ErrorPhase } from "../../shared/types"; +import { VSCodeSettings } from "../config/vscode-settings"; +import { normalizeEffort } from "../runtime/kimi-runtime"; +import type { SessionRuntime } from "../runtime/session-runtime"; +import { isWorkspacePathContained, relativeWorkspacePath } from "../utils/workspace-path"; +import { parseHostSlashCommand, runHostSlashCommand } from "./slash-command"; +import type { Handler } from "./types"; + +interface StreamChatParams { + content: string | ContentPart[]; + model: string; + effort?: string; + thinking?: boolean; + planMode?: boolean; + sessionId?: string; +} + +interface RespondApprovalParams { + requestId: string; + response: ApprovalResponse; +} + +interface RespondQuestionParams { + rpcRequestId: string; + questionRequestId: string; + answers: Record; +} + +const injectedEditorContextSessions = new Map(); + +async function buildSystemContext(sessionId: string, ctx: Parameters[1]): Promise { + const mode = VSCodeSettings.editorContext; + if (mode === "never") return ""; + + const editor = vscode.window.activeTextEditor; + if (!editor || !ctx.workDirUri || !(await isWorkspacePathContained(ctx.workDirUri, editor.document.uri))) { + return ""; + } + + const document = editor.document; + const relativePath = relativeWorkspacePath(ctx.workDirUri, document.uri); + if (relativePath === undefined) return ""; + const lastPath = injectedEditorContextSessions.get(sessionId); + if (mode === "onConversationStart" && lastPath !== undefined) return ""; + if (mode === "onFileChange" && lastPath === relativePath) return ""; + + injectedEditorContextSessions.set(sessionId, relativePath); + const selection = editor.selection; + const selectionInfo = selection.isEmpty + ? "" + : ` (L${selection.start.line + 1}-${selection.end.line + 1} selected)`; + const unsavedInfo = document.isDirty ? ", unsaved" : ""; + return `Editor context (use only if relevant to user's query): ${relativePath}:${selection.active.line + 1}${selectionInfo}${unsavedInfo}.\n`; +} + +function prependSystemContext(content: string | ContentPart[], context: string): string | ContentPart[] { + if (!context) return content; + if (typeof content === "string") return `${content}\n${context}`; + + const index = content.findIndex((part) => part.type === "text"); + if (index < 0) return [{ type: "text", text: context }, ...content]; + const copy = [...content]; + const text = copy[index] as Extract; + copy[index] = { type: "text", text: context + text.text }; + return copy; +} + +const streamChat: Handler = async (params, ctx) => { + if (!ctx.workDir) { + emitPreflightError(ctx, "NO_WORKSPACE", "Please open a folder to start."); + void vscode.window.showWarningMessage("Kimi: Please open a folder first.", "Open Folder").then((action) => { + if (action) void vscode.commands.executeCommand("vscode.openFolder"); + }); + return { done: false }; + } + + if (VSCodeSettings.autosave) { + try { + await ctx.saveAllDirty(); + } catch (error) { + emitCaughtError(ctx, error, "preflight"); + return { done: false }; + } + } + + let runtime: SessionRuntime; + try { + runtime = await ctx.getOrCreateSession( + params.model, + params.effort ?? (params.thinking === true ? "on" : "off"), + params.sessionId, + ); + } catch (error) { + emitCaughtError(ctx, error, "preflight"); + return { done: false }; + } + + try { + // Attach no longer overwrites session modes with the configured defaults + // (resumed sessions keep their own), so apply the model/effort that the + // composer submitted with this prompt before the turn starts. + const status = await runtime.session.getStatus(); + let model = status.model; + if (params.model && model !== params.model) { + await runtime.session.setModel(params.model); + model = params.model; + } + const effort = normalizeEffort(params.effort ?? (params.thinking === true ? "on" : "off")); + if (status.thinkingEffort !== effort) { + await runtime.session.setThinking(effort); + } + if (params.planMode !== undefined && status.planMode !== params.planMode) { + await runtime.session.setPlanMode(params.planMode); + } + runtime.announceSessionStart(model); + } catch (error) { + emitCaughtError(ctx, error, "preflight", runtime.id); + return { done: false }; + } + + const slash = parseHostSlashCommand(params.content); + if (slash !== undefined) { + try { + return { done: await runHostSlashCommand(runtime, slash, ctx) }; + } catch (error) { + emitCaughtError(ctx, error, "runtime", runtime.id); + return { done: false }; + } + } + + const systemContext = await buildSystemContext(runtime.id, ctx); + try { + const result = await runtime.prompt(prependSystemContext(params.content, systemContext)); + return { done: result.status === "finished" }; + } catch (error) { + emitCaughtError(ctx, error, "runtime", runtime.id); + return { done: false }; + } +}; + +const abortChat: Handler = async (_, ctx) => { + const runtime = ctx.getSession(); + // Do not claim an abort when there is no runtime to cancel — the webview + // would otherwise show the task as stopped while the engine keeps running. + if (runtime === undefined) return { aborted: false }; + await runtime.cancel(); + return { aborted: true }; +}; + +const respondApproval: Handler = async (params, ctx) => { + return { ok: ctx.getSession()?.respondApproval(params.requestId, params.response) ?? false }; +}; + +const respondQuestion: Handler = async (params, ctx) => { + const id = params.questionRequestId || params.rpcRequestId; + return { ok: ctx.getSession()?.respondQuestion(id, params.answers) ?? false }; +}; + +const setPlanMode: Handler<{ enabled: boolean }, { ok: boolean; planMode: boolean }> = async (params, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined) return { ok: false, planMode: false }; + await runtime.session.setPlanMode(params.enabled); + return { ok: true, planMode: params.enabled }; +}; + +const steerChat: Handler<{ content: string | ContentPart[] }, { ok: boolean }> = async (params, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined || !runtime.isBusy) return { ok: false }; + await runtime.steer(params.content); + return { ok: true }; +}; + +const resetSession: Handler = async (_, ctx) => { + const runtime = ctx.getSession(); + if (runtime !== undefined) injectedEditorContextSessions.delete(runtime.id); + await ctx.closeSession(); + ctx.fileManager.clearTracked(ctx.webviewId); + return { ok: true }; +}; + +export const chatHandlers: Record> = { + [Methods.StreamChat]: streamChat, + [Methods.AbortChat]: abortChat, + [Methods.RespondApproval]: respondApproval, + [Methods.RespondQuestion]: respondQuestion, + [Methods.SetPlanMode]: setPlanMode, + [Methods.SteerChat]: steerChat, + [Methods.ResetSession]: resetSession, +}; + +function emitCaughtError( + ctx: Parameters[1], + error: unknown, + phase: ErrorPhase, + sessionId?: string, +): void { + const code = isKimiError(error) ? error.code : "internal"; + const detail = error instanceof Error ? error.message : String(error); + ctx.logError(`Chat ${phase} request failed`, error); + ctx.broadcast( + Events.StreamEvent, + { + type: "error", + code, + message: getUserMessage(code, detail), + detail, + phase, + ...(sessionId === undefined ? {} : { _sessionId: sessionId }), + }, + ctx.webviewId, + ); +} + +function emitPreflightError(ctx: Parameters[1], code: string, message: string): void { + ctx.broadcast( + Events.StreamEvent, + { type: "error", code, message, phase: "preflight" }, + ctx.webviewId, + ); +} diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..87d9b7dcf27aaf04595b3d514fdb86c59786a451 --- /dev/null +++ b/apps/vscode/src/handlers/config.handler.ts @@ -0,0 +1,205 @@ +import * as vscode from "vscode"; +import { + effectiveModelAlias, + type KimiConfig as SdkKimiConfig, + type ModelAlias, + type ProviderType, + type ThinkingEffort, +} from "@moonshot-ai/kimi-code-sdk"; + +import { Methods } from "../../shared/bridge"; +import type { + KimiConfig as WebviewKimiConfig, + ModelConfig, + SlashCommandInfo, +} from "../../shared/legacy-sdk"; +import type { ExtensionConfig, SessionConfig } from "../../shared/types"; +import { VSCodeSettings } from "../config/vscode-settings"; +import type { Handler } from "./types"; + +const SLASH_COMMANDS: SlashCommandInfo[] = [ + { name: "init", aliases: [], description: "Analyze the codebase and generate AGENTS.md" }, + { name: "compact", aliases: [], description: "Compact the conversation context" }, + { name: "clear", aliases: ["reset"], description: "Clear the context" }, + { name: "yolo", aliases: [], description: "Toggle YOLO mode (auto-approve tool actions; may still ask questions)" }, + { + name: "auto", + aliases: ["afk"], + description: "Toggle Auto mode (fully autonomous; the agent will not ask questions)", + }, + { name: "plan", aliases: [], description: "Toggle plan mode. Usage: /plan [on|off|view|clear]" }, + { + name: "add-dir", + aliases: [], + description: "Add a directory to the workspace. Usage: /add-dir ", + }, + { name: "export", aliases: [], description: "Export current session context to a markdown file" }, + { name: "import", aliases: [], description: "Import context from a file or session ID" }, +]; + +const saveConfig: Handler = async (params, ctx) => { + const effort = sessionConfigEffort(params); + const effortChanged = params.effortChanged !== false; + const config = await ctx.harness.getConfig({ reload: true }); + const model = config.models?.[params.model]; + // Resolve with the provider type the way the TUI's effectiveModelForHost + // does: without it the Anthropic fallback profile (e.g. `claude-latest`) + // never matches, so the inferred default that gates persistence is missed. + const providerType = + model === undefined ? undefined : (config.providers?.[model.provider]?.type ?? model.protocol); + const full = thinkingConfig( + effort, + model === undefined ? undefined : effectiveModelAlias(model, providerType), + ); + // Re-confirming the effort already shown is not an explicit choice — + // persist the model but leave the stored effort preference alone (the TUI's + // persistModelSelection rule). + const patch = effortChanged ? full : { enabled: full.enabled }; + if ( + config.defaultModel !== params.model + || config.thinking?.enabled !== patch.enabled + || (effortChanged && config.thinking?.effort !== patch.effort) + ) { + await ctx.harness.setConfig({ + defaultModel: params.model, + thinking: patch, + }); + } + + const runtime = ctx.getSession(); + if (runtime !== undefined) { + const status = await runtime.session.getStatus(); + if (status.model !== params.model) await runtime.session.setModel(params.model); + if (status.thinkingEffort !== effort) await runtime.session.setThinking(effort); + } + return { ok: true }; +}; + +const getExtensionConfig: Handler = async () => { + return VSCodeSettings.getExtensionConfig(); +}; + +const openSettings: Handler = async () => { + await vscode.commands.executeCommand("workbench.action.openSettings", "kimi"); + return { ok: true }; +}; + +const getModels: Handler = async (_, ctx) => { + const config = await ctx.harness.getConfig({ reload: true }); + return toWebviewConfig(config); +}; + +const getSlashCommands: Handler = async (_, ctx) => { + if (!ctx.workDir) return SLASH_COMMANDS; + try { + const skills = await ctx.harness.listWorkspaceSkills(ctx.workDir); + const skillCommands = skills + .filter((skill) => isUserActivatableSkill(skill.type)) + .toSorted((left, right) => left.name.localeCompare(right.name)) + .map((skill) => ({ + name: `skill:${skill.name}`, + aliases: [], + description: skill.description ?? "", + })); + return [...SLASH_COMMANDS, ...skillCommands]; + } catch (error) { + ctx.logError("Unable to list workspace skills", error); + return SLASH_COMMANDS; + } +}; + +const showLogs: Handler = async (_, ctx) => { + ctx.showLogs(); + return { ok: true }; +}; + +const reloadWebview: Handler = async (_, ctx) => { + await ctx.closeSession(); + ctx.fileManager.clearTracked(ctx.webviewId); + ctx.reloadWebview(); + return { ok: true }; +}; + +export const configHandlers = { + [Methods.SaveConfig]: saveConfig, + [Methods.GetExtensionConfig]: getExtensionConfig, + [Methods.OpenSettings]: openSettings, + [Methods.GetModels]: getModels, + [Methods.GetSlashCommands]: getSlashCommands, + [Methods.ShowLogs]: showLogs, + [Methods.ReloadWebview]: reloadWebview, +} as Record>; + +export function toWebviewConfig(config: SdkKimiConfig): WebviewKimiConfig { + const models: ModelConfig[] = Object.entries(config.models ?? {}) + // Resolve with the provider type the way saveConfig does: without it the + // Anthropic fallback profile never matches, and the webview's effort + // persistence seed would gate on a different effective model. + .map(([id, model]) => + toWebviewModel(id, model, config.providers?.[model.provider]?.type ?? model.protocol), + ) + .toSorted((left, right) => left.name.localeCompare(right.name)); + return { + defaultModel: config.defaultModel ?? models[0]?.id ?? null, + defaultThinking: config.thinking?.enabled !== false, + defaultThinkingEffort: config.thinking?.effort, + models, + }; +} + +function toWebviewModel(id: string, model: ModelAlias, providerType?: ProviderType): ModelConfig { + const effective = effectiveModelAlias(model, providerType); + return { + id, + name: effective.displayName ?? effective.model ?? id, + provider: effective.provider, + capabilities: [...(effective.capabilities ?? [])], + adaptive_thinking: effective.adaptiveThinking, + support_efforts: + effective.supportEfforts === undefined ? undefined : [...effective.supportEfforts], + default_effort: effective.defaultEffort, + }; +} + +function sessionConfigEffort(config: SessionConfig): ThinkingEffort { + if (config.effort !== undefined) return config.effort as ThinkingEffort; + return config.thinking === true ? "on" : "off"; +} + +/** + * Project a thinking effort to the `[thinking]` config patch persisted to + * config.toml — mirrors the TUI's thinkingEffortToConfig. "off" disables + * thinking; "on" is the boolean-model on-signal, so it only persists + * `enabled`. A concrete effort persists as the global default, EXCEPT when it + * ranks above the model's effective default effort: `support_efforts` is + * ordered by strength, and a pick more expensive than the default stays + * session-only and records just `enabled`, so it never becomes the global + * default for every new session. The default here is the effective model's, + * however it arose — declared via the catalog or overrides, or synthesized + * by the protocol-profile inference (`withAnthropicProfile` resolves Claude + * models to "high", so an "xhigh" pick stays session-only there). When the + * effective model carries no default effort at all, its highest declared + * level stays session-only (the historical rule). When the model's levels + * are unknown the concrete effort is persisted as-is. + */ +function thinkingConfig( + effort: ThinkingEffort, + model?: Pick, +): { enabled: boolean; effort?: string } { + if (effort === "off") return { enabled: false }; + if (effort === "on") return { enabled: true }; + const efforts = model?.supportEfforts; + if (efforts !== undefined && efforts.includes(effort)) { + const declared = model?.defaultEffort; + const ceiling = + declared !== undefined && efforts.includes(declared) + ? efforts.indexOf(declared) + : efforts.length - 2; + if (efforts.indexOf(effort) > ceiling) return { enabled: true }; + } + return { enabled: true, effort }; +} + +function isUserActivatableSkill(type: string | undefined): boolean { + return type === undefined || type === "prompt" || type === "inline" || type === "flow"; +} diff --git a/apps/vscode/src/handlers/file.handler.ts b/apps/vscode/src/handlers/file.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ae238aaefce59053a212552c06a4991c3d8be1d --- /dev/null +++ b/apps/vscode/src/handlers/file.handler.ts @@ -0,0 +1,247 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { Events, Methods } from "../../shared/bridge"; +import type { FileChange, ProjectFile } from "../../shared/types"; +import type { BaselineSession } from "../managers/baseline.manager"; +import { + isWorkspacePathContained, + resolveWorkspacePath, + type WorkspacePath, +} from "../utils/workspace-path"; +import type { Handler, HandlerContext } from "./types"; + +interface GetProjectFilesParams { + query?: string; +} +interface PickMediaParams { maxCount?: number; includeVideo?: boolean } +interface FilePathParams { filePath: string } +interface OptionalFilePathParams { filePath?: string } +interface PathsParams { paths: string[] } +interface CheckFilesExistParams { paths: string[] } + +const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp"]; +const VIDEO_EXTENSIONS = ["mp4", "webm", "mov"]; +const IMAGE_MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", +}; + +const FILE_SUGGEST_LIMIT = 20; + +const getProjectFiles: Handler = async (params, ctx) => { + if (!ctx.workDirUri || !ctx.workDir) return []; + const suggested = await suggestFiles(ctx, params?.query ?? ""); + if (suggested !== undefined) return suggested; + return ctx.fileManager.searchFiles(ctx.workDirUri, params?.query); +}; + +async function suggestFiles(ctx: HandlerContext, query: string): Promise { + try { + const result = await ctx.harness.suggestFiles(ctx.requireWorkDir(), { query, limit: FILE_SUGGEST_LIMIT }); + if (result === undefined) return undefined; + return result.items.map((item) => ({ + path: item.path, + name: item.name, + isDirectory: item.kind === "directory", + matchPositions: [...item.matchPositions], + })); + } catch (error) { + ctx.logError("File suggest failed", error); + return []; + } +} + +const pickMedia: Handler = async (params) => { + const maxCount = params.maxCount ?? 9; + const includeVideo = params.includeVideo ?? true; + const filters: Record = { Images: IMAGE_EXTENSIONS }; + if (includeVideo) { + filters["Videos"] = VIDEO_EXTENSIONS; + filters["All Media"] = [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS]; + } + const uris = await vscode.window.showOpenDialog({ canSelectMany: true, filters, title: "Select Media" }); + if (!uris) return []; + + const results: string[] = []; + for (const uri of uris.slice(0, maxCount)) { + try { + const extension = path.extname(uri.fsPath).toLowerCase().slice(1); + const isVideo = VIDEO_EXTENSIONS.includes(extension); + const stat = await vscode.workspace.fs.stat(uri); + if (stat.size > (isVideo ? 20 : 10) * 1024 * 1024) continue; + const bytes = await vscode.workspace.fs.readFile(uri); + results.push(`data:${mediaMime(extension)};base64,${Buffer.from(bytes).toString("base64")}`); + } catch { + // Skip a file that disappears or cannot be read without failing the whole picker. + } + } + return results; +}; + +const openFile: Handler = async ({ filePath }, ctx) => { + const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath); + if (resolved === undefined) return { ok: false }; + await vscode.commands.executeCommand("vscode.open", resolved.uri); + return { ok: true }; +}; + +const openFileDiff: Handler = async ({ filePath }, ctx) => { + const sessionId = ctx.getSessionId(); + const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath); + if (!sessionId || resolved === undefined) return { ok: false }; + + const baselineUri = vscode.Uri.from({ + scheme: "kimi-baseline", + path: `/${resolved.relativePath}`, + query: new URLSearchParams({ sessionId }).toString(), + }); + await vscode.commands.executeCommand( + "vscode.diff", + baselineUri, + resolved.uri, + `${path.basename(resolved.relativePath)} (changes from Kimi)`, + ); + return { ok: true }; +}; + +const trackFiles: Handler = async ({ paths }, ctx) => { + const session = requireBaselineSession(ctx); + const workDirUri = ctx.requireWorkDirUri(); + for (const filePath of paths) { + const resolved = await resolveWorkspaceFile(workDirUri, filePath, true); + if (resolved !== undefined) ctx.fileManager.trackFile(ctx.webviewId, resolved.uri.fsPath); + } + const changes = await ctx.baselineManager.getChanges(session); + ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId); + return changes; +}; + +const clearTrackedFiles: Handler = async (_, ctx) => { + ctx.fileManager.clearTracked(ctx.webviewId); + ctx.broadcast(Events.FileChangesUpdated, [], ctx.webviewId); + return { ok: true }; +}; + +const revertFiles: Handler = async (params, ctx) => { + const session = requireBaselineSession(ctx); + if (params.filePath) { + const resolved = await resolveWorkspaceFile(ctx.requireWorkDirUri(), params.filePath, true); + if (resolved === undefined) return { ok: false }; + await ctx.baselineManager.undo(session, resolved.relativePath); + } else { + await ctx.baselineManager.undoAll(session); + ctx.fileManager.clearTracked(ctx.webviewId); + } + await ctx.fileManager.refreshChanges(ctx.webviewId); + return { ok: true }; +}; + +const keepChanges: Handler = async (params, ctx) => { + const session = requireBaselineSession(ctx); + if (params.filePath) { + const resolved = await resolveWorkspaceFile(ctx.requireWorkDirUri(), params.filePath, true); + if (resolved === undefined) return { ok: false }; + await ctx.baselineManager.keep(session, resolved.relativePath); + ctx.fileManager.getTracked(ctx.webviewId).delete(resolved.uri.fsPath); + } else { + await ctx.baselineManager.keepAll(session); + ctx.fileManager.clearTracked(ctx.webviewId); + } + await ctx.fileManager.refreshChanges(ctx.webviewId); + return { ok: true }; +}; + +const checkFileExists: Handler = async ({ filePath }, ctx) => { + if (!ctx.workDirUri) return false; + return (await resolveExistingWorkspaceFile(ctx.workDirUri, filePath)) !== undefined; +}; + +const checkFilesExist: Handler> = async ({ paths }, ctx) => { + if (!ctx.workDirUri) return {}; + return Object.fromEntries( + await Promise.all( + paths.map(async (filePath) => [ + filePath, + (await resolveExistingWorkspaceFile(ctx.workDirUri!, filePath)) !== undefined, + ] as const), + ), + ); +}; + +const getImageDataUri: Handler = async ({ filePath }, ctx) => { + if (!ctx.workDirUri) return null; + const resolved = await resolveExistingWorkspaceFile(ctx.workDirUri, decodeURIComponent(filePath)); + if (resolved === undefined) return null; + const mime = IMAGE_MIME_TYPES[path.extname(resolved.relativePath).toLowerCase()]; + if (!mime) return null; + try { + // Same cap as the media picker: an oversized image inlined as a data URI + // would live in the webview DOM (and its decoded bitmap) forever. + const stat = await vscode.workspace.fs.stat(resolved.uri); + if (stat.size > 10 * 1024 * 1024) return null; + const bytes = await vscode.workspace.fs.readFile(resolved.uri); + return `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`; + } catch { + return null; + } +}; + +export const fileHandlers: Record> = { + [Methods.GetProjectFiles]: getProjectFiles, + [Methods.PickMedia]: pickMedia, + [Methods.OpenFile]: openFile, + [Methods.OpenFileDiff]: openFileDiff, + [Methods.TrackFiles]: trackFiles, + [Methods.ClearTrackedFiles]: clearTrackedFiles, + [Methods.RevertFiles]: revertFiles, + [Methods.KeepChanges]: keepChanges, + [Methods.CheckFileExists]: checkFileExists, + [Methods.CheckFilesExist]: checkFilesExist, + [Methods.GetImageDataUri]: getImageDataUri, +}; + +function requireBaselineSession(ctx: Parameters[1]): BaselineSession { + const session = ctx.fileManager.getSession(ctx.webviewId); + if (session === null) throw new Error("No active session."); + return session; +} + +async function resolveWorkspaceFile( + workDirUri: vscode.Uri, + filePath: string, + allowMissing = false, +): Promise { + const resolved = resolveWorkspacePath(workDirUri, filePath); + if (resolved === undefined || !(await isWorkspacePathContained(workDirUri, resolved.uri, { allowMissing }))) { + return undefined; + } + return resolved; +} + +async function resolveExistingWorkspaceFile( + workDirUri: vscode.Uri, + filePath: string, +): Promise { + const resolved = await resolveWorkspaceFile(workDirUri, filePath); + if (resolved === undefined) return undefined; + try { + await vscode.workspace.fs.stat(resolved.uri); + return resolved; + } catch { + return undefined; + } +} + +function mediaMime(extension: string): string { + return ({ + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", + mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", + } as Record)[extension] ?? "application/octet-stream"; +} diff --git a/apps/vscode/src/handlers/index.ts b/apps/vscode/src/handlers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa0a72b14973f0b0288cad736ff55fd87e4858cd --- /dev/null +++ b/apps/vscode/src/handlers/index.ts @@ -0,0 +1,20 @@ +import { configHandlers } from "./config.handler"; +import { mcpHandlers } from "./mcp.handler"; +import { sessionHandlers } from "./session.handler"; +import { chatHandlers } from "./chat.handler"; +import { fileHandlers } from "./file.handler"; +import { workspaceHandlers } from "./workspace.handler"; +import { authHandlers } from "./auth.handler"; +import type { Handler } from "./types"; + +export type { Handler, HandlerContext, BroadcastFn, ReloadWebviewFn, ShowLogsFn } from "./types"; + +export const handlers: Record> = { + ...workspaceHandlers, + ...configHandlers, + ...mcpHandlers, + ...sessionHandlers, + ...chatHandlers, + ...fileHandlers, + ...authHandlers, +}; diff --git a/apps/vscode/src/handlers/mcp.handler.ts b/apps/vscode/src/handlers/mcp.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..72c883d7479dfaad0f5a4e77aa06b5ab6455074c --- /dev/null +++ b/apps/vscode/src/handlers/mcp.handler.ts @@ -0,0 +1,322 @@ +import * as vscode from "vscode"; +import type { + McpManagedServerInfo, + McpServerConfig as SdkMcpServerConfig, + McpTestResult, +} from "@moonshot-ai/kimi-code-sdk"; + +import { Events, Methods } from "../../shared/bridge"; +import { + MCP_SECRET_MASK, + type MCPServerConfig, + type MCPTestResult, + type UpdateMCPServerRequest, +} from "../../shared/legacy-sdk"; +import type { Handler } from "./types"; + +const SENSITIVE_MCP_KEY_WORDS = new Set([ + "authorization", + "cookie", + "credential", + "credentials", + "passwd", + "password", + "secret", + "token", +]); + +interface NameParams { name: string } + +export const mcpHandlers: Record> = { + [Methods.GetMCPServers]: async (_, ctx): Promise => { + return listWorkspaceServers(ctx); + }, + + [Methods.AddMCPServer]: async (params: MCPServerConfig, ctx): Promise => { + const server = restoreMaskedSecrets(undefined, params); + await ctx.harness.addMcpServer(toSdkServer(server)); + const servers = await listWorkspaceServers(ctx); + ctx.broadcast(Events.MCPServersChanged, servers); + return servers; + }, + + [Methods.UpdateMCPServer]: async ( + params: UpdateMCPServerRequest | MCPServerConfig, + ctx, + ): Promise => { + const request = normalizeUpdateRequest(params); + const current = ( + await ctx.harness.listMcpServers({ cwd: ctx.workDir ?? undefined }) + ).find((server) => server.name === request.originalName); + const edited = restoreMaskedSecrets(current, request.server); + const next = mergeEditableServer(current, edited, request.replaceEditableFields); + await updateOrRenameServer(ctx.harness, request.originalName, current, next); + const servers = await listWorkspaceServers(ctx); + ctx.broadcast(Events.MCPServersChanged, servers); + return servers; + }, + + [Methods.RemoveMCPServer]: async ({ name }: NameParams, ctx): Promise => { + await ctx.harness.removeMcpServer(name); + const servers = await listWorkspaceServers(ctx); + ctx.broadcast(Events.MCPServersChanged, servers); + return servers; + }, + + [Methods.AuthMCP]: async ({ name }: NameParams, ctx) => { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Kimi: Authenticating "${name}"...`, + cancellable: false, + }, + async () => { + try { + await ctx.harness.authenticateMcpServer(name, { + onAuthorizationUrl: async (url) => vscode.env.openExternal(vscode.Uri.parse(url)), + }); + await vscode.window.showInformationMessage(`Kimi: OAuth completed for "${name}"`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await vscode.window.showErrorMessage(`Kimi: OAuth failed for "${name}": ${message}`); + throw error; + } + }, + ); + return { ok: true }; + }, + + [Methods.ResetAuthMCP]: async ({ name }: NameParams, ctx) => { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Kimi: Resetting auth for "${name}"...`, + cancellable: false, + }, + async () => { + try { + await ctx.harness.resetMcpServerAuth(name); + await vscode.window.showInformationMessage(`Kimi: Auth reset for "${name}"`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await vscode.window.showErrorMessage(`Kimi: Reset auth failed for "${name}": ${message}`); + throw error; + } + }, + ); + return { ok: true }; + }, + + [Methods.TestMCP]: async ({ name }: NameParams, ctx): Promise => { + void vscode.window.showInformationMessage(`Kimi: Testing MCP server "${name}"...`); + const result = toWebviewTestResult(await ctx.harness.testMcpServer(name, { + cwd: ctx.workDir ?? undefined, + })); + if (!result.success) { + ctx.logError(`MCP server test failed for "${name}"`, new Error(result.output)); + } + return result; + }, +}; + +/** + * The workspace-aware server list shown in the modal. The mutation RPCs + * (add/update/remove) return a list resolved without a cwd, so the webview + * refresh after every mutation must re-list with the workspace cwd — + * otherwise project-layer entries drop out of the modal until the next full + * load. + */ +async function listWorkspaceServers(ctx: Parameters[1]): Promise { + return toWebviewServers(await ctx.harness.listMcpServers({ cwd: ctx.workDir ?? undefined })); +} + +function toWebviewServers(servers: readonly McpManagedServerInfo[]): MCPServerConfig[] { + return servers + .filter((server) => server.transport === "stdio" || server.transport === "http") + .map((server) => { + // The management view's source/origin/mutable tags stay in the webview + // payload so the panel can hide mutating controls on read-only entries; + // only the nested plugin origin detail is dropped. + const { plugin: _plugin, ...config } = server; + if (config.transport === "stdio") { + return { ...config, env: maskSecretValues(config.env) } as MCPServerConfig; + } + return { ...config, headers: maskSecretValues(config.headers) } as MCPServerConfig; + }); +} + +function maskSecretValues(values: Record | undefined): Record | undefined { + if (values === undefined) return undefined; + return Object.fromEntries(Object.entries(values).map(([key, value]) => [ + key, + isSensitiveMcpKey(key) ? MCP_SECRET_MASK : value, + ])); +} + +function isSensitiveMcpKey(key: string): boolean { + const words = key + .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + const compact = words.join(""); + return words.some((word) => SENSITIVE_MCP_KEY_WORDS.has(word)) + || words.includes("key") + || compact === "proxyauthorization" + || compact === "setcookie" + || compact.endsWith("apikey") + || compact.endsWith("accesskey") + || compact.endsWith("privatekey"); +} + +function restoreMaskedSecrets( + current: SdkMcpServerConfig | undefined, + edited: MCPServerConfig, +): MCPServerConfig { + if (edited.transport === "stdio") { + const currentEnv = current?.transport === "stdio" ? current.env : undefined; + return { + ...edited, + env: restoreMaskedRecord("environment variable", currentEnv, edited.env, false), + }; + } + const currentHeaders = current?.transport === "http" ? current.headers : undefined; + return { + ...edited, + headers: restoreMaskedRecord("header", currentHeaders, edited.headers, true), + }; +} + +function restoreMaskedRecord( + label: string, + current: Record | undefined, + edited: Record | undefined, + caseInsensitiveKeys: boolean, +): Record | undefined { + if (edited === undefined) return undefined; + return Object.fromEntries(Object.entries(edited).map(([key, value]) => { + if (value !== MCP_SECRET_MASK) return [key, value]; + const stored = findStoredSecret(current, key, caseInsensitiveKeys); + if (stored === undefined) { + throw new Error(`Cannot preserve masked MCP ${label} "${key}" because no stored value exists`); + } + return [key, stored]; + })); +} + +function findStoredSecret( + current: Record | undefined, + key: string, + caseInsensitiveKeys: boolean, +): string | undefined { + if (current === undefined) return undefined; + if (Object.hasOwn(current, key)) return current[key]; + if (!caseInsensitiveKeys) return undefined; + const normalized = key.toLowerCase(); + const match = Object.entries(current).find(([storedKey]) => storedKey.toLowerCase() === normalized); + return match?.[1]; +} + +function toSdkServer(server: MCPServerConfig): SdkMcpServerConfig { + const name = server.name.trim(); + if (server.transport === "stdio") { + return { + name, + transport: "stdio", + command: server.command?.trim() ?? "", + args: server.args, + env: server.env, + }; + } + return { + name, + transport: "http", + url: server.url?.trim() ?? "", + headers: server.headers, + auth: server.auth, + bearerTokenEnvVar: server.bearerTokenEnvVar, + }; +} + +function mergeEditableServer( + current: SdkMcpServerConfig | undefined, + edited: MCPServerConfig, + replaceEditableFields: boolean, +): SdkMcpServerConfig { + const next = toSdkServer(edited); + if (current === undefined || current.transport !== next.transport) return next; + if (!replaceEditableFields) { + return mergeReleasedFormUpdate(current, next); + } + return { ...current, ...next } as SdkMcpServerConfig; +} + +function mergeReleasedFormUpdate( + current: SdkMcpServerConfig, + next: SdkMcpServerConfig, +): SdkMcpServerConfig { + const defined = Object.fromEntries( + Object.entries(next).filter(([, value]) => value !== undefined), + ); + return { ...current, ...defined } as SdkMcpServerConfig; +} + +function normalizeUpdateRequest( + params: UpdateMCPServerRequest | MCPServerConfig, +): { + readonly originalName: string; + readonly server: MCPServerConfig; + readonly replaceEditableFields: boolean; +} { + if ("server" in params) { + return { + originalName: params.originalName.trim(), + server: params.server, + replaceEditableFields: true, + }; + } + return { + originalName: params.name.trim(), + server: params, + replaceEditableFields: false, + }; +} + +async function updateOrRenameServer( + harness: Pick< + Parameters[1]["harness"], + "addMcpServer" | "updateMcpServer" | "removeMcpServer" + >, + originalName: string, + current: SdkMcpServerConfig | undefined, + next: SdkMcpServerConfig, +): Promise { + if (next.name === originalName) { + await harness.updateMcpServer(next); + return; + } + if (current === undefined) { + throw new Error(`MCP server "${originalName}" was not found`); + } + + await harness.addMcpServer(next); + try { + await harness.removeMcpServer(originalName); + } catch (error) { + await harness.removeMcpServer(next.name).catch(() => undefined); + throw error; + } +} + +function toWebviewTestResult(result: McpTestResult): MCPTestResult { + return { success: result.success, output: sanitizeMcpDiagnostic(result.output) }; +} + +function sanitizeMcpDiagnostic(output: string): string { + return output + .replaceAll(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]") + .replaceAll( + /(["']?(?:authorization|cookie|credentials?|password|passwd|secret|token|api[-_ ]?key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi, + "$1[redacted]", + ); +} diff --git a/apps/vscode/src/handlers/session.handler.ts b/apps/vscode/src/handlers/session.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6413c29a40e8ea0f39977a7b7d52fc460906da7 --- /dev/null +++ b/apps/vscode/src/handlers/session.handler.ts @@ -0,0 +1,273 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; +import type { SessionSummary } from "@moonshot-ai/kimi-code-sdk"; + +import { Events, Methods } from "../../shared/bridge"; +import type { SessionInfo } from "../../shared/legacy-sdk"; +import type { BaselineSession } from "../managers/baseline.manager"; +import { replaySessionToWebviewEvents } from "../runtime/replay-adapter"; +import { areSameFsPath, isFsPathInsideOrEqual } from "../utils/fs-path"; +import { + isWorkspacePathContained, + workDirUriFromPath, +} from "../utils/workspace-path"; +import type { Handler } from "./types"; + +const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +interface LoadHistoryParams { + kimiSessionId: string; +} + +interface DeleteSessionParams { + sessionId: string; +} + +interface ForkSessionParams { + sessionId: string; + turnIndex: number; +} + +export const sessionHandlers: Record> = { + [Methods.GetKimiSessions]: async (_, ctx): Promise => { + if (!ctx.workDir) return []; + return (await ctx.harness.listSessions({ workDir: ctx.workDir })).map(toSessionInfo); + }, + + [Methods.GetAllKimiSessions]: async (_, ctx): Promise => { + if (!ctx.workspaceRoot) return []; + return (await ctx.harness.listSessions()) + .filter((session) => isInsideOrEqual(ctx.workspaceRoot!, session.workDir)) + .map(toSessionInfo); + }, + + [Methods.GetRegisteredWorkDirs]: async (_, ctx): Promise => { + if (!ctx.workspaceRoot) return []; + const sessions = await ctx.harness.listSessions(); + const candidates = [ctx.workspaceRoot, ctx.workDir, ...sessions.map((session) => session.workDir)]; + return [ + ...new Set( + candidates.filter((workDir): workDir is string => workDir !== null && isInsideOrEqual(ctx.workspaceRoot!, workDir)), + ), + ].toSorted(); + }, + + [Methods.SetWorkDir]: async (params: { workDir: string | null }, ctx) => { + if (!ctx.workspaceRoot || !ctx.workspaceRootUri) return { ok: false }; + const target = params.workDir; + if (target) { + const targetUri = workDirUriFromPath(ctx.workspaceRootUri, ctx.workspaceRoot, target); + if (targetUri === undefined || !(await isWorkspacePathContained(ctx.workspaceRootUri, targetUri))) { + return { ok: false }; + } + } + try { + await ctx.setCustomWorkDir(target); + } catch { + return { ok: false }; + } + return { ok: true, workDir: target ?? ctx.workspaceRoot }; + }, + + [Methods.BrowseWorkDir]: async (_, ctx) => { + if (!ctx.workspaceRoot || !ctx.workspaceRootUri) return { ok: false, workDir: null }; + const workspaceUri = ctx.workspaceRootUri; + let subdirectories: string[] = []; + try { + const entries = await vscode.workspace.fs.readDirectory(workspaceUri); + subdirectories = entries + .filter(([name, type]) => type === vscode.FileType.Directory && !name.startsWith(".")) + .map(([name]) => name) + .toSorted(); + } catch { + // The native picker remains available when directory enumeration fails. + } + + const picked = await vscode.window.showQuickPick( + [ + { label: "$(folder) Browse...", description: "Open folder picker", alwaysShow: true }, + { label: "", kind: vscode.QuickPickItemKind.Separator }, + ...subdirectories.map((name) => ({ + label: `$(folder) ${name}`, + description: path.join(ctx.workspaceRoot!, name), + })), + ], + { placeHolder: "Select a subdirectory or browse...", title: "Working Directory" }, + ); + if (!picked) return { ok: false, workDir: null }; + + let selectedUri: vscode.Uri; + if (picked.label === "$(folder) Browse...") { + const result = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: workspaceUri, + openLabel: "Select Working Directory", + }); + if (!result?.[0]) return { ok: false, workDir: null }; + selectedUri = result[0]; + } else if (picked.description) { + const pickedUri = workDirUriFromPath(workspaceUri, ctx.workspaceRoot, picked.description); + if (pickedUri === undefined) return { ok: false, workDir: null }; + selectedUri = pickedUri; + } else { + return { ok: false, workDir: null }; + } + + if (!(await isWorkspacePathContained(workspaceUri, selectedUri))) { + await vscode.window.showWarningMessage("Selected directory must be within the workspace."); + return { ok: false, workDir: null }; + } + const selected = selectedUri.fsPath; + await ctx.setCustomWorkDir(selected === ctx.workspaceRoot ? null : selected); + return { ok: true, workDir: selected }; + }, + + [Methods.LoadKimiSessionHistory]: async (params: LoadHistoryParams, ctx) => { + if (!ctx.workDir || !isSessionId(params.kimiSessionId)) return []; + const runtime = await ctx.resumeSession(params.kimiSessionId); + if (!areSameFsPath(runtime.session.workDir, ctx.workDir)) { + await ctx.closeSession(); + throw new Error("The selected session belongs to a different working directory."); + } + + let history: ReturnType; + try { + const resumeState = runtime.session.getResumeState(); + if (resumeState?.agents["main"] === undefined) { + throw new Error("Session history is unavailable."); + } + history = replaySessionToWebviewEvents(resumeState, runtime.id); + } catch (error) { + await ctx.closeSession(); + throw error; + } + + ctx.fileManager.clearTracked(ctx.webviewId); + const baseline = baselineSession(runtime.summary ?? { + id: runtime.id, + workDir: runtime.session.workDir, + }); + try { + const changes = await ctx.baselineManager.getChanges(baseline); + for (const change of changes) { + ctx.fileManager.trackFile(ctx.webviewId, path.join(baseline.workDir, change.path)); + } + ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId); + } catch (error) { + ctx.logError("Unable to restore session file changes", error); + ctx.broadcast(Events.FileChangesUpdated, [], ctx.webviewId); + void Promise.resolve( + vscode.window.showWarningMessage( + "Kimi: This conversation opened, but its file change history is unavailable.", + "Show Logs", + ), + ) + .then((action) => { + if (action === "Show Logs") ctx.showLogs(); + }) + .catch((noticeError: unknown) => { + ctx.logError("Unable to show the file change warning", noticeError); + }); + } + return history; + }, + + [Methods.DeleteKimiSession]: async (params: DeleteSessionParams, ctx): Promise<{ ok: boolean }> => { + if (!isSessionId(params.sessionId) || !ctx.workspaceRoot) return { ok: false }; + const summary = (await ctx.harness.listSessions({ sessionId: params.sessionId }))[0]; + if (summary === undefined || !isInsideOrEqual(ctx.workspaceRoot, summary.workDir)) { + return { ok: false }; + } + const affectedViews = ctx.runtime.getSession(params.sessionId)?.subscribers ?? []; + await ctx.runtime.deleteSession(params.sessionId); + await ctx.baselineManager.deleteSession(params.sessionId); + for (const webviewId of affectedViews) { + ctx.fileManager.clearSession(webviewId); + ctx.broadcast(Events.FileChangesUpdated, [], webviewId); + if (webviewId !== ctx.webviewId) { + ctx.broadcast(Events.NewConversation, {}, webviewId); + } + } + return { ok: true }; + }, + + [Methods.ForkKimiSession]: async (params: ForkSessionParams, ctx) => { + if (!ctx.workDir || !isSessionId(params.sessionId) || !Number.isInteger(params.turnIndex) || params.turnIndex < 0) { + return null; + } + const summaries = await ctx.harness.listSessions({ sessionId: params.sessionId }); + const sourceSummary = summaries[0]; + if ( + sourceSummary === undefined || + !ctx.workspaceRoot || + !isInsideOrEqual(ctx.workspaceRoot, sourceSummary.workDir) + ) return null; + + const forkSettledSession = async () => { + const fork = await ctx.harness.forkSession({ id: params.sessionId, turnIndex: params.turnIndex }); + const targetSummary = fork.summary; + if (targetSummary === undefined) { + await fork.close(); + throw new Error("Forked session metadata is unavailable."); + } + + let materializeError: unknown; + try { + await ctx.baselineManager.materializeToFork( + baselineSession(sourceSummary), + baselineSession(targetSummary), + ); + } catch (error) { + materializeError = error; + } + + try { + await fork.close(); + } catch (error) { + ctx.logError("Unable to close a forked session", error); + } + if (materializeError !== undefined) { + await ctx.harness.deleteSession(targetSummary.id).catch((error: unknown) => { + ctx.logError(`Unable to remove failed fork "${targetSummary.id}"`, error); + }); + await ctx.baselineManager.deleteSession(targetSummary.id).catch((error: unknown) => { + ctx.logError(`Unable to remove failed fork baseline "${targetSummary.id}"`, error); + }); + throw materializeError instanceof Error + ? materializeError + : new Error("Unable to materialize the forked session baseline.", { + cause: materializeError, + }); + } + return { sessionId: targetSummary.id }; + }; + + const active = ctx.runtime.getSession(params.sessionId); + return active === undefined + ? forkSettledSession() + : active.runExclusiveAfterCancelling(forkSettledSession); + }, +}; + +function toSessionInfo(summary: SessionSummary): SessionInfo { + return { + id: summary.id, + workDir: summary.workDir, + updatedAt: summary.updatedAt, + brief: summary.title ?? summary.lastPrompt ?? "", + }; +} + +function baselineSession(summary: Pick): BaselineSession { + return { id: summary.id, workDir: summary.workDir, metadata: summary.metadata }; +} + +function isInsideOrEqual(root: string, candidate: string): boolean { + return isFsPathInsideOrEqual(root, candidate); +} + +function isSessionId(value: string): boolean { + return SESSION_ID.test(value); +} diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..087c823fb0284726c781647a86398bedc9a5f65b --- /dev/null +++ b/apps/vscode/src/handlers/slash-command.ts @@ -0,0 +1,314 @@ +import type { Stats } from "node:fs"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import * as vscode from "vscode"; + +import type { SessionRuntime } from "../runtime/session-runtime"; +import { + buildExportMarkdown, + isImportableTextFile, + isSensitiveFile, + stringifyContextHistory, +} from "../utils/session-context"; +import type { HandlerContext } from "./types"; + +const HOST_COMMANDS = new Set([ + "init", + "compact", + "clear", + "reset", + "yolo", + "auto", + "afk", + "plan", + "add-dir", + "export", + "import", +]); +const MAX_IMPORT_BYTES = 10 * 1024 * 1024; + +export interface HostSlashCommand { + readonly name: string; + readonly args: string; + readonly raw: string; +} + +export function parseHostSlashCommand(content: string | readonly unknown[]): HostSlashCommand | undefined { + if (typeof content !== "string") return undefined; + const raw = content.trim(); + const match = /^\/([^\s]+)(?:\s+(.*))?\s*$/s.exec(raw); + if (match === null) return undefined; + const name = match[1]!.toLowerCase(); + if (!HOST_COMMANDS.has(name) && !name.startsWith("skill:")) return undefined; + return { name, args: match[2]?.trim() ?? "", raw }; +} + +export async function runHostSlashCommand( + runtime: SessionRuntime, + command: HostSlashCommand, + ctx: HandlerContext, +): Promise { + if (command.name.startsWith("skill:")) { + const skillName = command.name.slice("skill:".length); + const result = await runtime.runTurnAction(command.raw, () => + runtime.session.activateSkill(skillName, command.args || undefined)); + return result.status === "finished"; + } + + const actionId = runtime.beginHostAction(command.raw, command.name === "import"); + const emit = (text: string): void => runtime.emitHostText(text, actionId); + try { + if (command.name === "import") { + const result = await importContext(runtime, command.args, ctx); + emit(result.message); + if (result.sensitive) { + void vscode.window.showWarningMessage( + "Kimi: The imported file may contain API keys, tokens, or credentials.", + ); + } + } else { + switch (command.name) { + case "init": + await runtime.session.init(); + emit("AGENTS.md has been generated."); + break; + case "compact": + await runtime.compactHostAction(actionId, command.args || undefined); + emit("The context has been compacted."); + break; + case "clear": + case "reset": + await runtime.session.clearContext(); + emit("The context has been cleared."); + break; + case "yolo": + await toggleLegacyPermission(runtime, "yolo", emit); + break; + case "auto": + case "afk": + await toggleLegacyPermission(runtime, "afk", emit); + break; + case "plan": + await runPlanCommand(runtime, command.args, emit); + break; + case "add-dir": + await runAddDirCommand(runtime, command.args, emit); + break; + case "export": + await exportContext(runtime, command.args, emit); + break; + } + } + + if (runtime.wasHostActionCancelled(actionId)) return false; + runtime.completeHostAction("finished", actionId); + return true; + } catch (error) { + if (runtime.wasHostActionCancelled(actionId)) return false; + runtime.failHostAction(actionId); + throw error; + } finally { + runtime.releaseHostAction(actionId); + } +} + +async function toggleLegacyPermission( + runtime: SessionRuntime, + kind: "yolo" | "afk", + emit: (text: string) => void, +): Promise { + const flags = await runtime.toggleLegacyApproval(kind); + + if (kind === "yolo") { + emit(flags.yolo + ? "You only live once! Tool actions will be auto-approved; the agent may still ask questions." + : flags.afk + ? "Yolo disabled, but Auto is still on — tool calls remain auto-approved." + : "You only die once! Actions will require approval."); + return; + } + emit(flags.afk + ? "Auto mode enabled. Questions will be auto-dismissed and tool calls auto-approved." + : flags.yolo + ? "Auto mode disabled. You are back at the keyboard. Yolo is still on." + : "Auto mode disabled. You are back at the keyboard."); +} + +async function runPlanCommand( + runtime: SessionRuntime, + args: string, + emit: (text: string) => void, +): Promise { + const subcommand = args.trim().toLowerCase(); + if (subcommand === "view") { + const plan = await runtime.session.getPlan(); + emit(plan?.content.trim() || "No plan file found for this session."); + return; + } + if (subcommand === "clear") { + await runtime.session.clearPlan(); + emit("Plan cleared."); + return; + } + const status = await runtime.session.getStatus(); + const enabled = subcommand === "on" ? true : subcommand === "off" ? false : !status.planMode; + if (subcommand && subcommand !== "on" && subcommand !== "off") { + throw new Error(`Unknown plan subcommand: ${subcommand}`); + } + if (status.planMode !== enabled) await runtime.session.setPlanMode(enabled); + if (!enabled) { + emit("Plan mode OFF. All tools are now available."); + return; + } + const plan = await runtime.session.getPlan().catch(() => null); + emit(plan?.path + ? `Plan mode ON. Plan file: ${plan.path}` + : "Plan mode ON."); +} + +async function runAddDirCommand( + runtime: SessionRuntime, + args: string, + emit: (text: string) => void, +): Promise { + const input = stripMatchingQuotes(args.trim()); + if (!input || input.toLowerCase() === "list") { + const dirs = runtime.session.summary?.additionalDirs ?? []; + emit(dirs.length === 0 + ? "No additional directories. Usage: /add-dir " + : ["Additional directories:", ...dirs.map((path) => ` - ${path}`)].join("\n")); + return; + } + const result = await runtime.session.addAdditionalDir(input, { persist: false }); + emit(`Added directory to workspace: ${result.additionalDirs.at(-1) ?? input}`); +} + +async function exportContext( + runtime: SessionRuntime, + args: string, + emit: (text: string) => void, +): Promise { + const context = await runtime.session.getContext(); + if (context.history.length === 0) { + emit("No messages to export."); + return; + } + const now = new Date(); + const defaultName = defaultExportName(runtime.id, now); + const outputPath = await resolveExportPath(args, runtime.session.workDir, defaultName); + const markdown = buildExportMarkdown({ + sessionId: runtime.id, + workDir: runtime.session.workDir, + history: context.history, + tokenCount: context.tokenCount, + now, + }); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, markdown, "utf8"); + emit( + `Exported ${String(context.history.length)} messages to ${outputPath}\n\n` + + "Note: The exported file may contain sensitive information. Please be cautious when sharing it externally.", + ); + void vscode.window.showInformationMessage("Kimi: Session exported.", "Open File").then((action) => { + if (action !== "Open File") return; + void vscode.window.showTextDocument(vscode.Uri.file(outputPath)); + }); +} + +async function importContext( + runtime: SessionRuntime, + args: string, + ctx: HandlerContext, +): Promise<{ message: string; sensitive: boolean }> { + const target = stripMatchingQuotes(args.trim()); + if (!target) throw new Error("Usage: /import "); + + const candidate = resolveUserPath(target, runtime.session.workDir); + const file = await fileInfo(candidate); + if (file?.isDirectory()) throw new Error("The specified path is a directory; please provide a file to import."); + if (file?.isFile()) { + if (!isImportableTextFile(candidate)) { + throw new Error(`Unsupported file type '${candidate.slice(candidate.lastIndexOf("."))}'. /import only supports text-based files.`); + } + if (file.size > MAX_IMPORT_BYTES) { + throw new Error(`File is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum import size is 10 MB.`); + } + const bytes = await readFile(candidate); + let content: string; + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`Cannot import '${basename(candidate)}': the file is not valid UTF-8 text.`); + } + if (!content.trim()) throw new Error("The file is empty, nothing to import."); + const source = `file '${basename(candidate)}'`; + await runtime.session.importContext(content, source); + return { + message: `Imported context from ${source} (${String(content.length)} chars).`, + sensitive: isSensitiveFile(basename(candidate)), + }; + } + + if (target === runtime.id) throw new Error("Cannot import the current session into itself."); + const summary = (await ctx.harness.listSessions({ + workDir: runtime.session.workDir, + sessionId: target, + })).find((session) => session.id === target); + if (summary === undefined) throw new Error(`'${target}' is not a valid file path or session ID.`); + + const activeSource = ctx.runtime.getSession(target)?.session; + const sourceSession = activeSource ?? await ctx.harness.resumeSession({ id: target }); + try { + const sourceContext = await sourceSession.getContext(); + if (sourceContext.history.length === 0) throw new Error("The source session has no messages."); + const content = stringifyContextHistory(sourceContext.history); + if (Buffer.byteLength(content, "utf8") > MAX_IMPORT_BYTES) { + throw new Error("Session content is too large. Maximum import size is 10 MB."); + } + const source = `session '${target}'`; + await runtime.session.importContext(content, source); + return { + message: `Imported context from ${source} (${String(content.length)} chars).`, + sensitive: false, + }; + } finally { + if (activeSource === undefined) await sourceSession.close(); + } +} + +async function resolveExportPath(args: string, workDir: string, defaultName: string): Promise { + const raw = stripMatchingQuotes(args.trim()); + if (!raw) return join(workDir, defaultName); + const resolved = resolveUserPath(raw, workDir); + const info = await fileInfo(resolved); + return raw.endsWith("/") || raw.endsWith("\\") || info?.isDirectory() + ? join(resolved, defaultName) + : resolved; +} + +function defaultExportName(sessionId: string, now: Date): string { + const timestamp = now.toISOString().replaceAll(/[-:]/g, "").replace("T", "-").slice(0, 15); + return `kimi-export-${sessionId.slice(0, 8)}-${timestamp}.md`; +} + +function resolveUserPath(value: string, workDir: string): string { + const expanded = value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value; + return isAbsolute(expanded) ? expanded : resolve(workDir, expanded); +} + +async function fileInfo(path: string): Promise { + try { + return await stat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function stripMatchingQuotes(value: string): string { + if (value.length < 2) return value; + const first = value[0]; + const last = value.at(-1); + return (first === last && (first === '"' || first === "'")) ? value.slice(1, -1) : value; +} diff --git a/apps/vscode/src/handlers/types.ts b/apps/vscode/src/handlers/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..c50fe482b66fe0e7663346dbb333345938e1d7d1 --- /dev/null +++ b/apps/vscode/src/handlers/types.ts @@ -0,0 +1,41 @@ +import type * as vscode from "vscode"; +import type { FileManager } from "../managers/file.manager"; +import type { BaselineManager } from "../managers/baseline.manager"; +import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk"; +import type { KimiRuntime } from "../runtime/kimi-runtime"; +import type { SessionRuntime } from "../runtime/session-runtime"; + +export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void; + +export type ReloadWebviewFn = (webviewId: string) => void; + +export type ShowLogsFn = () => void; + +export interface HandlerContext { + webviewId: string; + workDir: string | null; + workDirUri: vscode.Uri | null; + workspaceRoot: string | null; + workspaceRootUri: vscode.Uri | null; + workspaceState: vscode.Memento; + requireWorkDir: () => string; + requireWorkDirUri: () => vscode.Uri; + broadcast: BroadcastFn; + fileManager: FileManager; + baselineManager: BaselineManager; + runtime: KimiRuntime; + harness: KimiHarness; + reloadWebview: () => void; + showLogs: () => void; + logError: (message: string, error: unknown) => void; + + getSession: () => SessionRuntime | undefined; + getSessionId: () => string | null; + getOrCreateSession: (model: string, effort: string, sessionId?: string) => Promise; + resumeSession: (sessionId: string) => Promise; + closeSession: () => Promise; + saveAllDirty: () => Promise; + setCustomWorkDir: (workDir: string | null) => Promise; +} + +export type Handler = (params: TParams, ctx: HandlerContext) => Promise; diff --git a/apps/vscode/src/handlers/workspace.handler.ts b/apps/vscode/src/handlers/workspace.handler.ts new file mode 100644 index 0000000000000000000000000000000000000000..401e5bcdc5649e0ea7666188f048090abfc53406 --- /dev/null +++ b/apps/vscode/src/handlers/workspace.handler.ts @@ -0,0 +1,44 @@ +import * as vscode from "vscode"; +import { Methods } from "../../shared/bridge"; +import type { Handler } from "./types"; +import type { WorkspaceStatus } from "shared/types"; + +const INPUT_HISTORY_KEY = "kimi.inputHistory"; +const MAX_HISTORY_SIZE = 100; + +const checkWorkspace: Handler = async (_, ctx) => { + return { + hasWorkspace: ctx.workDir !== null, + path: ctx.workDir ?? undefined, + workspaceRoot: ctx.workspaceRoot ?? undefined, + }; +}; + +const openFolder: Handler = async () => { + await vscode.commands.executeCommand("vscode.openFolder"); + return { ok: true }; +}; + +const getInputHistory: Handler = async (_, ctx) => { + return ctx.workspaceState.get(INPUT_HISTORY_KEY, []); +}; + +const addInputHistory: Handler<{ text: string }, { ok: boolean }> = async ({ text }, ctx) => { + const history = ctx.workspaceState.get(INPUT_HISTORY_KEY, []); + // 避免重复添加相同的最近一条 + if (history[history.length - 1] !== text) { + history.push(text); + if (history.length > MAX_HISTORY_SIZE) { + history.shift(); + } + await ctx.workspaceState.update(INPUT_HISTORY_KEY, history); + } + return { ok: true }; +}; + +export const workspaceHandlers: Record> = { + [Methods.CheckWorkspace]: checkWorkspace, + [Methods.OpenFolder]: openFolder, + [Methods.GetInputHistory]: getInputHistory, + [Methods.AddInputHistory]: addInputHistory, +}; diff --git a/apps/vscode/src/managers/baseline.manager.ts b/apps/vscode/src/managers/baseline.manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..46498aafea734d09f7a10a9cbef8500e599eb149 --- /dev/null +++ b/apps/vscode/src/managers/baseline.manager.ts @@ -0,0 +1,864 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; +import { + lstat, + mkdir, + readFile, + readdir, + realpath, + rename, + rm, + stat, + unlink, + writeFile, +} from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { FileChange } from '../../shared/types'; +import { relativeFsPath } from '../utils/fs-path'; + +const MANIFEST_VERSION = 1; +const SNAPSHOT_HASH = /^[a-f0-9]{64}$/; + +export interface BaselineSession { + readonly id: string; + readonly workDir: string; + readonly metadata?: Readonly>; +} + +interface ManifestEntry { + readonly snapshot: string; + readonly existedBefore: boolean; +} + +interface BaselineManifestV1 { + readonly version: 1; + readonly sessionId: string; + readonly entries: Readonly>; + readonly acceptedLegacyPaths: readonly string[]; +} + +interface MutableManifest { + version: 1; + sessionId: string; + entries: Record; + acceptedLegacyPaths: string[]; +} + +interface ResolvedFile { + readonly absolutePath: string; + readonly relativePath: string; +} + +interface BaselineValue { + readonly content: string; + readonly existedBefore: boolean; +} + +export class BaselineError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'BaselineError'; + } +} + +export class BaselineManager { + private readonly baselinesRoot: string; + private readonly updates = new Map>(); + + constructor(globalStorageRoot: string, homeNamespace = 'default') { + if (globalStorageRoot.length === 0) { + throw new BaselineError('The VSCode global storage path is empty'); + } + if (homeNamespace.length === 0) { + throw new BaselineError('The Kimi home namespace is empty'); + } + this.baselinesRoot = path.join(globalStorageRoot, 'baselines', hash(homeNamespace)); + } + + /** + * Capture the file synchronously before returning control to the caller. + * Persistence is serialized per session and completes through the returned + * promise, but no `await` occurs before the original file has been read. + */ + async capture(session: BaselineSession, filePath: string): Promise { + const resolved = resolveSessionFile(session, filePath); + const captured = captureOriginal(resolved.absolutePath); + + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const localPath = equivalentPath( + session, + Object.keys(manifest.entries), + resolved.relativePath, + ); + if (localPath !== undefined) return; + + const accepted = new Set(manifest.acceptedLegacyPaths); + const acceptedPath = equivalentPath(session, accepted, resolved.relativePath); + if (acceptedPath === undefined) { + const legacyExists = await this.hasLegacyBaseline(session, resolved.relativePath); + if (legacyExists) return; + } + + const snapshot = hash(captured.content); + await this.writeSnapshot(session.id, snapshot, captured.content); + if (acceptedPath !== undefined) accepted.delete(acceptedPath); + + const next = mutableManifest(manifest); + next.entries[resolved.relativePath] = { + snapshot, + existedBefore: captured.existedBefore, + }; + next.acceptedLegacyPaths = uniquePaths(session, accepted); + await this.writeManifest(next); + }); + } + + async getChanges(session: BaselineSession): Promise { + await this.waitForUpdates([session.id]); + const manifest = await this.readManifest(session); + const relativePaths = await this.effectivePaths(session, manifest); + const changes: FileChange[] = []; + + for (const relativePath of relativePaths) { + const baseline = await this.readEffectiveBaseline(session, relativePath, manifest); + if (baseline === undefined) continue; + + const resolved = resolveSessionFile(session, relativePath); + const currentContent = await readCurrentFile(resolved.absolutePath); + if (currentContent === undefined) { + if (baseline.existedBefore) { + changes.push({ + path: relativePath, + status: 'Deleted', + additions: 0, + deletions: countLines(baseline.content), + }); + } + continue; + } + + if (!baseline.existedBefore) { + changes.push({ + path: relativePath, + status: 'Added', + additions: countLines(currentContent), + deletions: 0, + }); + continue; + } + + if (currentContent !== baseline.content) { + const diff = computeLineDiff(baseline.content, currentContent); + changes.push({ + path: relativePath, + status: 'Modified', + additions: diff.additions, + deletions: diff.deletions, + }); + } + } + + return changes; + } + + async getContent(session: BaselineSession, filePath: string): Promise { + await this.waitForUpdates([session.id]); + const resolved = resolveSessionFile(session, filePath); + const manifest = await this.readManifest(session); + const baseline = await this.readEffectiveBaseline(session, resolved.relativePath, manifest); + if (baseline === undefined) { + throw new BaselineError( + `No baseline exists for "${resolved.relativePath}" in session "${session.id}"`, + ); + } + return baseline.content; + } + + async undo(session: BaselineSession, filePath: string): Promise { + const resolved = resolveSessionFile(session, filePath); + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const baseline = await this.readEffectiveBaseline(session, resolved.relativePath, manifest); + if (baseline === undefined) { + throw new BaselineError( + `No baseline exists for "${resolved.relativePath}" in session "${session.id}"`, + ); + } + await restoreFile(session.workDir, resolved.absolutePath, baseline); + }); + } + + async undoAll(session: BaselineSession): Promise { + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const relativePaths = await this.effectivePaths(session, manifest); + for (const relativePath of relativePaths) { + const baseline = await this.readEffectiveBaseline(session, relativePath, manifest); + if (baseline === undefined) continue; + await restoreFile( + session.workDir, + resolveSessionFile(session, relativePath).absolutePath, + baseline, + ); + } + }); + } + + async keep(session: BaselineSession, filePath: string): Promise { + const resolved = resolveSessionFile(session, filePath); + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const localPath = equivalentPath( + session, + Object.keys(manifest.entries), + resolved.relativePath, + ); + const hadLocal = localPath !== undefined; + const hasLegacy = await this.hasLegacyBaseline(session, resolved.relativePath); + if (!hadLocal && !hasLegacy) return; + + const next = mutableManifest(manifest); + if (localPath !== undefined) delete next.entries[localPath]; + const accepted = new Set(next.acceptedLegacyPaths); + const acceptedPath = equivalentPath(session, accepted, resolved.relativePath); + if (acceptedPath !== undefined) accepted.delete(acceptedPath); + if (hasLegacy) accepted.add(resolved.relativePath); + next.acceptedLegacyPaths = uniquePaths(session, accepted); + + await this.writeManifest(next); + await this.removeUnreferencedSnapshots(session.id, next); + }); + } + + async keepAll(session: BaselineSession): Promise { + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const legacyPaths = await this.listLegacyPaths(session); + const next = mutableManifest(manifest); + next.entries = {}; + next.acceptedLegacyPaths = uniquePaths(session, [ + ...next.acceptedLegacyPaths, + ...legacyPaths, + ]); + + await this.writeManifest(next); + await this.removeUnreferencedSnapshots(session.id, next); + }); + } + + async materializeToFork(source: BaselineSession, target: BaselineSession): Promise { + if (source.id === target.id) { + throw new BaselineError('Cannot materialize a baseline fork onto the source session'); + } + + await this.serialize([source.id, target.id], async () => { + const sourceManifest = await this.readManifest(source); + const sourcePaths = await this.effectivePaths(source, sourceManifest); + const values = new Map(); + for (const relativePath of sourcePaths) { + const baseline = await this.readEffectiveBaseline(source, relativePath, sourceManifest); + if (baseline !== undefined) values.set(relativePath, baseline); + } + + const targetManifest = await this.readManifest(target); + const next = mutableManifest(targetManifest); + const accepted = uniquePaths(target, [ + ...next.acceptedLegacyPaths, + ...sourceManifest.acceptedLegacyPaths, + ]); + + for (const [relativePath, baseline] of values) { + const existingPath = equivalentPath(target, Object.keys(next.entries), relativePath); + if (existingPath !== undefined) continue; + const snapshot = hash(baseline.content); + await this.writeSnapshot(target.id, snapshot, baseline.content); + next.entries[relativePath] = { + snapshot, + existedBefore: baseline.existedBefore, + }; + } + + next.acceptedLegacyPaths = accepted; + await this.writeManifest(next); + }); + } + + async deleteSession(sessionId: string): Promise { + requireSessionId(sessionId); + await this.serialize([sessionId], async () => { + await rm(this.sessionRoot(sessionId), { recursive: true, force: true }); + }); + } + + private async effectivePaths( + session: BaselineSession, + manifest: BaselineManifestV1, + ): Promise { + const paths = new Map(); + for (const relativePath of Object.keys(manifest.entries)) { + paths.set(pathComparisonKey(session, relativePath), relativePath); + } + const accepted = new Set( + manifest.acceptedLegacyPaths.map((relativePath) => + pathComparisonKey(session, relativePath), + ), + ); + for (const relativePath of await this.listLegacyPaths(session)) { + const key = pathComparisonKey(session, relativePath); + if (!accepted.has(key) && !paths.has(key)) paths.set(key, relativePath); + } + return [...paths.values()].toSorted(); + } + + private async readEffectiveBaseline( + session: BaselineSession, + relativePath: string, + manifest: BaselineManifestV1, + ): Promise { + const localPath = equivalentPath(session, Object.keys(manifest.entries), relativePath); + const local = localPath === undefined ? undefined : manifest.entries[localPath]; + if (localPath !== undefined && local !== undefined) { + const content = await this.readSnapshot(session.id, local.snapshot, localPath); + return { content, existedBefore: local.existedBefore }; + } + + if (equivalentPath(session, manifest.acceptedLegacyPaths, relativePath) !== undefined) { + return undefined; + } + return this.readLegacyBaseline(session, relativePath); + } + + private async readManifest(session: BaselineSession): Promise { + requireSession(session); + let text: string; + try { + text = await readFile(this.manifestPath(session.id), 'utf-8'); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return emptyManifest(session.id); + throw new BaselineError(`Unable to read baseline manifest for session "${session.id}"`, { + cause: error, + }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch (error) { + throw new BaselineError(`Baseline manifest for session "${session.id}" is invalid JSON`, { + cause: error, + }); + } + return parseManifest(parsed, session); + } + + private async writeManifest(manifest: BaselineManifestV1): Promise { + if ( + Object.keys(manifest.entries).length === 0 && + manifest.acceptedLegacyPaths.length === 0 + ) { + await rm(this.sessionRoot(manifest.sessionId), { recursive: true, force: true }); + return; + } + + const text = `${JSON.stringify(manifest, null, 2)}\n`; + await atomicWrite(this.manifestPath(manifest.sessionId), text); + } + + private async writeSnapshot(sessionId: string, snapshot: string, content: string): Promise { + const snapshotPath = this.snapshotPath(sessionId, snapshot); + try { + const existing = await readFile(snapshotPath, 'utf-8'); + if (hash(existing) !== snapshot) { + throw new BaselineError( + `Baseline snapshot "${snapshot}" for session "${sessionId}" is corrupt`, + ); + } + return; + } catch (error) { + if (!isErrorCode(error, 'ENOENT')) { + if (error instanceof BaselineError) throw error; + throw new BaselineError( + `Unable to inspect baseline snapshot "${snapshot}" for session "${sessionId}"`, + { cause: error }, + ); + } + } + await atomicWrite(snapshotPath, content); + } + + private async readSnapshot( + sessionId: string, + snapshot: string, + relativePath: string, + ): Promise { + let content: string; + try { + content = await readFile(this.snapshotPath(sessionId, snapshot), 'utf-8'); + } catch (error) { + throw new BaselineError( + `Unable to read baseline snapshot for "${relativePath}" in session "${sessionId}"`, + { cause: error }, + ); + } + if (hash(content) !== snapshot) { + throw new BaselineError( + `Baseline snapshot for "${relativePath}" in session "${sessionId}" is corrupt`, + ); + } + return content; + } + + private async removeUnreferencedSnapshots( + sessionId: string, + manifest: BaselineManifestV1, + ): Promise { + const snapshotsDir = this.snapshotsRoot(sessionId); + let names: string[]; + try { + names = await readdir(snapshotsDir); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return; + throw new BaselineError(`Unable to clean baseline snapshots for session "${sessionId}"`, { + cause: error, + }); + } + + const referenced = new Set(Object.values(manifest.entries).map((entry) => entry.snapshot)); + await Promise.all( + names.map(async (name) => { + if (referenced.has(name)) return; + await rm(path.join(snapshotsDir, name), { force: true }); + }), + ); + } + + private async listLegacyPaths(session: BaselineSession): Promise { + const root = legacyBaselineRoot(session); + if (root === undefined) return []; + + const result: string[] = []; + await walkLegacyBaselines(root, '', result); + return result.toSorted(); + } + + private async hasLegacyBaseline( + session: BaselineSession, + relativePath: string, + ): Promise { + const legacyPath = legacyBaselinePath(session, relativePath); + if (legacyPath === undefined) return false; + try { + const info = await stat(legacyPath); + if (!info.isFile()) { + throw new BaselineError(`Legacy baseline "${relativePath}" is not a regular file`); + } + return true; + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return false; + if (error instanceof BaselineError) throw error; + throw new BaselineError(`Unable to inspect legacy baseline "${relativePath}"`, { + cause: error, + }); + } + } + + private async readLegacyBaseline( + session: BaselineSession, + relativePath: string, + ): Promise { + const legacyPath = legacyBaselinePath(session, relativePath); + if (legacyPath === undefined) return undefined; + + let info; + try { + info = await stat(legacyPath); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return undefined; + throw new BaselineError(`Unable to inspect legacy baseline "${relativePath}"`, { + cause: error, + }); + } + if (!info.isFile()) { + throw new BaselineError(`Legacy baseline "${relativePath}" is not a regular file`); + } + + try { + const content = await readFile(legacyPath, 'utf-8'); + return { content, existedBefore: content.length > 0 }; + } catch (error) { + throw new BaselineError(`Unable to read legacy baseline "${relativePath}"`, { + cause: error, + }); + } + } + + private async serialize(sessionIds: readonly string[], operation: () => Promise): Promise { + const ids = [...new Set(sessionIds)].toSorted(); + for (const id of ids) requireSessionId(id); + + const previous = ids.map((id) => this.updates.get(id) ?? Promise.resolve()); + const run = Promise.all(previous).then(operation); + const settled = run.then( + () => undefined, + () => undefined, + ); + for (const id of ids) this.updates.set(id, settled); + void settled.then(() => { + for (const id of ids) { + if (this.updates.get(id) === settled) this.updates.delete(id); + } + }); + return run; + } + + private async waitForUpdates(sessionIds: readonly string[]): Promise { + await Promise.all(sessionIds.map((id) => this.updates.get(id) ?? Promise.resolve())); + } + + private sessionRoot(sessionId: string): string { + return path.join(this.baselinesRoot, hash(sessionId)); + } + + private manifestPath(sessionId: string): string { + return path.join(this.sessionRoot(sessionId), 'manifest.json'); + } + + private snapshotsRoot(sessionId: string): string { + return path.join(this.sessionRoot(sessionId), 'snapshots'); + } + + private snapshotPath(sessionId: string, snapshot: string): string { + if (!SNAPSHOT_HASH.test(snapshot)) { + throw new BaselineError(`Invalid baseline snapshot hash "${snapshot}"`); + } + return path.join(this.snapshotsRoot(sessionId), snapshot); + } +} + +function emptyManifest(sessionId: string): BaselineManifestV1 { + return { version: MANIFEST_VERSION, sessionId, entries: {}, acceptedLegacyPaths: [] }; +} + +function mutableManifest(manifest: BaselineManifestV1): MutableManifest { + return { + version: MANIFEST_VERSION, + sessionId: manifest.sessionId, + entries: { ...manifest.entries }, + acceptedLegacyPaths: [...manifest.acceptedLegacyPaths], + }; +} + +function parseManifest(value: unknown, session: BaselineSession): BaselineManifestV1 { + if (!isRecord(value) || value['version'] !== MANIFEST_VERSION) { + throw new BaselineError(`Unsupported baseline manifest for session "${session.id}"`); + } + if (value['sessionId'] !== session.id) { + throw new BaselineError(`Baseline manifest does not belong to session "${session.id}"`); + } + + const rawEntries = value['entries']; + const rawAccepted = value['acceptedLegacyPaths']; + if (!isRecord(rawEntries) || !Array.isArray(rawAccepted)) { + throw new BaselineError(`Invalid baseline manifest for session "${session.id}"`); + } + + const entries: Record = {}; + const entryKeys = new Set(); + for (const [rawPath, rawEntry] of Object.entries(rawEntries)) { + if ( + !isRecord(rawEntry) || + typeof rawEntry['snapshot'] !== 'string' || + !SNAPSHOT_HASH.test(rawEntry['snapshot']) || + typeof rawEntry['existedBefore'] !== 'boolean' + ) { + throw new BaselineError(`Invalid baseline entry "${rawPath}" in session "${session.id}"`); + } + const relativePath = resolveSessionFile(session, rawPath).relativePath; + const comparisonKey = pathComparisonKey(session, relativePath); + if (relativePath !== rawPath || entryKeys.has(comparisonKey)) { + throw new BaselineError(`Unsafe baseline path "${rawPath}" in session "${session.id}"`); + } + entryKeys.add(comparisonKey); + entries[relativePath] = { + snapshot: rawEntry['snapshot'], + existedBefore: rawEntry['existedBefore'], + }; + } + + const acceptedLegacyPaths: string[] = []; + for (const rawPath of rawAccepted) { + if (typeof rawPath !== 'string') { + throw new BaselineError(`Invalid accepted legacy path in session "${session.id}"`); + } + const relativePath = resolveSessionFile(session, rawPath).relativePath; + if (relativePath !== rawPath) { + throw new BaselineError(`Unsafe accepted legacy path "${rawPath}" in session "${session.id}"`); + } + if (equivalentPath(session, acceptedLegacyPaths, relativePath) === undefined) { + acceptedLegacyPaths.push(relativePath); + } + } + + return { + version: MANIFEST_VERSION, + sessionId: session.id, + entries, + acceptedLegacyPaths: uniquePaths(session, acceptedLegacyPaths), + }; +} + +function equivalentPath( + session: BaselineSession, + paths: Iterable, + candidate: string, +): string | undefined { + const candidateKey = pathComparisonKey(session, candidate); + for (const existing of paths) { + if (pathComparisonKey(session, existing) === candidateKey) return existing; + } + return undefined; +} + +function uniquePaths(session: BaselineSession, paths: Iterable): string[] { + const unique = new Map(); + for (const relativePath of paths) { + const key = pathComparisonKey(session, relativePath); + if (!unique.has(key)) unique.set(key, relativePath); + } + return [...unique.values()].toSorted(); +} + +function pathComparisonKey(session: BaselineSession, relativePath: string): string { + return isWindowsAbsolute(session.workDir) ? relativePath.toLowerCase() : relativePath; +} + +function resolveSessionFile(session: BaselineSession, filePath: string): ResolvedFile { + requireSession(session); + if (filePath.length === 0) throw new BaselineError('The baseline file path is empty'); + + const windows = isWindowsAbsolute(session.workDir); + if (!windows && isWindowsAbsolute(filePath)) { + throw new BaselineError(`File "${filePath}" is outside workspace "${session.workDir}"`); + } + + const paths = windows ? path.win32 : path; + const root = paths.resolve(session.workDir); + const absolutePath = paths.resolve(root, filePath); + const relativePath = paths.relative(root, absolutePath); + const parentPrefix = `..${paths.sep}`; + if ( + relativePath.length === 0 || + relativePath === '..' || + relativePath.startsWith(parentPrefix) || + paths.isAbsolute(relativePath) + ) { + throw new BaselineError(`File "${filePath}" is outside workspace "${session.workDir}"`); + } + + return { + absolutePath, + relativePath: windows ? relativePath.replaceAll('\\', '/') : relativePath, + }; +} + +function isWindowsAbsolute(value: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); +} + +function legacyBaselineRoot(session: BaselineSession): string | undefined { + const source = session.metadata?.['kimi_cli_source_path']; + if (typeof source !== 'string' || source.length === 0) return undefined; + + const sourceIsWindows = isWindowsAbsolute(source); + if (sourceIsWindows !== (process.platform === 'win32')) return undefined; + if (!path.isAbsolute(source)) return undefined; + return path.join(source, 'baseline'); +} + +function legacyBaselinePath( + session: BaselineSession, + relativePath: string, +): string | undefined { + const root = legacyBaselineRoot(session); + if (root === undefined) return undefined; + const resolved = resolveSessionFile(session, relativePath); + return path.join(root, ...resolved.relativePath.split('/')); +} + +async function walkLegacyBaselines( + directory: string, + relativeDirectory: string, + result: string[], +): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (isErrorCode(error, 'ENOENT') && relativeDirectory.length === 0) return; + throw new BaselineError(`Unable to list legacy baseline directory "${directory}"`, { + cause: error, + }); + } + + for (const entry of entries) { + const relativePath = relativeDirectory + ? `${relativeDirectory}/${entry.name}` + : entry.name; + if (entry.isDirectory()) { + await walkLegacyBaselines(path.join(directory, entry.name), relativePath, result); + } else if (entry.isFile()) { + result.push(relativePath); + } + } +} + +function captureOriginal(absolutePath: string): BaselineValue { + let info; + try { + info = statSync(absolutePath); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return { content: '', existedBefore: false }; + throw new BaselineError(`Unable to inspect original file "${absolutePath}"`, { + cause: error, + }); + } + if (!info.isFile()) { + throw new BaselineError(`Original path "${absolutePath}" is not a regular file`); + } + + try { + return { content: readFileSync(absolutePath, 'utf-8'), existedBefore: true }; + } catch (error) { + throw new BaselineError(`Unable to capture original file "${absolutePath}"`, { + cause: error, + }); + } +} + +async function readCurrentFile(absolutePath: string): Promise { + try { + return await readFile(absolutePath, 'utf-8'); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return undefined; + throw new BaselineError(`Unable to read current file "${absolutePath}"`, { cause: error }); + } +} + +async function restoreFile( + workDir: string, + absolutePath: string, + baseline: BaselineValue, +): Promise { + await requireContainedRestorePath(workDir, absolutePath); + if (!baseline.existedBefore) { + try { + await unlink(absolutePath); + } catch (error) { + if (!isErrorCode(error, 'ENOENT')) { + throw new BaselineError(`Unable to remove newly created file "${absolutePath}"`, { + cause: error, + }); + } + } + return; + } + + try { + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, baseline.content, 'utf-8'); + } catch (error) { + throw new BaselineError(`Unable to restore file "${absolutePath}"`, { cause: error }); + } +} + +async function requireContainedRestorePath(workDir: string, absolutePath: string): Promise { + try { + const [realWorkDir, realTarget] = await Promise.all([ + realpath(workDir), + realExistingPath(absolutePath), + ]); + if (relativeFsPath(realWorkDir, realTarget) === undefined) { + throw new BaselineError(`Refusing to restore path outside the session workspace: "${absolutePath}"`); + } + } catch (error) { + if (error instanceof BaselineError) throw error; + throw new BaselineError(`Unable to validate restore path "${absolutePath}"`, { cause: error }); + } +} + +async function realExistingPath(candidate: string): Promise { + let current = candidate; + while (true) { + try { + return await realpath(current); + } catch (error) { + if (!isErrorCode(error, 'ENOENT')) throw error; + let isDanglingSymlink = false; + try { + isDanglingSymlink = (await lstat(current)).isSymbolicLink(); + } catch (lstatError) { + if (!isErrorCode(lstatError, 'ENOENT')) throw lstatError; + } + if (isDanglingSymlink) throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } +} + +async function atomicWrite(targetPath: string, content: string): Promise { + await mkdir(path.dirname(targetPath), { recursive: true, mode: 0o700 }); + const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temporaryPath, content, { encoding: 'utf-8', mode: 0o600 }); + await rename(temporaryPath, targetPath); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw new BaselineError(`Unable to atomically write "${targetPath}"`, { cause: error }); + } +} + +function requireSession(session: BaselineSession): void { + requireSessionId(session.id); + if (session.workDir.length === 0) throw new BaselineError('The session workspace path is empty'); +} + +function requireSessionId(sessionId: string): void { + if (sessionId.length === 0) throw new BaselineError('The baseline session id is empty'); +} + +function hash(value: string): string { + return createHash('sha256').update(value, 'utf-8').digest('hex'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isErrorCode(error: unknown, code: string): boolean { + return isRecord(error) && error['code'] === code; +} + +function countLines(content: string): number { + if (content.length === 0) return 0; + return content.replaceAll('\r\n', '\n').split('\n').length; +} + +function computeLineDiff( + oldContent: string, + newContent: string, +): { additions: number; deletions: number } { + const lines = (content: string): string[] => + content.length === 0 ? [] : content.replaceAll('\r\n', '\n').split('\n'); + const oldLines = lines(oldContent); + const newLines = lines(newContent); + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + return { + additions: newLines.filter((line) => !oldSet.has(line)).length, + deletions: oldLines.filter((line) => !newSet.has(line)).length, + }; +} diff --git a/apps/vscode/src/managers/file.manager.ts b/apps/vscode/src/managers/file.manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..1427bcdfc2ca8b03ba2264ede6279a360b9c90dc --- /dev/null +++ b/apps/vscode/src/managers/file.manager.ts @@ -0,0 +1,169 @@ +import * as vscode from "vscode"; +import * as path from "node:path"; +import { BaselineManager, type BaselineSession } from "./baseline.manager"; +import { Events } from "../../shared/bridge"; +import type { ProjectFile } from "../../shared/types"; +import { buildCaseInsensitiveGlobLiteral } from "../utils/string"; +import { + isWorkspacePathContained, + relativeWorkspacePath, +} from "../utils/workspace-path"; + +export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void; + +const IGNORE_DIRS = new Set([ + "node_modules", + ".git", + ".svn", + ".hg", + "dist", + "build", + "out", + ".next", + ".nuxt", + "__pycache__", + ".cache", + ".venv", + "venv", + ".gradle", + ".idea", + ".DS_Store", + "Thumbs.db", + "coverage", + ".nyc_output", + ".pytest_cache", + ".mypy_cache", + ".tox", + ".eggs", + ".sass-cache", + ".parcel-cache", + "bower_components", + "jspm_packages", + ".turbo", +]); + +const SEARCH_EXCLUDE = `{${[...IGNORE_DIRS].map((d) => `**/${d}`).join(",")}}`; + +interface ViewState { + session: BaselineSession | null; + trackedFiles: Set; +} + +export class FileManager { + private viewStates = new Map(); + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly baselineManager: BaselineManager, + private broadcast: BroadcastFn, + ) { + // Watch for file changes + const watcher = vscode.workspace.createFileSystemWatcher("**/*"); + + const refresh = (uri: vscode.Uri) => { + void this.onFileChange(uri).catch((error) => { + console.error("[kimi-vscode] Unable to refresh file changes", error); + }); + }; + watcher.onDidChange(refresh); + watcher.onDidCreate(refresh); + watcher.onDidDelete(refresh); + + this.disposables.push(watcher); + } + + private getViewState(webviewId: string): ViewState { + let state = this.viewStates.get(webviewId); + if (!state) { + state = { session: null, trackedFiles: new Set() }; + this.viewStates.set(webviewId, state); + } + return state; + } + + setSession(webviewId: string, session: BaselineSession): void { + this.getViewState(webviewId).session = session; + } + + clearSession(webviewId: string): void { + const state = this.getViewState(webviewId); + state.session = null; + state.trackedFiles.clear(); + } + + getSessionId(webviewId: string): string | null { + return this.getViewState(webviewId).session?.id ?? null; + } + + getSession(webviewId: string): BaselineSession | null { + return this.getViewState(webviewId).session; + } + + trackFile(webviewId: string, absolutePath: string): void { + this.getViewState(webviewId).trackedFiles.add(absolutePath); + } + + getTracked(webviewId: string): Set { + return this.getViewState(webviewId).trackedFiles; + } + + clearTracked(webviewId: string): void { + this.getViewState(webviewId).trackedFiles.clear(); + } + + disposeView(webviewId: string): void { + this.viewStates.delete(webviewId); + } + + private async onFileChange(uri: vscode.Uri): Promise { + const absolutePath = uri.fsPath; + + for (const [webviewId, state] of this.viewStates) { + if (!state.session || !state.trackedFiles.has(absolutePath)) { + continue; + } + + await this.refreshChanges(webviewId); + } + } + + async refreshChanges(webviewId: string): Promise { + const state = this.getViewState(webviewId); + if (state.session === null) { + this.broadcast(Events.FileChangesUpdated, [], webviewId); + return; + } + const changes = await this.baselineManager.getChanges(state.session); + this.broadcast(Events.FileChangesUpdated, changes, webviewId); + } + + async searchFiles(workDirUri: vscode.Uri, query?: string): Promise { + query = query ? buildCaseInsensitiveGlobLiteral(query) : ""; + const pattern = query ? `**/*${query}*` : "**/*"; + const files = await vscode.workspace.findFiles( + new vscode.RelativePattern(workDirUri, pattern), + new vscode.RelativePattern(workDirUri, SEARCH_EXCLUDE), + 200, + ); + const results = await Promise.all( + files.map(async (uri): Promise => { + const relativePath = relativeWorkspacePath(workDirUri, uri); + if (relativePath === undefined || !(await isWorkspacePathContained(workDirUri, uri))) return undefined; + return { + path: relativePath, + name: path.posix.basename(relativePath), + isDirectory: false, + }; + }), + ); + return results.filter((result): result is ProjectFile => result !== undefined); + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + this.viewStates.clear(); + } +} diff --git a/apps/vscode/src/migration/index.ts b/apps/vscode/src/migration/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e22499bd62651fba689c3fa7c1fd05849d09663 --- /dev/null +++ b/apps/vscode/src/migration/index.ts @@ -0,0 +1 @@ +export * from "./legacy-migration.manager"; diff --git a/apps/vscode/src/migration/legacy-migration.manager.ts b/apps/vscode/src/migration/legacy-migration.manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..af741a68c776b6188cb508c667d0e849e7a04781 --- /dev/null +++ b/apps/vscode/src/migration/legacy-migration.manager.ts @@ -0,0 +1,698 @@ +import { readdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve, win32 } from "node:path"; + +import { + detectMigration, + runMigration, + defaultPlansSourceDir, + shouldSuppressMigration, + type MigrationPlan, + type MigrationReport, + type MigrationScope, +} from "@moonshot-ai/migration-legacy"; + +const FULL_MIGRATION_SCOPE = { + config: true, + mcp: true, + userHistory: true, + skills: true, + sessions: true, +} satisfies MigrationScope; + +export type LegacyMigrationSourceOrigin = "default" | "legacy-vscode-setting"; + +export type LegacyMigrationWarningCode = + | "invalid-share-dir" + | "relative-share-dir" + | "source-equals-target" + | "source-not-directory" + | "source-unreadable" + | "legacy-session-unreadable" + | "detection-failed"; + +export interface LegacyMigrationWarning { + readonly code: LegacyMigrationWarningCode; + readonly message: string; + readonly sourceHome?: string; +} + +export interface LegacyMigrationReauthItem { + readonly sourceHome: string; + readonly name: string; +} + +export interface LegacyMigrationNotices { + readonly oauthLoginsRequiringRelogin: readonly LegacyMigrationReauthItem[]; + readonly mcpOauthServersRequiringReauth: readonly LegacyMigrationReauthItem[]; +} + +export interface LegacyMigrationSourcePreview { + readonly sourceHome: string; + readonly origin: LegacyMigrationSourceOrigin; + readonly hasConfig: boolean; + readonly hasMcp: boolean; + readonly hasUserHistory: boolean; + readonly hasSkills: boolean; + readonly hasPlans: boolean; + readonly totalSessions: number; + readonly sessionIssues: number; +} + +export interface LegacyMigrationPromptModel { + readonly kind: "legacy-migration"; + readonly message: string; + readonly actions: readonly [ + { readonly id: "now"; readonly label: "Migrate Now" }, + { readonly id: "later"; readonly label: "Later" }, + ]; + readonly sources: readonly LegacyMigrationSourcePreview[]; + readonly notices: LegacyMigrationNotices; +} + +export interface LegacyMigrationDiscovery { + readonly prompt: LegacyMigrationPromptModel | null; + readonly suppressedSources: readonly LegacyMigrationSourcePreview[]; + readonly warnings: readonly LegacyMigrationWarning[]; + readonly notices: LegacyMigrationNotices; +} + +export interface LegacyMigrationManagerOptions { + /** Harness-resolved homeDir. */ + readonly targetHome: string; + /** Defaults to the legacy kimi-cli home (`~/.kimi`). Injectable for isolated tests. */ + readonly defaultSourceHome?: string; + /** Defaults to the legacy kimi-cli plans dir (`~/.kimi/plans`). Injectable for isolated tests. */ + readonly plansSourceDir?: string; + /** First workspace root. Used only to resolve a relative legacy KIMI_SHARE_DIR. */ + readonly workspaceRoot?: string | null; + /** The removed `kimi.environmentVariables` VS Code setting, read once for migration. */ + readonly legacyEnvironmentVariables?: unknown; +} + +export type LegacyMigrationFailureCode = + | "run-failed" + | "legacy-config-unreadable" + | "legacy-mcp-unreadable" + | "session-failed"; + +export interface LegacyMigrationFailure { + readonly code: LegacyMigrationFailureCode; + readonly sourceHome: string; + readonly item?: string; + readonly message: string; +} + +export interface LegacyMigrationTotals { + readonly configFiles: number; + readonly mcpServers: number; + readonly userHistoryEntries: number; + readonly skills: number; + readonly planFiles: number; + readonly sessions: number; + readonly alreadyMigratedSessions: number; + readonly skippedItems: number; + readonly conflicts: number; + readonly failures: number; +} + +export interface LegacyMigrationSourceResult { + readonly source: LegacyMigrationSourcePreview; + readonly status: "completed" | "partial" | "failed"; + readonly report?: MigrationReport; + readonly failures: readonly LegacyMigrationFailure[]; + readonly error?: unknown; +} + +export interface LegacyMigrationRunResult { + readonly status: "completed" | "partial" | "failed" | "nothing-to-migrate"; + readonly message: string; + readonly sources: readonly LegacyMigrationSourceResult[]; + readonly suppressedSources: readonly LegacyMigrationSourcePreview[]; + readonly totals: LegacyMigrationTotals; + readonly warnings: readonly LegacyMigrationWarning[]; + readonly notices: LegacyMigrationNotices; + readonly manualActions: readonly string[]; +} + +interface InspectedSource { + readonly preview: LegacyMigrationSourcePreview; + readonly plan: MigrationPlan; +} + +interface InspectionResult { + readonly pending: readonly InspectedSource[]; + readonly suppressed: readonly LegacyMigrationSourcePreview[]; + readonly warnings: readonly LegacyMigrationWarning[]; + readonly notices: LegacyMigrationNotices; +} + +interface SourceCandidate { + readonly sourceHome: string; + readonly origin: LegacyMigrationSourceOrigin; +} + +/** + * Coordinates legacy kimi-cli migration for the VS Code host while keeping all + * data translation inside the shared migration package. + */ +export class LegacyMigrationManager { + readonly targetHome: string; + + private readonly defaultSourceHome: string; + private readonly workspaceRoot: string | null; + private readonly legacyEnvironmentVariables: unknown; + private readonly plansSourceDir: string; + + constructor(options: LegacyMigrationManagerOptions) { + if (options.targetHome.trim().length === 0) { + throw new Error("LegacyMigrationManager requires a non-empty targetHome."); + } + this.targetHome = resolve(options.targetHome); + this.defaultSourceHome = resolve(options.defaultSourceHome ?? join(homedir(), ".kimi")); + this.workspaceRoot = + options.workspaceRoot === undefined || options.workspaceRoot === null + ? null + : resolve(options.workspaceRoot); + this.legacyEnvironmentVariables = options.legacyEnvironmentVariables; + this.plansSourceDir = options.plansSourceDir ?? defaultPlansSourceDir(); + } + + /** Detect first-launch work without changing the source or target. */ + async discover(): Promise { + const inspection = await this.inspect(false); + const sources = inspection.pending.map((source) => source.preview); + return { + prompt: + sources.length === 0 + ? null + : { + kind: "legacy-migration", + message: + "Legacy Kimi data was found. Migrate config, MCP servers, history, skills, and sessions into Kimi Code? Your old data will be kept.", + actions: [ + { id: "now", label: "Migrate Now" }, + { id: "later", label: "Later" }, + ], + sources, + notices: inspection.notices, + }, + suppressedSources: inspection.suppressed, + warnings: inspection.warnings, + notices: inspection.notices, + }; + } + + /** Run the migration selected from the first-launch prompt. */ + async migrateNow(): Promise { + return this.execute(false); + } + + /** + * Explicit command-palette retry. Marker suppression is intentionally + * bypassed because a completed marker may also describe a partially failed + * run, while the shared migrator itself remains idempotent. + */ + async retry(): Promise { + return this.execute(true); + } + + private async execute(ignoreMarker: boolean): Promise { + const inspection = await this.inspect(ignoreMarker); + const sourceResults: LegacyMigrationSourceResult[] = []; + + for (const source of inspection.pending) { + try { + const report = await runMigration({ + // The shared package owns its migration/schema version. It must not + // be coupled to the VS Code extension's release version. + plan: source.plan, + scope: FULL_MIGRATION_SCOPE, + source: source.preview.sourceHome, + target: this.targetHome, + plansSourceDir: this.plansSourceDir, + }); + const failures = failuresFromReport(source, report); + sourceResults.push({ + source: source.preview, + status: failures.length === 0 ? "completed" : "partial", + report, + failures, + }); + } catch (error) { + sourceResults.push({ + source: source.preview, + status: "failed", + failures: [ + { + code: "run-failed", + sourceHome: source.preview.sourceHome, + message: formatError(error), + }, + ], + error, + }); + } + } + + const status = runStatus(sourceResults); + const totals = aggregateTotals(sourceResults); + const manualActions = aggregateManualActions(sourceResults); + return { + status, + message: runMessage(status, totals), + sources: sourceResults, + suppressedSources: inspection.suppressed, + totals, + warnings: inspection.warnings, + notices: mergeRunNotices(inspection.notices, sourceResults), + manualActions, + }; + } + + private async inspect(ignoreMarker: boolean): Promise { + const { candidates, warnings } = this.sourceCandidates(); + const pending: InspectedSource[] = []; + const suppressed: LegacyMigrationSourcePreview[] = []; + const oauthLoginsRequiringRelogin: LegacyMigrationReauthItem[] = []; + const mcpOauthServersRequiringReauth: LegacyMigrationReauthItem[] = []; + + for (const candidate of candidates) { + const sourceCheck = await checkSourceDirectory(candidate.sourceHome); + if (sourceCheck === "missing") continue; + if (sourceCheck === "not-directory") { + warnings.push({ + code: "source-not-directory", + sourceHome: candidate.sourceHome, + message: `Legacy migration source is not a directory: ${candidate.sourceHome}`, + }); + continue; + } + if (sourceCheck === "unreadable") { + warnings.push({ + code: "source-unreadable", + sourceHome: candidate.sourceHome, + message: `Legacy migration source cannot be read: ${candidate.sourceHome}`, + }); + continue; + } + + let plan: MigrationPlan; + try { + plan = await detectMigration({ + sourcePath: candidate.sourceHome, + plansSourcePath: this.plansSourceDir, + }); + } catch (error) { + warnings.push({ + code: "detection-failed", + sourceHome: candidate.sourceHome, + message: `Unable to inspect legacy data at ${candidate.sourceHome}: ${formatError(error)}`, + }); + continue; + } + + oauthLoginsRequiringRelogin.push( + ...plan.oauthCredentials.map((name) => ({ sourceHome: candidate.sourceHome, name })), + ); + mcpOauthServersRequiringReauth.push( + ...plan.detectedMcpOauthServers.map((name) => ({ + sourceHome: candidate.sourceHome, + name, + })), + ); + + const hasSkills = plan.hasSkills; + const sessionScanFailures = plan.sessionScanFailures ?? []; + warnings.push( + ...sessionScanFailures.map((failure) => ({ + code: "legacy-session-unreadable" as const, + sourceHome: candidate.sourceHome, + message: `${failure.reason} Source: ${failure.sourcePath}`, + })), + ); + const preview: LegacyMigrationSourcePreview = { + sourceHome: candidate.sourceHome, + origin: candidate.origin, + hasConfig: plan.hasConfig, + hasMcp: plan.hasMcp, + hasUserHistory: plan.hasUserHistory, + hasSkills, + hasPlans: plan.hasPlans, + totalSessions: plan.totalSessions, + sessionIssues: sessionScanFailures.length, + }; + if (!hasMigratableData(preview)) continue; + + if ( + !ignoreMarker && + shouldSuppressMigration({ + sourceHome: candidate.sourceHome, + targetHome: this.targetHome, + }) + ) { + suppressed.push(preview); + continue; + } + + pending.push({ + preview, + plan, + }); + } + + return { + pending, + suppressed, + warnings, + notices: { + oauthLoginsRequiringRelogin: dedupeReauthItems(oauthLoginsRequiringRelogin), + mcpOauthServersRequiringReauth: dedupeReauthItems( + mcpOauthServersRequiringReauth, + ), + }, + }; + } + + private sourceCandidates(): { + candidates: SourceCandidate[]; + warnings: LegacyMigrationWarning[]; + } { + const warnings: LegacyMigrationWarning[] = []; + const candidates: SourceCandidate[] = [ + { sourceHome: this.defaultSourceHome, origin: "default" }, + ]; + const shareDir = readLegacyShareDir(this.legacyEnvironmentVariables); + + if (shareDir.kind === "invalid") { + warnings.push({ + code: "invalid-share-dir", + message: shareDir.message, + }); + } else if (shareDir.kind === "value") { + let sourceHome: string | undefined; + if (isAbsolute(shareDir.value)) { + sourceHome = resolve(shareDir.value); + } else if (this.workspaceRoot === null) { + warnings.push({ + code: "invalid-share-dir", + message: + "The legacy KIMI_SHARE_DIR is relative, but no workspace is open; this migration source was ignored.", + }); + } else { + sourceHome = resolve(this.workspaceRoot, shareDir.value); + warnings.push({ + code: "relative-share-dir", + sourceHome, + message: `The legacy relative KIMI_SHARE_DIR was resolved against the workspace: ${sourceHome}`, + }); + } + + if (sourceHome !== undefined && samePath(sourceHome, this.targetHome)) { + warnings.push({ + code: "source-equals-target", + sourceHome, + message: "The legacy KIMI_SHARE_DIR resolves to the Kimi Code home and was ignored.", + }); + } else if ( + sourceHome !== undefined && + !candidates.some((candidate) => samePath(candidate.sourceHome, sourceHome)) + ) { + candidates.push({ sourceHome, origin: "legacy-vscode-setting" }); + } + } + + return { candidates, warnings }; + } +} + +function readLegacyShareDir( + environmentVariables: unknown, +): { readonly kind: "missing" } | { readonly kind: "value"; readonly value: string } | { + readonly kind: "invalid"; + readonly message: string; +} { + if (environmentVariables === undefined) return { kind: "missing" }; + if ( + typeof environmentVariables !== "object" || + environmentVariables === null || + Array.isArray(environmentVariables) + ) { + return { + kind: "invalid", + message: "The legacy kimi.environmentVariables setting is invalid and was ignored.", + }; + } + + const value = (environmentVariables as Record)["KIMI_SHARE_DIR"]; + if (value === undefined) return { kind: "missing" }; + if (typeof value !== "string" || value.trim().length === 0) { + return { + kind: "invalid", + message: "The legacy KIMI_SHARE_DIR must be a non-empty string and was ignored.", + }; + } + return { kind: "value", value }; +} + +async function checkSourceDirectory( + sourceHome: string, +): Promise<"ok" | "missing" | "not-directory" | "unreadable"> { + try { + const sourceStat = await stat(sourceHome); + if (!sourceStat.isDirectory()) return "not-directory"; + } catch (error) { + return isMissingError(error) ? "missing" : "unreadable"; + } + + try { + await readdir(sourceHome); + return "ok"; + } catch { + return "unreadable"; + } +} + +function isMissingError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { readonly code?: unknown }).code === "ENOENT" + ); +} + +function hasMigratableData(source: LegacyMigrationSourcePreview): boolean { + return ( + source.hasConfig || + source.hasMcp || + source.hasUserHistory || + source.hasSkills || + source.hasPlans || + source.totalSessions > 0 || + source.sessionIssues > 0 + ); +} + +function failuresFromReport( + source: InspectedSource, + report: MigrationReport, +): LegacyMigrationFailure[] { + const failures: LegacyMigrationFailure[] = []; + if (report.summary.config.sourceUnreadable) { + failures.push({ + code: "legacy-config-unreadable", + sourceHome: source.preview.sourceHome, + item: "config.toml", + message: "The legacy config could not be read or parsed; review it manually.", + }); + } + if (report.summary.mcp.sourceUnreadable) { + failures.push({ + code: "legacy-mcp-unreadable", + sourceHome: source.preview.sourceHome, + item: "mcp.json", + message: "The legacy mcp.json could not be parsed; review it manually.", + }); + } + failures.push( + ...report.summary.sessions.sessionsFailed.map((failure) => ({ + code: "session-failed" as const, + sourceHome: source.preview.sourceHome, + item: failure.sourcePath, + message: failure.reason, + })), + ); + return failures; +} + +function runStatus( + sources: readonly LegacyMigrationSourceResult[], +): LegacyMigrationRunResult["status"] { + if (sources.length === 0) return "nothing-to-migrate"; + if (sources.every((source) => source.status === "failed")) return "failed"; + if (sources.some((source) => source.status !== "completed")) return "partial"; + return "completed"; +} + +function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): LegacyMigrationTotals { + let configFiles = 0; + let mcpServers = 0; + let userHistoryEntries = 0; + let skills = 0; + let planFiles = 0; + let sessions = 0; + let alreadyMigratedSessions = 0; + let skippedItems = 0; + let conflicts = 0; + let failures = 0; + + for (const source of sources) { + failures += source.failures.length; + const summary = source.report?.summary; + if (summary === undefined) continue; + configFiles += summary.config.migrated ? 1 : 0; + mcpServers += summary.mcp.mergedServers.length; + userHistoryEntries += summary.userHistory.copied; + skills += summary.skills.copied; + planFiles += summary.plans.copied; + sessions += summary.sessions.sessionsMigrated; + alreadyMigratedSessions += summary.sessions.sessionsAlreadyMigrated; + skippedItems += + summary.userHistory.skippedExisting + + summary.skills.skippedExisting + + summary.sessions.sessionsSkippedPlaceholder + + summary.sessions.sessionsSkippedEmpty + + summary.sessions.sessionsSkippedMalformed; + conflicts += + summary.config.configConflicts.length + + summary.mcp.keptNewForConflicts.length + + summary.sessions.sessionsConflicts.length; + } + + return { + configFiles, + mcpServers, + userHistoryEntries, + skills, + planFiles, + sessions, + alreadyMigratedSessions, + skippedItems, + conflicts, + failures, + }; +} + +function aggregateManualActions( + sources: readonly LegacyMigrationSourceResult[], +): readonly string[] { + const actions: string[] = []; + for (const source of sources) { + for (const failure of source.failures) { + if (failure.code === "run-failed") { + actions.push( + `Fix access to ${source.source.sourceHome} or the Kimi Code home, then run “Kimi Code: Migrate Legacy Data” again.`, + ); + } else { + actions.push( + `${failure.message} Source: ${failure.item ?? source.source.sourceHome}`, + ); + } + } + + const summary = source.report?.summary; + if (summary === undefined) continue; + if (summary.config.wroteSiblingDueToConflict) { + actions.push("Review and merge config.migrated-from-kimi-cli.toml."); + } + if (summary.config.wroteTuiSibling) { + actions.push("Review and merge tui.migrated-from-kimi-cli.toml."); + } + if (summary.mcp.wroteSiblingDueToConflict) { + actions.push("Review and merge mcp.migrated-from-kimi-cli.json."); + } + if (summary.sessions.sessionsConflicts.length > 0) { + actions.push( + `${summary.sessions.sessionsConflicts.length} legacy session(s) conflicted with existing target sessions and were kept unchanged.`, + ); + } + } + return [...new Set(actions)]; +} + +function mergeRunNotices( + detectionNotices: LegacyMigrationNotices, + sources: readonly LegacyMigrationSourceResult[], +): LegacyMigrationNotices { + const oauth = [...detectionNotices.oauthLoginsRequiringRelogin]; + const mcpOauth = [...detectionNotices.mcpOauthServersRequiringReauth]; + for (const source of sources) { + const notices = source.report?.notices; + if (notices === undefined) continue; + oauth.push( + ...notices.oauthLoginsRequiringRelogin.map((name) => ({ + sourceHome: source.source.sourceHome, + name, + })), + ); + mcpOauth.push( + ...notices.mcpOauthServersRequiringReauth.map((name) => ({ + sourceHome: source.source.sourceHome, + name, + })), + ); + } + return { + oauthLoginsRequiringRelogin: dedupeReauthItems(oauth), + mcpOauthServersRequiringReauth: dedupeReauthItems(mcpOauth), + }; +} + +function dedupeReauthItems( + items: readonly LegacyMigrationReauthItem[], +): readonly LegacyMigrationReauthItem[] { + const seen = new Set(); + return items.filter((item) => { + const key = `${pathKey(item.sourceHome)}\0${item.name}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function runMessage( + status: LegacyMigrationRunResult["status"], + totals: LegacyMigrationTotals, +): string { + if (status === "nothing-to-migrate") return "No legacy Kimi data needs migration."; + if (status === "failed") { + return "Legacy migration failed. Fix the reported path or data error, then retry from the command palette."; + } + const migrated = `${totals.configFiles} config, ${totals.mcpServers} MCP server(s), ${totals.userHistoryEntries} history item(s), ${totals.skills} skill(s), ${totals.planFiles} plan file(s), and ${totals.sessions} session(s)`; + if (status === "partial") { + return `Legacy migration completed with ${totals.failures} failure(s): ${migrated}. Review the details and retry from the command palette.`; + } + return `Legacy migration complete: ${migrated}. Old data was kept.`; +} + +function formatError(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const messages: string[] = []; + let current: unknown = error; + while (current instanceof Error) { + messages.push(current.message || current.name); + current = current.cause; + } + return messages.join(": "); +} + +function samePath(left: string, right: string): boolean { + return pathKey(left) === pathKey(right); +} + +function pathKey(path: string): string { + if (process.platform === "win32") return win32.resolve(path).toLowerCase(); + const windowsAbsolute = win32.isAbsolute(path); + return windowsAbsolute ? win32.resolve(path).toLowerCase() : resolve(path); +} diff --git a/apps/vscode/src/raw-modules.d.ts b/apps/vscode/src/raw-modules.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..c65c77acd5196573f4eb0563a686883d59eb0aab --- /dev/null +++ b/apps/vscode/src/raw-modules.d.ts @@ -0,0 +1,4 @@ +declare module "*?raw" { + const text: string; + export default text; +} diff --git a/apps/vscode/src/runtime/event-adapter.ts b/apps/vscode/src/runtime/event-adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..68eedfd4b469f54976f45627279a343b6106260d --- /dev/null +++ b/apps/vscode/src/runtime/event-adapter.ts @@ -0,0 +1,463 @@ +import type { Event } from '@moonshot-ai/kimi-code-sdk'; + +import type { + DisplayBlock, + LegacyWireEvent, + StatusUpdate, + TokenUsage, + TurnBegin, +} from '../../shared/legacy-sdk'; +import type { ErrorPhase, UIStreamEvent } from '../../shared/types'; +import { toLegacyDisplay } from './tool-display'; + +const DEFAULT_MAIN_AGENT_ID = 'main'; + +export interface AdapterTokenUsage { + readonly inputOther: number; + readonly output: number; + readonly inputCacheRead: number; + readonly inputCacheCreation: number; +} + +export interface SubagentParent { + readonly parentAgentId: string; + readonly parentToolCallId: string; +} + +export interface EventAdapterState { + readonly subagentParents: Readonly>; + readonly turnUsageByAgent: Readonly>; + readonly toolDisplays: Readonly>; +} + +export interface AdaptedToolCallPartEvent { + readonly type: 'ToolCallPart'; + readonly payload: { + /** Lets the Webview update the right call when tool arguments interleave. */ + readonly tool_call_id: string; + readonly arguments_part?: string | null; + }; + readonly _sessionId?: string; +} + +export type AdaptedUIStreamEvent = UIStreamEvent | AdaptedToolCallPartEvent; + +type SdkTurnEndedEvent = Extract; + +export interface TurnTerminalMetadata { + /** Stable within one adapter stream and suitable for terminal-event de-duplication. */ + readonly key: string; + readonly sessionId: string; + readonly agentId: string; + readonly turnId: number; + readonly reason: SdkTurnEndedEvent['reason']; + readonly error?: NonNullable; +} + +export interface EventAdapterResult { + readonly state: EventAdapterState; + readonly event?: AdaptedUIStreamEvent; + /** SessionRuntime owns conversion of this metadata to exactly one complete/error event. */ + readonly terminal?: TurnTerminalMetadata; +} + +export interface AdaptSdkEventOptions { + /** The SDK turn-start event intentionally does not repeat prompt content. */ + readonly pendingInput?: TurnBegin['user_input']; + readonly mainAgentId?: string; + readonly errorPhase?: ErrorPhase; +} + +export function createEventAdapterState(): EventAdapterState { + return { + subagentParents: {}, + turnUsageByAgent: {}, + toolDisplays: {}, + }; +} + +/** + * Purely projects one public Node SDK event into the released Webview protocol. + * The returned state must be passed into the next call; the input state is never mutated. + */ +export function adaptSdkEvent( + state: EventAdapterState, + sdkEvent: Event, + options: AdaptSdkEventOptions = {}, +): EventAdapterResult { + const mainAgentId = options.mainAgentId ?? DEFAULT_MAIN_AGENT_ID; + + if (sdkEvent.type === 'subagent.spawned') { + const parentAgentId = sdkEvent.parentAgentId ?? sdkEvent.callerAgentId ?? sdkEvent.agentId; + return { + state: { + ...state, + subagentParents: { + ...state.subagentParents, + [sdkEvent.subagentId]: { + parentAgentId, + parentToolCallId: scopedToolCallId( + parentAgentId, + sdkEvent.parentToolCallId, + mainAgentId, + ), + }, + }, + }, + }; + } + + if (sdkEvent.type === 'turn.started') { + const nextState = resetTurnUsage(state, sdkEvent.agentId); + if (sdkEvent.agentId !== mainAgentId || options.pendingInput === undefined) { + return { state: nextState }; + } + return { + state: nextState, + event: withSessionId( + { + type: 'TurnBegin', + payload: { user_input: options.pendingInput }, + }, + sdkEvent.sessionId, + ), + }; + } + + if (sdkEvent.type === 'turn.ended') { + if (sdkEvent.agentId !== mainAgentId) return { state }; + return { + state, + terminal: { + key: `${sdkEvent.sessionId}:${sdkEvent.agentId}:${sdkEvent.turnId}`, + sessionId: sdkEvent.sessionId, + agentId: sdkEvent.agentId, + turnId: sdkEvent.turnId, + reason: sdkEvent.reason, + error: sdkEvent.error, + }, + }; + } + + if (sdkEvent.type === 'error') { + if (sdkEvent.agentId !== mainAgentId) return { state }; + return { + state, + event: { + type: 'error', + code: sdkEvent.code, + message: sdkEvent.message, + detail: serializeDetails(sdkEvent.details), + phase: options.errorPhase ?? 'runtime', + _sessionId: sdkEvent.sessionId, + }, + }; + } + + const mapped = mapLegacyWireEvent(state, sdkEvent, mainAgentId); + if (mapped.event === undefined) return { state: mapped.state }; + + const routed = routeSubagentEvent( + mapped.state, + sdkEvent.agentId, + mapped.event, + mainAgentId, + ); + if (routed === undefined) return { state: mapped.state }; + + return { + state: mapped.state, + event: withSessionId(routed, sdkEvent.sessionId), + }; +} + +export function toLegacyToolName(name: string): string { + switch (name) { + case 'Bash': + return 'Shell'; + case 'Read': + return 'ReadFile'; + case 'Write': + return 'WriteFile'; + case 'Edit': + return 'StrReplaceFile'; + case 'TodoList': + return 'SetTodoList'; + default: + return name; + } +} + +interface MappedLegacyWireEvent { + readonly state: EventAdapterState; + readonly event?: LegacyWireEvent; +} + +function mapLegacyWireEvent( + state: EventAdapterState, + sdkEvent: Event, + mainAgentId: string, +): MappedLegacyWireEvent { + switch (sdkEvent.type) { + case 'turn.step.started': + return { + state, + event: { type: 'StepBegin', payload: { n: sdkEvent.step } }, + }; + case 'turn.step.retrying': + return { + state, + event: { + type: 'StatusUpdate', + payload: { + retrying: { + next_attempt: sdkEvent.nextAttempt, + max_attempts: sdkEvent.maxAttempts, + delay_ms: sdkEvent.delayMs, + message: sdkEvent.errorMessage, + }, + }, + }, + }; + case 'turn.step.interrupted': + return { + state, + event: { type: 'StepInterrupted', payload: {} }, + }; + case 'assistant.delta': + return { + state, + event: { type: 'ContentPart', payload: { type: 'text', text: sdkEvent.delta } }, + }; + case 'hook.result': + return { + state, + event: { type: 'ContentPart', payload: { type: 'text', text: sdkEvent.content } }, + }; + case 'thinking.delta': + return { + state, + event: { type: 'ContentPart', payload: { type: 'think', think: sdkEvent.delta } }, + }; + case 'tool.call.started': { + const toolCallId = scopedToolCallId( + sdkEvent.agentId, + sdkEvent.toolCallId, + mainAgentId, + ); + const display = sdkEvent.display === undefined ? undefined : toLegacyDisplay(sdkEvent.display); + return { + state: display === undefined + ? state + : { + ...state, + toolDisplays: { ...state.toolDisplays, [toolCallId]: display }, + }, + event: { + type: 'ToolCall', + payload: { + type: 'function', + id: toolCallId, + function: { + name: toLegacyToolName(sdkEvent.name), + arguments: serializeArguments(sdkEvent.args), + }, + }, + }, + }; + } + case 'tool.call.delta': { + const event: AdaptedToolCallPartEvent = { + type: 'ToolCallPart', + payload: { + tool_call_id: scopedToolCallId( + sdkEvent.agentId, + sdkEvent.toolCallId, + mainAgentId, + ), + arguments_part: sdkEvent.argumentsPart, + }, + }; + return { state, event: event as LegacyWireEvent }; + } + case 'tool.result': { + const toolCallId = scopedToolCallId( + sdkEvent.agentId, + sdkEvent.toolCallId, + mainAgentId, + ); + const display = state.toolDisplays[toolCallId] ?? []; + const toolDisplays = { ...state.toolDisplays }; + delete toolDisplays[toolCallId]; + const output = serializeToolOutput(sdkEvent.output); + return { + state: { ...state, toolDisplays }, + event: { + type: 'ToolResult', + payload: { + tool_call_id: toolCallId, + return_value: { + is_error: sdkEvent.isError === true, + output, + message: '', + display: [...display], + }, + }, + }, + }; + } + case 'agent.status.updated': + return mapStatusUpdate(state, sdkEvent); + case 'compaction.started': + return { + state, + event: { type: 'CompactionBegin', payload: {} }, + }; + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + return { + state, + event: { type: 'CompactionEnd', payload: {} }, + }; + default: + return { state }; + } +} + +function mapStatusUpdate( + state: EventAdapterState, + sdkEvent: Extract, +): MappedLegacyWireEvent { + const payload: StatusUpdate = {}; + const contextUsage = contextUsageRatio(sdkEvent); + if (contextUsage !== undefined) payload.context_usage = contextUsage; + if (sdkEvent.planMode !== undefined) payload.plan_mode = sdkEvent.planMode; + if (sdkEvent.model !== undefined) payload.model = sdkEvent.model; + if (sdkEvent.thinkingEffort !== undefined) payload.thinking_effort = sdkEvent.thinkingEffort; + + const currentTurn = sdkEvent.usage?.currentTurn; + if (currentTurn === undefined) { + return Object.keys(payload).length === 0 + ? { state } + : { state, event: { type: 'StatusUpdate', payload } }; + } + + const previous = state.turnUsageByAgent[sdkEvent.agentId]; + payload.token_usage = usageDelta(currentTurn, previous); + return { + state: { + ...state, + turnUsageByAgent: { + ...state.turnUsageByAgent, + [sdkEvent.agentId]: currentTurn, + }, + }, + event: { type: 'StatusUpdate', payload }, + }; +} + +function contextUsageRatio( + sdkEvent: Extract, +): number | undefined { + if (sdkEvent.contextUsage !== undefined) return sdkEvent.contextUsage; + const { contextTokens, maxContextTokens } = sdkEvent; + if ( + typeof contextTokens !== 'number' || + typeof maxContextTokens !== 'number' || + !Number.isFinite(contextTokens) || + !Number.isFinite(maxContextTokens) + ) { + return undefined; + } + return maxContextTokens > 0 ? contextTokens / maxContextTokens : undefined; +} + +function usageDelta(current: AdapterTokenUsage, previous: AdapterTokenUsage | undefined): TokenUsage { + return { + input_other: delta(current.inputOther, previous?.inputOther), + output: delta(current.output, previous?.output), + input_cache_read: delta(current.inputCacheRead, previous?.inputCacheRead), + input_cache_creation: delta( + current.inputCacheCreation, + previous?.inputCacheCreation, + ), + }; +} + +function delta(current: number, previous: number | undefined): number { + if (previous === undefined || current < previous) return current; + return current - previous; +} + +function resetTurnUsage(state: EventAdapterState, agentId: string): EventAdapterState { + if (state.turnUsageByAgent[agentId] === undefined) return state; + const nextUsage = { ...state.turnUsageByAgent }; + delete nextUsage[agentId]; + return { ...state, turnUsageByAgent: nextUsage }; +} + +function routeSubagentEvent( + state: EventAdapterState, + agentId: string, + event: LegacyWireEvent, + mainAgentId: string, +): LegacyWireEvent | undefined { + if (agentId === mainAgentId) return event; + + let currentAgentId = agentId; + let routed = event; + const visited = new Set(); + + while (currentAgentId !== mainAgentId) { + if (visited.has(currentAgentId)) return undefined; + visited.add(currentAgentId); + + const parent = state.subagentParents[currentAgentId]; + if (parent === undefined) return undefined; + routed = { + type: 'SubagentEvent', + payload: { + parent_tool_call_id: parent.parentToolCallId, + event: routed, + }, + }; + currentAgentId = parent.parentAgentId; + } + + return routed; +} + +function scopedToolCallId(agentId: string, toolCallId: string, mainAgentId: string): string { + return agentId === mainAgentId ? toolCallId : `${agentId}:${toolCallId}`; +} + +function withSessionId(event: LegacyWireEvent, sessionId: string): AdaptedUIStreamEvent { + return { ...event, _sessionId: sessionId } as AdaptedUIStreamEvent; +} + +function serializeArguments(args: unknown): string { + try { + return JSON.stringify(args) ?? '{}'; + } catch { + return '{}'; + } +} + +function serializeToolOutput(output: unknown): string { + if (typeof output === 'string') return output; + try { + return JSON.stringify(output, null, 2) ?? ''; + } catch { + return String(output); + } +} + +function serializeDetails(details: Record | undefined): string | undefined { + if (details === undefined) return undefined; + try { + return JSON.stringify(details, null, 2); + } catch { + return "[Unable to serialize error details]"; + } +} diff --git a/apps/vscode/src/runtime/kimi-runtime.ts b/apps/vscode/src/runtime/kimi-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..8302ef33c89047e2df5e31918c5f19f01fbec0a5 --- /dev/null +++ b/apps/vscode/src/runtime/kimi-runtime.ts @@ -0,0 +1,319 @@ +import { + createKimiHarness, + type KimiHarness, + type Session, + type SessionSummary, + type ThinkingEffort, +} from "@moonshot-ai/kimi-code-sdk"; + +import type { RuntimeBroadcast } from "./session-runtime"; +import { + corePermissionForLegacyApproval, + legacyApprovalMetadata, + readLegacyApprovalFlags, + readMigratedLegacyApprovalFlags, + withGlobalYoloMode, + type LegacyApprovalFlags, +} from "./legacy-approval"; +import { SessionRuntime } from "./session-runtime"; +import { areSameFsPath } from "../utils/fs-path"; + +export interface KimiRuntimeOptions { + readonly version: string; + readonly broadcast: RuntimeBroadcast; + readonly captureBaseline: ( + session: Pick, + filePath: string, + webviewIds: readonly string[], + ) => void; + readonly log: (message: string, error?: unknown) => void; + readonly homeDir?: string; + readonly harness?: KimiHarness; +} + +export interface OpenSessionOptions { + readonly webviewId: string; + readonly workDir: string; + readonly sessionId?: string; + readonly model: string; + readonly effort: string; + readonly yoloMode: boolean; +} + +/** Extension-host owner for one in-process Node SDK harness. */ +export class KimiRuntime { + readonly harness: KimiHarness; + + private readonly broadcast: RuntimeBroadcast; + private readonly captureBaseline: KimiRuntimeOptions["captureBaseline"]; + private readonly log: KimiRuntimeOptions["log"]; + private readonly sessions = new Map(); + private readonly sessionByView = new Map(); + private readonly viewChains = new Map>(); + private closed = false; + + constructor(options: KimiRuntimeOptions) { + this.broadcast = options.broadcast; + this.captureBaseline = options.captureBaseline; + this.log = options.log; + this.harness = + options.harness ?? + createKimiHarness({ + homeDir: options.homeDir, + identity: { + productName: "kimi-code-vscode", + version: options.version, + platform: "kimi_code_vscode", + }, + uiMode: "vscode", + }); + } + + getSessionForView(webviewId: string): SessionRuntime | undefined { + const id = this.sessionByView.get(webviewId); + return id === undefined ? undefined : this.sessions.get(id); + } + + getSession(id: string): SessionRuntime | undefined { + return this.sessions.get(id); + } + + async openSession(options: OpenSessionOptions): Promise { + return this.serializeView(options.webviewId, () => this.openSessionInner(options)); + } + + private async openSessionInner(options: OpenSessionOptions): Promise { + this.ensureOpen(); + const current = this.getSessionForView(options.webviewId); + const requestedId = options.sessionId ?? current?.id; + + if ( + current !== undefined && + requestedId === current.id && + areSameFsPath(current.session.workDir, options.workDir) + ) { + await applySessionSettings(current.session, options, current.legacyApprovalFlags); + await current.announceStatus(options.webviewId); + return current; + } + + let runtime = requestedId === undefined ? undefined : this.sessions.get(requestedId); + if (runtime !== undefined) { + assertSessionWorkDir(runtime.session, options.workDir); + await applySessionSettings(runtime.session, options, runtime.legacyApprovalFlags); + await this.detachViewInner(options.webviewId); + } else { + const defaultApproval: LegacyApprovalFlags = { yolo: options.yoloMode, afk: false }; + const session = + requestedId === undefined + ? await this.harness.createSession({ + workDir: options.workDir, + model: options.model || undefined, + thinking: normalizeEffort(options.effort), + permission: corePermissionForLegacyApproval(defaultApproval), + metadata: legacyApprovalMetadata(defaultApproval), + }) + : await this.harness.resumeSession({ id: requestedId, includeSubagents: true }); + try { + assertSessionWorkDir(session, options.workDir); + const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); + const restoredApproval = + storedApproval ?? (await this.readMigratedLegacyApproval(session)) ?? defaultApproval; + const approval = withGlobalYoloMode(restoredApproval, options.yoloMode); + if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) { + await session.updateMetadata(legacyApprovalMetadata(approval)); + } + await applySessionSettings(session, options, approval); + await this.detachViewInner(options.webviewId); + runtime = this.wrapSession(session, approval); + } catch (error) { + await session.close().catch((closeError: unknown) => { + this.log("Failed to close a rejected session", closeError); + }); + throw error; + } + } + + runtime.subscribe(options.webviewId); + this.sessionByView.set(options.webviewId, runtime.id); + await runtime.announceStatus(options.webviewId); + return runtime; + } + + async attachResumedSession( + webviewId: string, + session: Session, + defaultYoloMode = false, + ): Promise { + return this.serializeView(webviewId, () => + this.attachResumedSessionInner(webviewId, session, defaultYoloMode), + ); + } + + private async attachResumedSessionInner( + webviewId: string, + session: Session, + defaultYoloMode: boolean, + ): Promise { + const existing = this.sessions.get(session.id); + if (existing !== undefined && this.sessionByView.get(webviewId) === session.id) { + existing.subscribe(webviewId); + await existing.announceStatus(webviewId); + return existing; + } + await this.detachViewInner(webviewId); + let runtime = existing ?? this.sessions.get(session.id); + if (runtime === undefined) { + try { + const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); + const restoredApproval = + storedApproval ?? + (await this.readMigratedLegacyApproval(session)) ?? + { yolo: defaultYoloMode, afk: false }; + const approval = withGlobalYoloMode(restoredApproval, defaultYoloMode); + if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) { + await session.updateMetadata(legacyApprovalMetadata(approval)); + } + const status = await session.getStatus(); + const permission = corePermissionForLegacyApproval(approval); + if (status.permission !== permission) await session.setPermission(permission); + runtime = this.wrapSession(session, approval); + } catch (error) { + await session.close().catch((closeError: unknown) => { + this.log("Failed to close a rejected session", closeError); + }); + throw error; + } + } + runtime.subscribe(webviewId); + this.sessionByView.set(webviewId, runtime.id); + await runtime.announceStatus(webviewId); + return runtime; + } + + async detachView(webviewId: string): Promise { + return this.serializeView(webviewId, () => this.detachViewInner(webviewId)); + } + + private async detachViewInner(webviewId: string): Promise { + const id = this.sessionByView.get(webviewId); + if (id === undefined) return; + this.sessionByView.delete(webviewId); + const runtime = this.sessions.get(id); + if (runtime === undefined) return; + runtime.unsubscribeView(webviewId); + if (runtime.subscribers.length === 0) { + this.sessions.delete(id); + await runtime.close(); + } + } + + // A view attaches to at most one session, so opens/detaches for one view + // must never overlap: concurrent callers that both miss `this.sessions` + // would wrap the same SDK session twice and double every streamed event. + private serializeView(webviewId: string, work: () => Promise): Promise { + const prev = this.viewChains.get(webviewId) ?? Promise.resolve(); + const run = prev.then(work, work); + const next = run.then( + () => undefined, + () => undefined, + ); + this.viewChains.set(webviewId, next); + void next.finally(() => { + if (this.viewChains.get(webviewId) === next) this.viewChains.delete(webviewId); + }); + return run; + } + + async closeSession(id: string): Promise { + const runtime = this.sessions.get(id); + if (runtime === undefined) { + await this.harness.closeSession(id); + return; + } + this.sessions.delete(id); + for (const webviewId of runtime.subscribers) { + this.sessionByView.delete(webviewId); + } + await runtime.close(); + } + + async deleteSession(id: string): Promise { + await this.closeSession(id); + await this.harness.deleteSession(id); + } + + async setYoloModeForActiveSessions(enabled: boolean): Promise { + await Promise.all( + [...this.sessions.values()].map((session) => session.setLegacyYoloMode(enabled)), + ); + } + + async dispose(): Promise { + if (this.closed) return; + this.closed = true; + await Promise.all([...this.sessions.values()].map((session) => session.close())); + this.sessions.clear(); + this.sessionByView.clear(); + await this.harness.close(); + } + + private wrapSession(session: Session, legacyApproval: LegacyApprovalFlags): SessionRuntime { + const runtime = new SessionRuntime({ + session, + legacyApproval, + broadcast: this.broadcast, + captureBaseline: this.captureBaseline, + log: this.log, + }); + this.sessions.set(session.id, runtime); + return runtime; + } + + private async readMigratedLegacyApproval( + session: Session, + ): Promise { + const metadata = session.summary?.metadata; + try { + return await readMigratedLegacyApprovalFlags(metadata); + } catch (error) { + this.log("Unable to restore legacy session approval settings", error); + return undefined; + } + } + + private ensureOpen(): void { + if (this.closed) throw new Error("Kimi runtime is closed."); + } +} + +async function applySessionSettings( + session: Session, + options: OpenSessionOptions, + legacyApproval: LegacyApprovalFlags, +): Promise { + const status = await session.getStatus(); + // Model and thinking effort are applied only when the session is created + // (see openSession). An existing session keeps its own — the global config + // values are defaults for new sessions, matching CLI/TUI resume semantics. + // Changes made in the pickers reach the active session through the + // SaveConfig handler instead. + const permission = corePermissionForLegacyApproval(legacyApproval); + if (status.permission !== permission) { + await session.setPermission(permission); + } +} + +export function normalizeEffort(effort: string): ThinkingEffort { + return (effort.trim() || "off") as ThinkingEffort; +} + +function flagsDiffer(a: LegacyApprovalFlags, b: LegacyApprovalFlags): boolean { + return a.yolo !== b.yolo || a.afk !== b.afk; +} + +function assertSessionWorkDir(session: Pick, expectedWorkDir: string): void { + if (!areSameFsPath(session.workDir, expectedWorkDir)) { + throw new Error("The selected session belongs to a different working directory."); + } +} diff --git a/apps/vscode/src/runtime/legacy-approval.ts b/apps/vscode/src/runtime/legacy-approval.ts new file mode 100644 index 0000000000000000000000000000000000000000..82bf8f178f29f2716406e6c1b463a8a7f53889c3 --- /dev/null +++ b/apps/vscode/src/runtime/legacy-approval.ts @@ -0,0 +1,71 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { JsonObject, PermissionMode } from "@moonshot-ai/kimi-code-sdk"; + +export const LEGACY_APPROVAL_METADATA_KEY = "vscode_legacy_approval"; + +export interface LegacyApprovalFlags { + readonly yolo: boolean; + readonly afk: boolean; +} + +export function readLegacyApprovalFlags( + metadata: Readonly> | undefined, +): LegacyApprovalFlags | undefined { + const value = metadata?.[LEGACY_APPROVAL_METADATA_KEY]; + return parseLegacyApprovalFlags(value); +} + +export async function readMigratedLegacyApprovalFlags( + metadata: Readonly> | undefined, +): Promise { + const sourcePath = metadata?.["kimi_cli_source_path"]; + if (typeof sourcePath !== "string" || sourcePath.length === 0) return undefined; + let text: string; + try { + text = await readFile(join(sourcePath, "state.json"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + const state = JSON.parse(text) as { readonly approval?: unknown }; + return parseLegacyApprovalFlags(state.approval); +} + +function parseLegacyApprovalFlags(value: unknown): LegacyApprovalFlags | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const yolo = Reflect.get(value, "yolo"); + const afk = Reflect.get(value, "afk"); + if (typeof yolo !== "boolean" && typeof afk !== "boolean") return undefined; + return { + yolo: typeof yolo === "boolean" ? yolo : false, + afk: typeof afk === "boolean" ? afk : false, + }; +} + +export function legacyApprovalMetadata(flags: LegacyApprovalFlags): JsonObject { + return { + [LEGACY_APPROVAL_METADATA_KEY]: { + yolo: flags.yolo, + afk: flags.afk, + }, + }; +} + +export function corePermissionForLegacyApproval(flags: LegacyApprovalFlags): PermissionMode { + if (flags.afk) return "auto"; + return flags.yolo ? "yolo" : "manual"; +} + +/** + * The global `kimi.yoloMode` setting is authoritative whenever a session + * attaches to the runtime; afk stays per-session because it has no global + * setting counterpart. + */ +export function withGlobalYoloMode( + flags: LegacyApprovalFlags, + yoloMode: boolean, +): LegacyApprovalFlags { + return flags.yolo === yoloMode ? flags : { yolo: yoloMode, afk: flags.afk }; +} diff --git a/apps/vscode/src/runtime/replay-adapter.ts b/apps/vscode/src/runtime/replay-adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..facd978ac680a5ae13cf77b25b12cc20f4e626ca --- /dev/null +++ b/apps/vscode/src/runtime/replay-adapter.ts @@ -0,0 +1,594 @@ +import type { + AgentReplayRecord, + ContentPart, + PromptOrigin, + ResumedAgentState, + ResumedSessionState, +} from "@moonshot-ai/kimi-code-sdk"; + +import type { + ContentPart as LegacyContentPart, + DisplayBlock, + LegacyWireEvent, + RunResult, + TokenUsage, + ToolCall, +} from "../../shared/legacy-sdk"; +import type { UIStreamEvent } from "../../shared/types"; +import { toLegacyToolName } from "./event-adapter"; +import { toLegacyDisplay } from "./tool-display"; + +interface SubagentReplayInvocation { + readonly parentAgentId: string; + readonly parentToolCallId: string; + readonly childAgentId: string; + readonly startedAt: number; + readonly order: number; + records: readonly AgentReplayRecord[]; +} + +interface SubagentReplayIndex { + readonly byParentCall: ReadonlyMap; +} + +/** Projects a complete resumed SDK session, including persisted subagent steps. */ +export function replaySessionToWebviewEvents( + state: ResumedSessionState, + sessionId: string, +): UIStreamEvent[] { + const main = state.agents["main"]; + if (main === undefined) throw new Error("Session history is unavailable."); + return replayAgentToWebviewEvents(main, sessionId, buildSubagentReplayIndex(state)); +} + +/** Projects the public SDK resume replay into the released Webview protocol. */ +export function replayToWebviewEvents( + agent: ResumedAgentState, + sessionId: string, +): UIStreamEvent[] { + return replayAgentToWebviewEvents(agent, sessionId); +} + +function replayAgentToWebviewEvents( + agent: ResumedAgentState, + sessionId: string, + subagents?: SubagentReplayIndex, +): UIStreamEvent[] { + const events: UIStreamEvent[] = []; + let turnOpen = false; + let step = 0; + const toolDisplays = new Map(); + + events.push( + withSession( + { + type: "StatusUpdate", + payload: { + ...(agent.config.modelAlias === undefined ? {} : { model: agent.config.modelAlias }), + thinking_effort: agent.config.thinkingEffort, + plan_mode: agent.plan !== null, + }, + }, + sessionId, + ), + ); + + const completeTurn = () => { + if (!turnOpen) return; + const result: RunResult = { status: "finished" }; + events.push({ type: "stream_complete", result, _sessionId: sessionId }); + turnOpen = false; + }; + + const ensureStep = () => { + if (!turnOpen) return; + if (step === 0) { + step = 1; + events.push(withSession({ type: "StepBegin", payload: { n: step } }, sessionId)); + } + }; + + for (const record of agent.replay) { + switch (record.type) { + case "message": { + const message = record.message; + if (message.role === "user") { + if (!isVisibleUserMessage(message.origin)) break; + const imported = importedContextReplay(message.content); + completeTurn(); + step = 0; + turnOpen = true; + events.push( + withSession( + { + type: "TurnBegin", + payload: { + user_input: imported?.input ?? replayUserInput(message.content, message.origin), + }, + }, + sessionId, + ), + ); + if (imported !== undefined) { + ensureStep(); + events.push( + withSession( + { type: "ContentPart", payload: { type: "text", text: imported.confirmation } }, + sessionId, + ), + ); + } + break; + } + + if (message.role === "assistant") { + if (!turnOpen) break; + ensureStep(); + for (const part of toLegacyContent(message.content)) { + events.push(withSession({ type: "ContentPart", payload: part }, sessionId)); + } + for (const call of message.toolCalls) { + const display = message.toolCallDisplays?.[call.id]; + if (display !== undefined) { + toolDisplays.set(call.id, toLegacyDisplay(display)); + } + const toolCall: ToolCall = { + type: "function", + id: call.id, + function: { + name: toLegacyToolName(call.name), + arguments: call.arguments, + }, + }; + events.push(withSession({ type: "ToolCall", payload: toolCall }, sessionId)); + if (subagents !== undefined) { + for (const nested of renderSubagentInvocations( + subagents, + "main", + call.id, + [], + new Set(), + )) { + events.push(withSession(nested, sessionId)); + } + } + } + break; + } + + if (message.role === "tool" && turnOpen && message.toolCallId !== undefined) { + ensureStep(); + const display = toolDisplays.get(message.toolCallId) ?? []; + toolDisplays.delete(message.toolCallId); + events.push( + withSession( + { + type: "ToolResult", + payload: { + tool_call_id: message.toolCallId, + return_value: { + is_error: message.isError === true, + output: toLegacyContent(message.content), + message: "", + display: [...display], + }, + }, + }, + sessionId, + ), + ); + } + break; + } + case "compaction": + if (!turnOpen) break; + ensureStep(); + events.push(withSession({ type: "CompactionBegin", payload: {} }, sessionId)); + if (record.result !== undefined) { + events.push(withSession({ type: "CompactionEnd", payload: {} }, sessionId)); + } + break; + case "plan_updated": + if (turnOpen) { + ensureStep(); + events.push( + withSession( + { type: "StatusUpdate", payload: { plan_mode: record.enabled } }, + sessionId, + ), + ); + } + break; + case "config_updated": + case "permission_updated": + case "approval_result": + case "goal_updated": + break; + } + } + + completeTurn(); + const resumedStatus = resumedStatusPayload(agent); + if (Object.keys(resumedStatus).length > 0) { + events.push(withSession({ type: "StatusUpdate", payload: resumedStatus }, sessionId)); + } + return events; +} + +function buildSubagentReplayIndex(state: ResumedSessionState): SubagentReplayIndex { + const invocations: SubagentReplayInvocation[] = []; + let order = 0; + + for (const [parentAgentId, parent] of Object.entries(state.agents)) { + const calls = new Map< + string, + { readonly name: string; readonly startedAt: number; readonly order: number } + >(); + for (const record of parent.replay) { + if (record.type !== "message") continue; + const { message } = record; + if (message.role === "assistant") { + for (const call of message.toolCalls) { + calls.set(call.id, { name: call.name, startedAt: record.time, order: order++ }); + } + continue; + } + if (message.role !== "tool" || message.toolCallId === undefined) continue; + const call = calls.get(message.toolCallId); + if (call === undefined || (call.name !== "Agent" && call.name !== "AgentSwarm")) continue; + for (const childAgentId of subagentIdsFromResult(call.name, message.content)) { + const metadata = state.sessionMetadata.agents[childAgentId]; + if (metadata?.parentAgentId !== parentAgentId || state.agents[childAgentId] === undefined) { + continue; + } + invocations.push({ + parentAgentId, + parentToolCallId: message.toolCallId, + childAgentId, + startedAt: call.startedAt, + order: call.order, + records: [], + }); + } + } + } + + const byChild = new Map(); + for (const invocation of invocations) { + const entries = byChild.get(invocation.childAgentId) ?? []; + entries.push(invocation); + byChild.set(invocation.childAgentId, entries); + } + for (const [childAgentId, entries] of byChild) { + entries.sort(compareInvocation); + const replay = state.agents[childAgentId]?.replay ?? []; + for (const [index, invocation] of entries.entries()) { + const next = entries[index + 1]; + invocation.records = replay.filter( + (record) => + record.time >= invocation.startedAt && + (next === undefined || record.time < next.startedAt), + ); + } + } + + const byParentCall = new Map(); + for (const invocation of invocations) { + const key = parentCallKey(invocation.parentAgentId, invocation.parentToolCallId); + const entries = byParentCall.get(key) ?? []; + entries.push(invocation); + byParentCall.set(key, entries); + } + for (const entries of byParentCall.values()) entries.sort(compareInvocation); + return { byParentCall }; +} + +function compareInvocation(a: SubagentReplayInvocation, b: SubagentReplayInvocation): number { + return a.startedAt - b.startedAt || a.order - b.order; +} + +function subagentIdsFromResult( + toolName: "Agent" | "AgentSwarm", + content: readonly ContentPart[], +): readonly string[] { + const text = content + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join(""); + if (toolName === "Agent") { + const header = text.split("\n\n", 1)[0] ?? text; + const match = /(?:^|\n)agent_id:\s*([^\s]+)\s*(?=\n|$)/.exec(header); + return match === null ? [] : [match[1]!]; + } + const pattern = /]*\bagent_id="([^"]+)"[^>]*\boutcome="[^"]+">/g; + return [...text.matchAll(pattern)].map((match) => match[1]!).filter(uniqueString); +} + +function uniqueString(value: string, index: number, values: readonly string[]): boolean { + return values.indexOf(value) === index; +} + +function renderSubagentInvocations( + index: SubagentReplayIndex, + parentAgentId: string, + parentToolCallId: string, + parentChain: readonly SubagentReplayInvocation[], + visited: ReadonlySet, +): LegacyWireEvent[] { + const result: LegacyWireEvent[] = []; + const invocations = index.byParentCall.get(parentCallKey(parentAgentId, parentToolCallId)) ?? []; + for (const invocation of invocations) { + const invocationKey = `${invocation.parentAgentId}\u0000${invocation.parentToolCallId}\u0000${invocation.childAgentId}\u0000${String(invocation.startedAt)}`; + if (visited.has(invocationKey)) continue; + const nextVisited = new Set([...visited, invocationKey]); + result.push( + ...renderSubagentInvocation(index, invocation, [invocation, ...parentChain], nextVisited), + ); + } + return result; +} + +function renderSubagentInvocation( + index: SubagentReplayIndex, + invocation: SubagentReplayInvocation, + chain: readonly SubagentReplayInvocation[], + visited: ReadonlySet, +): LegacyWireEvent[] { + const events: LegacyWireEvent[] = []; + const toolDisplays = new Map(); + let step = 0; + + const emit = (event: LegacyWireEvent) => { + events.push(wrapSubagentEvent(event, chain)); + }; + + for (const record of invocation.records) { + switch (record.type) { + case "message": { + const { message } = record; + if (message.role === "user") { + step = 0; + break; + } + if (message.role === "assistant") { + step += 1; + emit({ type: "StepBegin", payload: { n: step } }); + for (const part of toLegacyContent(message.content)) { + emit({ type: "ContentPart", payload: part }); + } + for (const call of message.toolCalls) { + const toolCallId = scopedReplayToolCallId(invocation.childAgentId, call.id); + const display = message.toolCallDisplays?.[call.id]; + if (display !== undefined) toolDisplays.set(toolCallId, toLegacyDisplay(display)); + emit({ + type: "ToolCall", + payload: { + type: "function", + id: toolCallId, + function: { + name: toLegacyToolName(call.name), + arguments: call.arguments, + }, + }, + }); + events.push( + ...renderSubagentInvocations( + index, + invocation.childAgentId, + call.id, + chain, + visited, + ), + ); + } + break; + } + if (message.role === "tool" && message.toolCallId !== undefined) { + const toolCallId = scopedReplayToolCallId( + invocation.childAgentId, + message.toolCallId, + ); + const display = toolDisplays.get(toolCallId) ?? []; + toolDisplays.delete(toolCallId); + emit({ + type: "ToolResult", + payload: { + tool_call_id: toolCallId, + return_value: { + is_error: message.isError === true, + output: toLegacyContent(message.content), + message: "", + display: [...display], + }, + }, + }); + } + break; + } + case "compaction": + emit({ type: "CompactionBegin", payload: {} }); + if (record.result !== undefined) emit({ type: "CompactionEnd", payload: {} }); + break; + case "plan_updated": + emit({ type: "StatusUpdate", payload: { plan_mode: record.enabled } }); + break; + case "config_updated": + case "permission_updated": + case "approval_result": + case "goal_updated": + break; + } + } + return events; +} + +function wrapSubagentEvent( + event: LegacyWireEvent, + chain: readonly SubagentReplayInvocation[], +): LegacyWireEvent { + let routed = event; + for (const invocation of chain) { + routed = { + type: "SubagentEvent", + payload: { + parent_tool_call_id: scopedReplayToolCallId( + invocation.parentAgentId, + invocation.parentToolCallId, + ), + event: routed, + }, + }; + } + return routed; +} + +function scopedReplayToolCallId(agentId: string, toolCallId: string): string { + return agentId === "main" ? toolCallId : `${agentId}:${toolCallId}`; +} + +function parentCallKey(agentId: string, toolCallId: string): string { + return `${agentId}\u0000${toolCallId}`; +} + +function resumedStatusPayload(agent: ResumedAgentState): { + context_usage?: number; + token_usage?: TokenUsage; +} { + const maxContextTokens = agent.config.modelCapabilities.max_context_tokens; + const totalUsage = agent.usage.total ?? sumUsage(agent.usage.byModel) ?? agent.usage.currentTurn; + return { + ...(maxContextTokens > 0 + ? { context_usage: agent.context.tokenCount / maxContextTokens } + : {}), + ...(totalUsage === undefined + ? {} + : { + token_usage: { + input_other: totalUsage.inputOther, + output: totalUsage.output, + input_cache_read: totalUsage.inputCacheRead, + input_cache_creation: totalUsage.inputCacheCreation, + }, + }), + }; +} + +function sumUsage( + byModel: ResumedAgentState["usage"]["byModel"], +): ResumedAgentState["usage"]["total"] { + if (byModel === undefined || Object.keys(byModel).length === 0) return undefined; + return Object.values(byModel).reduce( + (total, usage) => ({ + inputOther: total.inputOther + usage.inputOther, + output: total.output + usage.output, + inputCacheRead: total.inputCacheRead + usage.inputCacheRead, + inputCacheCreation: total.inputCacheCreation + usage.inputCacheCreation, + }), + { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }, + ); +} + +function toLegacyContent(content: readonly ContentPart[]): LegacyContentPart[] { + const result: LegacyContentPart[] = []; + for (const part of content) { + switch (part.type) { + case "text": + result.push({ type: "text", text: part.text }); + break; + case "think": + result.push({ + type: "think", + think: part.think, + ...(part.encrypted === undefined ? {} : { encrypted: part.encrypted }), + }); + break; + case "image_url": + result.push({ type: "image_url", image_url: { ...part.imageUrl } }); + break; + case "audio_url": + result.push({ type: "audio_url", audio_url: { ...part.audioUrl } }); + break; + case "video_url": + result.push({ type: "video_url", video_url: { ...part.videoUrl } }); + break; + } + } + return result; +} + +function isVisibleUserMessage(origin: PromptOrigin | undefined): boolean { + if (origin === undefined || origin.kind === "user") return true; + if (origin.kind === "skill_activation" || origin.kind === "plugin_command") { + return origin.trigger === "user-slash"; + } + return origin.kind === "shell_command" && origin.phase === "input"; +} + +function replayUserInput( + content: readonly ContentPart[], + origin: PromptOrigin | undefined, +): LegacyContentPart[] { + if (origin?.kind === "skill_activation" && origin.trigger === "user-slash") { + const args = origin.skillArgs?.trim(); + return [{ + type: "text", + text: `/skill:${origin.skillName}${args ? ` ${args}` : ""}`, + }]; + } + if (origin?.kind === "plugin_command") { + const args = origin.commandArgs?.trim(); + return [{ + type: "text", + text: `/${origin.pluginId}:${origin.commandName}${args ? ` ${args}` : ""}`, + }]; + } + return toLegacyContent(content); +} + +function importedContextReplay( + content: readonly ContentPart[], +): { input: LegacyContentPart[]; confirmation: string } | undefined { + const importedPart = content.find( + (part): part is Extract => + part.type === "text" && part.text.startsWith("\n([\s\S]*)\n<\/imported_context>$/.exec( + importedPart.text, + ); + if (match === null) return undefined; + const source = decodeXml(match[1]!); + const importedText = match[2]!; + const target = importTarget(source); + return { + input: [{ type: "text", text: `/import ${target}` }], + confirmation: `Imported context from ${source} (${String(importedText.length)} chars).`, + }; +} + +function importTarget(source: string): string { + const quoted = /^(?:file|session) '([\s\S]*)'$/.exec(source); + return quoted?.[1] ?? source; +} + +function decodeXml(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function withSession(event: T, sessionId: string): T & { _sessionId: string } { + return { ...event, _sessionId: sessionId }; +} + +export function replayRecordTurnCount(records: readonly AgentReplayRecord[]): number { + return records.filter( + (record) => + record.type === "message" && + record.message.role === "user" && + isVisibleUserMessage(record.message.origin), + ).length; +} diff --git a/apps/vscode/src/runtime/reverse-rpc.ts b/apps/vscode/src/runtime/reverse-rpc.ts new file mode 100644 index 0000000000000000000000000000000000000000..13a66f049ce69fafcc775b54bdfa1f47d7f8229a --- /dev/null +++ b/apps/vscode/src/runtime/reverse-rpc.ts @@ -0,0 +1,97 @@ +import { randomUUID } from "node:crypto"; + +import type { + ApprovalRequest, + ApprovalResponse as CoreApprovalResponse, + QuestionRequest, + QuestionResult, +} from "@moonshot-ai/kimi-code-sdk"; + +import type { ApprovalResponse, QuestionRequest as LegacyQuestionRequest } from "../../shared/legacy-sdk"; +import { describeToolDisplay, toLegacyDisplay } from "./tool-display"; + +export type ReverseRpcEvent = + | { type: "ApprovalRequest"; payload: ReturnType } + | { type: "QuestionRequest"; payload: LegacyQuestionRequest }; + +export class ReverseRpcController { + private readonly approvals = new Map void>(); + private readonly questions = new Map void>(); + + constructor(private readonly emit: (event: ReverseRpcEvent) => void) {} + + requestApproval(request: ApprovalRequest): Promise { + const id = randomUUID(); + return new Promise((resolve) => { + this.approvals.set(id, resolve); + this.emit({ type: "ApprovalRequest", payload: approvalPayload(id, request) }); + }); + } + + requestQuestion(request: QuestionRequest): Promise { + const id = randomUUID(); + return new Promise((resolve) => { + this.questions.set(id, resolve); + this.emit({ + type: "QuestionRequest", + payload: { + id, + tool_call_id: request.toolCallId ?? "", + questions: request.questions.map((question) => ({ + question: question.question, + header: question.header, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + multi_select: question.multiSelect, + })), + }, + }); + }); + } + + respondApproval(id: string, response: ApprovalResponse): boolean { + const resolve = this.approvals.get(id); + if (!resolve) return false; + this.approvals.delete(id); + if (response === "approve_for_session") { + resolve({ decision: "approved", scope: "session" }); + } else if (response === "approve") { + resolve({ decision: "approved" }); + } else { + resolve({ decision: "rejected" }); + } + return true; + } + + respondQuestion(id: string, answers: Record): boolean { + const resolve = this.questions.get(id); + if (!resolve) return false; + this.questions.delete(id); + resolve({ answers }); + return true; + } + + cancelAll(reason: string): void { + for (const resolve of this.approvals.values()) { + resolve({ decision: "cancelled", feedback: reason }); + } + for (const resolve of this.questions.values()) { + resolve(null); + } + this.approvals.clear(); + this.questions.clear(); + } +} + +function approvalPayload(id: string, request: ApprovalRequest) { + return { + id, + tool_call_id: request.toolCallId, + sender: request.toolName, + action: request.action, + description: describeToolDisplay(request.display), + display: toLegacyDisplay(request.display), + }; +} diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts new file mode 100644 index 0000000000000000000000000000000000000000..16de8026499aedf8765e37f3b9136d19d5acccc3 --- /dev/null +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -0,0 +1,656 @@ +import { + isKimiError, + type ContentPart as SdkContentPart, + type Event, + type PromptInput, + type Session, + type SessionSummary, +} from "@moonshot-ai/kimi-code-sdk"; + +import type { ContentPart as LegacyContentPart, ApprovalResponse } from "../../shared/legacy-sdk"; +import { Events } from "../../shared/bridge"; +import { getUserMessage } from "../../shared/errors"; +import type { ErrorPhase, UIStreamEvent } from "../../shared/types"; +import { + adaptSdkEvent, + createEventAdapterState, + type EventAdapterState, + type TurnTerminalMetadata, +} from "./event-adapter"; +import { + corePermissionForLegacyApproval, + legacyApprovalMetadata, + type LegacyApprovalFlags, +} from "./legacy-approval"; +import { ReverseRpcController } from "./reverse-rpc"; + +export type RuntimeBroadcast = (event: string, data: unknown, webviewId?: string) => void; + +export interface SessionRuntimeOptions { + readonly session: Session; + readonly legacyApproval: LegacyApprovalFlags; + readonly broadcast: RuntimeBroadcast; + readonly captureBaseline: ( + session: Pick, + filePath: string, + webviewIds: readonly string[], + ) => void; + readonly log: (message: string, error?: unknown) => void; +} + +interface ActivePrompt { + readonly input: LegacyContentPart[] | string; + started: boolean; + settled: boolean; + resolve: (result: PromptResult) => void; +} + +const ALREADY_GENERATING_MESSAGE = "A response is already being generated for this session."; + +export interface PromptResult { + readonly status: "finished" | "cancelled" | "failed"; +} + +interface SuppressedError { + readonly code: string; + readonly message: string; +} + +interface PendingHostCompaction { + readonly actionId: number; + readonly resolve: (result: "completed" | "cancelled") => void; + readonly reject: (error: unknown) => void; +} + +/** + * Owns the one SDK event subscription and reverse-RPC handlers for a session. + * Any number of Webviews may subscribe without replacing each other's approval + * handler or duplicating streamed events. + */ +export class SessionRuntime { + readonly session: Session; + + private readonly broadcast: RuntimeBroadcast; + private readonly captureBaseline: SessionRuntimeOptions["captureBaseline"]; + private readonly log: SessionRuntimeOptions["log"]; + private readonly webviewIds = new Set(); + private readonly reverseRpc: ReverseRpcController; + private readonly unsubscribe: () => void; + private adapterState: EventAdapterState = createEventAdapterState(); + private activePrompt: ActivePrompt | undefined; + private hostActionActive = false; + private hostActionSequence = 0; + private activeHostActionId: number | undefined; + private readonly cancelledHostActions = new Set(); + private pendingHostCompaction: PendingHostCompaction | undefined; + private readonly activeWorkSettledWaiters = new Set<() => void>(); + private exclusiveActionActive = false; + private readonly terminalKeys = new Set(); + private suppressedError: SuppressedError | undefined; + private legacyApproval: LegacyApprovalFlags; + private closed = false; + + constructor(options: SessionRuntimeOptions) { + this.session = options.session; + this.broadcast = options.broadcast; + this.captureBaseline = options.captureBaseline; + this.log = options.log; + this.legacyApproval = options.legacyApproval; + this.reverseRpc = new ReverseRpcController((event) => this.emitStreamEvent(event)); + + // Forward every approval request to the user. The engine permission mode + // (mapped from the legacy flags) already auto-approves what yolo/auto + // allow internally; anything that reaches this handler is an exception + // (sensitive file, plan review, ask rule) the user must decide on. + this.session.setApprovalHandler((request) => this.reverseRpc.requestApproval(request)); + this.session.setQuestionHandler((request) => this.reverseRpc.requestQuestion(request)); + this.unsubscribe = this.session.onEvent((event) => this.onSdkEvent(event)); + } + + get id(): string { + return this.session.id; + } + + get summary(): SessionSummary | undefined { + return this.session.summary; + } + + get subscribers(): readonly string[] { + return [...this.webviewIds]; + } + + get isBusy(): boolean { + return this.hasActiveWork || this.exclusiveActionActive; + } + + get legacyApprovalFlags(): LegacyApprovalFlags { + return this.legacyApproval; + } + + async toggleLegacyApproval(kind: keyof LegacyApprovalFlags): Promise { + const next = { ...this.legacyApproval, [kind]: !this.legacyApproval[kind] }; + await this.applyLegacyApproval(next); + return next; + } + + async setLegacyYoloMode(enabled: boolean): Promise { + if (this.legacyApproval.yolo === enabled) return; + await this.applyLegacyApproval({ ...this.legacyApproval, yolo: enabled }); + } + + subscribe(webviewId: string): void { + this.ensureOpen(); + this.webviewIds.add(webviewId); + } + + /** + * Push the session's current status to a view. Called whenever a view + * opens or re-enters a session so the display (model, thinking effort, + * plan mode) matches engine truth instead of the global defaults. + */ + async announceStatus(webviewId: string): Promise { + this.ensureOpen(); + const status = await this.session.getStatus(); + if (this.closed || !this.webviewIds.has(webviewId)) return; + this.broadcast( + Events.StreamEvent, + { + type: "StatusUpdate", + payload: { + model: status.model, + thinking_effort: status.thinkingEffort, + plan_mode: status.planMode, + context_usage: status.contextUsage, + }, + _sessionId: this.id, + }, + webviewId, + ); + } + + unsubscribeView(webviewId: string): void { + this.webviewIds.delete(webviewId); + } + + async prompt(input: string | LegacyContentPart[]): Promise { + return this.runTurnAction(input, () => this.session.prompt(toSdkPromptInput(input))); + } + + async runTurnAction( + input: string | LegacyContentPart[], + action: () => Promise, + ): Promise { + this.ensureOpen(); + if (this.isBusy) { + // A re-entrant turn request must never disturb the active turn — it fails + // only itself. When a turn or host action is running, its later terminal + // stream event unlocks every subscribed view, so a non-terminal warning + // is enough. An exclusive operation (e.g. fork materialization) emits no + // such terminal event, so reject terminally: the caller's composer must + // unlock rather than hang until the handshake timeout. + this.emitError( + new Error(ALREADY_GENERATING_MESSAGE), + "runtime", + { terminal: this.hasActiveWork ? false : undefined }, + ); + return { status: "failed" }; + } + + let resolveCompletion!: (result: PromptResult) => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + const active: ActivePrompt = { + input, + started: false, + settled: false, + resolve: resolveCompletion, + }; + this.activePrompt = active; + + try { + await action(); + } catch (error) { + // Only settle the prompt this call created. Once the event pipeline or a + // cancel has settled it, the failure was already reported — settling it + // again would misreport the active turn and could emit a duplicate error. + if (!active.settled) { + this.emitError(error, active.started ? "runtime" : "preflight"); + this.settlePrompt({ status: "failed" }); + } + } + + return completion; + } + + beginHostAction(input: string | LegacyContentPart[], forkable = false): number { + this.ensureOpen(); + if (this.isBusy) { + throw new Error(ALREADY_GENERATING_MESSAGE); + } + const actionId = ++this.hostActionSequence; + this.hostActionActive = true; + this.activeHostActionId = actionId; + this.emitStreamEvent({ + type: "TurnBegin", + payload: { user_input: input, forkable }, + _sessionId: this.id, + }); + this.emitStreamEvent({ + type: "StepBegin", + payload: { n: 1 }, + _sessionId: this.id, + }); + return actionId; + } + + emitHostText(text: string, actionId = this.activeHostActionId): void { + if (!this.hostActionActive || actionId !== this.activeHostActionId || text.length === 0) return; + this.emitStreamEvent({ + type: "ContentPart", + payload: { type: "text", text }, + _sessionId: this.id, + }); + } + + announceSessionStart(model?: string): void { + this.emitStreamEvent({ + type: "session_start", + sessionId: this.id, + ...(model === undefined ? {} : { model }), + _sessionId: this.id, + }); + } + + completeHostAction( + status: "finished" | "cancelled" = "finished", + actionId = this.activeHostActionId, + ): void { + if (!this.hostActionActive || actionId !== this.activeHostActionId) return; + this.hostActionActive = false; + this.activeHostActionId = undefined; + this.emitStreamEvent({ + type: "stream_complete", + result: { status }, + _sessionId: this.id, + }); + this.notifyActiveWorkSettled(); + } + + failHostAction(actionId: number): void { + if (!this.hostActionActive || actionId !== this.activeHostActionId) return; + this.hostActionActive = false; + this.activeHostActionId = undefined; + this.notifyActiveWorkSettled(); + } + + wasHostActionCancelled(actionId: number): boolean { + return this.cancelledHostActions.has(actionId); + } + + releaseHostAction(actionId: number): void { + this.cancelledHostActions.delete(actionId); + } + + async compactHostAction(actionId: number, instruction?: string): Promise { + if (!this.hostActionActive || actionId !== this.activeHostActionId) { + throw new Error("The host action is no longer active."); + } + if (this.pendingHostCompaction !== undefined) { + throw new Error("A context compaction is already running."); + } + + let resolveCompletion!: (result: "completed" | "cancelled") => void; + let rejectCompletion!: (error: unknown) => void; + const completion = new Promise<"completed" | "cancelled">((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + this.pendingHostCompaction = { + actionId, + resolve: resolveCompletion, + reject: rejectCompletion, + }; + + try { + await this.session.compact(instruction === undefined ? {} : { instruction }); + } catch (error) { + if (this.pendingHostCompaction?.actionId === actionId) { + this.pendingHostCompaction = undefined; + rejectCompletion(error); + } + } + + const result = await completion; + if (result === "cancelled") { + throw new Error("Context compaction was cancelled."); + } + } + + async cancel(): Promise { + // Always reach the engine, even when the host believes nothing is active. + // The host-side bookkeeping can drift from engine truth after an abnormal + // error path; session.cancel() is a harmless no-op when the engine is + // idle, but it is the only way to recover a turn the host lost track of. + if (this.closed) return; + this.reverseRpc.cancelAll("Turn cancelled"); + const cancellingHostAction = this.hostActionActive; + const hostActionId = this.activeHostActionId; + if (cancellingHostAction && hostActionId !== undefined) { + this.cancelledHostActions.add(hostActionId); + this.completeHostAction("cancelled", hostActionId); + } + // A manual compaction is not a model turn, so Session.cancel() alone does + // not stop it. Calling both public cancellation surfaces is harmless when + // the other operation is idle and keeps the Stop button correct for both + // normal turns and host-side slash commands such as /compact and /init. + const results = await Promise.allSettled([ + this.session.cancel(), + ...(cancellingHostAction ? [this.session.cancelCompaction()] : []), + ]); + const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failure !== undefined) throw failure.reason; + } + + /** + * Stop any in-flight work, wait for its terminal event, and keep new turns + * out until the supplied operation finishes. Forking uses this so it reads a + * fully settled session instead of racing the asynchronous cancel event. + */ + async runExclusiveAfterCancelling(action: () => Promise): Promise { + this.ensureOpen(); + if (this.exclusiveActionActive) { + throw new Error("Another session operation is already in progress."); + } + + this.exclusiveActionActive = true; + try { + const settled = this.waitForActiveWorkToSettle(); + await this.cancel(); + await settled; + this.ensureOpen(); + return await action(); + } finally { + this.exclusiveActionActive = false; + } + } + + async steer(input: string | LegacyContentPart[]): Promise { + this.ensureOpen(); + await this.session.steer(toSdkPromptInput(input)); + this.emitStreamEvent({ + type: "SteerInput", + payload: { user_input: input }, + _sessionId: this.id, + }); + } + + respondApproval(id: string, response: ApprovalResponse): boolean { + return this.reverseRpc.respondApproval(id, response); + } + + respondQuestion(id: string, answers: Record): boolean { + return this.reverseRpc.respondQuestion(id, answers); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.pendingHostCompaction?.reject(new Error("Session closed during context compaction.")); + this.pendingHostCompaction = undefined; + this.reverseRpc.cancelAll("Session closed"); + this.unsubscribe(); + this.session.setApprovalHandler(undefined); + this.session.setQuestionHandler(undefined); + if (this.activePrompt !== undefined || this.hostActionActive) { + try { + await this.session.cancel(); + } catch (error) { + this.log("Failed to cancel the active turn while closing a session", error); + } + if (this.activePrompt !== undefined) this.settlePrompt({ status: "cancelled" }); + this.hostActionActive = false; + this.activeHostActionId = undefined; + this.notifyActiveWorkSettled(); + } + this.cancelledHostActions.clear(); + await this.session.close(); + this.webviewIds.clear(); + } + + private async applyLegacyApproval(flags: LegacyApprovalFlags): Promise { + this.ensureOpen(); + const permission = corePermissionForLegacyApproval(flags); + const status = await this.session.getStatus(); + const permissionChanged = status.permission !== permission; + if (permissionChanged) await this.session.setPermission(permission); + try { + await this.session.updateMetadata(legacyApprovalMetadata(flags)); + } catch (error) { + if (permissionChanged) { + await this.session.setPermission(status.permission).catch((rollbackError: unknown) => { + this.log("Failed to restore session permission after a metadata error", rollbackError); + }); + } + throw error; + } + this.legacyApproval = flags; + } + + private onSdkEvent(event: Event): void { + if (this.closed) return; + + if (event.type === "compaction.completed" || event.type === "compaction.cancelled") { + const pending = this.pendingHostCompaction; + if (pending !== undefined) { + this.pendingHostCompaction = undefined; + pending.resolve(event.type === "compaction.completed" ? "completed" : "cancelled"); + } + } + + if (event.type === "turn.started" && event.agentId === "main" && this.activePrompt !== undefined) { + this.activePrompt.started = true; + } + + if (event.type === "tool.call.started") { + this.captureFileBaseline(event); + } + + if (event.type === "turn.step.retrying") { + this.log( + `Provider retry ${event.nextAttempt}/${event.maxAttempts} in ${event.delayMs}ms`, + new Error(event.errorMessage), + ); + } + + if (event.type === "error" && this.consumeSuppressedError(event.code, event.message)) { + return; + } + + const pendingInput = this.activePrompt?.input; + const adapted = adaptSdkEvent(this.adapterState, event, { + pendingInput, + errorPhase: this.activePrompt?.started === false ? "preflight" : "runtime", + }); + this.adapterState = adapted.state; + + if (adapted.terminal !== undefined) { + this.emitTerminal(adapted.terminal); + return; + } + + if (adapted.event !== undefined) { + // Errors the core reports while the active turn keeps running (they are + // not followed by a terminal turn.ended) must not look turn-ending to the + // Webview — otherwise the UI unlocks mid-turn and the next send collides + // with the still-active prompt. + const wireEvent = + adapted.event.type === "error" && this.activePrompt?.started === true + ? { ...adapted.event, terminal: false as const } + : adapted.event; + this.emitStreamEvent(wireEvent); + if (adapted.event.type === "error" && this.activePrompt !== undefined && !this.activePrompt.started) { + this.settlePrompt({ status: "failed" }); + } + } + } + + private captureFileBaseline(event: Extract): void { + if (event.name !== "Write" && event.name !== "Edit") return; + if (!isRecord(event.args)) return; + const filePath = event.args["path"]; + if (typeof filePath !== "string" || filePath.length === 0) return; + + const summary = this.session.summary; + this.captureBaseline( + { + id: this.session.id, + workDir: this.session.workDir, + metadata: summary?.metadata, + }, + filePath, + this.subscribers, + ); + } + + private emitTerminal(terminal: TurnTerminalMetadata): void { + if (this.terminalKeys.has(terminal.key)) return; + this.terminalKeys.add(terminal.key); + + if (terminal.reason === "completed") { + this.emitStreamEvent({ + type: "stream_complete", + result: { status: "finished" }, + _sessionId: terminal.sessionId, + }); + this.settlePrompt({ status: "finished" }); + return; + } + + if (terminal.reason === "cancelled") { + this.reverseRpc.cancelAll("Turn cancelled"); + this.emitStreamEvent({ + type: "stream_complete", + result: { status: "cancelled" }, + _sessionId: terminal.sessionId, + }); + this.settlePrompt({ status: "cancelled" }); + return; + } + + const code = terminal.error?.code ?? `turn.${terminal.reason}`; + this.reverseRpc.cancelAll("Turn ended"); + const detail = terminal.error?.message ?? `Turn ended with reason: ${terminal.reason}`; + const message = getUserMessage(code, detail); + this.log("Session turn failed", new Error(`${code}: ${detail}`)); + this.emitStreamEvent({ + type: "error", + code, + message, + detail, + phase: "runtime", + _sessionId: terminal.sessionId, + }); + if (terminal.error !== undefined) { + this.suppressedError = { code: terminal.error.code, message: terminal.error.message }; + } + this.settlePrompt({ status: "failed" }); + } + + private consumeSuppressedError(code: string, message: string): boolean { + const suppressed = this.suppressedError; + if (suppressed === undefined) return false; + this.suppressedError = undefined; + return suppressed.code === code && suppressed.message === message; + } + + private emitError(error: unknown, phase: ErrorPhase, options?: { readonly terminal?: boolean }): void { + const code = isKimiError(error) ? error.code : "internal"; + const detail = error instanceof Error ? error.message : String(error); + this.log(`Session ${phase} error`, error); + this.emitStreamEvent({ + type: "error", + code, + message: getUserMessage(code, detail), + detail, + phase, + _sessionId: this.session.id, + terminal: options?.terminal, + }); + } + + private emitStreamEvent(event: UIStreamEvent | { type: string; payload: unknown }): void { + for (const webviewId of this.webviewIds) { + this.broadcast(Events.StreamEvent, event, webviewId); + } + } + + private settlePrompt(result: PromptResult): void { + const active = this.activePrompt; + if (active === undefined || active.settled) return; + active.settled = true; + this.activePrompt = undefined; + active.resolve(result); + this.notifyActiveWorkSettled(); + } + + private get hasActiveWork(): boolean { + return this.activePrompt !== undefined || this.hostActionActive; + } + + private waitForActiveWorkToSettle(): Promise { + if (!this.hasActiveWork) return Promise.resolve(); + return new Promise((resolve) => { + this.activeWorkSettledWaiters.add(resolve); + }); + } + + private notifyActiveWorkSettled(): void { + if (this.hasActiveWork) return; + for (const resolve of this.activeWorkSettledWaiters) resolve(); + this.activeWorkSettledWaiters.clear(); + } + + private ensureOpen(): void { + if (this.closed) throw new Error("Session is closed."); + } +} + +export function toSdkPromptInput(input: string | LegacyContentPart[]): string | PromptInput { + if (typeof input === "string") return input; + const parts: SdkContentPart[] = []; + for (const part of input) { + switch (part.type) { + case "text": + parts.push({ type: "text", text: part.text }); + break; + case "image_url": + parts.push({ + type: "image_url", + imageUrl: { + url: part.image_url.url, + ...(part.image_url.id === null || part.image_url.id === undefined ? {} : { id: part.image_url.id }), + }, + }); + break; + case "video_url": + parts.push({ + type: "video_url", + videoUrl: { + url: part.video_url.url, + ...(part.video_url.id === null || part.video_url.id === undefined ? {} : { id: part.video_url.id }), + }, + }); + break; + case "audio_url": + case "think": + // PromptInput intentionally accepts user text/images/videos only. + break; + } + } + return parts as PromptInput; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/vscode/src/runtime/tool-display.ts b/apps/vscode/src/runtime/tool-display.ts new file mode 100644 index 0000000000000000000000000000000000000000..fe77509774877eb23a463e483261e7e5ef2fa8ab --- /dev/null +++ b/apps/vscode/src/runtime/tool-display.ts @@ -0,0 +1,75 @@ +import type { ToolInputDisplay } from "@moonshot-ai/kimi-code-sdk"; + +import type { DisplayBlock } from "../../shared/legacy-sdk"; + +export function describeToolDisplay(display: ToolInputDisplay): string { + switch (display.kind) { + case "command": + return display.command; + case "file_io": + return `${display.operation} ${display.path}`; + case "diff": + return `Edit ${display.path}`; + case "search": + return `Search for ${display.query}`; + case "url_fetch": + return display.url; + case "agent_call": + return display.prompt; + case "skill_call": + return display.args ? `${display.skill_name} ${display.args}` : display.skill_name; + case "todo_list": + return "Update the task list"; + case "task": + return display.description; + case "task_stop": + return display.task_description; + case "plan_review": + return display.plan; + case "goal_start": + return display.objective; + case "generic": + return display.summary; + } +} + +export function toLegacyDisplay(display: ToolInputDisplay): DisplayBlock[] { + switch (display.kind) { + case "command": + return [{ type: "shell", language: display.language ?? "bash", command: display.command }]; + case "diff": + return [{ type: "diff", path: display.path, old_text: display.before, new_text: display.after }]; + case "file_io": + if ( + display.before !== undefined || + display.after !== undefined || + display.content !== undefined + ) { + return [{ + type: "diff", + path: display.path, + old_text: display.before ?? "", + new_text: display.after ?? display.content ?? "", + }]; + } + return [{ type: "brief", text: describeToolDisplay(display) }]; + case "todo_list": + return [{ + type: "todo", + items: display.items.map((item) => ({ + title: item.title, + status: item.status === "done" || item.status === "in_progress" ? item.status : "pending", + })), + }]; + case "search": + case "url_fetch": + case "agent_call": + case "skill_call": + case "task": + case "task_stop": + case "plan_review": + case "goal_start": + case "generic": + return [{ type: "brief", text: describeToolDisplay(display) }]; + } +} diff --git a/apps/vscode/src/utils/context.ts b/apps/vscode/src/utils/context.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f1058707373e53ef4b31c4612133779c4e39f17 --- /dev/null +++ b/apps/vscode/src/utils/context.ts @@ -0,0 +1,9 @@ +import * as vscode from "vscode"; +import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk"; + +export async function updateLoginContext(harness: KimiHarness): Promise { + const status = await harness.auth.status(); + const loggedIn = status.providers.some((provider) => provider.hasToken); + await vscode.commands.executeCommand("setContext", "kimi.isLoggedIn", loggedIn); + return loggedIn; +} diff --git a/apps/vscode/src/utils/fs-path.ts b/apps/vscode/src/utils/fs-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..d82a2dbb89dafe637db1f32140a87b4eb01f8cfc --- /dev/null +++ b/apps/vscode/src/utils/fs-path.ts @@ -0,0 +1,28 @@ +import * as path from "node:path"; + +/** Return a forward-slash relative path when `candidate` is inside `root`. */ +export function relativeFsPath(root: string, candidate: string): string | undefined { + const paths = isWindowsPath(root) || isWindowsPath(candidate) ? path.win32 : path; + const relativePath = paths.relative(paths.resolve(root), paths.resolve(candidate)); + if ( + relativePath === "" || + (!relativePath.startsWith(`..${paths.sep}`) && relativePath !== ".." && !paths.isAbsolute(relativePath)) + ) { + return relativePath.split(paths.sep).join("/"); + } + return undefined; +} + +/** Whether two native filesystem paths identify the same location. */ +export function areSameFsPath(left: string, right: string): boolean { + return relativeFsPath(left, right) === ""; +} + +/** Whether `candidate` is `root` or a descendant using native path rules. */ +export function isFsPathInsideOrEqual(root: string, candidate: string): boolean { + return relativeFsPath(root, candidate) !== undefined; +} + +function isWindowsPath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); +} diff --git a/apps/vscode/src/utils/session-context.ts b/apps/vscode/src/utils/session-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4ff4ce5b631282d751e07cfe382ab5cdc7f57ea --- /dev/null +++ b/apps/vscode/src/utils/session-context.ts @@ -0,0 +1,232 @@ +import type { + ContentPart, + ContextMessage, + PromptOrigin, + ToolCall, +} from "@moonshot-ai/kimi-code-sdk"; + +const INTERNAL_ORIGINS = new Set([ + "injection", + "system_trigger", + "compaction_summary", + "hook_result", + "cron_job", + "cron_missed", +]); + +const TOOL_HINT_KEYS = ["path", "file_path", "command", "query", "url", "name", "pattern"]; + +export function buildExportMarkdown(input: { + readonly sessionId: string; + readonly workDir: string; + readonly history: readonly ContextMessage[]; + readonly tokenCount: number; + readonly now: Date; +}): string { + const turns = groupIntoTurns(input.history); + const firstUser = input.history.find( + (message) => message.role === "user" && !isInternalMessage(message), + ); + const topic = firstUser === undefined ? "" : shorten(stringifyParts(firstUser.content), 80); + const toolCalls = input.history.reduce((count, message) => count + message.toolCalls.length, 0); + const lines = [ + "---", + `session_id: ${input.sessionId}`, + `exported_at: ${input.now.toISOString()}`, + `work_dir: ${input.workDir}`, + `message_count: ${String(input.history.length)}`, + `token_count: ${String(input.tokenCount)}`, + "---", + "", + "# Kimi Session Export", + "", + "## Overview", + "", + topic ? `- **Topic**: ${topic}` : "- **Topic**: (empty)", + `- **Conversation**: ${String(turns.length)} turns | ${String(toolCalls)} tool calls`, + "", + "---", + "", + ]; + + for (let index = 0; index < turns.length; index += 1) { + lines.push(formatTurn(turns[index]!, index + 1)); + } + return lines.join("\n"); +} + +export function stringifyContextHistory(history: readonly ContextMessage[]): string { + const messages: string[] = []; + for (const message of history) { + if (isInternalMessage(message)) continue; + const sections: string[] = []; + const content = stringifyParts(message.content); + if (content.trim()) sections.push(content); + if (message.toolCalls.length > 0) { + sections.push(message.toolCalls.map(stringifyToolCall).join("\n")); + } + if (sections.length === 0) continue; + const callId = message.role === "tool" && message.toolCallId + ? ` (call_id: ${message.toolCallId})` + : ""; + messages.push(`[${message.role.toUpperCase()}]${callId}\n${sections.join("\n")}`); + } + return messages.join("\n\n"); +} + +export function isImportableTextFile(fileName: string): boolean { + const dot = fileName.lastIndexOf("."); + if (dot <= 0) return true; + return IMPORTABLE_EXTENSIONS.has(fileName.slice(dot).toLowerCase()); +} + +export function isSensitiveFile(fileName: string): boolean { + const normalized = fileName.toLowerCase(); + return [".env", "credentials", "secrets", ".pem", ".key", ".p12", ".pfx", ".keystore"] + .some((pattern) => normalized.includes(pattern)); +} + +function isInternalMessage(message: ContextMessage): boolean { + return message.origin !== undefined && INTERNAL_ORIGINS.has(message.origin.kind); +} + +function groupIntoTurns(history: readonly ContextMessage[]): ContextMessage[][] { + const turns: ContextMessage[][] = []; + let current: ContextMessage[] = []; + for (const message of history) { + if (isInternalMessage(message)) continue; + if (message.role === "user" && current.length > 0) { + turns.push(current); + current = []; + } + current.push(message); + } + if (current.length > 0) turns.push(current); + return turns; +} + +function formatTurn(messages: readonly ContextMessage[], turnNumber: number): string { + const lines = [`## Turn ${String(turnNumber)}`, ""]; + const toolInfo = new Map(); + let assistantHeading = false; + for (const message of messages) { + if (message.role === "user") { + lines.push("### User", "", stringifyParts(message.content), ""); + continue; + } + if (message.role === "assistant") { + if (!assistantHeading) { + lines.push("### Assistant", ""); + assistantHeading = true; + } + const content = formatPartsMarkdown(message.content); + if (content) lines.push(content, ""); + for (const call of message.toolCalls) { + const hint = toolCallHint(call); + toolInfo.set(call.id, { name: call.name, hint }); + lines.push(formatToolCallMarkdown(call, hint), ""); + } + continue; + } + if (message.role === "tool") { + const info = toolInfo.get(message.toolCallId ?? "") ?? { name: "unknown", hint: "" }; + const hint = info.hint ? ` (\`${info.hint}\`)` : ""; + lines.push( + `
Tool Result: ${info.name}${hint}`, + "", + ``, + formatPartsMarkdown(message.content), + "", + "
", + "", + ); + continue; + } + lines.push(`### ${capitalize(message.role)}`, "", formatPartsMarkdown(message.content), ""); + } + return lines.join("\n"); +} + +function formatToolCallMarkdown(call: ToolCall, hint: string): string { + let args = call.arguments ?? "{}"; + try { + args = JSON.stringify(JSON.parse(args), null, 2); + } catch { + // Preserve malformed arguments exactly as recorded. + } + const suffix = hint ? ` (\`${hint}\`)` : ""; + return `#### Tool Call: ${call.name}${suffix}\n\n\`\`\`json\n${args}\n\`\`\``; +} + +function toolCallHint(call: ToolCall): string { + let args: unknown; + try { + args = JSON.parse(call.arguments ?? "{}"); + } catch { + return ""; + } + if (!isRecord(args)) return ""; + for (const key of TOOL_HINT_KEYS) { + const value = args[key]; + if (typeof value === "string" && value.trim()) return shorten(value, 60); + } + return ""; +} + +function stringifyToolCall(call: ToolCall): string { + let args = call.arguments ?? "{}"; + try { + args = JSON.stringify(JSON.parse(args)); + } catch { + // Preserve malformed arguments exactly as recorded. + } + return `Tool Call: ${call.name}(${args})`; +} + +function formatPartsMarkdown(parts: readonly ContentPart[]): string { + return parts.map(formatPartMarkdown).filter(Boolean).join("\n"); +} + +function formatPartMarkdown(part: ContentPart): string { + switch (part.type) { + case "text": return part.text; + case "think": return part.think.trim() + ? `
Thinking\n\n${part.think}\n\n
` + : ""; + case "image_url": return "[image]"; + case "audio_url": return "[audio]"; + case "video_url": return "[video]"; + } +} + +function stringifyParts(parts: readonly ContentPart[]): string { + return parts.map((part) => { + if (part.type === "text") return part.text; + if (part.type === "think") return part.think.trim() ? `\n${part.think}\n` : ""; + if (part.type === "image_url") return "[image]"; + if (part.type === "audio_url") return "[audio]"; + return "[video]"; + }).filter(Boolean).join("\n"); +} + +function capitalize(value: string): string { + return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1); +} + +function shorten(value: string, width: number): string { + return value.length <= width ? value : `${value.slice(0, width)}…`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const IMPORTABLE_EXTENSIONS = new Set([ + ".md", ".markdown", ".txt", ".text", ".rst", ".json", ".jsonl", ".yaml", ".yml", + ".toml", ".ini", ".cfg", ".conf", ".csv", ".tsv", ".xml", ".env", ".properties", + ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".kt", ".go", ".rs", ".c", ".cpp", + ".h", ".hpp", ".cs", ".rb", ".php", ".swift", ".scala", ".sh", ".bash", ".zsh", + ".fish", ".ps1", ".bat", ".cmd", ".r", ".lua", ".pl", ".pm", ".ex", ".exs", + ".erl", ".hs", ".ml", ".sql", ".graphql", ".proto", ".html", ".htm", ".css", + ".scss", ".sass", ".less", ".svg", ".log", ".tex", ".bib", ".org", ".adoc", ".wiki", +]); diff --git a/apps/vscode/src/utils/string.ts b/apps/vscode/src/utils/string.ts new file mode 100644 index 0000000000000000000000000000000000000000..801d46ede4a50b387bed587205b25e3d283b8c59 --- /dev/null +++ b/apps/vscode/src/utils/string.ts @@ -0,0 +1,17 @@ +export function buildCaseInsensitiveGlobLiteral(input: string) { + let out = ""; + for (const ch of input) { + if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")) { + const l = ch.toLowerCase(); + const u = ch.toUpperCase(); + out += `[${l}${u}]`; + } else { + if ("*?[]{}\\".includes(ch)) { + out += "\\" + ch; + } else { + out += ch; + } + } + } + return out; +} diff --git a/apps/vscode/src/utils/workspace-path.ts b/apps/vscode/src/utils/workspace-path.ts new file mode 100644 index 0000000000000000000000000000000000000000..422ed0add42107704b8e893c6b10eb013655315a --- /dev/null +++ b/apps/vscode/src/utils/workspace-path.ts @@ -0,0 +1,171 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { lstat, realpath } from "node:fs/promises"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { relativeFsPath } from "./fs-path"; + +export interface WorkspacePath { + readonly uri: vscode.Uri; + readonly relativePath: string; +} + +export function workDirUriFromPath( + workspaceRootUri: vscode.Uri, + workspaceRoot: string, + workDir: string, +): vscode.Uri | undefined { + const relativePath = relativeFsPath(workspaceRoot, workDir); + if (relativePath === undefined) return undefined; + return relativePath === "" + ? workspaceRootUri + : vscode.Uri.joinPath(workspaceRootUri, ...toPathSegments(relativePath)); +} + +export function resolveWorkspacePath( + workDirUri: vscode.Uri, + input: string, + options: { allowRoot?: boolean } = {}, +): WorkspacePath | undefined { + const normalized = input.replaceAll("\\", "/"); + if ( + path.posix.isAbsolute(normalized) || + path.win32.isAbsolute(input) || + /^[A-Za-z]:/.test(input) + ) return undefined; + + const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== "."); + if (segments.includes("..")) return undefined; + if (segments.length === 0) { + return options.allowRoot === true ? { uri: workDirUri, relativePath: "" } : undefined; + } + + return { + uri: vscode.Uri.joinPath(workDirUri, ...segments), + relativePath: segments.join("/"), + }; +} + +export function relativeWorkspacePath(rootUri: vscode.Uri, candidateUri: vscode.Uri): string | undefined { + if (rootUri.scheme !== candidateUri.scheme) return undefined; + + // VS Code exposes native Windows file paths through `fsPath`, while URI + // paths always use `/`. Comparing the URI strings makes the same drive or + // UNC path look different when only separator or drive/share casing differs. + // Native filesystem semantics are the right contract for `file:` URIs. + if (rootUri.scheme === "file") { + const relativePath = relativeFsPath(rootUri.fsPath, candidateUri.fsPath); + return relativePath === "" ? undefined : relativePath; + } + + if (rootUri.authority !== candidateUri.authority) return undefined; + + const relativePath = path.posix.relative(normalizeUriPath(rootUri.path), normalizeUriPath(candidateUri.path)); + if ( + relativePath === "" || + relativePath === ".." || + relativePath.startsWith("../") || + path.posix.isAbsolute(relativePath) + ) { + return undefined; + } + return relativePath; +} + +export async function isWorkspacePathContained( + rootUri: vscode.Uri, + candidateUri: vscode.Uri, + options: { allowMissing?: boolean } = {}, +): Promise { + if (rootUri.toString() !== candidateUri.toString() && relativeWorkspacePath(rootUri, candidateUri) === undefined) { + return false; + } + if (rootUri.scheme !== "file" || candidateUri.scheme !== "file") return true; + + try { + const [realRoot, realCandidate] = await Promise.all([ + realpath(rootUri.fsPath), + options.allowMissing === true ? realExistingPath(candidateUri.fsPath) : realpath(candidateUri.fsPath), + ]); + return relativeFsPath(realRoot, realCandidate) !== undefined; + } catch { + return false; + } +} + +export function isWorkspacePathContainedSync( + rootUri: vscode.Uri, + candidateUri: vscode.Uri, + options: { allowMissing?: boolean } = {}, +): boolean { + if (rootUri.toString() !== candidateUri.toString() && relativeWorkspacePath(rootUri, candidateUri) === undefined) { + return false; + } + if (rootUri.scheme !== "file" || candidateUri.scheme !== "file") return true; + + try { + const realRoot = realpathSync(rootUri.fsPath); + const realCandidate = + options.allowMissing === true + ? realExistingPathSync(candidateUri.fsPath) + : realpathSync(candidateUri.fsPath); + return relativeFsPath(realRoot, realCandidate) !== undefined; + } catch { + return false; + } +} + +async function realExistingPath(candidate: string): Promise { + let current = candidate; + while (true) { + try { + return await realpath(current); + } catch (error) { + if (!isMissingPathError(error)) throw error; + let isDanglingSymlink = false; + try { + isDanglingSymlink = (await lstat(current)).isSymbolicLink(); + } catch (lstatError) { + if (!isMissingPathError(lstatError)) throw lstatError; + } + if (isDanglingSymlink) throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } +} + +function realExistingPathSync(candidate: string): string { + let current = candidate; + while (true) { + try { + return realpathSync(current); + } catch (error) { + if (!isMissingPathError(error)) throw error; + let isDanglingSymlink = false; + try { + isDanglingSymlink = lstatSync(current).isSymbolicLink(); + } catch (lstatError) { + if (!isMissingPathError(lstatError)) throw lstatError; + } + if (isDanglingSymlink) throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } +} + +function isMissingPathError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} + +function toPathSegments(value: string): string[] { + return value.replaceAll("\\", "/").split("/").filter(Boolean); +} + +function normalizeUriPath(value: string): string { + const normalized = path.posix.normalize(value); + return normalized.length > 1 ? normalized.replace(/\/$/, "") : normalized; +} diff --git a/apps/vscode/webview-ui/public/kimi-banner-dark.svg b/apps/vscode/webview-ui/public/kimi-banner-dark.svg new file mode 100644 index 0000000000000000000000000000000000000000..746420861b909d0c73af5dae27824839e8764ce1 --- /dev/null +++ b/apps/vscode/webview-ui/public/kimi-banner-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/vscode/webview-ui/public/kimi-banner-light.svg b/apps/vscode/webview-ui/public/kimi-banner-light.svg new file mode 100644 index 0000000000000000000000000000000000000000..bdfe65a773322a16b0481e44e080d457f0c35669 --- /dev/null +++ b/apps/vscode/webview-ui/public/kimi-banner-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/vscode/webview-ui/public/kimi-logo.png b/apps/vscode/webview-ui/public/kimi-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..033c0f4fb2e28a9daeca0205812d15bf3e547893 Binary files /dev/null and b/apps/vscode/webview-ui/public/kimi-logo.png differ diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d16488b049cd828df4f296327f3d69f846b6d3c0 --- /dev/null +++ b/apps/vscode/webview-ui/src/App.tsx @@ -0,0 +1,147 @@ +// node/vscode_extension/webview-ui/src/App.tsx +import { useEffect, useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Header } from "./components/Header"; +import { ChatArea } from "./components/ChatArea"; +import { InputArea } from "./components/inputarea/InputArea"; +import { MCPServersModal } from "./components/MCPServersModal"; +import { WorkDirModal } from "./components/WorkDirModal"; +import { ConfigErrorScreen } from "./components/ConfigErrorScreen"; +import { LoginScreen } from "./components/LoginScreen"; +import { Toaster, toast } from "./components/ui/sonner"; +import { useChatStore, useSettingsStore } from "./stores"; +import { bridge, Events } from "./services"; +import { useAppInit, resolveAppView } from "./hooks/useAppInit"; +import { isPreflightError } from "shared/errors"; +import type { UIStreamEvent, StreamError, ExtensionConfig } from "shared/types"; +import "./styles/index.css"; + +function MainContent({ onAuthAction }: { onAuthAction: () => void }) { + const { processEvent, startNewConversation, sessionId } = useChatStore(); + const { setExtensionConfig, extensionConfig } = useSettingsStore(); + const queryClient = useQueryClient(); + + useEffect(() => { + return bridge.on(Events.StreamEvent, (event: UIStreamEvent) => { + // 只有当前已有 session 时才过滤,确保 session_start 能正常处理 + if (sessionId && "_sessionId" in event && event._sessionId && event._sessionId !== sessionId) { + console.log("Ignored stream event from another session:", event._sessionId); + return; + } + processEvent(event); + if (event.type === "error") { + const streamError = event as StreamError; + if (isPreflightError(streamError.code || "UNKNOWN")) { + toast.error(streamError.message); + } + } + }); + }, [processEvent, sessionId]); + + useEffect(() => { + const unsubs = [ + bridge.on(Events.MCPServersChanged, () => void queryClient.invalidateQueries({ queryKey: ["mcpServers"] })), + bridge.on(Events.ExtensionConfigChanged, ({ config }: { config: ExtensionConfig }) => setExtensionConfig(config)), + bridge.on(Events.FocusInput, () => document.querySelector("textarea")?.focus()), + bridge.on(Events.NewConversation, () => { + void startNewConversation().catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : String(error)); + }); + }), + ]; + return () => unsubs.forEach((u) => u()); + }, [queryClient, setExtensionConfig, startNewConversation]); + + useEffect(() => { + if (!extensionConfig.enableNewConversationShortcut) return; + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "n") { + e.preventDefault(); + void startNewConversation().catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : String(error)); + }); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [extensionConfig.enableNewConversationShortcut, startNewConversation]); + + return ( + <> +
+ +
+
+ +
+ + + + ); +} + +export default function App() { + const { status, errorMessage, modelsCount, refresh } = useAppInit(); + const [skippedLogin, setSkippedLogin] = useState(false); + const [showLogin, setShowLogin] = useState(false); + + const handleLoginSuccess = useCallback(() => { + setShowLogin(false); + setSkippedLogin(false); + refresh(); + }, [refresh]); + + const handleSkip = useCallback(() => { + setShowLogin(false); + setSkippedLogin(true); + }, []); + + const handleShowLogin = useCallback(() => { + setSkippedLogin(false); + setShowLogin(true); + }, []); + + const handleAuthAction = useCallback(() => { + setSkippedLogin(false); + setShowLogin(false); + refresh(); + }, [refresh]); + + const resolution = resolveAppView({ status, modelsCount, skippedLogin, showLogin }); + + // 登录界面:未登录且未跳过,或用户从其他界面主动选择登录 + if (resolution.view === "login") { + return ( +
+
+ + +
+ ); + } + + // 错误与设置状态界面;no-models 必须保留回到登录界面的入口 + if (resolution.view === "status") { + return ( +
+
+ + +
+ ); + } + + // 正常状态 + return ( +
+
+ + +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ActionMenu.tsx b/apps/vscode/webview-ui/src/components/ActionMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e4b79b4deff27dbfe4ddf2c4186097528ccbec2 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ActionMenu.tsx @@ -0,0 +1,149 @@ +import { useState } from "react"; +import { IconSettings, IconServer, IconLogout, IconLogin, IconLoader2, IconRefresh, IconFileText, IconFolder } from "@tabler/icons-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { useSettingsStore } from "@/stores"; +import { bridge } from "@/services"; +import { toast } from "@/components/ui/sonner"; +import { cn } from "@/lib/utils"; + +interface ActionMenuProps { + className?: string; + onAuthAction?: () => void; +} + +function MenuSection({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { + return ( +
+
+ {title} + {subtitle && {subtitle}} +
+ {children} +
+ ); +} + +function MenuItem({ onClick, disabled, danger, children }: { onClick: () => void; disabled?: boolean; danger?: boolean; children: React.ReactNode }) { + return ( + + ); +} + +export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const { setMCPModalOpen, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore(); + + const handleOpenSettings = () => { + void bridge.openSettings(); + setOpen(false); + }; + + const handleOpenMCPServers = () => { + setMCPModalOpen(true); + setOpen(false); + }; + + const handleChangeWorkDir = () => { + useSettingsStore.getState().setWorkDirModalOpen(true); + setOpen(false); + }; + + const handleReset = () => { + setOpen(false); + void bridge.reloadWebview(); + }; + + const handleShowLogs = () => { + void bridge.showLogs(); + setOpen(false); + }; + + const handleAuthAction = async () => { + setLoading(true); + try { + if (isLoggedIn) { + await bridge.logout(); + setIsLoggedIn(false); + } else { + const result = await bridge.login(); + if (result.success) { + setIsLoggedIn(true); + } else { + toast.error(result.error ?? "Sign-in failed. Check the logs for details."); + } + } + } finally { + setLoading(false); + setOpen(false); + } + onAuthAction?.(); + }; + + return ( + + + + + + + + + Working Directory + + + + MCP Servers + + + + General Config + ↗ + + + + + + + + + Show Logs + + + + Reset Kimi + + + + + + + { + void handleAuthAction(); + }} + disabled={loading} + danger={isLoggedIn} + > + {loading ? : isLoggedIn ? : } + {loading ? "Processing…" : isLoggedIn ? "Sign out" : "Sign in"} + + + + + ); +} diff --git a/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx b/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c2d877355563e765a3ce9dd7eb0a2361b2e0af3 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx @@ -0,0 +1,82 @@ +import { useState, useEffect } from "react"; +import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { useApprovalStore } from "@/stores"; +import { DisplayBlocks } from "./DisplayBlocks"; +import { cn } from "@/lib/utils"; +import type { ApprovalResponse } from "shared/legacy-sdk"; + +export function ApprovalDialog() { + const { pending, respondToRequest } = useApprovalStore(); + const [selectedIndex, setSelectedIndex] = useState(1); + const [expanded, setExpanded] = useState(false); + + const req = pending[0]; + + // Auto-expand if there's a diff block (code change) + useEffect(() => { + if (req) { + const hasDiff = req.display?.some((b) => b.type === "diff") ?? false; + setExpanded(hasDiff); + } + }, [req?.id]); + + if (!req) return null; + const hasDisplay = req.display && req.display.length > 0; + + const handleResponse = async (response: ApprovalResponse) => { + await respondToRequest(req.id, response); + setSelectedIndex(1); + setExpanded(false); + }; + + const options = [ + { key: "approve", label: "Yes", index: 1 }, + { key: "approve_for_session", label: "Yes, for this session", index: 2 }, + { key: "reject", label: "No", index: 3 }, + ] as const; + + return ( +
+
+
+
Allow this {req.action.toLowerCase()}?
+ {hasDisplay && ( + + )} +
+ +
{req.description}
+ + {hasDisplay && ( +
+ +
+ )} + +
{req.sender}
+ +
+ {options.map((opt) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/BottomToolbar.tsx b/apps/vscode/webview-ui/src/components/BottomToolbar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dde016c6bd0d99b084774ec90af3d0320db6dc3c --- /dev/null +++ b/apps/vscode/webview-ui/src/components/BottomToolbar.tsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from "react"; +import { IconChevronUp, IconStack2, IconFileCode } from "@tabler/icons-react"; +import { useChatStore } from "@/stores"; +import { bridge, Events } from "@/services"; +import { cn } from "@/lib/utils"; +import { FileChangesPanel } from "./FileChangesPanel"; +import { QueuedMessagesPanel } from "./QueuedMessagesPanel"; +import { ApprovalDialog } from "./ApprovalDialog"; +import { QuestionDialog } from "./QuestionDialog"; +import type { FileChange } from "shared/types"; + +type TabId = "queue" | "changes" | null; + +export function BottomToolbar() { + const { queue } = useChatStore(); + const [activeTab, setActiveTab] = useState(null); + const [fileChanges, setFileChanges] = useState([]); + + useEffect(() => { + return bridge.on(Events.FileChangesUpdated, setFileChanges); + }, []); + + // Auto-close tab when data becomes empty + useEffect(() => { + if (activeTab === "queue" && queue.length === 0) { + setActiveTab(null); + } + if (activeTab === "changes" && fileChanges.length === 0) { + setActiveTab(null); + } + }, [activeTab, queue.length, fileChanges.length]); + + const hasQueue = queue.length > 0; + const hasChanges = fileChanges.length > 0; + const hasTabs = hasQueue || hasChanges; + + const toggleTab = (tab: TabId) => { + setActiveTab((prev) => (prev === tab ? null : tab)); + }; + + const fileStats = fileChanges.reduce((a, c) => ({ additions: a.additions + c.additions, deletions: a.deletions + c.deletions }), { additions: 0, deletions: 0 }); + + return ( +
+ {/* ApprovalDialog and QuestionDialog - priority, shrink-0 */} + + + + {/* Queue/Changes panel - takes remaining space */} + {activeTab && ( +
+ {activeTab === "queue" && } + {activeTab === "changes" && } +
+ )} + + {/* Tab bar - always at bottom, shrink-0 */} + {hasTabs && ( +
+
+ {hasQueue && ( + + )} + + {hasChanges && ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ChatArea.tsx b/apps/vscode/webview-ui/src/components/ChatArea.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5d4f8e887eba6fdfd974d6a0d5dfa5ac59870638 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ChatArea.tsx @@ -0,0 +1,64 @@ +import ScrollToBottom, { useScrollToBottom, useSticky } from "react-scroll-to-bottom"; +import { IconArrowDown } from "@tabler/icons-react"; +import { ChatMessage } from "./ChatMessage"; +import { WelcomeScreen } from "./WelcomeScreen"; +import { useChatStore } from "@/stores"; +import { cn } from "@/lib/utils"; +import { getForkTurnIndex } from "shared/fork-turn-index"; + +function ScrollButton() { + const scrollToBottom = useScrollToBottom(); + const [sticky] = useSticky(); + + if (sticky) return null; + + return ( + + ); +} + +function MessageList() { + const messages = useChatStore((s) => s.messages); + const isStreaming = useChatStore((s) => s.isStreaming); + + return ( + <> +
+ {messages.map((message, idx) => ( + + ))} +
+ + + ); +} + +export function ChatArea() { + const messageCount = useChatStore((s) => s.messages.length); + + if (messageCount === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ + + +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f189be6dc56a6178bec342eae2eee8b1ec37c497 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -0,0 +1,340 @@ +import { useState, Fragment, memo } from "react"; +import { IconLoader3, IconGitFork } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { Content } from "@/lib/content"; +import { Markdown } from "./Markdown"; +import { ToolCallCard } from "./ToolRenderers"; +import { CopyButton } from "./CopyButton"; +import { ThinkingBlock } from "./ThinkingBlock"; +import { CompactionCard } from "./CompactionCard"; +import { MediaThumbnail } from "./MediaThumbnail"; +import { MediaPreviewModal } from "./MediaPreviewModal"; +import { InlineError } from "./InlineError"; +import { PlanCard } from "./PlanCard"; +import { StreamingConfirmDialog } from "./StreamingConfirmDialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; +import { useChatStore } from "@/stores"; +import { bridge } from "@/services"; +import type { ChatMessage as ChatMessageType, UIStep, UIStepItem } from "@/stores/chat.store"; +import type { ContentPart } from "shared/legacy-sdk"; + +interface ChatMessageProps { + message: ChatMessageType; + /** 0-indexed turn number for this message */ + turnIndex?: number; + isStreaming?: boolean; +} + +function ThinkingIndicator() { + return ( +
+ + Processing… +
+ ); +} + +function SteerBubble({ content }: { content: string | ContentPart[] }) { + const text = typeof content === "string" ? content : Content.getText(content); + return ( +
+
+

{text}

+
+
+ ); +} + +function StepItemRenderer({ item }: { item: UIStepItem }) { + switch (item.type) { + case "thinking": + return ; + case "text": + return ; + case "tool_use": + return ; + case "compaction": + return ; + case "steer": + return ; + default: + return null; + } +} + +function StepContent({ step, showConnector }: { step: UIStep; showConnector?: boolean }) { + const hasItems = step.items.length > 0; + const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction"); + const showIndicator = hasToolOrThinking; + const hasActiveItem = step.items.some((item) => (item.type === "text" || item.type === "thinking") && !item.finished); + + if (!hasItems) { + return null; + } + + return ( +
+ {showIndicator ? ( +
+
+ {showConnector && ( +
+ )} +
+ ) : ( +
+ )} +
+ {step.items.map((item, idx) => ( + + ))} +
+
+ ); +} + +function MessageMedia({ images, videos, onPreview }: { images: string[]; videos: string[]; onPreview: (src: string) => void }) { + if (images.length === 0 && videos.length === 0) { + return null; + } + return ( +
+ {images.map((src, idx) => ( + onPreview(src)} /> + ))} + {videos.map((src, idx) => ( + onPreview(src)} /> + ))} +
+ ); +} + +interface StepGroup { + planMode: boolean; + steps: UIStep[]; + startIndex: number; +} + +function groupStepsByPlanMode(steps: UIStep[]): StepGroup[] { + const groups: StepGroup[] = []; + for (let i = 0; i < steps.length; i++) { + const isPlan = steps[i].planMode === true; + const last = groups.at(-1); + if (last && last.planMode === isPlan) { + last.steps.push(steps[i]); + } else { + groups.push({ planMode: isPlan, steps: [steps[i]], startIndex: i }); + } + } + return groups; +} + +interface ForkButtonProps { + turnIndex: number; + className?: string; +} + +function ForkButton({ turnIndex, className }: ForkButtonProps) { + const [showConfirm, setShowConfirm] = useState(false); + const [isForking, setIsForking] = useState(false); + const sessionId = useChatStore((s) => s.sessionId); + const isStreaming = useChatStore((s) => s.isStreaming); + const loadSession = useChatStore((s) => s.loadSession); + + const handleFork = () => { + if (!sessionId || turnIndex < 0) return; + setShowConfirm(true); + }; + + const doFork = async () => { + if (!sessionId) return; + + setIsForking(true); + try { + const result = await bridge.forkSession(sessionId, turnIndex); + if (result) { + // Load the forked session + const events = await bridge.loadSessionHistory(result.sessionId); + await loadSession(result.sessionId, events); + } + } catch (error) { + toast.error(`Failed to fork conversation: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsForking(false); + setShowConfirm(false); + } + }; + + return ( + <> + + + { void doFork(); }} + confirmLoading={isForking} + confirmDisabled={isForking} + cancelDisabled={isForking} + /> + + ); +} + +function UserMessage({ message }: { message: ChatMessageType }) { + const [previewMedia, setPreviewMedia] = useState(null); + const displayContent = Content.getText(message.content); + const images = Content.getImages(message.content); + const videos = Content.getVideos(message.content); + + return ( +
+
+ {displayContent && ( + // FIX: removed whitespace-pre-wrap — it conflicted with ReactMarkdown's + // block-level elements (

,

    ,
  1. ), doubling vertical spacing. + // ReactMarkdown already handles paragraph breaks from \n\n. +
    + +
    + )} + +
+ setPreviewMedia(null)} /> +
+ ); +} + +function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMessageType; turnIndex?: number; isStreaming?: boolean }) { + const [previewMedia, setPreviewMedia] = useState(null); + const isCompacting = useChatStore((s) => s.isCompacting); + + const steps = message.steps || []; + const hasSteps = steps.length > 0; + const images = Content.getImages(message.content); + const videos = Content.getVideos(message.content); + + const stepHasIndicator = steps.map((step) => step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction")); + + const contentToCopy = (() => { + if (!hasSteps) { + return typeof message.content === "string" ? message.content : ""; + } + const lastStep = steps[steps.length - 1]; + const textItems = lastStep.items.filter((item) => item.type === "text"); + if (textItems.length > 0) { + return textItems.map((item) => (item as { type: "text"; content: string }).content).join("\n"); + } + return typeof message.content === "string" ? message.content : ""; + })(); + + if (!isStreaming && !hasMessageContent(message) && !message.inlineError) { + return null; + } + + const displayContent = typeof message.content === "string" ? message.content : ""; + const isShowingInlineError = message.inlineError && !isStreaming; + + return ( +
+
+
+
K
+
Kimi
+
+ +
+
+
+ {hasSteps && + groupStepsByPlanMode(steps).map((group, gi) => { + const totalSteps = steps.length; + const stepsContent = group.steps.map((step, i) => { + const globalIndex = group.startIndex + i; + const isLastInGroup = i === group.steps.length - 1; + const isLastOverall = globalIndex === totalSteps - 1; + const hasIndicator = stepHasIndicator[globalIndex]; + const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean); + const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall; + return ; + }); + + if (group.planMode) { + return {stepsContent}; + } + return {stepsContent}; + })} + {!hasSteps && displayContent && } + {(images.length > 0 || videos.length > 0) && ( +
+ +
+ )} +
+ + {/* 内嵌错误显示 */} + {isShowingInlineError && message.inlineError && ( +
+ +
+ )} +
+
{isStreaming && !isShowingInlineError && !isCompacting && }
+
+ {!isStreaming && contentToCopy.trim().length > 0 && ( +
+ + {message.forkable !== false && turnIndex !== undefined && turnIndex >= 0 && } +
+ )} +
+
+
+
+ setPreviewMedia(null)} /> +
+ ); +} + +function hasMessageContent(message: ChatMessageType): boolean { + if (!Content.isEmpty(message.content)) { + return true; + } + return message.steps?.some((s) => s.items.length > 0) ?? false; +} + +export const ChatMessage = memo(function ChatMessage({ message, turnIndex, isStreaming }: ChatMessageProps) { + if (message.role === "user") { + return ; + } + return ; +}); diff --git a/apps/vscode/webview-ui/src/components/ChatStatus.tsx b/apps/vscode/webview-ui/src/components/ChatStatus.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e11f842080bfece1b372fba10a86cc4025264e4a --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ChatStatus.tsx @@ -0,0 +1,117 @@ +import { useChatStore } from "@/stores"; +import { cn } from "@/lib/utils"; +import { IconArrowUp, IconArrowDown, IconBrandSpeedtest, IconRefresh } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +export function TokenInfo() { + const { lastStatus, tokenUsage, activeTokenUsage } = useChatStore(); + + const inputTotal = + tokenUsage.input_other + + tokenUsage.input_cache_read + + tokenUsage.input_cache_creation + + activeTokenUsage.input_other + + activeTokenUsage.input_cache_read + + activeTokenUsage.input_cache_creation; + + const outputTotal = tokenUsage.output + activeTokenUsage.output; + + const contextPercent = lastStatus?.context_usage ? Math.round(lastStatus.context_usage * 1000) / 10 : 0; + + return ( +
+
Token Usage
+
+
+ Context + 80 && "text-amber-500", contextPercent > 95 && "text-destructive")}>{contextPercent}% +
+
+ Input + {inputTotal.toLocaleString()} +
+
+ Output + {outputTotal.toLocaleString()} +
+
+
+ ); +} + +export function ChatStatus() { + const { lastStatus, tokenUsage, activeTokenUsage } = useChatStore(); + + if (!lastStatus) { + return null; + } + + const { context_usage } = lastStatus; + const retrying = lastStatus.retrying; + + const inputTotal = + tokenUsage.input_other + + tokenUsage.input_cache_read + + tokenUsage.input_cache_creation + + activeTokenUsage.input_other + + activeTokenUsage.input_cache_read + + activeTokenUsage.input_cache_creation; + + const outputTotal = tokenUsage.output + activeTokenUsage.output; + + const contextPercent = context_usage ? Math.round(context_usage * 1000) / 10 : 0; + + return ( +
+ {retrying && ( + + + + + Retry {retrying.next_attempt}/{retrying.max_attempts} + + + + Retrying in {Math.ceil(retrying.delay_ms / 1000)}s: {retrying.message} + + + )} + {retrying &&
} +
+ + + + + 80 && "text-amber-500", contextPercent > 95 && "text-destructive")}>{contextPercent}% + + + Context Window Usage + +
+
+
+ + + + + {inputTotal.toLocaleString()} + + + Total Input Tokens + +
+
+
+ + + + + {outputTotal.toLocaleString()} + + + Total Output Tokens + +
+
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/CompactionCard.tsx b/apps/vscode/webview-ui/src/components/CompactionCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7355baf6efe160cbcaf3a12412ee0d0a2dd2bbbd --- /dev/null +++ b/apps/vscode/webview-ui/src/components/CompactionCard.tsx @@ -0,0 +1,23 @@ +import { IconLoader2 } from "@tabler/icons-react"; +import { useChatStore } from "@/stores"; + +export function CompactionCard() { + const isCompacting = useChatStore((s) => s.isCompacting); + + return ( +
+
+ {isCompacting ? ( + + ) : ( +
+
+
+ )} +
+
{isCompacting ? "Compacting context…" : "Context compacted"}
+
+
+
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx b/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..877c93f769c927cd3bece888c0a771ac9a25f6c3 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx @@ -0,0 +1,185 @@ +import { useState } from "react"; +import { + IconAlertTriangle, + IconArrowLeft, + IconCheck, + IconCopy, + IconFileSettings, + IconFolderOpen, + IconLoader2, + IconRefresh, + IconTerminal2, +} from "@tabler/icons-react"; + +import { bridge } from "@/services"; +import { Button } from "@/components/ui/button"; +import { KimiMascot } from "./KimiMascot"; + +interface Props { + type: "loading" | "runtime-error" | "no-models" | "no-workspace"; + errorMessage?: string | null; + onRefresh?: () => void; + onBackToLogin?: () => void; +} + +function ErrorDetails({ message }: { message?: string | null }) { + const [copied, setCopied] = useState(false); + + if (!message) return null; + + const copyError = async () => { + await navigator.clipboard.writeText(message); + setCopied(true); + window.setTimeout(() => setCopied(false), 2_000); + }; + + return ( +
+
+
+ + Error details +
+ +
+
{message}
+
+ ); +} + +function NoModelsContent({ onRefresh, onBackToLogin }: Pick) { + return ( + <> +
+
+ + Model setup required +
+

+ Sign in with a Kimi account, or configure a provider and model in your shared Kimi Code config.toml. +

+
+ +
+
+ + Shared Kimi Code configuration +
+

+ VS Code and the terminal UI use the same Kimi Code home, configuration, credentials, and sessions. +

+
+ +
+ {onBackToLogin && ( + + )} + {onRefresh && ( + + )} +
+ + ); +} + +export function ConfigErrorScreen({ type, errorMessage, onRefresh, onBackToLogin }: Props) { + if (type === "loading") { + return ( +
+
+ +
+ + Starting Kimi Code… +
+
+
+ ); + } + + if (type === "no-workspace") { + return ( +
+
+ +
+
+ + No workspace open +
+

Open a folder to start using Kimi Code.

+
+ +
+
+ ); + } + + if (type === "no-models") { + return ( +
+
+ + +
+
+ ); + } + + return ( +
+
+ +
+
+ + Kimi Code could not start +
+

Check the error below. Full diagnostics are available in the Kimi Code output channel.

+
+ +
+ + {onRefresh && ( + + )} +
+
+
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/CopyButton.tsx b/apps/vscode/webview-ui/src/components/CopyButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31f71cb9928c177c32cff962447cfddb10a598ac --- /dev/null +++ b/apps/vscode/webview-ui/src/components/CopyButton.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { IconCopy, IconCheck } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +interface CopyButtonProps { + content: string; + className?: string; +} + +export function CopyButton({ content, className }: CopyButtonProps) { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = async () => { + if (!content) return; + + try { + await navigator.clipboard.writeText(content); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch (err) { + console.error("Failed to copy:", err); + } + }; + + if (!content) return null; + + return ( + + ); +} diff --git a/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f435e782dc8f29348531025d9488331b48928128 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/DisplayBlocks.tsx @@ -0,0 +1,227 @@ +import { useMemo } from "react"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import type { DisplayBlock, DiffBlock, TodoBlock, BriefBlock, ShellBlock } from "shared/legacy-sdk"; +import { cn } from "@/lib/utils"; +import * as Diff from "diff"; + +function useIsDark(): boolean { + return typeof document !== "undefined" && document.documentElement.classList.contains("dark"); +} + +interface DiffBlockProps { + block: DiffBlock; + maxHeight?: string; +} + +interface DiffPart { + value: string; + added?: boolean; + removed?: boolean; +} + +function renderDiffLine(parts: DiffPart[], type: "added" | "removed"): React.ReactNode { + return parts.map((part, i) => { + if (type === "removed") { + if (part.added) return null; + return ( + + {part.value} + + ); + } else { + if (part.removed) return null; + return ( + + {part.value} + + ); + } + }); +} + +// Extract diff computation to a pure function for memoization +function computeDiffLines(oldText: string, newText: string): { oldLines: DiffPart[][]; newLines: DiffPart[][] } { + const diffParts = Diff.diffWords(oldText, newText); + + const oldLines: DiffPart[][] = []; + const newLines: DiffPart[][] = []; + let currentOldLine: DiffPart[] = []; + let currentNewLine: DiffPart[] = []; + + for (const part of diffParts) { + const lines = part.value.split("\n"); + lines.forEach((line, lineIndex) => { + const isLastLine = lineIndex === lines.length - 1; + const partForLine: DiffPart = { + value: line, + added: part.added, + removed: part.removed, + }; + + if (!part.added) { + currentOldLine.push(partForLine); + } + if (!part.removed) { + currentNewLine.push(partForLine); + } + + if (!isLastLine) { + if (!part.added) { + oldLines.push(currentOldLine); + currentOldLine = []; + } + if (!part.removed) { + newLines.push(currentNewLine); + currentNewLine = []; + } + } + }); + } + + if (currentOldLine.length > 0) oldLines.push(currentOldLine); + if (currentNewLine.length > 0) newLines.push(currentNewLine); + + return { oldLines, newLines }; +} + +export function DiffBlockView({ block, maxHeight = "max-h-40" }: DiffBlockProps) { + const fileName = block.path.split("/").pop() || block.path; + const hasOld = block.old_text.length > 0; + const hasNew = block.new_text.length > 0; + + const { oldLines, newLines } = useMemo(() => computeDiffLines(block.old_text, block.new_text), [block.old_text, block.new_text]); + + return ( +
+
{fileName}
+
+ {hasOld && ( +
+
+ {oldLines.map((lineParts, i) => ( +
+ - + {renderDiffLine(lineParts, "removed") || " "} +
+ ))} +
+
+ )} + {hasNew && ( +
+
+ {newLines.map((lineParts, i) => ( +
+ + + {renderDiffLine(lineParts, "added") || " "} +
+ ))} +
+
+ )} +
+
+ ); +} + +interface TodoBlockProps { + block: TodoBlock; +} + +export function TodoBlockView({ block }: TodoBlockProps) { + return ( +
+ {block.items.map((item, i) => ( +
+ + {item.title} +
+ ))} +
+ ); +} + +interface BriefBlockProps { + block: BriefBlock; +} + +export function BriefBlockView({ block }: BriefBlockProps) { + return
{block.text}
; +} + +interface ShellBlockProps { + block: ShellBlock; + maxHeight?: string; +} + +export function ShellBlockView({ block, maxHeight = "max-h-40" }: ShellBlockProps) { + console.log("ShellBlockView render", { block }); + const isDark = useIsDark(); + const language = block.language || "bash"; + + return ( +
+
+ $ + Shell Command +
+
+ + {block.command} + +
+
+ ); +} + +interface DisplayBlockViewProps { + block: DisplayBlock; + maxHeight?: string; +} + +export function DisplayBlockView({ block, maxHeight }: DisplayBlockViewProps) { + switch (block.type) { + case "diff": + return ; + case "todo": + return ; + case "brief": + return ; + default: + return null; + } +} + +interface DisplayBlocksProps { + blocks: DisplayBlock[]; + maxHeight?: string; + className?: string; +} + +export function DisplayBlocks({ blocks, maxHeight, className }: DisplayBlocksProps) { + if (!blocks || blocks.length === 0) { + return null; + } + + return ( +
+ {blocks.map((block, i) => ( + + ))} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/FileChangesPanel.tsx b/apps/vscode/webview-ui/src/components/FileChangesPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ac6e6bdddd4741d729c3db643e7188b8dad09fc7 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/FileChangesPanel.tsx @@ -0,0 +1,185 @@ +import { useState } from "react"; +import { IconFilePlus, IconFileMinus, IconFileX, IconArrowBackUp, IconCheck, IconGitCompare } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useChatStore } from "@/stores"; +import { bridge } from "@/services"; +import { cn } from "@/lib/utils"; +import { FileChange } from "shared/types"; +import { toast } from "./ui/sonner"; + +const STATUS_CONFIG = { + Added: { icon: IconFilePlus, color: "text-green-600 dark:text-green-400" }, + Deleted: { icon: IconFileX, color: "text-red-600 dark:text-red-400" }, + Modified: { icon: IconFileMinus, color: "text-yellow-600 dark:text-yellow-400" }, +} as const; + +function getTotalStats(changes: FileChange[]) { + return changes.reduce( + (a, c) => ({ + additions: a.additions + c.additions, + deletions: a.deletions + c.deletions, + }), + { additions: 0, deletions: 0 }, + ); +} + +interface FileItemProps { + file: FileChange; + onRevert: () => void; + onKeep: () => void; + onViewDiff: () => void; + disabled: boolean; + isStreaming?: boolean; +} + +function FileItem({ file, onRevert, onKeep, onViewDiff, disabled, isStreaming }: FileItemProps) { + const { icon: Icon, color } = STATUS_CONFIG[file.status]; + const name = file.path.split("/").pop() || file.path; + const dir = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : ""; + + return ( +
+ +
+ {name} + {dir && {dir}} +
+
+ + + + + View Changes + + {!isStreaming && ( + <> + + + + + Undo Changes + + + + + + Keep Changes + + + )} +
+
+ +{file.additions} + -{file.deletions} +
+
+ ); +} + +interface FileChangesPanelProps { + changes: FileChange[]; +} + +export function FileChangesPanel({ changes }: FileChangesPanelProps) { + const { isStreaming } = useChatStore(); + const [loading, setLoading] = useState(false); + + const handleRevert = async (filePath?: string) => { + setLoading(true); + try { + await bridge.revertFiles(filePath); + } catch (error) { + toast.error(`Unable to undo changes: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setLoading(false); + } + }; + + const handleKeep = async (filePath?: string) => { + setLoading(true); + try { + await bridge.keepChanges(filePath); + } catch (error) { + toast.error(`Unable to keep changes: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setLoading(false); + } + }; + + const stats = getTotalStats(changes); + + if (!changes.length) { + return
No file changes
; + } + + return ( +
+ {/* Header with actions */} +
+
+ + {changes.length} file{changes.length !== 1 ? "s" : ""} + +
+ +{stats.additions} + -{stats.deletions} +
+
+ {!isStreaming && ( +
+ + +
+ )} +
+ + {/* File list */} +
+ {changes.map((file) => ( + { + void handleRevert(file.path); + }} + onKeep={() => { + void handleKeep(file.path); + }} + onViewDiff={() => { + void bridge.openFileDiff(file.path); + }} + disabled={loading} + isStreaming={isStreaming} + /> + ))} +
+
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx b/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ea4d8ee52916138e81bf14c5661f102f7f702a26 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/FilePickerMenu.tsx @@ -0,0 +1,132 @@ +import { Fragment, useEffect, useRef } from "react"; +import { IconFolder, IconFile, IconPhoto } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { mentionMatchSpans, type MentionMatchSpan } from "@/lib/mention-match"; + +export interface FileItem { + name: string; + path: string; + isDirectory: boolean; + matchPositions?: number[]; +} + +interface FilePickerMenuProps { + items: FileItem[]; + selectedIndex: number; + isLoading?: boolean; + isStale?: boolean; + showMediaOption?: boolean; + onSelectMedia?: () => void; + onSelectItem: (item: FileItem) => void; + onHover: (index: number) => void; +} + +function parentDir(path: string): string { + const trimmed = path.endsWith("/") ? path.slice(0, -1) : path; + const idx = trimmed.lastIndexOf("/"); + return idx === -1 ? "" : trimmed.slice(0, idx); +} + +function nameSpans(item: FileItem): MentionMatchSpan[] { + const path = item.path.endsWith("/") ? item.path.slice(0, -1) : item.path; + return mentionMatchSpans(item.name, item.matchPositions, Math.max(0, path.length - item.name.length)); +} + +function dirSpans(item: FileItem): MentionMatchSpan[] { + return mentionMatchSpans(parentDir(item.path), item.matchPositions, 0); +} + +export function FilePickerMenu({ + items, + selectedIndex, + isLoading, + isStale = false, + showMediaOption = true, + onSelectMedia, + onSelectItem, + onHover, +}: FilePickerMenuProps) { + const selectedRef = useRef(null); + const hoverSelectionRef = useRef(null); + + useEffect(() => { + if (hoverSelectionRef.current === selectedIndex) { + hoverSelectionRef.current = null; + return; + } + hoverSelectionRef.current = null; + selectedRef.current?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const handleHover = (index: number) => { + if (isStale) return; + hoverSelectionRef.current = index; + onHover(index); + }; + + const headerCount = showMediaOption ? 1 : 0; + + return ( +
+ {showMediaOption && onSelectMedia && ( + + )} +
+ {isLoading ? ( +
Loading…
+ ) : items.length === 0 ? ( +
No files found
+ ) : ( + items.map((item, idx) => { + const itemIndex = idx + headerCount; + const dir = parentDir(item.path); + return ( + + ); + }) + )} +
+
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/Header.tsx b/apps/vscode/webview-ui/src/components/Header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0ffcfd492e371447bcff0fefb2e378e386c3a0b7 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/Header.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { IconPlus, IconChevronDown, IconInfoCircle } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { StreamingConfirmDialog } from "./StreamingConfirmDialog"; +import { KimiLogo } from "./KimiLogo"; +import { SessionList } from "./SessionList"; +import { useChatStore } from "@/stores"; +import { ChatStatus, TokenInfo } from "./ChatStatus"; + +export function Header() { + const [showSessionList, setShowSessionList] = useState(false); + const [showSessionInfo, setShowSessionInfo] = useState(false); + const [showConfirmNew, setShowConfirmNew] = useState(false); + const { startNewConversation, sessionId, messages, isStreaming } = useChatStore(); + + const handleNewSession = async () => { + // If streaming, show confirmation dialog + if (isStreaming) { + setShowConfirmNew(true); + return; + } + + await doStartNewSession(); + }; + + const doStartNewSession = async () => { + await startNewConversation(); + setShowSessionList(false); + setShowConfirmNew(false); + }; + + return ( +
+
+ + Kimi Code +
+
+ {sessionId && ( + + )} + + + + + + + setShowSessionList(false)} /> + + + +
+ + + + + Session Details + Details for this conversation. + +
+
+
Session ID
+ {sessionId} +
+
+
Messages
+ {messages.length} +
+ +
+
+
+ + !open && setShowConfirmNew(false)} + title="Start New Conversation?" + description="The current conversation is still generating a response. Starting a new one will truncate the output. Are you sure you want to continue?" + confirmLabel="New Conversation" + onConfirm={() => { + void doStartNewSession(); + }} + /> +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/InlineError.tsx b/apps/vscode/webview-ui/src/components/InlineError.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ccbe75024bb947a81c3ede65c33409ea4b6f8d7 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/InlineError.tsx @@ -0,0 +1,36 @@ +import { IconAlertCircle, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { useChatStore } from "@/stores"; +import { cn } from "@/lib/utils"; +import type { InlineError as InlineErrorType } from "../stores/chat.store"; + +interface InlineErrorProps { + error: InlineErrorType; +} + +export function InlineError({ error }: InlineErrorProps) { + const { retryLastMessage, isStreaming } = useChatStore(); + + // 如果 detail 和 message 不同,则显示详细错误信息 + const showDetail = error.detail && error.detail !== error.message; + + return ( +
+
+ + {error.message} + +
+ {showDetail &&
{error.detail}
} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/KimiLogo.tsx b/apps/vscode/webview-ui/src/components/KimiLogo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d4a940a24c8390561d6e8769c0a5cd6133fdd940 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/KimiLogo.tsx @@ -0,0 +1,11 @@ +import { useExtensionImageUrl } from "./hooks/useExtensionImageUrl"; + +export function KimiLogo({ className }: { className?: string }) { + const logoUrl = useExtensionImageUrl("kimi-logo.png"); + + if (!logoUrl) { + return null; + } + + return KIMI; +} diff --git a/apps/vscode/webview-ui/src/components/KimiMascot.tsx b/apps/vscode/webview-ui/src/components/KimiMascot.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92a85a236712aaa96da309dfef33ccfd7b1f3cdd --- /dev/null +++ b/apps/vscode/webview-ui/src/components/KimiMascot.tsx @@ -0,0 +1,29 @@ +import { useState, useEffect } from "react"; +import { useExtensionImageUrl } from "./hooks/useExtensionImageUrl"; + +export function KimiMascot({ className }: { className?: string }) { + const [isDark, setIsDark] = useState(() => document.documentElement.classList.contains("dark")); + + useEffect(() => { + const checkTheme = () => { + setIsDark(document.documentElement.classList.contains("dark")); + }; + + const observer = new MutationObserver(checkTheme); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + + const imageName = isDark ? "kimi-banner-dark.svg" : "kimi-banner-light.svg"; + const logoUrl = useExtensionImageUrl(imageName); + + if (!logoUrl) { + return null; + } + + return KIMI; +} diff --git a/apps/vscode/webview-ui/src/components/LoginScreen.tsx b/apps/vscode/webview-ui/src/components/LoginScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60a1c5f490d8ba21b3f2e35feec2c12b0b2cbc00 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/LoginScreen.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from "react"; +import { IconLoader2, IconCopy, IconCheck, IconExternalLink, IconArrowRight } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { KimiMascot } from "./KimiMascot"; +import { bridge, Events } from "@/services"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface LoginScreenProps { + onLoginSuccess: () => void; + onSkip: () => void; +} + +type LoginState = "idle" | "pending" | "error"; + +function isPaymentRequiredError(error: string | null): boolean { + if (!error) return false; + return error.includes("402") || error.toLowerCase().includes("payment required"); +} + +export function LoginScreen({ onLoginSuccess, onSkip }: LoginScreenProps) { + const [state, setState] = useState("idle"); + const [url, setUrl] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [showSubscribeDialog, setShowSubscribeDialog] = useState(false); + + useEffect(() => { + return bridge.on<{ url: string }>(Events.LoginUrl, ({ url }) => { + setUrl(url); + }); + }, []); + + const handleLogin = async () => { + setState("pending"); + setUrl(null); + setError(null); + try { + const result = await bridge.login(); + if (result.success) { + onLoginSuccess(); + } else { + const errorMessage = result.error || "Login failed"; + if (isPaymentRequiredError(errorMessage)) { + setShowSubscribeDialog(true); + setState("idle"); + } else { + setState("error"); + setError(errorMessage); + } + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + if (isPaymentRequiredError(errorMessage)) { + setShowSubscribeDialog(true); + setState("idle"); + } else { + setState("error"); + setError(errorMessage); + } + } + }; + + const handleSubscribe = () => { + // TODO(region-split): derive this from the region profile's siteBase + // (`https://www.kimi.ai/code` for overseas logins). The webview cannot + // resolve the region itself — @moonshot-ai/kimi-code-oauth is not a + // webview dependency and its region resolver is Node-only — so the + // extension host needs to hand the site URL over the bridge first. + window.open("https://www.kimi.com/code", "_blank"); + setShowSubscribeDialog(false); + }; + + const handleCopyUrl = async () => { + if (!url) return; + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (state === "pending") { + return ( +
+
+ +
+
+ + Waiting for authentication… +
+

A browser window should open automatically. Complete the sign-in process there.

+
+ {url && ( +
+

If the browser didn't open, visit this URL:

+
+ {url} + +
+ + + Open in browser + +
+ )} +
+
+ ); + } + + return ( + <> +
+
+ +
+

Welcome to Kimi Code

+
+

Use Kimi Code with your Kimi account subscription or your existing API setup.

+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ +

Use your Kimi account and Kimi Code subscription.

+
+ +
+ +

Use your existing API key configuration.

+
+
+
+
+ + + + + Subscription Required + + Your account does not have an active Kimi Code subscription. Please subscribe to continue using Kimi Code with your account. + + + + setShowSubscribeDialog(false)}>Skip + Subscribe + + + + + ); +} diff --git a/apps/vscode/webview-ui/src/components/MCPServersModal.tsx b/apps/vscode/webview-ui/src/components/MCPServersModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77258a9dbbf783149d49eb07d1131c3d2981eeac --- /dev/null +++ b/apps/vscode/webview-ui/src/components/MCPServersModal.tsx @@ -0,0 +1,595 @@ +import { useState, useEffect, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + IconX, + IconPlus, + IconTrash, + IconServer, + IconKey, + IconRefresh, + IconPlugConnected, + IconLoader2, + IconWorld, + IconTerminal2, + IconBrandGithub, + IconChevronDown, +} from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useSettingsStore } from "@/stores"; +import { bridge } from "@/services"; +import { RECOMMENDED_MCP_SERVERS, recommendedToConfig, type RecommendedMCPServer } from "@/services/recommended-mcp"; +import { cn } from "@/lib/utils"; +import { MCP_SECRET_MASK, type MCPServerConfig } from "shared/legacy-sdk"; + +const MCP_SERVERS_KEY = ["mcpServers"] as const; +const NO_SERVERS: MCPServerConfig[] = []; + +type TransportType = "stdio" | "http"; + +interface KeyValueField { + key: string; + value: string; +} + +interface FormData { + name: string; + transport: TransportType; + url: string; + command: string; + args: string[]; + envVars: KeyValueField[]; + headerVars: KeyValueField[]; + bearerTokenEnvVar: string; + requiresAuth: boolean; +} + +function emptyForm(): FormData { + return { + name: "", + transport: "stdio", + url: "", + command: "", + args: [""], + envVars: [], + headerVars: [], + bearerTokenEnvVar: "", + requiresAuth: false, + }; +} + +function serverToForm(s?: MCPServerConfig): FormData { + if (!s) return emptyForm(); + const isHttp = s.transport === "http"; + return { + name: s.name, + transport: isHttp ? "http" : "stdio", + url: s.url ?? "", + command: s.command ?? "", + args: s.args ? [...s.args] : [], + envVars: s.env ? Object.entries(s.env).map(([key, value]) => ({ key, value })) : [], + headerVars: s.headers ? Object.entries(s.headers).map(([key, value]) => ({ key, value })) : [], + bearerTokenEnvVar: s.bearerTokenEnvVar ?? "", + requiresAuth: s.auth === "oauth", + }; +} + +function formToConfig(f: FormData): MCPServerConfig { + const env = f.envVars.reduce((acc, { key, value }) => (key.trim() ? { ...acc, [key.trim()]: value } : acc), {} as Record); + const headers = f.headerVars.reduce((acc, { key, value }) => (key.trim() ? { ...acc, [key.trim()]: value } : acc), {} as Record); + if (f.transport === "http") { + const bearerTokenEnvVar = f.bearerTokenEnvVar.trim(); + return { + name: f.name.trim(), + transport: "http", + url: f.url.trim(), + headers: Object.keys(headers).length > 0 ? headers : undefined, + bearerTokenEnvVar: bearerTokenEnvVar || undefined, + auth: f.requiresAuth ? "oauth" : undefined, + }; + } + const args = f.args.filter((arg) => arg.length > 0); + return { + name: f.name.trim(), + transport: "stdio", + command: f.command.trim(), + args: args.length > 0 ? args : undefined, + env: Object.keys(env).length > 0 ? env : undefined, + }; +} + +function validateForm(f: FormData): string | null { + if (!f.name.trim()) return "Name required"; + if (f.transport === "http" && !f.url.trim()) return "URL required"; + if (f.transport === "stdio" && !f.command.trim()) return "Command required"; + return null; +} + +function KeyValueFields({ + label, + fields, + onChange, +}: { + label: string; + fields: KeyValueField[]; + onChange: (fields: KeyValueField[]) => void; +}) { + return ( +
+
+ + +
+ {fields.map((field, index) => ( +
+ onChange(fields.map((item, itemIndex) => ( + itemIndex === index ? { ...item, key: event.target.value } : item + )))} + placeholder="KEY" + className="h-6 text-xs font-mono flex-1" + /> + = + onChange(fields.map((item, itemIndex) => ( + itemIndex === index ? { ...item, value: event.target.value } : item + )))} + placeholder="value" + className="h-6 text-xs font-mono flex-1" + /> + +
+ ))} +
+ ); +} + +function ServerForm({ + data, + onChange, + onSubmit, + onCancel, + submitLabel, +}: { + data: FormData; + onChange: (d: FormData) => void; + onSubmit: () => void; + onCancel: () => void; + submitLabel: string; +}) { + const [error, setError] = useState(null); + const set = (k: K, v: FormData[K]) => { + onChange({ ...data, [k]: v }); + setError(null); + }; + const handleSubmit = () => { + const err = validateForm(data); + if (err) { + setError(err); + return; + } + onSubmit(); + }; + + return ( +
+
+
+ + set("name", e.target.value)} className="h-7 text-xs" /> +
+
+ +
+ {(["stdio", "http"] as const).map((t) => ( + + ))} +
+
+
+ + {data.transport === "http" ? ( + <> +
+ + set("url", e.target.value)} placeholder="https://…" className="h-7 text-xs font-mono" /> + +
+ set("headerVars", headerVars)} + /> +
+ + set("bearerTokenEnvVar", e.target.value)} + placeholder="MCP_TOKEN" + className="h-7 text-xs font-mono" + /> +
+ + ) : ( +
+
+ + set("command", e.target.value)} placeholder="npx" className="h-7 text-xs font-mono" /> +
+
+
+ + +
+ {data.args.map((arg, index) => ( +
+ set("args", data.args.map((item, itemIndex) => ( + itemIndex === index ? event.target.value : item + )))} + placeholder={index === 0 ? "-y" : "@pkg/name"} + className="h-7 text-xs font-mono flex-1" + /> + +
+ ))} +
+
+ )} + + {data.transport === "stdio" && ( + set("envVars", envVars)} + /> + )} + + {error &&

{error}

} + +
+ + +
+
+ ); +} + +function ServerItem({ server, onDelete }: { server: MCPServerConfig; onDelete: () => void }) { + const [expanded, setExpanded] = useState(false); + const [form, setForm] = useState(() => serverToForm(server)); + const [testOutput, setTestOutput] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const queryClient = useQueryClient(); + + const isHttp = server.transport === "http"; + + const handleUpdate = async () => { + try { + const servers = await bridge.updateMCPServer(server.name, formToConfig(form)); + queryClient.setQueryData(MCP_SERVERS_KEY, servers); + setExpanded(false); + } catch (error) { + setTestOutput(`Update failed: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + const handleAction = async (action: () => Promise) => { + setIsLoading(true); + setTestOutput(null); + try { + await action(); + } catch (error) { + setExpanded(true); + setTestOutput(`Error: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsLoading(false); + } + }; + + const handleTest = () => + handleAction(async () => { + setExpanded(true); + const result = await bridge.testMCP(server.name); + setTestOutput(result.output); + }); + + const handleAuth = () => handleAction(() => bridge.authMCP(server.name)); + const handleResetAuth = () => handleAction(() => bridge.resetAuthMCP(server.name)); + + return ( +
+
setExpanded(!expanded)}> +
+ {isHttp ? : } +
+
+
+ {server.name} + {server.auth === "oauth" && OAuth} +
+

+ {isHttp ? server.url : ( + <> + {server.command} + {(server.args ?? []).map((arg, index) => {arg})} + + )} +

+
+
e.stopPropagation()}> + {server.auth === "oauth" && ( + <> + + + + )} + + {server.mutable !== false && ( + + )} +
+ +
+ + {expanded && ( +
+ {testOutput && ( +
+ {testOutput.split("\n").map((line, i) => ( +
+ {line} +
+ ))} +
+ )} + {server.mutable === false ? ( +

+ {server.source === "plugin" + ? `Contributed by plugin "${server.origin ?? ""}" — update the plugin manifest instead` + : `Defined in ${server.origin ?? "a project config file"} — edit that file instead`} +

+ ) : ( + { void handleUpdate(); }} onCancel={() => setExpanded(false)} submitLabel="Update" /> + )} +
+ )} +
+ ); +} + +function RecommendedItem({ server, onInstall, isInstalling }: { server: RecommendedMCPServer; onInstall: () => void; isInstalling: boolean }) { + return ( +
+
+ +
+
+
+ {server.name} + {server.github && ( + + + + )} +
+

{server.description}

+
+ +
+ ); +} + +export function MCPServersModal() { + const { mcpModalOpen, setMCPModalOpen } = useSettingsStore(); + const queryClient = useQueryClient(); + const [showAdd, setShowAdd] = useState(false); + const [addForm, setAddForm] = useState(() => emptyForm()); + const [installingRecommended, setInstallingRecommended] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(null); + + const serversQuery = useQuery({ + queryKey: MCP_SERVERS_KEY, + queryFn: () => bridge.getMCPServers(), + enabled: mcpModalOpen, + }); + const mcpServers = serversQuery.data ?? NO_SERVERS; + const loadError = serversQuery.isError ? (serversQuery.error instanceof Error ? serversQuery.error.message : String(serversQuery.error)) : null; + + useEffect(() => { + if (!showAdd) setAddForm(emptyForm()); + }, [showAdd]); + + const installedNames = useMemo(() => new Set(mcpServers.map((s) => s.name)), [mcpServers]); + + const handleAdd = async () => { + setActionError(null); + try { + const servers = await bridge.addMCPServer(formToConfig(addForm)); + queryClient.setQueryData(MCP_SERVERS_KEY, servers); + setShowAdd(false); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setIsDeleting(true); + setActionError(null); + try { + const servers = await bridge.removeMCPServer(deleteTarget); + queryClient.setQueryData(MCP_SERVERS_KEY, servers); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } + setIsDeleting(false); + setDeleteTarget(null); + }; + + const handleInstallRecommended = async (server: RecommendedMCPServer) => { + setInstallingRecommended(server.id); + setActionError(null); + try { + const config = recommendedToConfig(server); + const servers = await bridge.addMCPServer(config); + queryClient.setQueryData(MCP_SERVERS_KEY, servers); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } + setInstallingRecommended(null); + }; + + if (!mcpModalOpen) return null; + + return ( + <> +
+
+
+ +

MCP Servers

+
+
+ + +
+
+
+
+ {(actionError ?? loadError) && ( +
+ {actionError ?? loadError} +
+ )} + {showAdd && ( +
+
+ + Add MCP Server +
+ { void handleAdd(); }} onCancel={() => setShowAdd(false)} submitLabel="Add Server" /> +
+ )} + + {mcpServers.length > 0 && ( +
+ {mcpServers.map((server) => ( + setDeleteTarget(server.name)} /> + ))} +
+ )} + + {mcpServers.length === 0 && !showAdd && ( +
+ +

No MCP servers configured

+
+ )} + +
+

Recommended

+ {RECOMMENDED_MCP_SERVERS.filter((s) => !installedNames.has(s.id)).map((server) => ( + { void handleInstallRecommended(server); }} isInstalling={installingRecommended === server.id} /> + ))} + {RECOMMENDED_MCP_SERVERS.every((s) => installedNames.has(s.id)) && ( +

All recommended servers installed

+ )} +
+
+
+
+ + !open && setDeleteTarget(null)}> + + + Delete MCP Server? + This will remove "{deleteTarget}" from your configuration. This action cannot be undone. + + + Cancel + { void handleDelete(); }} disabled={isDeleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90"> + {isDeleting ? "Deleting…" : "Delete"} + + + + + + ); +} diff --git a/apps/vscode/webview-ui/src/components/Markdown.tsx b/apps/vscode/webview-ui/src/components/Markdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b467bb00fb9a80e281d67301cd5aed9e449ac0be --- /dev/null +++ b/apps/vscode/webview-ui/src/components/Markdown.tsx @@ -0,0 +1,277 @@ +import React, { memo, useMemo, useState, useEffect, useCallback } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { useQuery } from "@tanstack/react-query"; +import { IconVideo } from "@tabler/icons-react"; +import type { Components } from "react-markdown"; +import { parseSegments, parseColorSegments, extractPaths, checkFilesExist, hasColors, isLocalPath } from "@/lib/text-enrichment"; +import { CopyButton } from "@/components/CopyButton"; +import { MediaPreviewModal, StreamImagePreview, ImageLoadFail } from "@/components/MediaPreviewModal"; +import { getMediaTypeFromSrc } from "@/lib/media-utils"; +import { bridge } from "@/services"; + +interface MarkdownProps { + content: string; + className?: string; + enableEnrichment?: boolean; + enableLocalImageRender?: boolean; +} + +function useIsDark(): boolean { + const [isDark, setIsDark] = useState(() => typeof document !== "undefined" && document.documentElement.classList.contains("dark")); + useEffect(() => { + if (typeof document === "undefined") return; + const el = document.documentElement; + const obs = new MutationObserver(() => setIsDark(el.classList.contains("dark"))); + obs.observe(el, { attributes: true, attributeFilter: ["class"] }); + return () => obs.disconnect(); + }, []); + return isDark; +} + +function ColorSwatch({ color }: { color: string }) { + return ; +} + +export function FileLink({ path, display }: { path: string; display: string }) { + const onClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + void bridge.openFile(path); + }, + [path], + ); + return ( + + ); +} + +function VideoLink({ src }: { src: string }) { + const filename = src.split("/").pop() || src; + return ( + + ); +} + +function EnrichedText({ text, fileMap }: { text: string; fileMap: Record }) { + const segments = useMemo(() => parseSegments(text, fileMap), [text, fileMap]); + return ( + <> + {segments.map((seg, i) => { + if (seg.type === "color") { + return ( + + + {seg.value} + + ); + } + if (seg.type === "file") { + return ; + } + return {seg.value}; + })} + + ); +} + +function enrichChildren(children: React.ReactNode, fileMap: Record): React.ReactNode { + return React.Children.map(children, (child) => { + if (typeof child === "string") { + return ; + } + if (!React.isValidElement(child)) { + return child; + } + + // 跳过链接和代码块(但不跳过行内 code,因为会在 code 组件中单独处理) + if (typeof child.type === "string" && ["a", "pre"].includes(child.type)) { + return child; + } + const props = child.props as { children?: React.ReactNode }; + if (props.children === undefined) { + return child; + } + return React.cloneElement(child, undefined, enrichChildren(props.children, fileMap)); + }); +} + +function LocalImage({ src, alt, onPreview }: { src: string; alt?: string; onPreview: (uri: string) => void }) { + const { data } = useQuery({ + queryKey: ["localImage", src], + queryFn: () => bridge.getImageDataUri(src), + staleTime: 10000, + }); + + if (!data) return ; + return ; +} + +function ColorEnrichedText({ text }: { text: string }) { + const segments = useMemo(() => parseColorSegments(text), [text]); + return ( + <> + {segments.map((seg, i) => + seg.type === "color" ? ( + + + {seg.value} + + ) : ( + {seg.value} + ), + )} + + ); +} + +const CodeBlock = memo(function CodeBlock({ code, language, enableHighlight, style }: { code: string; language?: string; enableHighlight: boolean; style?: any }) { + return ( +
+ + {enableHighlight && language ? ( + + {code} + + ) : ( +
+          {code}
+        
+ )} +
+ ); +}); + +// FIX: Replaced unwrapSingleParagraph with unwrapParagraphs. +// The old version only stripped

when it was the sole child of

  • . +// In "loose lists" (items separated by blank lines) with nested sub-lists, +// ReactMarkdown produces [

    ,

      ] as children — two elements — so the +// old single-child check failed and the inner

      was kept, adding +// unwanted vertical spacing inside list items. +// This version strips

      wrappers from ALL children, regardless of count. +function unwrapParagraphs(children: React.ReactNode): React.ReactNode { + return React.Children.map(children, (child) => { + if (React.isValidElement(child) && child.type === "p") { + return (child.props as { children?: React.ReactNode }).children; + } + return child; + }); +} + +export const Markdown = memo(function Markdown({ content, className, enableEnrichment = true, enableLocalImageRender = true }: MarkdownProps) { + const isDark = useIsDark(); + const [fileMap, setFileMap] = useState>({}); + const [previewSrc, setPreviewSrc] = useState(null); + + useEffect(() => { + // When enableEnrichment is false, skip enrichment process + if (!enableEnrichment || !content) { + setFileMap({}); + return; + } + const paths = extractPaths(content); + if (!paths.length) { + setFileMap({}); + return; + } + let cancelled = false; + void checkFilesExist(paths).then((map) => { + if (!cancelled) { + setFileMap(map); + } + }); + return () => { + cancelled = true; + }; + }, [content, enableEnrichment]); + + const codeStyle = isDark ? (oneDark as any) : (oneLight as any); + + const components: Components = useMemo(() => { + const enrich = (children: React.ReactNode) => (enableEnrichment ? enrichChildren(children, fileMap) : children); + return { + p: ({ children }) =>

      {enrich(children)}

      , + li: ({ children }) =>
    • {enrich(unwrapParagraphs(children))}
    • , + strong: ({ children }) => {enrich(children)}, + em: ({ children }) => {enrich(children)}, + td: ({ children }) => {enrich(children)}, + th: ({ children }) => {enrich(children)}, + h1: ({ children }) =>

      {children}

      , + h2: ({ children }) =>

      {children}

      , + h3: ({ children }) =>

      {children}

      , + ul: ({ children }) =>
        {children}
      , + ol: ({ children }) =>
        {children}
      , + a: ({ href, children }) => ( + + {children} + + ), + blockquote: ({ children }) =>
      {children}
      , + table: ({ children }) => ( +
      + {children}
      +
      + ), + hr: () =>
      , + img: ({ src, alt }) => { + if (!src) return null; + if (!enableLocalImageRender) return {src}; + + if (getMediaTypeFromSrc(src) === "video") { + return isLocalPath(src) ? : null; + } + if (isLocalPath(src)) { + return ; + } + return ; + }, + code: ({ className: cn, children, ...props }: any) => { + const match = /language-(\w+)/.exec(cn || ""); + const code = String(children ?? "").replace(/\n$/, ""); + const isInline = !code.includes("\n") && !match; + + if (isInline) { + const showColor = enableEnrichment && hasColors(code); + return ( + + {showColor ? : children} + + ); + } + return ; + }, + }; + }, [enableEnrichment, enableLocalImageRender, fileMap, codeStyle]); + + if (!content) return null; + + return ( +
      + + {content} + + setPreviewSrc(null)} /> +
      + ); +}); diff --git a/apps/vscode/webview-ui/src/components/MediaPreviewModal.tsx b/apps/vscode/webview-ui/src/components/MediaPreviewModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d056fed8e00aa4b5113e0ccef19563d358cdf173 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/MediaPreviewModal.tsx @@ -0,0 +1,94 @@ +import { useState, useLayoutEffect, useCallback } from "react"; +import { IconX, IconPhoto } from "@tabler/icons-react"; +import { getMediaTypeFromDataUri } from "@/lib/media-utils"; + +const IMG_HEIGHT = 128; +const dimensionCache = new Map(); + +export function ImagePlaceholder() { + return ( + + + + ); +} + +export function ImageLoadFail({ path }: { path: string }) { + return ( + + + {path} + + ); +} + +export interface StreamImagePreviewProps { + src: string; + alt?: string; + onPreview: (uri: string) => void; +} + +export function StreamImagePreview({ src, alt, onPreview }: StreamImagePreviewProps) { + const [width, setWidth] = useState(() => dimensionCache.get(src) ?? null); + + useLayoutEffect(() => { + if (width !== null) return; + const img = new Image(); + img.onload = () => { + const w = Math.round(IMG_HEIGHT * (img.naturalWidth / img.naturalHeight)); + dimensionCache.set(src, w); + setWidth(w); + }; + img.src = src; + }, [src, width]); + + if (width === null) return ; + + return ( + {alt onPreview(src)} + /> + ); +} + +interface MediaPreviewModalProps { + src: string | null; + onClose: () => void; +} + +export function MediaPreviewModal({ src, onClose }: MediaPreviewModalProps) { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }, + [onClose], + ); + + useLayoutEffect(() => { + if (src) { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + } + }, [src, handleKeyDown]); + + if (!src) return null; + + const isVideo = getMediaTypeFromDataUri(src) === "video"; + + return ( +
      + + {isVideo ? ( +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/MediaThumbnail.tsx b/apps/vscode/webview-ui/src/components/MediaThumbnail.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ffc7dc56d2855a6123653f1e54da7f496ce22902 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/MediaThumbnail.tsx @@ -0,0 +1,64 @@ +import { IconX, IconPlayerPlay, IconLoader2 } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { getMediaTypeFromDataUri } from "@/lib/media-utils"; + +interface ThumbnailWrapperProps { + onClick?: () => void; + onRemove?: () => void; + sizeClass: string; + children: React.ReactNode; +} + +function ThumbnailWrapper({ onClick, onRemove, sizeClass, children }: ThumbnailWrapperProps) { + return ( +
      +
      + {children} +
      + {onRemove && ( + + )} +
      + ); +} + +interface MediaThumbnailProps { + src?: string; + onClick?: () => void; + onRemove?: () => void; + size?: "sm" | "md"; + className?: string; +} + +export function MediaThumbnail({ src, onClick, onRemove, size = "md", className }: MediaThumbnailProps) { + const sizeClass = size === "sm" ? "size-12" : "size-16"; + const isLoading = !src; + const isVideo = src && getMediaTypeFromDataUri(src) === "video"; + + return ( + + {isLoading ? ( +
      + +
      + ) : isVideo ? ( +
      +
      + ) : ( + Media + )} +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/PlanCard.tsx b/apps/vscode/webview-ui/src/components/PlanCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..df0decf0add9dd402dc38ddace139499e8e63b8d --- /dev/null +++ b/apps/vscode/webview-ui/src/components/PlanCard.tsx @@ -0,0 +1,30 @@ +import { type ReactNode, useState } from "react"; +import { IconClipboardList, IconChevronDown } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; + +interface PlanCardProps { + children: ReactNode; +} + +export function PlanCard({ children }: PlanCardProps) { + const [collapsed, setCollapsed] = useState(false); + + return ( +
      + + {!collapsed && ( +
      + {children} +
      + )} +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/PlanModeButton.tsx b/apps/vscode/webview-ui/src/components/PlanModeButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..be2d0c3f879427e81b33051a4d9ce5a229b05be2 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/PlanModeButton.tsx @@ -0,0 +1,32 @@ +import { IconClipboardList } from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface PlanModeButtonProps { + active: boolean; + onToggle: () => void; +} + +export function PlanModeButton({ active, onToggle }: PlanModeButtonProps) { + const tooltipText = active ? "Plan mode active (click to exit)" : "Enter plan mode"; + + return ( + + + + + {tooltipText} + + ); +} diff --git a/apps/vscode/webview-ui/src/components/QuestionDialog.tsx b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6294d9c8335c59a49da0d8ecfd14daf63aa5d6e4 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/QuestionDialog.tsx @@ -0,0 +1,182 @@ +import { useState, useEffect } from "react"; +import { useChatStore } from "@/stores"; +import { cn } from "@/lib/utils"; + +export function QuestionDialog() { + const { pendingQuestion, respondQuestion } = useChatStore(); + const [customInput, setCustomInput] = useState(""); + const [showCustom, setShowCustom] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(1); + const [questionIndex, setQuestionIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [multiSelected, setMultiSelected] = useState([]); + + const questions = pendingQuestion?.questions ?? []; + const question = questions[questionIndex]; + const isMultiSelect = question?.multi_select === true; + const isLastQuestion = questionIndex + 1 >= questions.length; + + useEffect(() => { + if (pendingQuestion) { + setShowCustom(false); + setCustomInput(""); + setSelectedIndex(1); + setQuestionIndex(0); + setAnswers({}); + setMultiSelected([]); + } + }, [pendingQuestion?.id]); + + if (!pendingQuestion || !question) return null; + + // Step through the questions one by one; submit all answers after the last. + const handleAnswer = async (answer: string) => { + const nextAnswers = { ...answers, [question.question]: answer }; + if (!isLastQuestion) { + setAnswers(nextAnswers); + setQuestionIndex(questionIndex + 1); + setShowCustom(false); + setCustomInput(""); + setSelectedIndex(1); + setMultiSelected([]); + } else { + await respondQuestion(nextAnswers); + } + }; + + const handleSelect = async (optionLabel: string) => { + if (isMultiSelect) { + setMultiSelected((prev) => + prev.includes(optionLabel) ? prev.filter((value) => value !== optionLabel) : [...prev, optionLabel], + ); + return; + } + await handleAnswer(optionLabel); + }; + + const handleCustomSubmit = async () => { + const value = customInput.trim(); + if (!value) return; + if (isMultiSelect) { + setMultiSelected((prev) => (prev.includes(value) ? prev : [...prev, value])); + setCustomInput(""); + setShowCustom(false); + return; + } + await handleAnswer(value); + }; + + const options = question.options || []; + const customIndex = options.length + 1; + const customValues = multiSelected.filter((value) => !options.some((option) => option.label === value)); + + return ( +
      +
      + {questions.length > 1 && ( +
      + Question {questionIndex + 1} of {questions.length} +
      + )} + {question.header &&
      {question.header}
      } +
      {question.question}
      + {isMultiSelect &&
      Select all that apply
      } +
      + {options.map((option, idx) => { + const isChecked = isMultiSelect && multiSelected.includes(option.label); + const isHighlighted = selectedIndex === idx + 1; + return ( + + ); + })} + {customValues.map((value) => ( + + ))} + {showCustom ? ( +
      + setCustomInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void handleCustomSubmit(); + if (e.key === "Escape") setShowCustom(false); + }} + placeholder="Enter your response…" + className="flex-1 px-2 py-1 rounded-md text-xs border border-border bg-background outline-none focus:border-blue-500" + /> + +
      + ) : ( + + )} + {isMultiSelect && ( + + )} +
      +
      +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx b/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dc6c982b50aff1b5c0371363408be2e278b3ba09 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/QueuedMessagesPanel.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { IconTrash, IconArrowUp, IconPencil, IconCheck, IconX, IconBolt } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { useChatStore } from "@/stores"; +import { bridge } from "@/services"; +import { Content } from "@/lib/content"; + +import type { ContentPart } from "shared/legacy-sdk"; + +function QueueItem({ id, content, isStreaming, onEdit }: { id: string; content: string | ContentPart[]; isStreaming: boolean; onEdit: (id: string) => void }) { + const { removeFromQueue, moveQueueItemUp, queue } = useChatStore(); + const text = Content.getText(content); + const hasMedia = Content.hasMedia(content); + const isFirst = queue[0]?.id === id; + + const handleSteer = async () => { + const result = await bridge.steerChat(content); + if (result.ok) { + removeFromQueue(id); + } + }; + + return ( +
      +
      +

      {text || (hasMedia ? "(media)" : "")}

      + {hasMedia && text && + media} +
      +
      + {isStreaming && ( + + )} + + {!isFirst && ( + + )} + +
      +
      + ); +} + +function EditingItem({ id, initialContent, onDone }: { id: string; initialContent: string; onDone: () => void }) { + const [text, setText] = useState(initialContent); + const { editQueueItem } = useChatStore(); + + const handleSave = () => { + if (text.trim()) { + editQueueItem(id, text); + } + onDone(); + }; + + return ( +
      + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSave(); + if (e.key === "Escape") onDone(); + }} + className="flex-1 min-w-0 text-xs bg-transparent border-b border-border outline-none py-0.5" + /> + + +
      + ); +} + +export function QueuedMessagesPanel() { + const { queue, isStreaming } = useChatStore(); + const [editingId, setEditingId] = useState(null); + + if (queue.length === 0) return null; + + return ( +
      + {queue.map((item) => + editingId === item.id ? ( + setEditingId(null)} /> + ) : ( + + ), + )} +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/SessionList.tsx b/apps/vscode/webview-ui/src/components/SessionList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ef2f25bb0c5b29d7baaabb92a21cd7849a9211f --- /dev/null +++ b/apps/vscode/webview-ui/src/components/SessionList.tsx @@ -0,0 +1,236 @@ +import { useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { IconSearch, IconDots, IconTrash, IconCheck } from "@tabler/icons-react"; +import { Input } from "@/components/ui/input"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { StreamingConfirmDialog } from "./StreamingConfirmDialog"; +import { bridge } from "@/services"; +import type { SessionInfo } from "shared/legacy-sdk"; +import { cn } from "@/lib/utils"; +import { useChatStore, useSettingsStore } from "@/stores"; +import { cleanSystemTags } from "shared/utils"; +import { toast } from "./ui/sonner"; + +interface SessionListProps { + onClose: () => void; +} + +const KIMI_SESSIONS_KEY = ["kimiSessions"] as const; +const NO_SESSIONS: SessionInfo[] = []; + +function formatRelativeDate(timestamp: number): string { + const diff = Date.now() - timestamp; + const m = Math.floor(diff / 60000); + const h = Math.floor(diff / 3600000); + const d = Math.floor(diff / 86400000); + if (m < 1) return "Just now"; + if (m < 60) return `${m}m ago`; + if (h < 24) return `${h}h ago`; + if (d < 7) return `${d}d ago`; + return new Date(timestamp).toLocaleDateString(); +} + +interface SessionItemProps { + session: SessionInfo; + isSelected: boolean; + onSelect: () => void; + onDelete: () => void; + dirLabel: string | null; // null = current dir, string = relative path +} + +function SessionItem({ session, isSelected, onSelect, onDelete, dirLabel }: SessionItemProps) { + const [isHovered, setIsHovered] = useState(false); + + return ( +
      setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={onSelect} + > +

      {cleanSystemTags(session.brief) || "Untitled"}

      +
      +
      + {isSelected && } + {formatRelativeDate(session.updatedAt)} + {dirLabel && · {dirLabel}} +
      +
      + + + + + + { + e.stopPropagation(); + onDelete(); + }} + > + + Delete + + + +
      +
      +
      + ); +} + +export function SessionList({ onClose }: SessionListProps) { + const { loadSession, sessionId, startNewConversation, isStreaming } = useChatStore(); + const { workspaceRoot, currentWorkDir, setCurrentWorkDir } = useSettingsStore(); + const [searchQuery, setSearchQuery] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [pendingSession, setPendingSession] = useState(null); + + const queryClient = useQueryClient(); + const { data: kimiSessions = NO_SESSIONS, isPending: loading } = useQuery({ + queryKey: KIMI_SESSIONS_KEY, + queryFn: () => bridge.getAllKimiSessions(), + }); + + const getWorkDirLabel = (sessionWorkDir: string): string | null => { + const activeWorkDir = currentWorkDir || workspaceRoot; + if (sessionWorkDir === activeWorkDir) return null; + if (!workspaceRoot) return sessionWorkDir; + // Show (root) for workspace root, relative path for subdirs + if (sessionWorkDir === workspaceRoot) { + return "/"; + } + if (sessionWorkDir.startsWith(workspaceRoot)) { + return "." + sessionWorkDir.slice(workspaceRoot.length); + } + return sessionWorkDir; + }; + + const filteredSessions = useMemo(() => { + if (!searchQuery.trim()) return kimiSessions; + const q = searchQuery.toLowerCase(); + return kimiSessions.filter((s) => s.brief.toLowerCase().includes(q)); + }, [kimiSessions, searchQuery]); + + const handleSelect = async (session: SessionInfo) => { + console.log("[SessionList] Loading session:", session.id); + + // If streaming, show confirmation dialog + if (isStreaming) { + setPendingSession(session); + return; + } + + await doLoadSession(session); + }; + + const doLoadSession = async (session: SessionInfo) => { + try { + // Switch workDir if session is from a different directory + const activeWorkDir = currentWorkDir || workspaceRoot; + if (session.workDir !== activeWorkDir) { + const newWorkDir = session.workDir === workspaceRoot ? null : session.workDir; + const result = await bridge.setWorkDir(newWorkDir); + if (result.ok) { + setCurrentWorkDir(newWorkDir); + } + } + const events = await bridge.loadSessionHistory(session.id); + await loadSession(session.id, events); + onClose(); + } catch (error) { + console.error("[SessionList] Failed to load session:", error); + toast.error(`Unable to open the conversation: ${error instanceof Error ? error.message : String(error)}`); + } + }; + + const handleConfirmSwitch = async () => { + if (!pendingSession) return; + await doLoadSession(pendingSession); + setPendingSession(null); + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + + setIsDeleting(true); + try { + await bridge.deleteSession(deleteTarget.id); + + if (sessionId === deleteTarget.id) { + await startNewConversation(); + } + + queryClient.setQueryData(KIMI_SESSIONS_KEY, (prev) => prev?.filter((s) => s.id !== deleteTarget.id) ?? []); + } catch (error) { + console.error("[SessionList] Failed to delete session:", error); + toast.error(`Unable to delete the conversation: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setIsDeleting(false); + setDeleteTarget(null); + } + }; + + return ( + <> +
      +
      +
      + + setSearchQuery(e.target.value)} className="pl-8 h-8 text-xs" /> +
      +
      +
      +
      + {loading ? ( +
      Loading…
      + ) : filteredSessions.length === 0 ? ( +
      {searchQuery ? "No conversations found" : "No conversations yet"}
      + ) : ( + filteredSessions.map((session) => ( + { + void handleSelect(session); + }} + onDelete={() => setDeleteTarget(session)} + dirLabel={getWorkDirLabel(session.workDir)} + /> + )) + )} +
      +
      +
      + + !open && setDeleteTarget(null)} + title="Delete Conversation?" + description="This will permanently delete this conversation. This action cannot be undone." + confirmLabel="Delete" + onConfirm={() => { + void handleDelete(); + }} + confirmDisabled={isDeleting} + cancelDisabled={isDeleting} + confirmLoading={isDeleting} + /> + + !open && setPendingSession(null)} + title="Switch Conversation?" + description="The current conversation is still generating a response. Switching will truncate the output. Are you sure you want to continue?" + confirmLabel="Switch" + onConfirm={() => { + void handleConfirmSwitch(); + }} + /> + + ); +} diff --git a/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx b/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c00ae2be58f70509fc3e5d57c0e2ebfc18fd5d36 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/SlashCommandMenu.tsx @@ -0,0 +1,85 @@ +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import type { SlashCommandInfo } from "shared/legacy-sdk"; + +interface SlashCommandMenuProps { + commands: SlashCommandInfo[]; + query: string; + selectedIndex: number; + onSelect: (name: string) => void; + onHover: (index: number) => void; +} + +function highlightMatch(text: string, query: string): React.ReactNode { + if (!query) { + return text; + } + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIdx = 0; + let qi = 0; + + for (let i = 0; i < text.length && qi < lowerQuery.length; i++) { + if (lowerText[i] === lowerQuery[qi]) { + if (i > lastIdx) { + parts.push(text.slice(lastIdx, i)); + } + parts.push( + + {text[i]} + , + ); + lastIdx = i + 1; + qi++; + } + } + + if (lastIdx < text.length) { + parts.push(text.slice(lastIdx)); + } + return parts.length > 0 ? parts : text; +} + +export function SlashCommandMenu({ commands, query, selectedIndex, onSelect, onHover }: SlashCommandMenuProps) { + const selectedRef = useRef(null); + const hoverSelectionRef = useRef(null); + + useEffect(() => { + if (hoverSelectionRef.current === selectedIndex) { + hoverSelectionRef.current = null; + return; + } + hoverSelectionRef.current = null; + selectedRef.current?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + const handleHover = (index: number) => { + hoverSelectionRef.current = index; + onHover(index); + }; + + if (commands.length === 0) { + return
      No commands found
      ; + } + + return ( +
      +
      + {commands.map((cmd, idx) => ( + + ))} +
      +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/StreamingConfirmDialog.tsx b/apps/vscode/webview-ui/src/components/StreamingConfirmDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..58348fb09dfa5e466dfccd8315465cd7f9ff3310 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/StreamingConfirmDialog.tsx @@ -0,0 +1,59 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface StreamingConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + onConfirm: () => void; + confirmDisabled?: boolean; + cancelDisabled?: boolean; + confirmLoading?: boolean; +} + +export function StreamingConfirmDialog({ + open, + onOpenChange, + title, + description = "The current conversation is still generating a response. This action will truncate the output. Are you sure you want to continue?", + confirmLabel = "Continue", + cancelLabel = "Cancel", + onConfirm, + confirmDisabled = false, + cancelDisabled = false, + confirmLoading = false, +}: StreamingConfirmDialogProps) { + return ( + + + + {title} + {description} + + + onOpenChange(false)} disabled={cancelDisabled}> + {cancelLabel} + + + {confirmLoading ? `${confirmLabel}…` : confirmLabel} + + + + + ); +} diff --git a/apps/vscode/webview-ui/src/components/ThinkingBlock.tsx b/apps/vscode/webview-ui/src/components/ThinkingBlock.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ff565bdd81f86cba1e8b09bf165b5f538c2446a --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ThinkingBlock.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; +import { IconChevronDown, IconLoader3, IconBulb } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { Markdown } from "./Markdown"; +import { useSettingsStore } from "@/stores"; + +interface ThinkingBlockProps { + content: string; + finished?: boolean; + compact?: boolean; +} + +export function ThinkingBlock({ content, finished, compact }: ThinkingBlockProps) { + const { extensionConfig } = useSettingsStore(); + const showThinkingContent = extensionConfig.showThinkingContent; + + const [expanded, setExpanded] = useState(extensionConfig.showThinkingExpanded); + const isStreaming = !finished; + + if (!showThinkingContent) { + // Hidden mode: static label, no interaction + return ( +
      +
      +
      + + Thinking + {isStreaming && } +
      +
      +
      + ); + } + + // Show mode: clickable, expandable/collapsible + if (!content && !isStreaming) { + return null; + } + + return ( +
      + + + {expanded && content && ( + + )} +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/ThinkingButton.tsx b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..236d53fcdbd88d0a7d07f738f68a4275e3c362d8 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ThinkingButton.tsx @@ -0,0 +1,80 @@ +import { IconBulb, IconCheck } from "@tabler/icons-react"; + +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { ThinkingMode } from "shared/legacy-sdk"; + +interface ThinkingButtonProps { + mode: ThinkingMode; + effort: string; + efforts?: string[]; + alwaysOn?: boolean; + disabled?: boolean; + /** When set, rendered as a muted wrapping note below the effort options + * (e.g. the mid-conversation switch cache-cost notice). */ + cacheNote?: string; + onToggle: () => void; + onSelectEffort: (effort: string) => void; +} + +function label(effort: string): string { + return effort.charAt(0).toUpperCase() + effort.slice(1); +} + +export function ThinkingButton({ mode, effort, efforts = [], alwaysOn = false, disabled, cacheNote, onToggle, onSelectEffort }: ThinkingButtonProps) { + if (mode === "none") return null; + + const active = effort !== "off" || alwaysOn; + const button = ( + + ); + + if (mode === "effort") { + const options = alwaysOn ? efforts : ["off", ...efforts]; + return ( + + + + {button} + + Thinking effort: {label(effort)} + + + {options.map((option) => ( + onSelectEffort(option)} className="text-xs gap-2"> + + {label(option)} + + ))} + {cacheNote !== undefined && ( +
      + {cacheNote} +
      + )} +
      +
      + ); + } + + const tooltip = mode === "always" ? "Thinking is always enabled for this model" : active ? "Thinking enabled" : "Enable thinking"; + return ( + + {button} + {tooltip} + + ); +} diff --git a/apps/vscode/webview-ui/src/components/ToolRenderers.tsx b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx new file mode 100644 index 0000000000000000000000000000000000000000..37affb02fcc3df4451e14c6daf3a879f1cce0dbd --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ToolRenderers.tsx @@ -0,0 +1,447 @@ +import { useState } from "react"; +import { + IconChevronDown, + IconChevronRight, + IconFile, + IconTerminal2, + IconFileText, + IconReplace, + IconFolderSearch, + IconSubtask, + IconListCheck, + IconSquareCheck, + IconSquare, + IconSquareChevronRight, +} from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { FileLink, Markdown } from "./Markdown"; +import { DisplayBlocks } from "./DisplayBlocks"; +import { formatContentOutput } from "shared/legacy-sdk"; +import { cleanSystemTags } from "shared/utils"; +import { ThinkingBlock } from "./ThinkingBlock"; +import type { UIToolCall, UIStep, UIStepItem } from "@/stores/chat.store"; +import type { ToolResult, DisplayBlock, TodoBlock } from "shared/legacy-sdk"; + +type ToolResultValue = ToolResult["return_value"]; + +interface ToolRendererProps { + call: UIToolCall; + result?: ToolResultValue; + subagentSteps?: UIStep[]; +} + +function parseArgs(args: string | null): Record { + if (!args) { + return {}; + } + try { + return JSON.parse(args); + } catch { + return { raw: args }; + } +} + +function formatOutput(output: string | object | object[]): string { + const raw = formatContentOutput(output as string); + return cleanSystemTags(raw); +} + +function getTodoBlock(display?: DisplayBlock[]): TodoBlock | null { + if (!display) { + return null; + } + return (display.find((b) => b.type === "todo") as TodoBlock) || null; +} + +function getRichDisplayBlocks(display?: DisplayBlock[]): DisplayBlock[] { + if (!display) { + return []; + } + return display.filter((b) => b.type === "diff"); +} + +function CodeBlock({ content, maxLines = 10 }: { content: string; maxLines?: number }) { + const [expanded, setExpanded] = useState(false); + const lines = content.split("\n"); + const shouldCollapse = lines.length > maxLines; + const displayContent = shouldCollapse && !expanded ? lines.slice(0, maxLines).join("\n") : content; + + return ( +
      +
      +        {displayContent}
      +        {shouldCollapse && !expanded && {"\n"}…}
      +      
      + {shouldCollapse && ( + + )} +
      + ); +} + +function StatusIndicator({ status }: { status: "pending" | "success" | "error" }) { + if (status === "pending") { + return ( + + + + + ); + } + return ; +} + +function ToolIcon({ name }: { name: string }) { + const iconClass = "size-3.5 text-muted-foreground"; + switch (name) { + case "Shell": + return ; + case "ReadFile": + return ; + case "WriteFile": + return ; + case "StrReplaceFile": + return ; + case "Glob": + return ; + case "Task": + return ; + case "SetTodoList": + return ; + default: + return ; + } +} + +function IORow({ label, children }: { label: "IN" | "OUT"; children: React.ReactNode }) { + return ( +
      + {label} +
      {children}
      +
      + ); +} + +function TodoStatusIcon({ status }: { status: string }) { + if (status === "done") { + return ( +
      + +
      + ); + } + if (status === "in_progress") { + return ; + } + return ; +} + +function SetTodoListTool({ result }: ToolRendererProps) { + const todoBlock = getTodoBlock(result?.display); + if (!todoBlock || !todoBlock.items || todoBlock.items.length === 0) { + return
      {!result?.is_error && "Todo list updated"}
      ; + } + return ( +
      +
      + {todoBlock.items.map((item, idx) => ( +
      +
      + +
      + {item.title} +
      + ))} +
      +
      + ); +} + +function ShellTool({ call, result }: ToolRendererProps) { + const args = parseArgs(call.arguments); + const command = (args.command as string) || ""; + const output = result ? formatOutput(result.output) : ""; + + return ( +
      + + {command} + + {result && output && ( + + + + )} +
      + ); +} + +function ReadFileTool({ call, result }: ToolRendererProps) { + const args = parseArgs(call.arguments); + const filePath = (args.path as string) || ""; + const lineOffset = args.line_offset as number | undefined; + const output = result ? formatOutput(result.output) : ""; + + return ( +
      + + + + {lineOffset && lineOffset > 1 && :L{lineOffset}} + + + {result && output && ( + + + + )} +
      + ); +} + +function WriteFileTool({ call, result }: ToolRendererProps) { + const args = parseArgs(call.arguments); + const filePath = (args.path as string) || ""; + const mode = (args.mode as string) || "overwrite"; + const richDisplay = getRichDisplayBlocks(result?.display); + const hasRichDisplay = richDisplay.length > 0; + + return ( +
      + + + + ({mode}) + + + {result && ( + + {hasRichDisplay ? ( + + ) : ( + {!result.is_error ? "✓ Written" : formatOutput(result.output)} + )} + + )} +
      + ); +} + +function StrReplaceFileTool({ call, result }: ToolRendererProps) { + const args = parseArgs(call.arguments); + const filePath = (args.path as string) || ""; + const richDisplay = getRichDisplayBlocks(result?.display); + const hasRichDisplay = richDisplay.length > 0; + + return ( +
      + + + + {result && ( + + {hasRichDisplay ? ( + + ) : ( + + {!result.is_error ? "✓ Replaced successfully" : formatOutput(result.output)} + + )} + + )} +
      + ); +} + +function GlobTool({ call, result }: ToolRendererProps) { + const args = parseArgs(call.arguments); + const pattern = (args.pattern as string) || ""; + const directory = args.directory as string | undefined; + const output = result ? formatOutput(result.output) : ""; + + return ( +
      + + + {pattern} + {directory && in {directory}} + + + {result && output && ( + + + + )} +
      + ); +} + +function GenericTool({ call, result }: ToolRendererProps) { + const args = parseArgs(call.arguments); + const output = result ? formatOutput(result.output) : ""; + const richDisplay = getRichDisplayBlocks(result?.display); + const hasRichDisplay = richDisplay.length > 0; + + return ( +
      + + + + {result && ( + + {hasRichDisplay ? ( + + ) : output ? ( + + ) : ( + {!result.is_error ? "✓ Done" : "✗ Failed"} + )} + + )} +
      + ); +} + +function SubagentStepItemRenderer({ item }: { item: UIStepItem }) { + if (item.type === "thinking") { + return ; + } + if (item.type === "text") { + return ; + } + if (item.type === "tool_use") { + return ; + } + return null; +} + +function TaskTool({ call, result, subagentSteps }: ToolRendererProps) { + const [showProcess, setShowProcess] = useState(false); + const args = parseArgs(call.arguments); + const description = (args.description as string) || ""; + const subagentName = (args.subagent_name as string) || (args.subagent_type as string) || "coder"; + const prompt = (args.prompt as string) || ""; + const hasSubagentSteps = subagentSteps && subagentSteps.length > 0; + + const finalOutput = (() => { + if (!hasSubagentSteps) { + return result ? formatOutput(result.output) : ""; + } + const lastStep = subagentSteps[subagentSteps.length - 1]; + const textItems = lastStep.items.filter((i) => i.type === "text"); + if (textItems.length > 0) { + return textItems.map((i) => (i as { type: "text"; content: string }).content).join("\n"); + } + return result ? formatOutput(result.output) : ""; + })(); + + return ( +
      +
      +
      + {subagentName} + {description} +
      + {prompt &&
      {prompt}
      } +
      + {hasSubagentSteps && ( +
      + + {showProcess && ( +
      + {subagentSteps.map((step) => ( +
      +
      Step {step.n}
      +
      + {step.items.map((item, idx) => ( + + ))} +
      +
      + ))} +
      + )} +
      + )} + {result && finalOutput && ( + + + + )} +
      + ); +} + +function getToolLabel(call: UIToolCall): string { + const args = parseArgs(call.arguments); + switch (call.name) { + case "Shell": + return (args.command as string) || "command"; + case "ReadFile": + return (args.path as string)?.split("/").pop() || "file"; + case "WriteFile": + return (args.path as string)?.split("/").pop() || "file"; + case "StrReplaceFile": + return (args.path as string)?.split("/").pop() || "file"; + case "Glob": + return (args.pattern as string) || "pattern"; + case "Task": + return (args.description as string) || "subagent task"; + case "SetTodoList": + return "Update Todos"; + default: + return ""; + } +} + +export function ToolCallCard({ call, result, subagentSteps }: ToolRendererProps) { + const [expanded, setExpanded] = useState(false); + const status = !result ? "pending" : !result.is_error ? "success" : "error"; + + const renderContent = () => { + const props = { call, result, subagentSteps }; + switch (call.name) { + case "Shell": + return ; + case "ReadFile": + return ; + case "WriteFile": + return ; + case "StrReplaceFile": + return ; + case "Glob": + return ; + case "Task": + case "Agent": + return ; + case "SetTodoList": + return ; + default: + return ; + } + }; + + return ( +
      + + {expanded &&
      {renderContent()}
      } +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/WelcomeScreen.tsx b/apps/vscode/webview-ui/src/components/WelcomeScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ea476e248956882358d2c5574e15a99c4c91f9a --- /dev/null +++ b/apps/vscode/webview-ui/src/components/WelcomeScreen.tsx @@ -0,0 +1,20 @@ +import { KimiMascot } from "./KimiMascot"; +import { useWelcomeHint } from "@/hooks/useWelcomeHint"; + +export function WelcomeScreen() { + const hint = useWelcomeHint(); + + return ( +
      + + {hint.component ? ( + hint.component + ) : ( +
      +

      {hint.title}

      +

      {hint.description}

      +
      + )} +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/WorkDirModal.tsx b/apps/vscode/webview-ui/src/components/WorkDirModal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1221696aff52c57a964b0050a0921d2d350fa58c --- /dev/null +++ b/apps/vscode/webview-ui/src/components/WorkDirModal.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { IconFolder, IconFolderOpen, IconCheck, IconHome } from "@tabler/icons-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useSettingsStore, useChatStore } from "@/stores"; +import { bridge } from "@/services"; +import { cn } from "@/lib/utils"; + +export function WorkDirModal() { + const { workDirModalOpen, setWorkDirModalOpen, currentWorkDir, workspaceRoot, setCurrentWorkDir } = useSettingsStore(); + const { startNewConversation } = useChatStore(); + const [loading, setLoading] = useState(false); + + const { data: workDirs = [], isPending, isError } = useQuery({ + queryKey: ["registeredWorkDirs"], + queryFn: () => bridge.getRegisteredWorkDirs(), + enabled: workDirModalOpen, + }); + + const handleSelect = async (dir: string | null) => { + setLoading(true); + try { + const result = await bridge.setWorkDir(dir); + if (result.ok) { + setCurrentWorkDir(result.workDir === workspaceRoot ? null : result.workDir); + await startNewConversation(); + setWorkDirModalOpen(false); + } + } finally { + setLoading(false); + } + }; + + const handleBrowse = async () => { + setLoading(true); + try { + const result = await bridge.browseWorkDir(); + if (result.ok && result.workDir) { + setCurrentWorkDir(result.workDir === workspaceRoot ? null : result.workDir); + await startNewConversation(); + setWorkDirModalOpen(false); + } + } finally { + setLoading(false); + } + }; + + const displayPath = (fullPath: string) => { + if (!workspaceRoot) return fullPath; + if (fullPath === workspaceRoot) return fullPath.split("/").pop() || fullPath; + return fullPath.replace(workspaceRoot, "."); + }; + + const isSelected = (dir: string) => { + if (!currentWorkDir) return dir === workspaceRoot; + return dir === currentWorkDir; + }; + + return ( + + + + Select Working Directory + + +
      + {isPending ? ( +
      Loading…
      + ) : isError ? ( +
      Failed to load working directories
      + ) : ( + workDirs.map((dir) => ( + + )) + )} +
      + + +
      + + {currentWorkDir && ( + + )} +
      + +
      +
      +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/index.ts b/apps/vscode/webview-ui/src/components/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..41671569ed8bf0da4a5d6a119a049011276898b2 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/index.ts @@ -0,0 +1,26 @@ +export { Header } from "./Header"; +export { SessionList } from "./SessionList"; +export { WelcomeScreen } from "./WelcomeScreen"; +export { ChatMessage } from "./ChatMessage"; +export { ChatArea } from "./ChatArea"; +export { ChatStatus } from "./ChatStatus"; +export { InputArea } from "./inputarea/InputArea"; +export { ActionMenu } from "./ActionMenu"; +export { KimiLogo } from "./KimiLogo"; +export { KimiMascot } from "./KimiMascot"; +export { ToolCallCard } from "./ToolRenderers"; +export { ApprovalDialog } from "./ApprovalDialog"; +export { MCPServersModal } from "./MCPServersModal"; +export { WorkDirModal } from "./WorkDirModal"; +export { ThinkingBlock } from "./ThinkingBlock"; +export { ThinkingButton } from "./ThinkingButton"; +export { CompactionCard } from "./CompactionCard"; +export { ConfigErrorScreen } from "./ConfigErrorScreen"; +export { LoginScreen } from "./LoginScreen"; +export { MediaPreviewModal } from "./MediaPreviewModal"; +export { MediaThumbnail } from "./MediaThumbnail"; +export { BottomToolbar } from "./BottomToolbar"; +export { InlineError } from "./InlineError"; +export { QuestionDialog } from "./QuestionDialog"; +export { PlanCard } from "./PlanCard"; +export { PlanModeButton } from "./PlanModeButton"; diff --git a/apps/vscode/webview-ui/src/components/ui/alert-dialog.tsx b/apps/vscode/webview-ui/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..42c0aea5f21a1b61f827fbb57655fd450630949b --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ui/alert-dialog.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ); +} + +function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & Pick, "variant" | "size">) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & Pick, "variant" | "size">) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/apps/vscode/webview-ui/src/components/ui/card.tsx b/apps/vscode/webview-ui/src/components/ui/card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b82b584fce3288f3679e7ba09216d85bc9d1e6db --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ui/card.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, size = "default", ...props }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
      img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", + className, + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      + ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return
      ; +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return
      ; +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return
      ; +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return
      ; +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return
      ; +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/apps/vscode/webview-ui/src/components/ui/field.tsx b/apps/vscode/webview-ui/src/components/ui/field.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c0f75afc3604c9ea2b5028f0b68c9b15d35bacac --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ui/field.tsx @@ -0,0 +1,153 @@ +import { useMemo } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
      [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3 flex flex-col", className)} {...props} /> + ); +} + +function FieldLegend({ className, variant = "legend", ...props }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
      [data-slot=field-group]]:gap-4 group/field-group @container/field-group flex w-full flex-col", className)} + {...props} + /> + ); +} + +const fieldVariants = cva("data-[invalid=true]:text-destructive gap-2 group/field flex w-full", { + variants: { + orientation: { + vertical: "flex-col [&>*]:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center [&>[data-slot=field-label]]:flex-auto has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto @md/field-group:[&>[data-slot=field-label]]:flex-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, +}); + +function Field({ className, orientation = "vertical", ...props }: React.ComponentProps<"div"> & VariantProps) { + return
      ; +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return
      ; +} + +function FieldLabel({ className, ...props }: React.ComponentProps) { + return ( +