SaylorTwift HF Staff commited on
Commit
f59fbe2
·
verified ·
1 Parent(s): 6a2bc3b

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. apps/vscode/.vscode/launch.json +29 -0
  2. apps/vscode/.vscode/tasks.json +77 -0
  3. apps/vscode/scripts/extension-host-smoke.mjs +180 -0
  4. apps/vscode/src/KimiWebviewProvider.ts +182 -0
  5. apps/vscode/src/bridge-handler.ts +350 -0
  6. apps/vscode/src/config/vscode-settings.ts +64 -0
  7. apps/vscode/src/extension.ts +280 -0
  8. apps/vscode/src/handlers/auth.handler.ts +56 -0
  9. apps/vscode/src/handlers/chat.handler.ts +226 -0
  10. apps/vscode/src/handlers/config.handler.ts +205 -0
  11. apps/vscode/src/handlers/file.handler.ts +247 -0
  12. apps/vscode/src/handlers/index.ts +20 -0
  13. apps/vscode/src/handlers/mcp.handler.ts +322 -0
  14. apps/vscode/src/handlers/session.handler.ts +273 -0
  15. apps/vscode/src/handlers/slash-command.ts +314 -0
  16. apps/vscode/src/handlers/types.ts +41 -0
  17. apps/vscode/src/handlers/workspace.handler.ts +44 -0
  18. apps/vscode/src/managers/baseline.manager.ts +864 -0
  19. apps/vscode/src/managers/file.manager.ts +169 -0
  20. apps/vscode/src/migration/index.ts +1 -0
  21. apps/vscode/src/migration/legacy-migration.manager.ts +698 -0
  22. apps/vscode/src/raw-modules.d.ts +4 -0
  23. apps/vscode/src/runtime/event-adapter.ts +463 -0
  24. apps/vscode/src/runtime/kimi-runtime.ts +319 -0
  25. apps/vscode/src/runtime/legacy-approval.ts +71 -0
  26. apps/vscode/src/runtime/replay-adapter.ts +594 -0
  27. apps/vscode/src/runtime/reverse-rpc.ts +97 -0
  28. apps/vscode/src/runtime/session-runtime.ts +656 -0
  29. apps/vscode/src/runtime/tool-display.ts +75 -0
  30. apps/vscode/src/utils/context.ts +9 -0
  31. apps/vscode/src/utils/fs-path.ts +28 -0
  32. apps/vscode/src/utils/session-context.ts +232 -0
  33. apps/vscode/src/utils/string.ts +17 -0
  34. apps/vscode/src/utils/workspace-path.ts +171 -0
  35. apps/vscode/webview-ui/public/kimi-banner-dark.svg +6 -0
  36. apps/vscode/webview-ui/public/kimi-banner-light.svg +6 -0
  37. apps/vscode/webview-ui/public/kimi-logo.png +0 -0
  38. apps/vscode/webview-ui/src/App.tsx +147 -0
  39. apps/vscode/webview-ui/src/components/ActionMenu.tsx +149 -0
  40. apps/vscode/webview-ui/src/components/ApprovalDialog.tsx +82 -0
  41. apps/vscode/webview-ui/src/components/BottomToolbar.tsx +96 -0
  42. apps/vscode/webview-ui/src/components/ChatArea.tsx +64 -0
  43. apps/vscode/webview-ui/src/components/ChatMessage.tsx +340 -0
  44. apps/vscode/webview-ui/src/components/ChatStatus.tsx +117 -0
  45. apps/vscode/webview-ui/src/components/CompactionCard.tsx +23 -0
  46. apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx +185 -0
  47. apps/vscode/webview-ui/src/components/CopyButton.tsx +45 -0
  48. apps/vscode/webview-ui/src/components/DisplayBlocks.tsx +227 -0
  49. apps/vscode/webview-ui/src/components/FileChangesPanel.tsx +185 -0
  50. apps/vscode/webview-ui/src/components/FilePickerMenu.tsx +132 -0
apps/vscode/.vscode/launch.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "Kimi Code: Extension Development Host (isolated)",
6
+ "type": "extensionHost",
7
+ "request": "launch",
8
+ "preLaunchTask": "vscode: dev",
9
+ "args": [
10
+ "--extensionDevelopmentPath=${workspaceFolder}",
11
+ "--user-data-dir=${workspaceFolder}/../../.tmp/vscode-extension-dev/user-data",
12
+ "--extensions-dir=${workspaceFolder}/../../.tmp/vscode-extension-dev/extensions",
13
+ "--disable-workspace-trust",
14
+ "--skip-welcome",
15
+ "--skip-release-notes",
16
+ "${workspaceFolder}/../../.tmp/vscode-extension-dev/workspace"
17
+ ],
18
+ "env": {
19
+ "KIMI_CODE_HOME": "${workspaceFolder}/../../.tmp/vscode-extension-dev/kimi-home"
20
+ },
21
+ "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/dist/**/*.mjs"],
22
+ "sourceMaps": true,
23
+ "resolveSourceMapLocations": [
24
+ "${workspaceFolder}/**",
25
+ "!**/node_modules/**"
26
+ ]
27
+ }
28
+ ]
29
+ }
apps/vscode/.vscode/tasks.json ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "2.0.0",
3
+ "tasks": [
4
+ {
5
+ "label": "vscode: prepare isolated dev",
6
+ "type": "process",
7
+ "command": "pnpm",
8
+ "args": ["run", "dev:prepare"],
9
+ "options": {
10
+ "cwd": "${workspaceFolder}"
11
+ },
12
+ "problemMatcher": []
13
+ },
14
+ {
15
+ "label": "vscode: watch extension",
16
+ "type": "process",
17
+ "command": "pnpm",
18
+ "args": ["run", "dev:extension"],
19
+ "options": {
20
+ "cwd": "${workspaceFolder}"
21
+ },
22
+ "isBackground": true,
23
+ "problemMatcher": {
24
+ "owner": "typescript",
25
+ "fileLocation": "absolute",
26
+ "pattern": {
27
+ "regexp": "^(.*?):(\\d+):(\\d+):\\s+(.*)$",
28
+ "file": 1,
29
+ "line": 2,
30
+ "column": 3,
31
+ "message": 4
32
+ },
33
+ "background": {
34
+ "activeOnStart": true,
35
+ "beginsPattern": ".*",
36
+ "endsPattern": "Extension dev build complete; watching for changes"
37
+ }
38
+ }
39
+ },
40
+ {
41
+ "label": "vscode: watch webview",
42
+ "type": "process",
43
+ "command": "pnpm",
44
+ "args": ["run", "dev:webview"],
45
+ "options": {
46
+ "cwd": "${workspaceFolder}"
47
+ },
48
+ "isBackground": true,
49
+ "problemMatcher": {
50
+ "owner": "vite",
51
+ "fileLocation": "absolute",
52
+ "pattern": {
53
+ "regexp": "^(.*?):(\\d+):(\\d+):\\s+(.*)$",
54
+ "file": 1,
55
+ "line": 2,
56
+ "column": 3,
57
+ "message": 4
58
+ },
59
+ "background": {
60
+ "activeOnStart": true,
61
+ "beginsPattern": ".*",
62
+ "endsPattern": "built in \\d+.*ms"
63
+ }
64
+ }
65
+ },
66
+ {
67
+ "label": "vscode: dev",
68
+ "dependsOrder": "parallel",
69
+ "dependsOn": [
70
+ "vscode: prepare isolated dev",
71
+ "vscode: watch extension",
72
+ "vscode: watch webview"
73
+ ],
74
+ "problemMatcher": []
75
+ }
76
+ ]
77
+ }
apps/vscode/scripts/extension-host-smoke.mjs ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import { runTests, runVSCodeCommand } from "@vscode/test-electron";
8
+
9
+ import { isMainModule } from "./vsix-targets.mjs";
10
+
11
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
12
+ const appDir = resolve(scriptDir, "..");
13
+ const defaultCachePath = join(tmpdir(), "kimi-vscode-test-cache");
14
+
15
+ export async function runExtensionHostSmoke(options = {}) {
16
+ const version = options.version ?? "stable";
17
+ const cacheRoot = resolve(options.cachePath ?? process.env.VSCODE_TEST_CACHE ?? defaultCachePath);
18
+ const vsixPath = resolve(options.vsixPath ?? defaultVsixPath());
19
+ await access(vsixPath);
20
+ await mkdir(cacheRoot, { recursive: true });
21
+
22
+ // A stable request must never reuse an older stable download. @vscode/test-electron
23
+ // falls back to a cached build when version discovery fails, which would turn an
24
+ // offline run into a false green. Exact versions remain cached by version.
25
+ const disposableCache = version === "stable";
26
+ const cachePath = disposableCache
27
+ ? await mkdtemp(join(cacheRoot, "stable-"))
28
+ : cacheRoot;
29
+
30
+ const root = await mkdtemp(join(tmpdir(), "kvh-"));
31
+ const paths = {
32
+ root,
33
+ extensions: join(root, "ext"),
34
+ installUserData: join(root, "install"),
35
+ userData: join(root, "user"),
36
+ kimiHome: join(root, "home"),
37
+ osHome: join(root, "os-home"),
38
+ workspace: join(root, "ws"),
39
+ harness: join(root, "harness"),
40
+ report: join(root, "extension-host-report.json"),
41
+ };
42
+
43
+ try {
44
+ await Promise.all([
45
+ paths.extensions,
46
+ paths.installUserData,
47
+ paths.userData,
48
+ paths.kimiHome,
49
+ paths.osHome,
50
+ paths.workspace,
51
+ paths.harness,
52
+ ].map((path) => mkdir(path, { recursive: true })));
53
+ await writeFile(join(paths.workspace, "README.md"), "# Kimi VSIX Extension Host smoke\n", "utf8");
54
+ await writeHarnessManifest(paths.harness);
55
+
56
+ const installProfileArgs = [
57
+ `--extensions-dir=${paths.extensions}`,
58
+ `--user-data-dir=${paths.installUserData}`,
59
+ ];
60
+ const profileArgs = [
61
+ `--extensions-dir=${paths.extensions}`,
62
+ `--user-data-dir=${paths.userData}`,
63
+ ];
64
+ const downloadOptions = { version, cachePath };
65
+ const install = await runVSCodeCommand(
66
+ ["--install-extension", vsixPath, "--force", ...installProfileArgs],
67
+ downloadOptions,
68
+ );
69
+ const installOutput = `${install.stdout}\n${install.stderr}`;
70
+ if (!/successfully installed|was successfully installed/i.test(installOutput)) {
71
+ throw new Error(`VSIX installation did not report success:\n${installOutput.trim()}`);
72
+ }
73
+
74
+ await runTests({
75
+ ...downloadOptions,
76
+ extensionDevelopmentPath: paths.harness,
77
+ extensionTestsPath: join(appDir, "test", "extension-host", "index.cjs"),
78
+ launchArgs: [
79
+ paths.workspace,
80
+ ...profileArgs,
81
+ "--disable-workspace-trust",
82
+ "--skip-welcome",
83
+ "--skip-release-notes",
84
+ ],
85
+ extensionTestsEnv: {
86
+ KIMI_CODE_HOME: paths.kimiHome,
87
+ KIMI_VSCODE_SMOKE_OS_HOME: paths.osHome,
88
+ KIMI_VSCODE_SMOKE_REPORT: paths.report,
89
+ KIMI_VSCODE_SMOKE_VSIX: basename(vsixPath),
90
+ },
91
+ });
92
+
93
+ const report = JSON.parse(await readFile(paths.report, "utf8"));
94
+ if (typeof report.vscode !== "string" || report.vscode.length === 0) {
95
+ throw new Error("Extension Host smoke did not report its actual VS Code version");
96
+ }
97
+ if (version !== "stable" && report.vscode !== version) {
98
+ throw new Error(
99
+ `Extension Host ran VS Code ${report.vscode}, expected requested version ${version}`,
100
+ );
101
+ }
102
+
103
+ return { version, vscodeVersion: report.vscode, vsixPath, cachePath };
104
+ } finally {
105
+ await Promise.all([
106
+ rm(root, { recursive: true, force: true }),
107
+ disposableCache ? rm(cachePath, { recursive: true, force: true }) : Promise.resolve(),
108
+ ]);
109
+ }
110
+ }
111
+
112
+ function defaultVsixPath() {
113
+ const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : process.arch;
114
+ return join(appDir, "artifacts", "vsix", `kimi-code-${process.platform}-${arch}.vsix`);
115
+ }
116
+
117
+ async function writeHarnessManifest(directory) {
118
+ await writeFile(
119
+ join(directory, "package.json"),
120
+ JSON.stringify({
121
+ name: "kimi-vscode-extension-host-smoke",
122
+ displayName: "Kimi VSCode Extension Host Smoke",
123
+ publisher: "local-test",
124
+ version: "0.0.0",
125
+ engines: { vscode: "^1.70.0" },
126
+ main: "./extension.cjs",
127
+ activationEvents: ["*"],
128
+ }),
129
+ "utf8",
130
+ );
131
+ await writeFile(
132
+ join(directory, "extension.cjs"),
133
+ "exports.activate = function activate() {}; exports.deactivate = function deactivate() {};\n",
134
+ "utf8",
135
+ );
136
+ }
137
+
138
+ function parseArguments(argv) {
139
+ const options = {};
140
+ for (let index = 0; index < argv.length; index += 1) {
141
+ const argument = argv[index];
142
+ if (argument === "--") continue;
143
+ if (argument === "--version") {
144
+ options.version = requiredValue(argv[++index], argument);
145
+ } else if (argument === "--vsix") {
146
+ options.vsixPath = requiredValue(argv[++index], argument);
147
+ } else if (argument === "--cache-path") {
148
+ options.cachePath = requiredValue(argv[++index], argument);
149
+ } else if (argument === "--help" || argument === "-h") {
150
+ options.help = true;
151
+ } else {
152
+ throw new Error(`Unknown option: ${argument}`);
153
+ }
154
+ }
155
+ return options;
156
+ }
157
+
158
+ function requiredValue(value, flag) {
159
+ if (value === undefined || value.startsWith("-")) throw new Error(`${flag} requires a value`);
160
+ return value;
161
+ }
162
+
163
+ async function main() {
164
+ const options = parseArguments(process.argv.slice(2));
165
+ if (options.help) {
166
+ console.log("Usage: node scripts/extension-host-smoke.mjs [--version <stable|x.y.z>] [--vsix <path>] [--cache-path <path>]");
167
+ return;
168
+ }
169
+ const result = await runExtensionHostSmoke(options);
170
+ console.log(
171
+ `VSIX Extension Host smoke passed: ${result.vsixPath} on VS Code ${result.vscodeVersion} (requested ${result.version})`,
172
+ );
173
+ }
174
+
175
+ if (isMainModule(import.meta.url)) {
176
+ main().catch((error) => {
177
+ console.error(`VSIX Extension Host smoke failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
178
+ process.exitCode = 1;
179
+ });
180
+ }
apps/vscode/src/KimiWebviewProvider.ts ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+ import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk";
3
+ import { Events } from "../shared/bridge";
4
+ import { BridgeHandler } from "./bridge-handler";
5
+
6
+ function getNonce(): string {
7
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
8
+ let nonce = "";
9
+ for (let i = 0; i < 32; i++) {
10
+ nonce += chars.charAt(Math.floor(Math.random() * chars.length));
11
+ }
12
+ return nonce;
13
+ }
14
+
15
+ /**
16
+ * Manages webview instances (sidebar and panels).
17
+ * Each webview gets a unique viewId for session isolation.
18
+ */
19
+ export class KimiWebviewProvider implements vscode.WebviewViewProvider {
20
+ private webviews = new Map<string, vscode.Webview>();
21
+ private bridgeHandler: BridgeHandler;
22
+
23
+ constructor(
24
+ private readonly extensionUri: vscode.Uri,
25
+ context: vscode.ExtensionContext,
26
+ showLogs: () => void,
27
+ writeLog: (message: string) => void,
28
+ ) {
29
+ this.bridgeHandler = new BridgeHandler(
30
+ this.broadcastInternal.bind(this),
31
+ context.workspaceState,
32
+ context.globalStorageUri.fsPath,
33
+ this.reloadWebview.bind(this),
34
+ showLogs,
35
+ writeLog,
36
+ );
37
+ }
38
+
39
+ dispose(): void {
40
+ void this.bridgeHandler.dispose();
41
+ }
42
+
43
+ shutdown(): Promise<void> {
44
+ return this.bridgeHandler.dispose();
45
+ }
46
+
47
+ get harness(): KimiHarness {
48
+ return this.bridgeHandler.runtime.harness;
49
+ }
50
+
51
+ resolveWebviewView(webviewView: vscode.WebviewView): void {
52
+ const webviewId = `sidebar_${crypto.randomUUID()}`;
53
+ this.setupWebview(webviewId, webviewView.webview);
54
+
55
+ webviewView.onDidDispose(() => {
56
+ void this.bridgeHandler.disposeView(webviewId);
57
+ this.webviews.delete(webviewId);
58
+ });
59
+ }
60
+
61
+ createPanel(): vscode.WebviewPanel {
62
+ const webviewId = `panel_${crypto.randomUUID()}`;
63
+
64
+ const panel = vscode.window.createWebviewPanel("kimiPanel", "Kimi Code", vscode.ViewColumn.One, {
65
+ enableScripts: true,
66
+ retainContextWhenHidden: true,
67
+ localResourceRoots: [this.extensionUri],
68
+ });
69
+
70
+ this.setupWebview(webviewId, panel.webview);
71
+
72
+ panel.onDidDispose(() => {
73
+ void this.bridgeHandler.disposeView(webviewId);
74
+ this.webviews.delete(webviewId);
75
+ });
76
+
77
+ return panel;
78
+ }
79
+
80
+ broadcast(event: string, data: unknown): void {
81
+ this.broadcastInternal(event, data);
82
+ }
83
+
84
+ async insertEditorMention(documentUri: vscode.Uri, selection: vscode.Selection): Promise<boolean> {
85
+ let inserted = false;
86
+ await Promise.all(
87
+ [...this.webviews.keys()].map(async (webviewId) => {
88
+ const mention = await this.bridgeHandler.getEditorMention(webviewId, documentUri, selection);
89
+ if (mention === null) return;
90
+ inserted = true;
91
+ this.broadcastInternal(Events.InsertMention, { mention }, webviewId);
92
+ }),
93
+ );
94
+ return inserted;
95
+ }
96
+
97
+ private setupWebview(webviewId: string, webview: vscode.Webview): void {
98
+ webview.options = {
99
+ enableScripts: true,
100
+ localResourceRoots: [this.extensionUri],
101
+ };
102
+
103
+ webview.html = this.getHtml(webviewId, webview);
104
+ this.webviews.set(webviewId, webview);
105
+
106
+ webview.onDidReceiveMessage(async (msg: unknown) => {
107
+ const result = await this.bridgeHandler.handle(msg, webviewId);
108
+ webview.postMessage(result);
109
+ });
110
+ }
111
+
112
+ private broadcastInternal(event: string, data: unknown, targetWebviewId?: string): void {
113
+ const msg = { event, data };
114
+
115
+ if (targetWebviewId) {
116
+ void this.webviews.get(targetWebviewId)?.postMessage(msg);
117
+ } else {
118
+ this.webviews.forEach((webview) => {
119
+ void webview.postMessage(msg);
120
+ });
121
+ }
122
+ }
123
+
124
+ private reloadWebview(webviewId: string): void {
125
+ const webview = this.webviews.get(webviewId);
126
+ if (webview) {
127
+ webview.html = this.getHtml(webviewId, webview);
128
+ }
129
+ }
130
+
131
+ reloadAllWebviews(): void {
132
+ this.webviews.forEach((webview, webviewId) => {
133
+ webview.html = this.getHtml(webviewId, webview);
134
+ });
135
+ }
136
+
137
+ async resetAllWebviews(): Promise<void> {
138
+ await Promise.all(
139
+ [...this.webviews.keys()].map((webviewId) => this.bridgeHandler.disposeView(webviewId)),
140
+ );
141
+ this.reloadAllWebviews();
142
+ }
143
+
144
+ getBaselineContent(sessionId: string, filePath: string): Promise<string> {
145
+ return this.bridgeHandler.getBaselineContent(sessionId, filePath);
146
+ }
147
+
148
+ async setYoloModeForActiveSessions(enabled: boolean): Promise<void> {
149
+ await this.bridgeHandler.runtime.setYoloModeForActiveSessions(enabled);
150
+ }
151
+
152
+ private getHtml(webviewId: string, webview: vscode.Webview): string {
153
+ const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js"));
154
+ const baseUri = webview.asWebviewUri(this.extensionUri).toString();
155
+ const nonce = getNonce();
156
+
157
+ const csp = [
158
+ `default-src 'none'`,
159
+ `style-src ${webview.cspSource} 'unsafe-inline'`,
160
+ `img-src ${webview.cspSource} data: blob:`,
161
+ `font-src ${webview.cspSource}`,
162
+ `media-src ${webview.cspSource} data: blob:`,
163
+ `connect-src ${webview.cspSource}`,
164
+ `worker-src ${webview.cspSource} blob:`,
165
+ `script-src 'nonce-${nonce}' ${webview.cspSource}`,
166
+ ].join("; ");
167
+
168
+ return `<!DOCTYPE html>
169
+ <html lang="en">
170
+ <head>
171
+ <meta charset="UTF-8">
172
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
173
+ <meta http-equiv="Content-Security-Policy" content="${csp}">
174
+ <title>Kimi Code</title>
175
+ </head>
176
+ <body data-baseuri="${baseUri}" data-webviewid="${webviewId}">
177
+ <div id="root"></div>
178
+ <script nonce="${nonce}" src="${scriptUri.toString()}"></script>
179
+ </body>
180
+ </html>`;
181
+ }
182
+ }
apps/vscode/src/bridge-handler.ts ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as path from "node:path";
2
+ import * as vscode from "vscode";
3
+
4
+ import {
5
+ validateRpcMessage,
6
+ type RpcMethod,
7
+ type RpcResult,
8
+ } from "../shared/bridge";
9
+ import { VSCodeSettings } from "./config/vscode-settings";
10
+ import { handlers, type BroadcastFn, type HandlerContext, type ReloadWebviewFn, type ShowLogsFn } from "./handlers";
11
+ import { BaselineManager, type BaselineSession } from "./managers/baseline.manager";
12
+ import { FileManager } from "./managers/file.manager";
13
+ import { KimiRuntime } from "./runtime/kimi-runtime";
14
+ import type { SessionRuntime } from "./runtime/session-runtime";
15
+ import { areSameFsPath } from "./utils/fs-path";
16
+ import {
17
+ isWorkspacePathContained,
18
+ isWorkspacePathContainedSync,
19
+ relativeWorkspacePath,
20
+ resolveWorkspacePath,
21
+ type WorkspacePath,
22
+ workDirUriFromPath,
23
+ } from "./utils/workspace-path";
24
+
25
+ export class BridgeHandler {
26
+ readonly baselineManager: BaselineManager;
27
+ readonly runtime: KimiRuntime;
28
+
29
+ private readonly customWorkDirs = new Map<string, string>();
30
+ private readonly fileManager: FileManager;
31
+
32
+ constructor(
33
+ private readonly broadcast: BroadcastFn,
34
+ private readonly workspaceState: vscode.Memento,
35
+ globalStoragePath: string,
36
+ private readonly reloadWebview: ReloadWebviewFn,
37
+ private readonly showLogs: ShowLogsFn,
38
+ private readonly writeLog: (message: string) => void,
39
+ ) {
40
+ try {
41
+ this.runtime = new KimiRuntime({
42
+ version: VSCodeSettings.getExtensionConfig().version,
43
+ broadcast,
44
+ captureBaseline: (session, filePath, webviewIds) => {
45
+ this.captureFileBaseline(session, filePath, webviewIds);
46
+ },
47
+ log: (message, error) => this.logRuntimeError(message, error),
48
+ });
49
+ } catch (error) {
50
+ throw new Error(
51
+ `Failed to start the Kimi engine: ${error instanceof Error ? error.message : String(error)}.`,
52
+ { cause: error },
53
+ );
54
+ }
55
+ this.baselineManager = new BaselineManager(globalStoragePath, this.runtime.harness.homeDir);
56
+ this.fileManager = new FileManager(this.baselineManager, broadcast);
57
+ }
58
+
59
+ async handle(value: unknown, webviewId: string): Promise<RpcResult> {
60
+ const startedAt = Date.now();
61
+ const validation = validateRpcMessage(value);
62
+ if (!validation.ok) {
63
+ this.trace(validation.id, validation.method, Date.now() - startedAt, false);
64
+ this.logRuntimeError(`Bridge request rejected: ${validation.method}`, validation.error);
65
+ return { id: validation.id, error: validation.error };
66
+ }
67
+
68
+ const msg = validation.message;
69
+ try {
70
+ const result = await this.dispatch(msg.method, msg.params, webviewId);
71
+ this.trace(msg.id, msg.method, Date.now() - startedAt, true);
72
+ return { id: msg.id, result };
73
+ } catch (error) {
74
+ this.trace(msg.id, msg.method, Date.now() - startedAt, false);
75
+ this.logRuntimeError(`Bridge request failed: ${msg.method}`, error);
76
+ return {
77
+ id: msg.id,
78
+ error: error instanceof Error ? error.message : String(error),
79
+ };
80
+ }
81
+ }
82
+
83
+ private get workspaceRoot(): string | null {
84
+ return this.workspaceRootUri?.fsPath ?? null;
85
+ }
86
+
87
+ private get workspaceRootUri(): vscode.Uri | null {
88
+ return vscode.workspace.workspaceFolders?.[0]?.uri ?? null;
89
+ }
90
+
91
+ private getWorkDir(webviewId: string): string | null {
92
+ return this.customWorkDirs.get(webviewId) ?? this.workspaceRoot;
93
+ }
94
+
95
+ private getWorkDirUri(webviewId: string): vscode.Uri | null {
96
+ const workspaceRoot = this.workspaceRoot;
97
+ const workspaceRootUri = this.workspaceRootUri;
98
+ const workDir = this.getWorkDir(webviewId);
99
+ if (workspaceRoot === null || workspaceRootUri === null || workDir === null) return null;
100
+ return workDirUriFromPath(workspaceRootUri, workspaceRoot, workDir) ?? null;
101
+ }
102
+
103
+ private async setCustomWorkDir(webviewId: string, workDir: string | null): Promise<void> {
104
+ const workspaceRoot = this.workspaceRoot;
105
+ const workspaceRootUri = this.workspaceRootUri;
106
+ if (workspaceRoot === null || workspaceRootUri === null) throw new Error("No workspace folder open");
107
+ if (workDir !== null) {
108
+ const workDirUri = workDirUriFromPath(workspaceRootUri, workspaceRoot, workDir);
109
+ if (workDirUri === undefined || !(await isWorkspacePathContained(workspaceRootUri, workDirUri))) {
110
+ throw new Error("Working directory must be within the workspace");
111
+ }
112
+ }
113
+ if (workDir && workDir !== this.workspaceRoot) {
114
+ this.customWorkDirs.set(webviewId, workDir);
115
+ } else {
116
+ this.customWorkDirs.delete(webviewId);
117
+ }
118
+ await this.runtime.detachView(webviewId);
119
+ this.fileManager.clearSession(webviewId);
120
+ }
121
+
122
+ private requireWorkDir(webviewId: string): string {
123
+ const workDir = this.getWorkDir(webviewId);
124
+ if (!workDir) throw new Error("No workspace folder open");
125
+ return workDir;
126
+ }
127
+
128
+ private requireWorkDirUri(webviewId: string): vscode.Uri {
129
+ const workDirUri = this.getWorkDirUri(webviewId);
130
+ if (!workDirUri) throw new Error("No workspace folder open");
131
+ return workDirUri;
132
+ }
133
+
134
+ private async dispatch(method: RpcMethod, params: unknown, webviewId: string): Promise<unknown> {
135
+ if (!Object.hasOwn(handlers, method)) throw new Error(`Unknown method: ${method}`);
136
+ const handler = handlers[method];
137
+ if (!handler) throw new Error(`Unknown method: ${method}`);
138
+ return handler(params, this.createContext(webviewId));
139
+ }
140
+
141
+ private createContext(webviewId: string): HandlerContext {
142
+ return {
143
+ webviewId,
144
+ workDir: this.getWorkDir(webviewId),
145
+ workDirUri: this.getWorkDirUri(webviewId),
146
+ workspaceRoot: this.workspaceRoot,
147
+ workspaceRootUri: this.workspaceRootUri,
148
+ workspaceState: this.workspaceState,
149
+ requireWorkDir: () => this.requireWorkDir(webviewId),
150
+ requireWorkDirUri: () => this.requireWorkDirUri(webviewId),
151
+ broadcast: this.broadcast,
152
+ fileManager: this.fileManager,
153
+ baselineManager: this.baselineManager,
154
+ runtime: this.runtime,
155
+ harness: this.runtime.harness,
156
+ reloadWebview: () => this.reloadWebview(webviewId),
157
+ showLogs: this.showLogs,
158
+ logError: (message, error) => this.logRuntimeError(message, error),
159
+ getSession: () => this.runtime.getSessionForView(webviewId),
160
+ getSessionId: () => this.fileManager.getSessionId(webviewId),
161
+ getOrCreateSession: async (model, effort, sessionId) => {
162
+ const runtime = await this.runtime.openSession({
163
+ webviewId,
164
+ workDir: this.requireWorkDir(webviewId),
165
+ model,
166
+ effort,
167
+ yoloMode: VSCodeSettings.yoloMode,
168
+ ...(sessionId === undefined ? {} : { sessionId }),
169
+ });
170
+ this.fileManager.setSession(webviewId, baselineSession(runtime));
171
+ return runtime;
172
+ },
173
+ resumeSession: async (sessionId) => {
174
+ const current = this.runtime.getSession(sessionId);
175
+ const session =
176
+ current?.session ??
177
+ (await this.runtime.harness.resumeSession({ id: sessionId, includeSubagents: true }));
178
+ if (!areSameFsPath(session.workDir, this.requireWorkDir(webviewId))) {
179
+ if (current === undefined) {
180
+ await session.close().catch((error: unknown) => {
181
+ this.logRuntimeError("Unable to close a rejected session", error);
182
+ });
183
+ }
184
+ throw new Error("The selected session belongs to a different working directory.");
185
+ }
186
+ const runtime = await this.runtime.attachResumedSession(
187
+ webviewId,
188
+ session,
189
+ VSCodeSettings.yoloMode,
190
+ );
191
+ this.fileManager.setSession(webviewId, baselineSession(runtime));
192
+ return runtime;
193
+ },
194
+ closeSession: async () => {
195
+ await this.runtime.detachView(webviewId);
196
+ this.fileManager.clearSession(webviewId);
197
+ },
198
+ saveAllDirty: () => this.saveAllDirty(),
199
+ setCustomWorkDir: (workDir) => this.setCustomWorkDir(webviewId, workDir),
200
+ };
201
+ }
202
+
203
+ private async saveAllDirty(): Promise<void> {
204
+ const dirty = vscode.workspace.textDocuments.filter((document) => document.isDirty && !document.isUntitled);
205
+ await Promise.all(dirty.map((document) => document.save()));
206
+ }
207
+
208
+ async disposeView(webviewId: string): Promise<void> {
209
+ await this.runtime.detachView(webviewId);
210
+ this.customWorkDirs.delete(webviewId);
211
+ this.fileManager.disposeView(webviewId);
212
+ }
213
+
214
+ async getEditorMention(
215
+ webviewId: string,
216
+ documentUri: vscode.Uri,
217
+ selection: vscode.Selection,
218
+ ): Promise<string | null> {
219
+ const workDirUri = this.getWorkDirUri(webviewId);
220
+ // Mirror the CLI/TUI: no UI-level directory gate on mentions. Inside the
221
+ // working directory the mention is relative; outside it (for example a
222
+ // file under the session's additionalDirs) it falls back to the absolute
223
+ // path, and the session's tool layer decides readability. Virtual
224
+ // documents (untitled:, git:, ...) have no meaningful path to mention.
225
+ if (workDirUri === null || documentUri.scheme !== workDirUri.scheme) return null;
226
+ const filePath = relativeWorkspacePath(workDirUri, documentUri) ?? documentUri.fsPath;
227
+ // Quote paths containing spaces, as the CLI/TUI mention completers do, so
228
+ // whitespace cannot split the path; any line range goes after the quote.
229
+ const mentionTarget = filePath.includes(" ") ? `"${filePath}"` : filePath;
230
+
231
+ if (selection.isEmpty) return `@${mentionTarget}`;
232
+ return selection.start.line === selection.end.line
233
+ ? `@${mentionTarget}:${selection.start.line + 1}`
234
+ : `@${mentionTarget}:${selection.start.line + 1}-${selection.end.line + 1}`;
235
+ }
236
+
237
+ captureFileBaseline(
238
+ session: BaselineSession,
239
+ filePath: string,
240
+ webviewIds: readonly string[],
241
+ ): void {
242
+ const workspaceRoot = this.workspaceRoot;
243
+ const workspaceRootUri = this.workspaceRootUri;
244
+ if (workspaceRoot === null || workspaceRootUri === null) return;
245
+
246
+ const workDirUri = workDirUriFromPath(workspaceRootUri, workspaceRoot, session.workDir);
247
+ if (
248
+ workDirUri === undefined ||
249
+ !isWorkspacePathContainedSync(workspaceRootUri, workDirUri)
250
+ ) {
251
+ this.logRuntimeError(
252
+ "Unable to capture a file baseline",
253
+ new Error("Session working directory is outside the workspace"),
254
+ );
255
+ return;
256
+ }
257
+
258
+ const resolved = resolveSessionFilePath(workDirUri, session.workDir, filePath);
259
+ if (
260
+ resolved === undefined ||
261
+ !isWorkspacePathContainedSync(workDirUri, resolved.uri, { allowMissing: true })
262
+ ) {
263
+ this.logRuntimeError(
264
+ "Unable to capture a file baseline",
265
+ new Error("File is outside the session working directory"),
266
+ );
267
+ return;
268
+ }
269
+
270
+ const capture = this.baselineManager.capture(session, resolved.uri.fsPath);
271
+ void capture
272
+ .then(async () => {
273
+ await Promise.all(
274
+ webviewIds.map(async (webviewId) => {
275
+ this.fileManager.trackFile(webviewId, resolved.uri.fsPath);
276
+ await this.fileManager.refreshChanges(webviewId);
277
+ }),
278
+ );
279
+ })
280
+ .catch((error) => {
281
+ this.logRuntimeError("Unable to capture a file baseline", error);
282
+ });
283
+ }
284
+
285
+ async dispose(): Promise<void> {
286
+ this.fileManager.dispose();
287
+ await this.runtime.dispose();
288
+ }
289
+
290
+ async getBaselineContent(sessionId: string, filePath: string): Promise<string> {
291
+ const active = this.runtime.getSession(sessionId)?.summary;
292
+ const summary = active ?? (await this.runtime.harness.listSessions({ sessionId }))[0];
293
+ if (summary === undefined) throw new Error("Session was not found.");
294
+ return this.baselineManager.getContent(baselineSummary(summary), filePath);
295
+ }
296
+
297
+ private trace(id: string, method: string, durationMs: number, ok: boolean): void {
298
+ // Deliberately exclude params, prompt text, file paths, and credentials.
299
+ const line = `[bridge] id=${id} method=${method} ok=${String(ok)} durationMs=${durationMs}`;
300
+ console.debug(`[kimi-vscode] ${line}`);
301
+ this.writeLog(line);
302
+ }
303
+
304
+ private logRuntimeError(message: string, error?: unknown): void {
305
+ const detail = errorDetail(error);
306
+ const line = `${message}${detail ? `: ${detail}` : ""}`;
307
+ console.error(`[kimi-vscode] ${line}`);
308
+ this.writeLog(line);
309
+ }
310
+ }
311
+
312
+ function errorDetail(error: unknown): string {
313
+ if (error === undefined) return "";
314
+ if (error instanceof Error) return `${error.name}: ${error.message}`;
315
+ if (typeof error === "string") return error;
316
+ if (typeof error === "number" || typeof error === "bigint" || typeof error === "boolean") {
317
+ return String(error);
318
+ }
319
+ return "Unknown error";
320
+ }
321
+
322
+ function baselineSession(runtime: SessionRuntime): BaselineSession {
323
+ return baselineSummary({
324
+ id: runtime.id,
325
+ workDir: runtime.session.workDir,
326
+ metadata: runtime.summary?.metadata,
327
+ });
328
+ }
329
+
330
+ function baselineSummary(summary: Pick<BaselineSession, "id" | "workDir" | "metadata">): BaselineSession {
331
+ return {
332
+ id: summary.id,
333
+ workDir: summary.workDir,
334
+ ...(summary.metadata === undefined ? {} : { metadata: summary.metadata }),
335
+ };
336
+ }
337
+
338
+ function resolveSessionFilePath(
339
+ workDirUri: vscode.Uri,
340
+ workDir: string,
341
+ filePath: string,
342
+ ): WorkspacePath | undefined {
343
+ if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath)) {
344
+ const uri = workDirUriFromPath(workDirUri, workDir, filePath);
345
+ if (uri === undefined) return undefined;
346
+ const relativePath = relativeWorkspacePath(workDirUri, uri);
347
+ return relativePath === undefined ? undefined : { uri, relativePath };
348
+ }
349
+ return resolveWorkspacePath(workDirUri, filePath);
350
+ }
apps/vscode/src/config/vscode-settings.ts ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+ import type { ExtensionConfig } from "../../shared/types";
3
+
4
+ declare const __EXTENSION_VERSION__: string;
5
+ const EXTENSION_VERSION = typeof __EXTENSION_VERSION__ !== "undefined" ? __EXTENSION_VERSION__ : "0.0.0";
6
+
7
+ function getConfig() {
8
+ return vscode.workspace.getConfiguration("kimi");
9
+ }
10
+
11
+ export const VSCodeSettings = {
12
+ get yoloMode(): boolean {
13
+ return getConfig().get<boolean>("yoloMode", false);
14
+ },
15
+
16
+ get autosave(): boolean {
17
+ return getConfig().get<boolean>("autosave", true);
18
+ },
19
+
20
+ get enableNewConversationShortcut(): boolean {
21
+ return getConfig().get<boolean>("enableNewConversationShortcut", false);
22
+ },
23
+
24
+ get useCtrlEnterToSend(): boolean {
25
+ return getConfig().get<boolean>("useCtrlEnterToSend", false);
26
+ },
27
+
28
+ get showThinkingContent(): boolean {
29
+ return getConfig().get<boolean>("showThinkingContent", false);
30
+ },
31
+
32
+ get showThinkingExpanded(): boolean {
33
+ return getConfig().get<boolean>("showThinkingExpanded", false);
34
+ },
35
+
36
+ get editorContext(): "never" | "onConversationStart" | "onFileChange" {
37
+ return getConfig().get<"never" | "onConversationStart" | "onFileChange">("editorContext", "never");
38
+ },
39
+
40
+ getExtensionConfig(): ExtensionConfig {
41
+ return {
42
+ yoloMode: this.yoloMode,
43
+ autosave: this.autosave,
44
+ useCtrlEnterToSend: this.useCtrlEnterToSend,
45
+ enableNewConversationShortcut: this.enableNewConversationShortcut,
46
+ showThinkingContent: this.showThinkingContent,
47
+ showThinkingExpanded: this.showThinkingExpanded,
48
+ version: EXTENSION_VERSION,
49
+ };
50
+ },
51
+ };
52
+
53
+ export function onSettingsChange(callback: (changedKeys: string[]) => void): vscode.Disposable {
54
+ return vscode.workspace.onDidChangeConfiguration((e) => {
55
+ if (!e.affectsConfiguration("kimi")) {
56
+ return;
57
+ }
58
+ const keys = ["yoloMode", "autosave", "enableNewConversationShortcut", "useCtrlEnterToSend", "showThinkingContent", "showThinkingExpanded", "editorContext"];
59
+ const changedKeys = keys.filter((key) => e.affectsConfiguration(`kimi.${key}`));
60
+ if (changedKeys.length > 0) {
61
+ callback(changedKeys);
62
+ }
63
+ });
64
+ }
apps/vscode/src/extension.ts ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+
3
+ import { Events } from "../shared/bridge";
4
+ import { KimiWebviewProvider } from "./KimiWebviewProvider";
5
+ import { onSettingsChange, VSCodeSettings } from "./config/vscode-settings";
6
+ import {
7
+ LegacyMigrationManager,
8
+ type LegacyMigrationDiscovery,
9
+ type LegacyMigrationRunResult,
10
+ } from "./migration";
11
+ import { updateLoginContext } from "./utils/context";
12
+
13
+ let outputChannel: vscode.OutputChannel | undefined;
14
+ let provider: KimiWebviewProvider | undefined;
15
+
16
+ const LEGACY_REAUTH_NOTICE_KEY = "kimi.legacyMigration.reauthNotice.v1";
17
+ const LEGACY_WARNING_NOTICE_KEY = "kimi.legacyMigration.warningNotice.v1";
18
+
19
+ export async function activate(context: vscode.ExtensionContext): Promise<void> {
20
+ outputChannel = vscode.window.createOutputChannel("Kimi Code");
21
+ const remoteInfo = vscode.env.remoteName ? ` (remote: ${vscode.env.remoteName})` : "";
22
+ log(`Kimi Code ${VSCodeSettings.getExtensionConfig().version} activating${remoteInfo}`);
23
+
24
+ provider = new KimiWebviewProvider(
25
+ context.extensionUri,
26
+ context,
27
+ () => outputChannel?.show(),
28
+ (message) => log(message),
29
+ );
30
+ context.subscriptions.push(provider, outputChannel);
31
+
32
+ let isLoggedIn = false;
33
+ try {
34
+ isLoggedIn = await updateLoginContext(provider.harness);
35
+ } catch (error) {
36
+ logError("Unable to determine login status", error);
37
+ }
38
+
39
+ context.subscriptions.push(
40
+ vscode.workspace.registerTextDocumentContentProvider("kimi-baseline", {
41
+ provideTextDocumentContent: async (uri) => {
42
+ const sessionId = new URLSearchParams(uri.query).get("sessionId");
43
+ if (!sessionId || !provider) return "";
44
+ const relativePath = decodeURIComponent(uri.path.replace(/^\//, ""));
45
+ try {
46
+ return await provider.getBaselineContent(sessionId, relativePath);
47
+ } catch (error) {
48
+ logError("Unable to open baseline content", error);
49
+ return "";
50
+ }
51
+ },
52
+ }),
53
+ );
54
+
55
+ context.subscriptions.push(
56
+ onSettingsChange((changedKeys) => {
57
+ provider?.broadcast(Events.ExtensionConfigChanged, {
58
+ config: VSCodeSettings.getExtensionConfig(),
59
+ changedKeys,
60
+ });
61
+ if (changedKeys.includes("yoloMode")) {
62
+ void provider
63
+ ?.setYoloModeForActiveSessions(VSCodeSettings.yoloMode)
64
+ .catch((error) => logError("Unable to update session permission", error));
65
+ }
66
+ }),
67
+ vscode.window.registerWebviewViewProvider("kimi.webview", provider, {
68
+ webviewOptions: { retainContextWhenHidden: true },
69
+ }),
70
+ );
71
+
72
+ const migrationManager = new LegacyMigrationManager({
73
+ targetHome: provider.harness.homeDir,
74
+ workspaceRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath,
75
+ legacyEnvironmentVariables: vscode.workspace
76
+ .getConfiguration("kimi")
77
+ .get<unknown>("environmentVariables"),
78
+ });
79
+ let migrationInFlight: Promise<void> | undefined;
80
+ const runMigration = (retry: boolean): Promise<void> => {
81
+ if (migrationInFlight !== undefined) return migrationInFlight;
82
+ migrationInFlight = performMigration(migrationManager, retry).finally(() => {
83
+ migrationInFlight = undefined;
84
+ });
85
+ return migrationInFlight;
86
+ };
87
+
88
+ const commands: Record<string, () => void | Promise<void>> = {
89
+ "kimi.clearAllState": async () => {
90
+ await context.globalState.update("kimi.config", undefined);
91
+ await context.globalState.update("kimi.mcpServers", undefined);
92
+ await context.workspaceState.update("kimi.mcpEnabled", undefined);
93
+ await vscode.window.showInformationMessage("Kimi: Extension UI state cleared.");
94
+ },
95
+ "kimi.openInTab": () => {
96
+ provider?.createPanel();
97
+ },
98
+ "kimi.openInSideBar": async () => {
99
+ await vscode.commands.executeCommand("kimi.webview.focus");
100
+ },
101
+ "kimi.focusInput": async () => {
102
+ await vscode.commands.executeCommand("kimi.webview.focus");
103
+ provider?.broadcast(Events.FocusInput, {});
104
+ },
105
+ "kimi.insertMention": async () => {
106
+ const editor = vscode.window.activeTextEditor;
107
+ if (!editor) {
108
+ await vscode.window.showWarningMessage("No active editor");
109
+ return;
110
+ }
111
+ await vscode.commands.executeCommand("kimi.webview.focus");
112
+ if (!(await provider?.insertEditorMention(editor.document.uri, editor.selection))) {
113
+ await vscode.window.showWarningMessage("The active file is outside the selected working directory.");
114
+ }
115
+ },
116
+ "kimi.newConversation": async () => {
117
+ await vscode.commands.executeCommand("kimi.webview.focus");
118
+ provider?.broadcast(Events.NewConversation, {});
119
+ },
120
+ "kimi.showLogs": () => outputChannel?.show(),
121
+ "kimi.resetKimi": () => provider?.resetAllWebviews(),
122
+ "kimi.logout": async () => {
123
+ await vscode.commands.executeCommand("kimi.webview.focus");
124
+ await vscode.window.showInformationMessage("Use the logout button in Kimi settings.");
125
+ },
126
+ "kimi.migrateLegacyData": () => runMigration(true),
127
+ };
128
+
129
+ for (const [id, handler] of Object.entries(commands)) {
130
+ context.subscriptions.push(vscode.commands.registerCommand(id, handler));
131
+ }
132
+
133
+ void offerLegacyMigration(
134
+ migrationManager,
135
+ () => runMigration(false),
136
+ context.globalState,
137
+ isLoggedIn,
138
+ ).catch((error) => {
139
+ logError("Unable to check for legacy Kimi data", error);
140
+ });
141
+ log("Kimi Code activated");
142
+ }
143
+
144
+ export async function deactivate(): Promise<void> {
145
+ log("Kimi Code deactivating");
146
+ await provider?.shutdown();
147
+ provider = undefined;
148
+ }
149
+
150
+ function log(message: string): void {
151
+ outputChannel?.appendLine(`[${new Date().toISOString()}] ${message}`);
152
+ }
153
+
154
+ function logError(message: string, error: unknown): void {
155
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
156
+ log(`${message}: ${detail}`);
157
+ }
158
+
159
+ async function offerLegacyMigration(
160
+ manager: LegacyMigrationManager,
161
+ migrate: () => Promise<void>,
162
+ globalState: vscode.Memento,
163
+ isLoggedIn: boolean,
164
+ ): Promise<void> {
165
+ const discovery = await manager.discover();
166
+ logMigrationDiscovery(discovery);
167
+ const reauthNotice = legacyReauthNotice(discovery, isLoggedIn);
168
+ const warningNotice =
169
+ discovery.warnings.length === 0
170
+ ? null
171
+ : "Some legacy Kimi data could not be inspected. Use “Kimi Code: Migrate Legacy Data” to retry.";
172
+ if (discovery.prompt === null) {
173
+ if (reauthNotice !== null && !globalState.get<boolean>(LEGACY_REAUTH_NOTICE_KEY, false)) {
174
+ await vscode.window.showWarningMessage(reauthNotice);
175
+ await globalState.update(LEGACY_REAUTH_NOTICE_KEY, true);
176
+ }
177
+ if (
178
+ discovery.warnings.length > 0 &&
179
+ !globalState.get<boolean>(LEGACY_WARNING_NOTICE_KEY, false)
180
+ ) {
181
+ const action = await vscode.window.showWarningMessage(
182
+ warningNotice ?? "Some legacy Kimi data could not be inspected.",
183
+ "Show Logs",
184
+ );
185
+ await globalState.update(LEGACY_WARNING_NOTICE_KEY, true);
186
+ if (action === "Show Logs") outputChannel?.show();
187
+ }
188
+ return;
189
+ }
190
+
191
+ const action = await vscode.window.showInformationMessage(
192
+ [discovery.prompt.message, reauthNotice, warningNotice]
193
+ .filter((message) => message !== null)
194
+ .join(" "),
195
+ ...discovery.prompt.actions.map(({ label }) => label),
196
+ );
197
+ if (reauthNotice !== null) await globalState.update(LEGACY_REAUTH_NOTICE_KEY, true);
198
+ if (warningNotice !== null) await globalState.update(LEGACY_WARNING_NOTICE_KEY, true);
199
+ if (action === "Migrate Now") await migrate();
200
+ }
201
+
202
+ function legacyReauthNotice(
203
+ discovery: LegacyMigrationDiscovery,
204
+ isLoggedIn: boolean,
205
+ ): string | null {
206
+ const kimiLogins = isLoggedIn ? 0 : discovery.notices.oauthLoginsRequiringRelogin.length;
207
+ const mcpLogins = discovery.notices.mcpOauthServersRequiringReauth.length;
208
+ if (kimiLogins === 0 && mcpLogins === 0) return null;
209
+ if (kimiLogins > 0 && mcpLogins > 0) {
210
+ return "Legacy OAuth credentials are not copied. Sign in to Kimi Code and authorize your MCP servers again.";
211
+ }
212
+ return kimiLogins > 0
213
+ ? "Legacy OAuth credentials are not copied. Sign in to Kimi Code again."
214
+ : "Legacy MCP OAuth credentials are not copied. Authorize those MCP servers again.";
215
+ }
216
+
217
+ async function performMigration(
218
+ manager: LegacyMigrationManager,
219
+ retry: boolean,
220
+ ): Promise<void> {
221
+ log(`${retry ? "Retrying" : "Starting"} legacy Kimi data migration`);
222
+ const result = retry ? await manager.retry() : await manager.migrateNow();
223
+ logMigrationResult(result);
224
+
225
+ if (result.status === "completed" || result.status === "partial") {
226
+ try {
227
+ await provider?.harness.getConfig({ reload: true });
228
+ await provider?.resetAllWebviews();
229
+ } catch (error) {
230
+ logError("Migration finished, but the runtime config could not be reloaded", error);
231
+ }
232
+ }
233
+
234
+ const reauthCount =
235
+ result.notices.oauthLoginsRequiringRelogin.length +
236
+ result.notices.mcpOauthServersRequiringReauth.length;
237
+ const reauthNotice =
238
+ reauthCount === 0
239
+ ? ""
240
+ : ` ${reauthCount} OAuth connection(s) must be signed in again.`;
241
+ const message = `${result.message}${reauthNotice}`;
242
+ const needsLogs =
243
+ result.status === "partial" ||
244
+ result.status === "failed" ||
245
+ result.warnings.length > 0 ||
246
+ result.manualActions.length > 0;
247
+
248
+ if (result.status === "failed") {
249
+ const action = await vscode.window.showErrorMessage(message, "Show Logs");
250
+ if (action === "Show Logs") outputChannel?.show();
251
+ } else if (needsLogs) {
252
+ const action = await vscode.window.showWarningMessage(message, "Show Logs");
253
+ if (action === "Show Logs") outputChannel?.show();
254
+ } else {
255
+ await vscode.window.showInformationMessage(message);
256
+ }
257
+ }
258
+
259
+ function logMigrationDiscovery(discovery: LegacyMigrationDiscovery): void {
260
+ for (const warning of discovery.warnings) log(`Legacy migration warning: ${warning.message}`);
261
+ for (const source of discovery.suppressedSources) {
262
+ log(`Legacy migration already completed for ${source.sourceHome}`);
263
+ }
264
+ }
265
+
266
+ function logMigrationResult(result: LegacyMigrationRunResult): void {
267
+ const { totals } = result;
268
+ log(
269
+ `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}`,
270
+ );
271
+ for (const warning of result.warnings) log(`Legacy migration warning: ${warning.message}`);
272
+ for (const source of result.sources) {
273
+ for (const failure of source.failures) {
274
+ log(`Legacy migration failure (${failure.sourceHome}): ${failure.message}`);
275
+ }
276
+ }
277
+ for (const action of result.manualActions) log(`Legacy migration action: ${action}`);
278
+ }
279
+
280
+ export { log };
apps/vscode/src/handlers/auth.handler.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+
3
+ import { OAuthAccessDeniedError } from "@moonshot-ai/kimi-code-oauth";
4
+
5
+ import { Events, Methods } from "../../shared/bridge";
6
+ import type { LoginResult } from "../../shared/legacy-sdk";
7
+ import type { LoginStatus } from "../../shared/types";
8
+ import { updateLoginContext } from "../utils/context";
9
+ import type { Handler } from "./types";
10
+
11
+ export const authHandlers: Record<string, Handler<any, any>> = {
12
+ [Methods.CheckLoginStatus]: async (_, ctx): Promise<LoginStatus> => {
13
+ return { loggedIn: await updateLoginContext(ctx.harness) };
14
+ },
15
+
16
+ [Methods.Login]: async (_, ctx): Promise<LoginResult> => {
17
+ try {
18
+ await ctx.harness.auth.login(undefined, {
19
+ onDeviceCode: async (authorization) => {
20
+ const url = authorization.verificationUriComplete || authorization.verificationUri;
21
+ ctx.broadcast(Events.LoginUrl, { url }, ctx.webviewId);
22
+ await vscode.env.openExternal(vscode.Uri.parse(url));
23
+ },
24
+ });
25
+ await updateLoginContext(ctx.harness);
26
+ return { success: true };
27
+ } catch (error) {
28
+ ctx.logError("Kimi login failed", error);
29
+ await updateLoginContext(ctx.harness).catch((statusError: unknown) => {
30
+ ctx.logError("Unable to refresh login status after a failed login", statusError);
31
+ });
32
+ const message = error instanceof Error ? error.message : String(error);
33
+ return {
34
+ success: false,
35
+ error: error instanceof OAuthAccessDeniedError ? `Login cancelled: ${message}` : message,
36
+ };
37
+ }
38
+ },
39
+
40
+ [Methods.Logout]: async (_, ctx): Promise<LoginResult> => {
41
+ try {
42
+ await ctx.harness.auth.logout();
43
+ await updateLoginContext(ctx.harness);
44
+ return { success: true };
45
+ } catch (error) {
46
+ ctx.logError("Kimi logout failed", error);
47
+ await updateLoginContext(ctx.harness).catch((statusError: unknown) => {
48
+ ctx.logError("Unable to refresh login status after a failed logout", statusError);
49
+ });
50
+ return {
51
+ success: false,
52
+ error: error instanceof Error ? error.message : String(error),
53
+ };
54
+ }
55
+ },
56
+ };
apps/vscode/src/handlers/chat.handler.ts ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+ import { isKimiError } from "@moonshot-ai/kimi-code-sdk";
3
+
4
+ import { Events, Methods } from "../../shared/bridge";
5
+ import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk";
6
+ import { getUserMessage } from "../../shared/errors";
7
+ import type { ErrorPhase } from "../../shared/types";
8
+ import { VSCodeSettings } from "../config/vscode-settings";
9
+ import { normalizeEffort } from "../runtime/kimi-runtime";
10
+ import type { SessionRuntime } from "../runtime/session-runtime";
11
+ import { isWorkspacePathContained, relativeWorkspacePath } from "../utils/workspace-path";
12
+ import { parseHostSlashCommand, runHostSlashCommand } from "./slash-command";
13
+ import type { Handler } from "./types";
14
+
15
+ interface StreamChatParams {
16
+ content: string | ContentPart[];
17
+ model: string;
18
+ effort?: string;
19
+ thinking?: boolean;
20
+ planMode?: boolean;
21
+ sessionId?: string;
22
+ }
23
+
24
+ interface RespondApprovalParams {
25
+ requestId: string;
26
+ response: ApprovalResponse;
27
+ }
28
+
29
+ interface RespondQuestionParams {
30
+ rpcRequestId: string;
31
+ questionRequestId: string;
32
+ answers: Record<string, string>;
33
+ }
34
+
35
+ const injectedEditorContextSessions = new Map<string, string>();
36
+
37
+ async function buildSystemContext(sessionId: string, ctx: Parameters<Handler>[1]): Promise<string> {
38
+ const mode = VSCodeSettings.editorContext;
39
+ if (mode === "never") return "";
40
+
41
+ const editor = vscode.window.activeTextEditor;
42
+ if (!editor || !ctx.workDirUri || !(await isWorkspacePathContained(ctx.workDirUri, editor.document.uri))) {
43
+ return "";
44
+ }
45
+
46
+ const document = editor.document;
47
+ const relativePath = relativeWorkspacePath(ctx.workDirUri, document.uri);
48
+ if (relativePath === undefined) return "";
49
+ const lastPath = injectedEditorContextSessions.get(sessionId);
50
+ if (mode === "onConversationStart" && lastPath !== undefined) return "";
51
+ if (mode === "onFileChange" && lastPath === relativePath) return "";
52
+
53
+ injectedEditorContextSessions.set(sessionId, relativePath);
54
+ const selection = editor.selection;
55
+ const selectionInfo = selection.isEmpty
56
+ ? ""
57
+ : ` (L${selection.start.line + 1}-${selection.end.line + 1} selected)`;
58
+ const unsavedInfo = document.isDirty ? ", unsaved" : "";
59
+ return `<system>Editor context (use only if relevant to user's query): ${relativePath}:${selection.active.line + 1}${selectionInfo}${unsavedInfo}.</system>\n`;
60
+ }
61
+
62
+ function prependSystemContext(content: string | ContentPart[], context: string): string | ContentPart[] {
63
+ if (!context) return content;
64
+ if (typeof content === "string") return `${content}\n${context}`;
65
+
66
+ const index = content.findIndex((part) => part.type === "text");
67
+ if (index < 0) return [{ type: "text", text: context }, ...content];
68
+ const copy = [...content];
69
+ const text = copy[index] as Extract<ContentPart, { type: "text" }>;
70
+ copy[index] = { type: "text", text: context + text.text };
71
+ return copy;
72
+ }
73
+
74
+ const streamChat: Handler<StreamChatParams, { done: boolean }> = async (params, ctx) => {
75
+ if (!ctx.workDir) {
76
+ emitPreflightError(ctx, "NO_WORKSPACE", "Please open a folder to start.");
77
+ void vscode.window.showWarningMessage("Kimi: Please open a folder first.", "Open Folder").then((action) => {
78
+ if (action) void vscode.commands.executeCommand("vscode.openFolder");
79
+ });
80
+ return { done: false };
81
+ }
82
+
83
+ if (VSCodeSettings.autosave) {
84
+ try {
85
+ await ctx.saveAllDirty();
86
+ } catch (error) {
87
+ emitCaughtError(ctx, error, "preflight");
88
+ return { done: false };
89
+ }
90
+ }
91
+
92
+ let runtime: SessionRuntime;
93
+ try {
94
+ runtime = await ctx.getOrCreateSession(
95
+ params.model,
96
+ params.effort ?? (params.thinking === true ? "on" : "off"),
97
+ params.sessionId,
98
+ );
99
+ } catch (error) {
100
+ emitCaughtError(ctx, error, "preflight");
101
+ return { done: false };
102
+ }
103
+
104
+ try {
105
+ // Attach no longer overwrites session modes with the configured defaults
106
+ // (resumed sessions keep their own), so apply the model/effort that the
107
+ // composer submitted with this prompt before the turn starts.
108
+ const status = await runtime.session.getStatus();
109
+ let model = status.model;
110
+ if (params.model && model !== params.model) {
111
+ await runtime.session.setModel(params.model);
112
+ model = params.model;
113
+ }
114
+ const effort = normalizeEffort(params.effort ?? (params.thinking === true ? "on" : "off"));
115
+ if (status.thinkingEffort !== effort) {
116
+ await runtime.session.setThinking(effort);
117
+ }
118
+ if (params.planMode !== undefined && status.planMode !== params.planMode) {
119
+ await runtime.session.setPlanMode(params.planMode);
120
+ }
121
+ runtime.announceSessionStart(model);
122
+ } catch (error) {
123
+ emitCaughtError(ctx, error, "preflight", runtime.id);
124
+ return { done: false };
125
+ }
126
+
127
+ const slash = parseHostSlashCommand(params.content);
128
+ if (slash !== undefined) {
129
+ try {
130
+ return { done: await runHostSlashCommand(runtime, slash, ctx) };
131
+ } catch (error) {
132
+ emitCaughtError(ctx, error, "runtime", runtime.id);
133
+ return { done: false };
134
+ }
135
+ }
136
+
137
+ const systemContext = await buildSystemContext(runtime.id, ctx);
138
+ try {
139
+ const result = await runtime.prompt(prependSystemContext(params.content, systemContext));
140
+ return { done: result.status === "finished" };
141
+ } catch (error) {
142
+ emitCaughtError(ctx, error, "runtime", runtime.id);
143
+ return { done: false };
144
+ }
145
+ };
146
+
147
+ const abortChat: Handler<void, { aborted: boolean }> = async (_, ctx) => {
148
+ const runtime = ctx.getSession();
149
+ // Do not claim an abort when there is no runtime to cancel — the webview
150
+ // would otherwise show the task as stopped while the engine keeps running.
151
+ if (runtime === undefined) return { aborted: false };
152
+ await runtime.cancel();
153
+ return { aborted: true };
154
+ };
155
+
156
+ const respondApproval: Handler<RespondApprovalParams, { ok: boolean }> = async (params, ctx) => {
157
+ return { ok: ctx.getSession()?.respondApproval(params.requestId, params.response) ?? false };
158
+ };
159
+
160
+ const respondQuestion: Handler<RespondQuestionParams, { ok: boolean }> = async (params, ctx) => {
161
+ const id = params.questionRequestId || params.rpcRequestId;
162
+ return { ok: ctx.getSession()?.respondQuestion(id, params.answers) ?? false };
163
+ };
164
+
165
+ const setPlanMode: Handler<{ enabled: boolean }, { ok: boolean; planMode: boolean }> = async (params, ctx) => {
166
+ const runtime = ctx.getSession();
167
+ if (runtime === undefined) return { ok: false, planMode: false };
168
+ await runtime.session.setPlanMode(params.enabled);
169
+ return { ok: true, planMode: params.enabled };
170
+ };
171
+
172
+ const steerChat: Handler<{ content: string | ContentPart[] }, { ok: boolean }> = async (params, ctx) => {
173
+ const runtime = ctx.getSession();
174
+ if (runtime === undefined || !runtime.isBusy) return { ok: false };
175
+ await runtime.steer(params.content);
176
+ return { ok: true };
177
+ };
178
+
179
+ const resetSession: Handler<void, { ok: boolean }> = async (_, ctx) => {
180
+ const runtime = ctx.getSession();
181
+ if (runtime !== undefined) injectedEditorContextSessions.delete(runtime.id);
182
+ await ctx.closeSession();
183
+ ctx.fileManager.clearTracked(ctx.webviewId);
184
+ return { ok: true };
185
+ };
186
+
187
+ export const chatHandlers: Record<string, Handler<any, any>> = {
188
+ [Methods.StreamChat]: streamChat,
189
+ [Methods.AbortChat]: abortChat,
190
+ [Methods.RespondApproval]: respondApproval,
191
+ [Methods.RespondQuestion]: respondQuestion,
192
+ [Methods.SetPlanMode]: setPlanMode,
193
+ [Methods.SteerChat]: steerChat,
194
+ [Methods.ResetSession]: resetSession,
195
+ };
196
+
197
+ function emitCaughtError(
198
+ ctx: Parameters<Handler>[1],
199
+ error: unknown,
200
+ phase: ErrorPhase,
201
+ sessionId?: string,
202
+ ): void {
203
+ const code = isKimiError(error) ? error.code : "internal";
204
+ const detail = error instanceof Error ? error.message : String(error);
205
+ ctx.logError(`Chat ${phase} request failed`, error);
206
+ ctx.broadcast(
207
+ Events.StreamEvent,
208
+ {
209
+ type: "error",
210
+ code,
211
+ message: getUserMessage(code, detail),
212
+ detail,
213
+ phase,
214
+ ...(sessionId === undefined ? {} : { _sessionId: sessionId }),
215
+ },
216
+ ctx.webviewId,
217
+ );
218
+ }
219
+
220
+ function emitPreflightError(ctx: Parameters<Handler>[1], code: string, message: string): void {
221
+ ctx.broadcast(
222
+ Events.StreamEvent,
223
+ { type: "error", code, message, phase: "preflight" },
224
+ ctx.webviewId,
225
+ );
226
+ }
apps/vscode/src/handlers/config.handler.ts ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+ import {
3
+ effectiveModelAlias,
4
+ type KimiConfig as SdkKimiConfig,
5
+ type ModelAlias,
6
+ type ProviderType,
7
+ type ThinkingEffort,
8
+ } from "@moonshot-ai/kimi-code-sdk";
9
+
10
+ import { Methods } from "../../shared/bridge";
11
+ import type {
12
+ KimiConfig as WebviewKimiConfig,
13
+ ModelConfig,
14
+ SlashCommandInfo,
15
+ } from "../../shared/legacy-sdk";
16
+ import type { ExtensionConfig, SessionConfig } from "../../shared/types";
17
+ import { VSCodeSettings } from "../config/vscode-settings";
18
+ import type { Handler } from "./types";
19
+
20
+ const SLASH_COMMANDS: SlashCommandInfo[] = [
21
+ { name: "init", aliases: [], description: "Analyze the codebase and generate AGENTS.md" },
22
+ { name: "compact", aliases: [], description: "Compact the conversation context" },
23
+ { name: "clear", aliases: ["reset"], description: "Clear the context" },
24
+ { name: "yolo", aliases: [], description: "Toggle YOLO mode (auto-approve tool actions; may still ask questions)" },
25
+ {
26
+ name: "auto",
27
+ aliases: ["afk"],
28
+ description: "Toggle Auto mode (fully autonomous; the agent will not ask questions)",
29
+ },
30
+ { name: "plan", aliases: [], description: "Toggle plan mode. Usage: /plan [on|off|view|clear]" },
31
+ {
32
+ name: "add-dir",
33
+ aliases: [],
34
+ description: "Add a directory to the workspace. Usage: /add-dir <path>",
35
+ },
36
+ { name: "export", aliases: [], description: "Export current session context to a markdown file" },
37
+ { name: "import", aliases: [], description: "Import context from a file or session ID" },
38
+ ];
39
+
40
+ const saveConfig: Handler<SessionConfig, { ok: boolean }> = async (params, ctx) => {
41
+ const effort = sessionConfigEffort(params);
42
+ const effortChanged = params.effortChanged !== false;
43
+ const config = await ctx.harness.getConfig({ reload: true });
44
+ const model = config.models?.[params.model];
45
+ // Resolve with the provider type the way the TUI's effectiveModelForHost
46
+ // does: without it the Anthropic fallback profile (e.g. `claude-latest`)
47
+ // never matches, so the inferred default that gates persistence is missed.
48
+ const providerType =
49
+ model === undefined ? undefined : (config.providers?.[model.provider]?.type ?? model.protocol);
50
+ const full = thinkingConfig(
51
+ effort,
52
+ model === undefined ? undefined : effectiveModelAlias(model, providerType),
53
+ );
54
+ // Re-confirming the effort already shown is not an explicit choice —
55
+ // persist the model but leave the stored effort preference alone (the TUI's
56
+ // persistModelSelection rule).
57
+ const patch = effortChanged ? full : { enabled: full.enabled };
58
+ if (
59
+ config.defaultModel !== params.model
60
+ || config.thinking?.enabled !== patch.enabled
61
+ || (effortChanged && config.thinking?.effort !== patch.effort)
62
+ ) {
63
+ await ctx.harness.setConfig({
64
+ defaultModel: params.model,
65
+ thinking: patch,
66
+ });
67
+ }
68
+
69
+ const runtime = ctx.getSession();
70
+ if (runtime !== undefined) {
71
+ const status = await runtime.session.getStatus();
72
+ if (status.model !== params.model) await runtime.session.setModel(params.model);
73
+ if (status.thinkingEffort !== effort) await runtime.session.setThinking(effort);
74
+ }
75
+ return { ok: true };
76
+ };
77
+
78
+ const getExtensionConfig: Handler<void, ExtensionConfig> = async () => {
79
+ return VSCodeSettings.getExtensionConfig();
80
+ };
81
+
82
+ const openSettings: Handler<void, { ok: boolean }> = async () => {
83
+ await vscode.commands.executeCommand("workbench.action.openSettings", "kimi");
84
+ return { ok: true };
85
+ };
86
+
87
+ const getModels: Handler<void, WebviewKimiConfig> = async (_, ctx) => {
88
+ const config = await ctx.harness.getConfig({ reload: true });
89
+ return toWebviewConfig(config);
90
+ };
91
+
92
+ const getSlashCommands: Handler<void, SlashCommandInfo[]> = async (_, ctx) => {
93
+ if (!ctx.workDir) return SLASH_COMMANDS;
94
+ try {
95
+ const skills = await ctx.harness.listWorkspaceSkills(ctx.workDir);
96
+ const skillCommands = skills
97
+ .filter((skill) => isUserActivatableSkill(skill.type))
98
+ .toSorted((left, right) => left.name.localeCompare(right.name))
99
+ .map((skill) => ({
100
+ name: `skill:${skill.name}`,
101
+ aliases: [],
102
+ description: skill.description ?? "",
103
+ }));
104
+ return [...SLASH_COMMANDS, ...skillCommands];
105
+ } catch (error) {
106
+ ctx.logError("Unable to list workspace skills", error);
107
+ return SLASH_COMMANDS;
108
+ }
109
+ };
110
+
111
+ const showLogs: Handler<void, { ok: boolean }> = async (_, ctx) => {
112
+ ctx.showLogs();
113
+ return { ok: true };
114
+ };
115
+
116
+ const reloadWebview: Handler<void, { ok: boolean }> = async (_, ctx) => {
117
+ await ctx.closeSession();
118
+ ctx.fileManager.clearTracked(ctx.webviewId);
119
+ ctx.reloadWebview();
120
+ return { ok: true };
121
+ };
122
+
123
+ export const configHandlers = {
124
+ [Methods.SaveConfig]: saveConfig,
125
+ [Methods.GetExtensionConfig]: getExtensionConfig,
126
+ [Methods.OpenSettings]: openSettings,
127
+ [Methods.GetModels]: getModels,
128
+ [Methods.GetSlashCommands]: getSlashCommands,
129
+ [Methods.ShowLogs]: showLogs,
130
+ [Methods.ReloadWebview]: reloadWebview,
131
+ } as Record<string, Handler<any, any>>;
132
+
133
+ export function toWebviewConfig(config: SdkKimiConfig): WebviewKimiConfig {
134
+ const models: ModelConfig[] = Object.entries(config.models ?? {})
135
+ // Resolve with the provider type the way saveConfig does: without it the
136
+ // Anthropic fallback profile never matches, and the webview's effort
137
+ // persistence seed would gate on a different effective model.
138
+ .map(([id, model]) =>
139
+ toWebviewModel(id, model, config.providers?.[model.provider]?.type ?? model.protocol),
140
+ )
141
+ .toSorted((left, right) => left.name.localeCompare(right.name));
142
+ return {
143
+ defaultModel: config.defaultModel ?? models[0]?.id ?? null,
144
+ defaultThinking: config.thinking?.enabled !== false,
145
+ defaultThinkingEffort: config.thinking?.effort,
146
+ models,
147
+ };
148
+ }
149
+
150
+ function toWebviewModel(id: string, model: ModelAlias, providerType?: ProviderType): ModelConfig {
151
+ const effective = effectiveModelAlias(model, providerType);
152
+ return {
153
+ id,
154
+ name: effective.displayName ?? effective.model ?? id,
155
+ provider: effective.provider,
156
+ capabilities: [...(effective.capabilities ?? [])],
157
+ adaptive_thinking: effective.adaptiveThinking,
158
+ support_efforts:
159
+ effective.supportEfforts === undefined ? undefined : [...effective.supportEfforts],
160
+ default_effort: effective.defaultEffort,
161
+ };
162
+ }
163
+
164
+ function sessionConfigEffort(config: SessionConfig): ThinkingEffort {
165
+ if (config.effort !== undefined) return config.effort as ThinkingEffort;
166
+ return config.thinking === true ? "on" : "off";
167
+ }
168
+
169
+ /**
170
+ * Project a thinking effort to the `[thinking]` config patch persisted to
171
+ * config.toml — mirrors the TUI's thinkingEffortToConfig. "off" disables
172
+ * thinking; "on" is the boolean-model on-signal, so it only persists
173
+ * `enabled`. A concrete effort persists as the global default, EXCEPT when it
174
+ * ranks above the model's effective default effort: `support_efforts` is
175
+ * ordered by strength, and a pick more expensive than the default stays
176
+ * session-only and records just `enabled`, so it never becomes the global
177
+ * default for every new session. The default here is the effective model's,
178
+ * however it arose — declared via the catalog or overrides, or synthesized
179
+ * by the protocol-profile inference (`withAnthropicProfile` resolves Claude
180
+ * models to "high", so an "xhigh" pick stays session-only there). When the
181
+ * effective model carries no default effort at all, its highest declared
182
+ * level stays session-only (the historical rule). When the model's levels
183
+ * are unknown the concrete effort is persisted as-is.
184
+ */
185
+ function thinkingConfig(
186
+ effort: ThinkingEffort,
187
+ model?: Pick<ModelAlias, "supportEfforts" | "defaultEffort">,
188
+ ): { enabled: boolean; effort?: string } {
189
+ if (effort === "off") return { enabled: false };
190
+ if (effort === "on") return { enabled: true };
191
+ const efforts = model?.supportEfforts;
192
+ if (efforts !== undefined && efforts.includes(effort)) {
193
+ const declared = model?.defaultEffort;
194
+ const ceiling =
195
+ declared !== undefined && efforts.includes(declared)
196
+ ? efforts.indexOf(declared)
197
+ : efforts.length - 2;
198
+ if (efforts.indexOf(effort) > ceiling) return { enabled: true };
199
+ }
200
+ return { enabled: true, effort };
201
+ }
202
+
203
+ function isUserActivatableSkill(type: string | undefined): boolean {
204
+ return type === undefined || type === "prompt" || type === "inline" || type === "flow";
205
+ }
apps/vscode/src/handlers/file.handler.ts ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as path from "node:path";
2
+ import * as vscode from "vscode";
3
+
4
+ import { Events, Methods } from "../../shared/bridge";
5
+ import type { FileChange, ProjectFile } from "../../shared/types";
6
+ import type { BaselineSession } from "../managers/baseline.manager";
7
+ import {
8
+ isWorkspacePathContained,
9
+ resolveWorkspacePath,
10
+ type WorkspacePath,
11
+ } from "../utils/workspace-path";
12
+ import type { Handler, HandlerContext } from "./types";
13
+
14
+ interface GetProjectFilesParams {
15
+ query?: string;
16
+ }
17
+ interface PickMediaParams { maxCount?: number; includeVideo?: boolean }
18
+ interface FilePathParams { filePath: string }
19
+ interface OptionalFilePathParams { filePath?: string }
20
+ interface PathsParams { paths: string[] }
21
+ interface CheckFilesExistParams { paths: string[] }
22
+
23
+ const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp"];
24
+ const VIDEO_EXTENSIONS = ["mp4", "webm", "mov"];
25
+ const IMAGE_MIME_TYPES: Record<string, string> = {
26
+ ".png": "image/png",
27
+ ".jpg": "image/jpeg",
28
+ ".jpeg": "image/jpeg",
29
+ ".gif": "image/gif",
30
+ ".webp": "image/webp",
31
+ ".svg": "image/svg+xml",
32
+ ".bmp": "image/bmp",
33
+ ".ico": "image/x-icon",
34
+ };
35
+
36
+ const FILE_SUGGEST_LIMIT = 20;
37
+
38
+ const getProjectFiles: Handler<GetProjectFilesParams | undefined, ProjectFile[]> = async (params, ctx) => {
39
+ if (!ctx.workDirUri || !ctx.workDir) return [];
40
+ const suggested = await suggestFiles(ctx, params?.query ?? "");
41
+ if (suggested !== undefined) return suggested;
42
+ return ctx.fileManager.searchFiles(ctx.workDirUri, params?.query);
43
+ };
44
+
45
+ async function suggestFiles(ctx: HandlerContext, query: string): Promise<ProjectFile[] | undefined> {
46
+ try {
47
+ const result = await ctx.harness.suggestFiles(ctx.requireWorkDir(), { query, limit: FILE_SUGGEST_LIMIT });
48
+ if (result === undefined) return undefined;
49
+ return result.items.map((item) => ({
50
+ path: item.path,
51
+ name: item.name,
52
+ isDirectory: item.kind === "directory",
53
+ matchPositions: [...item.matchPositions],
54
+ }));
55
+ } catch (error) {
56
+ ctx.logError("File suggest failed", error);
57
+ return [];
58
+ }
59
+ }
60
+
61
+ const pickMedia: Handler<PickMediaParams, string[]> = async (params) => {
62
+ const maxCount = params.maxCount ?? 9;
63
+ const includeVideo = params.includeVideo ?? true;
64
+ const filters: Record<string, string[]> = { Images: IMAGE_EXTENSIONS };
65
+ if (includeVideo) {
66
+ filters["Videos"] = VIDEO_EXTENSIONS;
67
+ filters["All Media"] = [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS];
68
+ }
69
+ const uris = await vscode.window.showOpenDialog({ canSelectMany: true, filters, title: "Select Media" });
70
+ if (!uris) return [];
71
+
72
+ const results: string[] = [];
73
+ for (const uri of uris.slice(0, maxCount)) {
74
+ try {
75
+ const extension = path.extname(uri.fsPath).toLowerCase().slice(1);
76
+ const isVideo = VIDEO_EXTENSIONS.includes(extension);
77
+ const stat = await vscode.workspace.fs.stat(uri);
78
+ if (stat.size > (isVideo ? 20 : 10) * 1024 * 1024) continue;
79
+ const bytes = await vscode.workspace.fs.readFile(uri);
80
+ results.push(`data:${mediaMime(extension)};base64,${Buffer.from(bytes).toString("base64")}`);
81
+ } catch {
82
+ // Skip a file that disappears or cannot be read without failing the whole picker.
83
+ }
84
+ }
85
+ return results;
86
+ };
87
+
88
+ const openFile: Handler<FilePathParams, { ok: boolean }> = async ({ filePath }, ctx) => {
89
+ const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath);
90
+ if (resolved === undefined) return { ok: false };
91
+ await vscode.commands.executeCommand("vscode.open", resolved.uri);
92
+ return { ok: true };
93
+ };
94
+
95
+ const openFileDiff: Handler<FilePathParams, { ok: boolean }> = async ({ filePath }, ctx) => {
96
+ const sessionId = ctx.getSessionId();
97
+ const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath);
98
+ if (!sessionId || resolved === undefined) return { ok: false };
99
+
100
+ const baselineUri = vscode.Uri.from({
101
+ scheme: "kimi-baseline",
102
+ path: `/${resolved.relativePath}`,
103
+ query: new URLSearchParams({ sessionId }).toString(),
104
+ });
105
+ await vscode.commands.executeCommand(
106
+ "vscode.diff",
107
+ baselineUri,
108
+ resolved.uri,
109
+ `${path.basename(resolved.relativePath)} (changes from Kimi)`,
110
+ );
111
+ return { ok: true };
112
+ };
113
+
114
+ const trackFiles: Handler<PathsParams, FileChange[]> = async ({ paths }, ctx) => {
115
+ const session = requireBaselineSession(ctx);
116
+ const workDirUri = ctx.requireWorkDirUri();
117
+ for (const filePath of paths) {
118
+ const resolved = await resolveWorkspaceFile(workDirUri, filePath, true);
119
+ if (resolved !== undefined) ctx.fileManager.trackFile(ctx.webviewId, resolved.uri.fsPath);
120
+ }
121
+ const changes = await ctx.baselineManager.getChanges(session);
122
+ ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId);
123
+ return changes;
124
+ };
125
+
126
+ const clearTrackedFiles: Handler<void, { ok: boolean }> = async (_, ctx) => {
127
+ ctx.fileManager.clearTracked(ctx.webviewId);
128
+ ctx.broadcast(Events.FileChangesUpdated, [], ctx.webviewId);
129
+ return { ok: true };
130
+ };
131
+
132
+ const revertFiles: Handler<OptionalFilePathParams, { ok: boolean }> = async (params, ctx) => {
133
+ const session = requireBaselineSession(ctx);
134
+ if (params.filePath) {
135
+ const resolved = await resolveWorkspaceFile(ctx.requireWorkDirUri(), params.filePath, true);
136
+ if (resolved === undefined) return { ok: false };
137
+ await ctx.baselineManager.undo(session, resolved.relativePath);
138
+ } else {
139
+ await ctx.baselineManager.undoAll(session);
140
+ ctx.fileManager.clearTracked(ctx.webviewId);
141
+ }
142
+ await ctx.fileManager.refreshChanges(ctx.webviewId);
143
+ return { ok: true };
144
+ };
145
+
146
+ const keepChanges: Handler<OptionalFilePathParams, { ok: boolean }> = async (params, ctx) => {
147
+ const session = requireBaselineSession(ctx);
148
+ if (params.filePath) {
149
+ const resolved = await resolveWorkspaceFile(ctx.requireWorkDirUri(), params.filePath, true);
150
+ if (resolved === undefined) return { ok: false };
151
+ await ctx.baselineManager.keep(session, resolved.relativePath);
152
+ ctx.fileManager.getTracked(ctx.webviewId).delete(resolved.uri.fsPath);
153
+ } else {
154
+ await ctx.baselineManager.keepAll(session);
155
+ ctx.fileManager.clearTracked(ctx.webviewId);
156
+ }
157
+ await ctx.fileManager.refreshChanges(ctx.webviewId);
158
+ return { ok: true };
159
+ };
160
+
161
+ const checkFileExists: Handler<FilePathParams, boolean> = async ({ filePath }, ctx) => {
162
+ if (!ctx.workDirUri) return false;
163
+ return (await resolveExistingWorkspaceFile(ctx.workDirUri, filePath)) !== undefined;
164
+ };
165
+
166
+ const checkFilesExist: Handler<CheckFilesExistParams, Record<string, boolean>> = async ({ paths }, ctx) => {
167
+ if (!ctx.workDirUri) return {};
168
+ return Object.fromEntries(
169
+ await Promise.all(
170
+ paths.map(async (filePath) => [
171
+ filePath,
172
+ (await resolveExistingWorkspaceFile(ctx.workDirUri!, filePath)) !== undefined,
173
+ ] as const),
174
+ ),
175
+ );
176
+ };
177
+
178
+ const getImageDataUri: Handler<FilePathParams, string | null> = async ({ filePath }, ctx) => {
179
+ if (!ctx.workDirUri) return null;
180
+ const resolved = await resolveExistingWorkspaceFile(ctx.workDirUri, decodeURIComponent(filePath));
181
+ if (resolved === undefined) return null;
182
+ const mime = IMAGE_MIME_TYPES[path.extname(resolved.relativePath).toLowerCase()];
183
+ if (!mime) return null;
184
+ try {
185
+ // Same cap as the media picker: an oversized image inlined as a data URI
186
+ // would live in the webview DOM (and its decoded bitmap) forever.
187
+ const stat = await vscode.workspace.fs.stat(resolved.uri);
188
+ if (stat.size > 10 * 1024 * 1024) return null;
189
+ const bytes = await vscode.workspace.fs.readFile(resolved.uri);
190
+ return `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`;
191
+ } catch {
192
+ return null;
193
+ }
194
+ };
195
+
196
+ export const fileHandlers: Record<string, Handler<any, any>> = {
197
+ [Methods.GetProjectFiles]: getProjectFiles,
198
+ [Methods.PickMedia]: pickMedia,
199
+ [Methods.OpenFile]: openFile,
200
+ [Methods.OpenFileDiff]: openFileDiff,
201
+ [Methods.TrackFiles]: trackFiles,
202
+ [Methods.ClearTrackedFiles]: clearTrackedFiles,
203
+ [Methods.RevertFiles]: revertFiles,
204
+ [Methods.KeepChanges]: keepChanges,
205
+ [Methods.CheckFileExists]: checkFileExists,
206
+ [Methods.CheckFilesExist]: checkFilesExist,
207
+ [Methods.GetImageDataUri]: getImageDataUri,
208
+ };
209
+
210
+ function requireBaselineSession(ctx: Parameters<Handler>[1]): BaselineSession {
211
+ const session = ctx.fileManager.getSession(ctx.webviewId);
212
+ if (session === null) throw new Error("No active session.");
213
+ return session;
214
+ }
215
+
216
+ async function resolveWorkspaceFile(
217
+ workDirUri: vscode.Uri,
218
+ filePath: string,
219
+ allowMissing = false,
220
+ ): Promise<WorkspacePath | undefined> {
221
+ const resolved = resolveWorkspacePath(workDirUri, filePath);
222
+ if (resolved === undefined || !(await isWorkspacePathContained(workDirUri, resolved.uri, { allowMissing }))) {
223
+ return undefined;
224
+ }
225
+ return resolved;
226
+ }
227
+
228
+ async function resolveExistingWorkspaceFile(
229
+ workDirUri: vscode.Uri,
230
+ filePath: string,
231
+ ): Promise<WorkspacePath | undefined> {
232
+ const resolved = await resolveWorkspaceFile(workDirUri, filePath);
233
+ if (resolved === undefined) return undefined;
234
+ try {
235
+ await vscode.workspace.fs.stat(resolved.uri);
236
+ return resolved;
237
+ } catch {
238
+ return undefined;
239
+ }
240
+ }
241
+
242
+ function mediaMime(extension: string): string {
243
+ return ({
244
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
245
+ mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime",
246
+ } as Record<string, string>)[extension] ?? "application/octet-stream";
247
+ }
apps/vscode/src/handlers/index.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { configHandlers } from "./config.handler";
2
+ import { mcpHandlers } from "./mcp.handler";
3
+ import { sessionHandlers } from "./session.handler";
4
+ import { chatHandlers } from "./chat.handler";
5
+ import { fileHandlers } from "./file.handler";
6
+ import { workspaceHandlers } from "./workspace.handler";
7
+ import { authHandlers } from "./auth.handler";
8
+ import type { Handler } from "./types";
9
+
10
+ export type { Handler, HandlerContext, BroadcastFn, ReloadWebviewFn, ShowLogsFn } from "./types";
11
+
12
+ export const handlers: Record<string, Handler<any, any>> = {
13
+ ...workspaceHandlers,
14
+ ...configHandlers,
15
+ ...mcpHandlers,
16
+ ...sessionHandlers,
17
+ ...chatHandlers,
18
+ ...fileHandlers,
19
+ ...authHandlers,
20
+ };
apps/vscode/src/handlers/mcp.handler.ts ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+ import type {
3
+ McpManagedServerInfo,
4
+ McpServerConfig as SdkMcpServerConfig,
5
+ McpTestResult,
6
+ } from "@moonshot-ai/kimi-code-sdk";
7
+
8
+ import { Events, Methods } from "../../shared/bridge";
9
+ import {
10
+ MCP_SECRET_MASK,
11
+ type MCPServerConfig,
12
+ type MCPTestResult,
13
+ type UpdateMCPServerRequest,
14
+ } from "../../shared/legacy-sdk";
15
+ import type { Handler } from "./types";
16
+
17
+ const SENSITIVE_MCP_KEY_WORDS = new Set([
18
+ "authorization",
19
+ "cookie",
20
+ "credential",
21
+ "credentials",
22
+ "passwd",
23
+ "password",
24
+ "secret",
25
+ "token",
26
+ ]);
27
+
28
+ interface NameParams { name: string }
29
+
30
+ export const mcpHandlers: Record<string, Handler<any, any>> = {
31
+ [Methods.GetMCPServers]: async (_, ctx): Promise<MCPServerConfig[]> => {
32
+ return listWorkspaceServers(ctx);
33
+ },
34
+
35
+ [Methods.AddMCPServer]: async (params: MCPServerConfig, ctx): Promise<MCPServerConfig[]> => {
36
+ const server = restoreMaskedSecrets(undefined, params);
37
+ await ctx.harness.addMcpServer(toSdkServer(server));
38
+ const servers = await listWorkspaceServers(ctx);
39
+ ctx.broadcast(Events.MCPServersChanged, servers);
40
+ return servers;
41
+ },
42
+
43
+ [Methods.UpdateMCPServer]: async (
44
+ params: UpdateMCPServerRequest | MCPServerConfig,
45
+ ctx,
46
+ ): Promise<MCPServerConfig[]> => {
47
+ const request = normalizeUpdateRequest(params);
48
+ const current = (
49
+ await ctx.harness.listMcpServers({ cwd: ctx.workDir ?? undefined })
50
+ ).find((server) => server.name === request.originalName);
51
+ const edited = restoreMaskedSecrets(current, request.server);
52
+ const next = mergeEditableServer(current, edited, request.replaceEditableFields);
53
+ await updateOrRenameServer(ctx.harness, request.originalName, current, next);
54
+ const servers = await listWorkspaceServers(ctx);
55
+ ctx.broadcast(Events.MCPServersChanged, servers);
56
+ return servers;
57
+ },
58
+
59
+ [Methods.RemoveMCPServer]: async ({ name }: NameParams, ctx): Promise<MCPServerConfig[]> => {
60
+ await ctx.harness.removeMcpServer(name);
61
+ const servers = await listWorkspaceServers(ctx);
62
+ ctx.broadcast(Events.MCPServersChanged, servers);
63
+ return servers;
64
+ },
65
+
66
+ [Methods.AuthMCP]: async ({ name }: NameParams, ctx) => {
67
+ await vscode.window.withProgress(
68
+ {
69
+ location: vscode.ProgressLocation.Notification,
70
+ title: `Kimi: Authenticating "${name}"...`,
71
+ cancellable: false,
72
+ },
73
+ async () => {
74
+ try {
75
+ await ctx.harness.authenticateMcpServer(name, {
76
+ onAuthorizationUrl: async (url) => vscode.env.openExternal(vscode.Uri.parse(url)),
77
+ });
78
+ await vscode.window.showInformationMessage(`Kimi: OAuth completed for "${name}"`);
79
+ } catch (error) {
80
+ const message = error instanceof Error ? error.message : String(error);
81
+ await vscode.window.showErrorMessage(`Kimi: OAuth failed for "${name}": ${message}`);
82
+ throw error;
83
+ }
84
+ },
85
+ );
86
+ return { ok: true };
87
+ },
88
+
89
+ [Methods.ResetAuthMCP]: async ({ name }: NameParams, ctx) => {
90
+ await vscode.window.withProgress(
91
+ {
92
+ location: vscode.ProgressLocation.Notification,
93
+ title: `Kimi: Resetting auth for "${name}"...`,
94
+ cancellable: false,
95
+ },
96
+ async () => {
97
+ try {
98
+ await ctx.harness.resetMcpServerAuth(name);
99
+ await vscode.window.showInformationMessage(`Kimi: Auth reset for "${name}"`);
100
+ } catch (error) {
101
+ const message = error instanceof Error ? error.message : String(error);
102
+ await vscode.window.showErrorMessage(`Kimi: Reset auth failed for "${name}": ${message}`);
103
+ throw error;
104
+ }
105
+ },
106
+ );
107
+ return { ok: true };
108
+ },
109
+
110
+ [Methods.TestMCP]: async ({ name }: NameParams, ctx): Promise<MCPTestResult> => {
111
+ void vscode.window.showInformationMessage(`Kimi: Testing MCP server "${name}"...`);
112
+ const result = toWebviewTestResult(await ctx.harness.testMcpServer(name, {
113
+ cwd: ctx.workDir ?? undefined,
114
+ }));
115
+ if (!result.success) {
116
+ ctx.logError(`MCP server test failed for "${name}"`, new Error(result.output));
117
+ }
118
+ return result;
119
+ },
120
+ };
121
+
122
+ /**
123
+ * The workspace-aware server list shown in the modal. The mutation RPCs
124
+ * (add/update/remove) return a list resolved without a cwd, so the webview
125
+ * refresh after every mutation must re-list with the workspace cwd —
126
+ * otherwise project-layer entries drop out of the modal until the next full
127
+ * load.
128
+ */
129
+ async function listWorkspaceServers(ctx: Parameters<Handler>[1]): Promise<MCPServerConfig[]> {
130
+ return toWebviewServers(await ctx.harness.listMcpServers({ cwd: ctx.workDir ?? undefined }));
131
+ }
132
+
133
+ function toWebviewServers(servers: readonly McpManagedServerInfo[]): MCPServerConfig[] {
134
+ return servers
135
+ .filter((server) => server.transport === "stdio" || server.transport === "http")
136
+ .map((server) => {
137
+ // The management view's source/origin/mutable tags stay in the webview
138
+ // payload so the panel can hide mutating controls on read-only entries;
139
+ // only the nested plugin origin detail is dropped.
140
+ const { plugin: _plugin, ...config } = server;
141
+ if (config.transport === "stdio") {
142
+ return { ...config, env: maskSecretValues(config.env) } as MCPServerConfig;
143
+ }
144
+ return { ...config, headers: maskSecretValues(config.headers) } as MCPServerConfig;
145
+ });
146
+ }
147
+
148
+ function maskSecretValues(values: Record<string, string> | undefined): Record<string, string> | undefined {
149
+ if (values === undefined) return undefined;
150
+ return Object.fromEntries(Object.entries(values).map(([key, value]) => [
151
+ key,
152
+ isSensitiveMcpKey(key) ? MCP_SECRET_MASK : value,
153
+ ]));
154
+ }
155
+
156
+ function isSensitiveMcpKey(key: string): boolean {
157
+ const words = key
158
+ .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
159
+ .toLowerCase()
160
+ .split(/[^a-z0-9]+/)
161
+ .filter(Boolean);
162
+ const compact = words.join("");
163
+ return words.some((word) => SENSITIVE_MCP_KEY_WORDS.has(word))
164
+ || words.includes("key")
165
+ || compact === "proxyauthorization"
166
+ || compact === "setcookie"
167
+ || compact.endsWith("apikey")
168
+ || compact.endsWith("accesskey")
169
+ || compact.endsWith("privatekey");
170
+ }
171
+
172
+ function restoreMaskedSecrets(
173
+ current: SdkMcpServerConfig | undefined,
174
+ edited: MCPServerConfig,
175
+ ): MCPServerConfig {
176
+ if (edited.transport === "stdio") {
177
+ const currentEnv = current?.transport === "stdio" ? current.env : undefined;
178
+ return {
179
+ ...edited,
180
+ env: restoreMaskedRecord("environment variable", currentEnv, edited.env, false),
181
+ };
182
+ }
183
+ const currentHeaders = current?.transport === "http" ? current.headers : undefined;
184
+ return {
185
+ ...edited,
186
+ headers: restoreMaskedRecord("header", currentHeaders, edited.headers, true),
187
+ };
188
+ }
189
+
190
+ function restoreMaskedRecord(
191
+ label: string,
192
+ current: Record<string, string> | undefined,
193
+ edited: Record<string, string> | undefined,
194
+ caseInsensitiveKeys: boolean,
195
+ ): Record<string, string> | undefined {
196
+ if (edited === undefined) return undefined;
197
+ return Object.fromEntries(Object.entries(edited).map(([key, value]) => {
198
+ if (value !== MCP_SECRET_MASK) return [key, value];
199
+ const stored = findStoredSecret(current, key, caseInsensitiveKeys);
200
+ if (stored === undefined) {
201
+ throw new Error(`Cannot preserve masked MCP ${label} "${key}" because no stored value exists`);
202
+ }
203
+ return [key, stored];
204
+ }));
205
+ }
206
+
207
+ function findStoredSecret(
208
+ current: Record<string, string> | undefined,
209
+ key: string,
210
+ caseInsensitiveKeys: boolean,
211
+ ): string | undefined {
212
+ if (current === undefined) return undefined;
213
+ if (Object.hasOwn(current, key)) return current[key];
214
+ if (!caseInsensitiveKeys) return undefined;
215
+ const normalized = key.toLowerCase();
216
+ const match = Object.entries(current).find(([storedKey]) => storedKey.toLowerCase() === normalized);
217
+ return match?.[1];
218
+ }
219
+
220
+ function toSdkServer(server: MCPServerConfig): SdkMcpServerConfig {
221
+ const name = server.name.trim();
222
+ if (server.transport === "stdio") {
223
+ return {
224
+ name,
225
+ transport: "stdio",
226
+ command: server.command?.trim() ?? "",
227
+ args: server.args,
228
+ env: server.env,
229
+ };
230
+ }
231
+ return {
232
+ name,
233
+ transport: "http",
234
+ url: server.url?.trim() ?? "",
235
+ headers: server.headers,
236
+ auth: server.auth,
237
+ bearerTokenEnvVar: server.bearerTokenEnvVar,
238
+ };
239
+ }
240
+
241
+ function mergeEditableServer(
242
+ current: SdkMcpServerConfig | undefined,
243
+ edited: MCPServerConfig,
244
+ replaceEditableFields: boolean,
245
+ ): SdkMcpServerConfig {
246
+ const next = toSdkServer(edited);
247
+ if (current === undefined || current.transport !== next.transport) return next;
248
+ if (!replaceEditableFields) {
249
+ return mergeReleasedFormUpdate(current, next);
250
+ }
251
+ return { ...current, ...next } as SdkMcpServerConfig;
252
+ }
253
+
254
+ function mergeReleasedFormUpdate(
255
+ current: SdkMcpServerConfig,
256
+ next: SdkMcpServerConfig,
257
+ ): SdkMcpServerConfig {
258
+ const defined = Object.fromEntries(
259
+ Object.entries(next).filter(([, value]) => value !== undefined),
260
+ );
261
+ return { ...current, ...defined } as SdkMcpServerConfig;
262
+ }
263
+
264
+ function normalizeUpdateRequest(
265
+ params: UpdateMCPServerRequest | MCPServerConfig,
266
+ ): {
267
+ readonly originalName: string;
268
+ readonly server: MCPServerConfig;
269
+ readonly replaceEditableFields: boolean;
270
+ } {
271
+ if ("server" in params) {
272
+ return {
273
+ originalName: params.originalName.trim(),
274
+ server: params.server,
275
+ replaceEditableFields: true,
276
+ };
277
+ }
278
+ return {
279
+ originalName: params.name.trim(),
280
+ server: params,
281
+ replaceEditableFields: false,
282
+ };
283
+ }
284
+
285
+ async function updateOrRenameServer(
286
+ harness: Pick<
287
+ Parameters<Handler>[1]["harness"],
288
+ "addMcpServer" | "updateMcpServer" | "removeMcpServer"
289
+ >,
290
+ originalName: string,
291
+ current: SdkMcpServerConfig | undefined,
292
+ next: SdkMcpServerConfig,
293
+ ): Promise<void> {
294
+ if (next.name === originalName) {
295
+ await harness.updateMcpServer(next);
296
+ return;
297
+ }
298
+ if (current === undefined) {
299
+ throw new Error(`MCP server "${originalName}" was not found`);
300
+ }
301
+
302
+ await harness.addMcpServer(next);
303
+ try {
304
+ await harness.removeMcpServer(originalName);
305
+ } catch (error) {
306
+ await harness.removeMcpServer(next.name).catch(() => undefined);
307
+ throw error;
308
+ }
309
+ }
310
+
311
+ function toWebviewTestResult(result: McpTestResult): MCPTestResult {
312
+ return { success: result.success, output: sanitizeMcpDiagnostic(result.output) };
313
+ }
314
+
315
+ function sanitizeMcpDiagnostic(output: string): string {
316
+ return output
317
+ .replaceAll(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]")
318
+ .replaceAll(
319
+ /(["']?(?:authorization|cookie|credentials?|password|passwd|secret|token|api[-_ ]?key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi,
320
+ "$1[redacted]",
321
+ );
322
+ }
apps/vscode/src/handlers/session.handler.ts ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as path from "node:path";
2
+ import * as vscode from "vscode";
3
+ import type { SessionSummary } from "@moonshot-ai/kimi-code-sdk";
4
+
5
+ import { Events, Methods } from "../../shared/bridge";
6
+ import type { SessionInfo } from "../../shared/legacy-sdk";
7
+ import type { BaselineSession } from "../managers/baseline.manager";
8
+ import { replaySessionToWebviewEvents } from "../runtime/replay-adapter";
9
+ import { areSameFsPath, isFsPathInsideOrEqual } from "../utils/fs-path";
10
+ import {
11
+ isWorkspacePathContained,
12
+ workDirUriFromPath,
13
+ } from "../utils/workspace-path";
14
+ import type { Handler } from "./types";
15
+
16
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
17
+
18
+ interface LoadHistoryParams {
19
+ kimiSessionId: string;
20
+ }
21
+
22
+ interface DeleteSessionParams {
23
+ sessionId: string;
24
+ }
25
+
26
+ interface ForkSessionParams {
27
+ sessionId: string;
28
+ turnIndex: number;
29
+ }
30
+
31
+ export const sessionHandlers: Record<string, Handler<any, any>> = {
32
+ [Methods.GetKimiSessions]: async (_, ctx): Promise<SessionInfo[]> => {
33
+ if (!ctx.workDir) return [];
34
+ return (await ctx.harness.listSessions({ workDir: ctx.workDir })).map(toSessionInfo);
35
+ },
36
+
37
+ [Methods.GetAllKimiSessions]: async (_, ctx): Promise<SessionInfo[]> => {
38
+ if (!ctx.workspaceRoot) return [];
39
+ return (await ctx.harness.listSessions())
40
+ .filter((session) => isInsideOrEqual(ctx.workspaceRoot!, session.workDir))
41
+ .map(toSessionInfo);
42
+ },
43
+
44
+ [Methods.GetRegisteredWorkDirs]: async (_, ctx): Promise<string[]> => {
45
+ if (!ctx.workspaceRoot) return [];
46
+ const sessions = await ctx.harness.listSessions();
47
+ const candidates = [ctx.workspaceRoot, ctx.workDir, ...sessions.map((session) => session.workDir)];
48
+ return [
49
+ ...new Set(
50
+ candidates.filter((workDir): workDir is string => workDir !== null && isInsideOrEqual(ctx.workspaceRoot!, workDir)),
51
+ ),
52
+ ].toSorted();
53
+ },
54
+
55
+ [Methods.SetWorkDir]: async (params: { workDir: string | null }, ctx) => {
56
+ if (!ctx.workspaceRoot || !ctx.workspaceRootUri) return { ok: false };
57
+ const target = params.workDir;
58
+ if (target) {
59
+ const targetUri = workDirUriFromPath(ctx.workspaceRootUri, ctx.workspaceRoot, target);
60
+ if (targetUri === undefined || !(await isWorkspacePathContained(ctx.workspaceRootUri, targetUri))) {
61
+ return { ok: false };
62
+ }
63
+ }
64
+ try {
65
+ await ctx.setCustomWorkDir(target);
66
+ } catch {
67
+ return { ok: false };
68
+ }
69
+ return { ok: true, workDir: target ?? ctx.workspaceRoot };
70
+ },
71
+
72
+ [Methods.BrowseWorkDir]: async (_, ctx) => {
73
+ if (!ctx.workspaceRoot || !ctx.workspaceRootUri) return { ok: false, workDir: null };
74
+ const workspaceUri = ctx.workspaceRootUri;
75
+ let subdirectories: string[] = [];
76
+ try {
77
+ const entries = await vscode.workspace.fs.readDirectory(workspaceUri);
78
+ subdirectories = entries
79
+ .filter(([name, type]) => type === vscode.FileType.Directory && !name.startsWith("."))
80
+ .map(([name]) => name)
81
+ .toSorted();
82
+ } catch {
83
+ // The native picker remains available when directory enumeration fails.
84
+ }
85
+
86
+ const picked = await vscode.window.showQuickPick(
87
+ [
88
+ { label: "$(folder) Browse...", description: "Open folder picker", alwaysShow: true },
89
+ { label: "", kind: vscode.QuickPickItemKind.Separator },
90
+ ...subdirectories.map((name) => ({
91
+ label: `$(folder) ${name}`,
92
+ description: path.join(ctx.workspaceRoot!, name),
93
+ })),
94
+ ],
95
+ { placeHolder: "Select a subdirectory or browse...", title: "Working Directory" },
96
+ );
97
+ if (!picked) return { ok: false, workDir: null };
98
+
99
+ let selectedUri: vscode.Uri;
100
+ if (picked.label === "$(folder) Browse...") {
101
+ const result = await vscode.window.showOpenDialog({
102
+ canSelectFiles: false,
103
+ canSelectFolders: true,
104
+ canSelectMany: false,
105
+ defaultUri: workspaceUri,
106
+ openLabel: "Select Working Directory",
107
+ });
108
+ if (!result?.[0]) return { ok: false, workDir: null };
109
+ selectedUri = result[0];
110
+ } else if (picked.description) {
111
+ const pickedUri = workDirUriFromPath(workspaceUri, ctx.workspaceRoot, picked.description);
112
+ if (pickedUri === undefined) return { ok: false, workDir: null };
113
+ selectedUri = pickedUri;
114
+ } else {
115
+ return { ok: false, workDir: null };
116
+ }
117
+
118
+ if (!(await isWorkspacePathContained(workspaceUri, selectedUri))) {
119
+ await vscode.window.showWarningMessage("Selected directory must be within the workspace.");
120
+ return { ok: false, workDir: null };
121
+ }
122
+ const selected = selectedUri.fsPath;
123
+ await ctx.setCustomWorkDir(selected === ctx.workspaceRoot ? null : selected);
124
+ return { ok: true, workDir: selected };
125
+ },
126
+
127
+ [Methods.LoadKimiSessionHistory]: async (params: LoadHistoryParams, ctx) => {
128
+ if (!ctx.workDir || !isSessionId(params.kimiSessionId)) return [];
129
+ const runtime = await ctx.resumeSession(params.kimiSessionId);
130
+ if (!areSameFsPath(runtime.session.workDir, ctx.workDir)) {
131
+ await ctx.closeSession();
132
+ throw new Error("The selected session belongs to a different working directory.");
133
+ }
134
+
135
+ let history: ReturnType<typeof replaySessionToWebviewEvents>;
136
+ try {
137
+ const resumeState = runtime.session.getResumeState();
138
+ if (resumeState?.agents["main"] === undefined) {
139
+ throw new Error("Session history is unavailable.");
140
+ }
141
+ history = replaySessionToWebviewEvents(resumeState, runtime.id);
142
+ } catch (error) {
143
+ await ctx.closeSession();
144
+ throw error;
145
+ }
146
+
147
+ ctx.fileManager.clearTracked(ctx.webviewId);
148
+ const baseline = baselineSession(runtime.summary ?? {
149
+ id: runtime.id,
150
+ workDir: runtime.session.workDir,
151
+ });
152
+ try {
153
+ const changes = await ctx.baselineManager.getChanges(baseline);
154
+ for (const change of changes) {
155
+ ctx.fileManager.trackFile(ctx.webviewId, path.join(baseline.workDir, change.path));
156
+ }
157
+ ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId);
158
+ } catch (error) {
159
+ ctx.logError("Unable to restore session file changes", error);
160
+ ctx.broadcast(Events.FileChangesUpdated, [], ctx.webviewId);
161
+ void Promise.resolve(
162
+ vscode.window.showWarningMessage(
163
+ "Kimi: This conversation opened, but its file change history is unavailable.",
164
+ "Show Logs",
165
+ ),
166
+ )
167
+ .then((action) => {
168
+ if (action === "Show Logs") ctx.showLogs();
169
+ })
170
+ .catch((noticeError: unknown) => {
171
+ ctx.logError("Unable to show the file change warning", noticeError);
172
+ });
173
+ }
174
+ return history;
175
+ },
176
+
177
+ [Methods.DeleteKimiSession]: async (params: DeleteSessionParams, ctx): Promise<{ ok: boolean }> => {
178
+ if (!isSessionId(params.sessionId) || !ctx.workspaceRoot) return { ok: false };
179
+ const summary = (await ctx.harness.listSessions({ sessionId: params.sessionId }))[0];
180
+ if (summary === undefined || !isInsideOrEqual(ctx.workspaceRoot, summary.workDir)) {
181
+ return { ok: false };
182
+ }
183
+ const affectedViews = ctx.runtime.getSession(params.sessionId)?.subscribers ?? [];
184
+ await ctx.runtime.deleteSession(params.sessionId);
185
+ await ctx.baselineManager.deleteSession(params.sessionId);
186
+ for (const webviewId of affectedViews) {
187
+ ctx.fileManager.clearSession(webviewId);
188
+ ctx.broadcast(Events.FileChangesUpdated, [], webviewId);
189
+ if (webviewId !== ctx.webviewId) {
190
+ ctx.broadcast(Events.NewConversation, {}, webviewId);
191
+ }
192
+ }
193
+ return { ok: true };
194
+ },
195
+
196
+ [Methods.ForkKimiSession]: async (params: ForkSessionParams, ctx) => {
197
+ if (!ctx.workDir || !isSessionId(params.sessionId) || !Number.isInteger(params.turnIndex) || params.turnIndex < 0) {
198
+ return null;
199
+ }
200
+ const summaries = await ctx.harness.listSessions({ sessionId: params.sessionId });
201
+ const sourceSummary = summaries[0];
202
+ if (
203
+ sourceSummary === undefined ||
204
+ !ctx.workspaceRoot ||
205
+ !isInsideOrEqual(ctx.workspaceRoot, sourceSummary.workDir)
206
+ ) return null;
207
+
208
+ const forkSettledSession = async () => {
209
+ const fork = await ctx.harness.forkSession({ id: params.sessionId, turnIndex: params.turnIndex });
210
+ const targetSummary = fork.summary;
211
+ if (targetSummary === undefined) {
212
+ await fork.close();
213
+ throw new Error("Forked session metadata is unavailable.");
214
+ }
215
+
216
+ let materializeError: unknown;
217
+ try {
218
+ await ctx.baselineManager.materializeToFork(
219
+ baselineSession(sourceSummary),
220
+ baselineSession(targetSummary),
221
+ );
222
+ } catch (error) {
223
+ materializeError = error;
224
+ }
225
+
226
+ try {
227
+ await fork.close();
228
+ } catch (error) {
229
+ ctx.logError("Unable to close a forked session", error);
230
+ }
231
+ if (materializeError !== undefined) {
232
+ await ctx.harness.deleteSession(targetSummary.id).catch((error: unknown) => {
233
+ ctx.logError(`Unable to remove failed fork "${targetSummary.id}"`, error);
234
+ });
235
+ await ctx.baselineManager.deleteSession(targetSummary.id).catch((error: unknown) => {
236
+ ctx.logError(`Unable to remove failed fork baseline "${targetSummary.id}"`, error);
237
+ });
238
+ throw materializeError instanceof Error
239
+ ? materializeError
240
+ : new Error("Unable to materialize the forked session baseline.", {
241
+ cause: materializeError,
242
+ });
243
+ }
244
+ return { sessionId: targetSummary.id };
245
+ };
246
+
247
+ const active = ctx.runtime.getSession(params.sessionId);
248
+ return active === undefined
249
+ ? forkSettledSession()
250
+ : active.runExclusiveAfterCancelling(forkSettledSession);
251
+ },
252
+ };
253
+
254
+ function toSessionInfo(summary: SessionSummary): SessionInfo {
255
+ return {
256
+ id: summary.id,
257
+ workDir: summary.workDir,
258
+ updatedAt: summary.updatedAt,
259
+ brief: summary.title ?? summary.lastPrompt ?? "",
260
+ };
261
+ }
262
+
263
+ function baselineSession(summary: Pick<SessionSummary, "id" | "workDir" | "metadata">): BaselineSession {
264
+ return { id: summary.id, workDir: summary.workDir, metadata: summary.metadata };
265
+ }
266
+
267
+ function isInsideOrEqual(root: string, candidate: string): boolean {
268
+ return isFsPathInsideOrEqual(root, candidate);
269
+ }
270
+
271
+ function isSessionId(value: string): boolean {
272
+ return SESSION_ID.test(value);
273
+ }
apps/vscode/src/handlers/slash-command.ts ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Stats } from "node:fs";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import * as vscode from "vscode";
6
+
7
+ import type { SessionRuntime } from "../runtime/session-runtime";
8
+ import {
9
+ buildExportMarkdown,
10
+ isImportableTextFile,
11
+ isSensitiveFile,
12
+ stringifyContextHistory,
13
+ } from "../utils/session-context";
14
+ import type { HandlerContext } from "./types";
15
+
16
+ const HOST_COMMANDS = new Set([
17
+ "init",
18
+ "compact",
19
+ "clear",
20
+ "reset",
21
+ "yolo",
22
+ "auto",
23
+ "afk",
24
+ "plan",
25
+ "add-dir",
26
+ "export",
27
+ "import",
28
+ ]);
29
+ const MAX_IMPORT_BYTES = 10 * 1024 * 1024;
30
+
31
+ export interface HostSlashCommand {
32
+ readonly name: string;
33
+ readonly args: string;
34
+ readonly raw: string;
35
+ }
36
+
37
+ export function parseHostSlashCommand(content: string | readonly unknown[]): HostSlashCommand | undefined {
38
+ if (typeof content !== "string") return undefined;
39
+ const raw = content.trim();
40
+ const match = /^\/([^\s]+)(?:\s+(.*))?\s*$/s.exec(raw);
41
+ if (match === null) return undefined;
42
+ const name = match[1]!.toLowerCase();
43
+ if (!HOST_COMMANDS.has(name) && !name.startsWith("skill:")) return undefined;
44
+ return { name, args: match[2]?.trim() ?? "", raw };
45
+ }
46
+
47
+ export async function runHostSlashCommand(
48
+ runtime: SessionRuntime,
49
+ command: HostSlashCommand,
50
+ ctx: HandlerContext,
51
+ ): Promise<boolean> {
52
+ if (command.name.startsWith("skill:")) {
53
+ const skillName = command.name.slice("skill:".length);
54
+ const result = await runtime.runTurnAction(command.raw, () =>
55
+ runtime.session.activateSkill(skillName, command.args || undefined));
56
+ return result.status === "finished";
57
+ }
58
+
59
+ const actionId = runtime.beginHostAction(command.raw, command.name === "import");
60
+ const emit = (text: string): void => runtime.emitHostText(text, actionId);
61
+ try {
62
+ if (command.name === "import") {
63
+ const result = await importContext(runtime, command.args, ctx);
64
+ emit(result.message);
65
+ if (result.sensitive) {
66
+ void vscode.window.showWarningMessage(
67
+ "Kimi: The imported file may contain API keys, tokens, or credentials.",
68
+ );
69
+ }
70
+ } else {
71
+ switch (command.name) {
72
+ case "init":
73
+ await runtime.session.init();
74
+ emit("AGENTS.md has been generated.");
75
+ break;
76
+ case "compact":
77
+ await runtime.compactHostAction(actionId, command.args || undefined);
78
+ emit("The context has been compacted.");
79
+ break;
80
+ case "clear":
81
+ case "reset":
82
+ await runtime.session.clearContext();
83
+ emit("The context has been cleared.");
84
+ break;
85
+ case "yolo":
86
+ await toggleLegacyPermission(runtime, "yolo", emit);
87
+ break;
88
+ case "auto":
89
+ case "afk":
90
+ await toggleLegacyPermission(runtime, "afk", emit);
91
+ break;
92
+ case "plan":
93
+ await runPlanCommand(runtime, command.args, emit);
94
+ break;
95
+ case "add-dir":
96
+ await runAddDirCommand(runtime, command.args, emit);
97
+ break;
98
+ case "export":
99
+ await exportContext(runtime, command.args, emit);
100
+ break;
101
+ }
102
+ }
103
+
104
+ if (runtime.wasHostActionCancelled(actionId)) return false;
105
+ runtime.completeHostAction("finished", actionId);
106
+ return true;
107
+ } catch (error) {
108
+ if (runtime.wasHostActionCancelled(actionId)) return false;
109
+ runtime.failHostAction(actionId);
110
+ throw error;
111
+ } finally {
112
+ runtime.releaseHostAction(actionId);
113
+ }
114
+ }
115
+
116
+ async function toggleLegacyPermission(
117
+ runtime: SessionRuntime,
118
+ kind: "yolo" | "afk",
119
+ emit: (text: string) => void,
120
+ ): Promise<void> {
121
+ const flags = await runtime.toggleLegacyApproval(kind);
122
+
123
+ if (kind === "yolo") {
124
+ emit(flags.yolo
125
+ ? "You only live once! Tool actions will be auto-approved; the agent may still ask questions."
126
+ : flags.afk
127
+ ? "Yolo disabled, but Auto is still on — tool calls remain auto-approved."
128
+ : "You only die once! Actions will require approval.");
129
+ return;
130
+ }
131
+ emit(flags.afk
132
+ ? "Auto mode enabled. Questions will be auto-dismissed and tool calls auto-approved."
133
+ : flags.yolo
134
+ ? "Auto mode disabled. You are back at the keyboard. Yolo is still on."
135
+ : "Auto mode disabled. You are back at the keyboard.");
136
+ }
137
+
138
+ async function runPlanCommand(
139
+ runtime: SessionRuntime,
140
+ args: string,
141
+ emit: (text: string) => void,
142
+ ): Promise<void> {
143
+ const subcommand = args.trim().toLowerCase();
144
+ if (subcommand === "view") {
145
+ const plan = await runtime.session.getPlan();
146
+ emit(plan?.content.trim() || "No plan file found for this session.");
147
+ return;
148
+ }
149
+ if (subcommand === "clear") {
150
+ await runtime.session.clearPlan();
151
+ emit("Plan cleared.");
152
+ return;
153
+ }
154
+ const status = await runtime.session.getStatus();
155
+ const enabled = subcommand === "on" ? true : subcommand === "off" ? false : !status.planMode;
156
+ if (subcommand && subcommand !== "on" && subcommand !== "off") {
157
+ throw new Error(`Unknown plan subcommand: ${subcommand}`);
158
+ }
159
+ if (status.planMode !== enabled) await runtime.session.setPlanMode(enabled);
160
+ if (!enabled) {
161
+ emit("Plan mode OFF. All tools are now available.");
162
+ return;
163
+ }
164
+ const plan = await runtime.session.getPlan().catch(() => null);
165
+ emit(plan?.path
166
+ ? `Plan mode ON. Plan file: ${plan.path}`
167
+ : "Plan mode ON.");
168
+ }
169
+
170
+ async function runAddDirCommand(
171
+ runtime: SessionRuntime,
172
+ args: string,
173
+ emit: (text: string) => void,
174
+ ): Promise<void> {
175
+ const input = stripMatchingQuotes(args.trim());
176
+ if (!input || input.toLowerCase() === "list") {
177
+ const dirs = runtime.session.summary?.additionalDirs ?? [];
178
+ emit(dirs.length === 0
179
+ ? "No additional directories. Usage: /add-dir <path>"
180
+ : ["Additional directories:", ...dirs.map((path) => ` - ${path}`)].join("\n"));
181
+ return;
182
+ }
183
+ const result = await runtime.session.addAdditionalDir(input, { persist: false });
184
+ emit(`Added directory to workspace: ${result.additionalDirs.at(-1) ?? input}`);
185
+ }
186
+
187
+ async function exportContext(
188
+ runtime: SessionRuntime,
189
+ args: string,
190
+ emit: (text: string) => void,
191
+ ): Promise<void> {
192
+ const context = await runtime.session.getContext();
193
+ if (context.history.length === 0) {
194
+ emit("No messages to export.");
195
+ return;
196
+ }
197
+ const now = new Date();
198
+ const defaultName = defaultExportName(runtime.id, now);
199
+ const outputPath = await resolveExportPath(args, runtime.session.workDir, defaultName);
200
+ const markdown = buildExportMarkdown({
201
+ sessionId: runtime.id,
202
+ workDir: runtime.session.workDir,
203
+ history: context.history,
204
+ tokenCount: context.tokenCount,
205
+ now,
206
+ });
207
+ await mkdir(dirname(outputPath), { recursive: true });
208
+ await writeFile(outputPath, markdown, "utf8");
209
+ emit(
210
+ `Exported ${String(context.history.length)} messages to ${outputPath}\n\n` +
211
+ "Note: The exported file may contain sensitive information. Please be cautious when sharing it externally.",
212
+ );
213
+ void vscode.window.showInformationMessage("Kimi: Session exported.", "Open File").then((action) => {
214
+ if (action !== "Open File") return;
215
+ void vscode.window.showTextDocument(vscode.Uri.file(outputPath));
216
+ });
217
+ }
218
+
219
+ async function importContext(
220
+ runtime: SessionRuntime,
221
+ args: string,
222
+ ctx: HandlerContext,
223
+ ): Promise<{ message: string; sensitive: boolean }> {
224
+ const target = stripMatchingQuotes(args.trim());
225
+ if (!target) throw new Error("Usage: /import <file_path or session_id>");
226
+
227
+ const candidate = resolveUserPath(target, runtime.session.workDir);
228
+ const file = await fileInfo(candidate);
229
+ if (file?.isDirectory()) throw new Error("The specified path is a directory; please provide a file to import.");
230
+ if (file?.isFile()) {
231
+ if (!isImportableTextFile(candidate)) {
232
+ throw new Error(`Unsupported file type '${candidate.slice(candidate.lastIndexOf("."))}'. /import only supports text-based files.`);
233
+ }
234
+ if (file.size > MAX_IMPORT_BYTES) {
235
+ throw new Error(`File is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum import size is 10 MB.`);
236
+ }
237
+ const bytes = await readFile(candidate);
238
+ let content: string;
239
+ try {
240
+ content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
241
+ } catch {
242
+ throw new Error(`Cannot import '${basename(candidate)}': the file is not valid UTF-8 text.`);
243
+ }
244
+ if (!content.trim()) throw new Error("The file is empty, nothing to import.");
245
+ const source = `file '${basename(candidate)}'`;
246
+ await runtime.session.importContext(content, source);
247
+ return {
248
+ message: `Imported context from ${source} (${String(content.length)} chars).`,
249
+ sensitive: isSensitiveFile(basename(candidate)),
250
+ };
251
+ }
252
+
253
+ if (target === runtime.id) throw new Error("Cannot import the current session into itself.");
254
+ const summary = (await ctx.harness.listSessions({
255
+ workDir: runtime.session.workDir,
256
+ sessionId: target,
257
+ })).find((session) => session.id === target);
258
+ if (summary === undefined) throw new Error(`'${target}' is not a valid file path or session ID.`);
259
+
260
+ const activeSource = ctx.runtime.getSession(target)?.session;
261
+ const sourceSession = activeSource ?? await ctx.harness.resumeSession({ id: target });
262
+ try {
263
+ const sourceContext = await sourceSession.getContext();
264
+ if (sourceContext.history.length === 0) throw new Error("The source session has no messages.");
265
+ const content = stringifyContextHistory(sourceContext.history);
266
+ if (Buffer.byteLength(content, "utf8") > MAX_IMPORT_BYTES) {
267
+ throw new Error("Session content is too large. Maximum import size is 10 MB.");
268
+ }
269
+ const source = `session '${target}'`;
270
+ await runtime.session.importContext(content, source);
271
+ return {
272
+ message: `Imported context from ${source} (${String(content.length)} chars).`,
273
+ sensitive: false,
274
+ };
275
+ } finally {
276
+ if (activeSource === undefined) await sourceSession.close();
277
+ }
278
+ }
279
+
280
+ async function resolveExportPath(args: string, workDir: string, defaultName: string): Promise<string> {
281
+ const raw = stripMatchingQuotes(args.trim());
282
+ if (!raw) return join(workDir, defaultName);
283
+ const resolved = resolveUserPath(raw, workDir);
284
+ const info = await fileInfo(resolved);
285
+ return raw.endsWith("/") || raw.endsWith("\\") || info?.isDirectory()
286
+ ? join(resolved, defaultName)
287
+ : resolved;
288
+ }
289
+
290
+ function defaultExportName(sessionId: string, now: Date): string {
291
+ const timestamp = now.toISOString().replaceAll(/[-:]/g, "").replace("T", "-").slice(0, 15);
292
+ return `kimi-export-${sessionId.slice(0, 8)}-${timestamp}.md`;
293
+ }
294
+
295
+ function resolveUserPath(value: string, workDir: string): string {
296
+ const expanded = value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value;
297
+ return isAbsolute(expanded) ? expanded : resolve(workDir, expanded);
298
+ }
299
+
300
+ async function fileInfo(path: string): Promise<Stats | undefined> {
301
+ try {
302
+ return await stat(path);
303
+ } catch (error) {
304
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
305
+ throw error;
306
+ }
307
+ }
308
+
309
+ function stripMatchingQuotes(value: string): string {
310
+ if (value.length < 2) return value;
311
+ const first = value[0];
312
+ const last = value.at(-1);
313
+ return (first === last && (first === '"' || first === "'")) ? value.slice(1, -1) : value;
314
+ }
apps/vscode/src/handlers/types.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type * as vscode from "vscode";
2
+ import type { FileManager } from "../managers/file.manager";
3
+ import type { BaselineManager } from "../managers/baseline.manager";
4
+ import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk";
5
+ import type { KimiRuntime } from "../runtime/kimi-runtime";
6
+ import type { SessionRuntime } from "../runtime/session-runtime";
7
+
8
+ export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void;
9
+
10
+ export type ReloadWebviewFn = (webviewId: string) => void;
11
+
12
+ export type ShowLogsFn = () => void;
13
+
14
+ export interface HandlerContext {
15
+ webviewId: string;
16
+ workDir: string | null;
17
+ workDirUri: vscode.Uri | null;
18
+ workspaceRoot: string | null;
19
+ workspaceRootUri: vscode.Uri | null;
20
+ workspaceState: vscode.Memento;
21
+ requireWorkDir: () => string;
22
+ requireWorkDirUri: () => vscode.Uri;
23
+ broadcast: BroadcastFn;
24
+ fileManager: FileManager;
25
+ baselineManager: BaselineManager;
26
+ runtime: KimiRuntime;
27
+ harness: KimiHarness;
28
+ reloadWebview: () => void;
29
+ showLogs: () => void;
30
+ logError: (message: string, error: unknown) => void;
31
+
32
+ getSession: () => SessionRuntime | undefined;
33
+ getSessionId: () => string | null;
34
+ getOrCreateSession: (model: string, effort: string, sessionId?: string) => Promise<SessionRuntime>;
35
+ resumeSession: (sessionId: string) => Promise<SessionRuntime>;
36
+ closeSession: () => Promise<void>;
37
+ saveAllDirty: () => Promise<void>;
38
+ setCustomWorkDir: (workDir: string | null) => Promise<void>;
39
+ }
40
+
41
+ export type Handler<TParams = void, TResult = unknown> = (params: TParams, ctx: HandlerContext) => Promise<TResult>;
apps/vscode/src/handlers/workspace.handler.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+ import { Methods } from "../../shared/bridge";
3
+ import type { Handler } from "./types";
4
+ import type { WorkspaceStatus } from "shared/types";
5
+
6
+ const INPUT_HISTORY_KEY = "kimi.inputHistory";
7
+ const MAX_HISTORY_SIZE = 100;
8
+
9
+ const checkWorkspace: Handler<void, WorkspaceStatus> = async (_, ctx) => {
10
+ return {
11
+ hasWorkspace: ctx.workDir !== null,
12
+ path: ctx.workDir ?? undefined,
13
+ workspaceRoot: ctx.workspaceRoot ?? undefined,
14
+ };
15
+ };
16
+
17
+ const openFolder: Handler<void, { ok: boolean }> = async () => {
18
+ await vscode.commands.executeCommand("vscode.openFolder");
19
+ return { ok: true };
20
+ };
21
+
22
+ const getInputHistory: Handler<void, string[]> = async (_, ctx) => {
23
+ return ctx.workspaceState.get<string[]>(INPUT_HISTORY_KEY, []);
24
+ };
25
+
26
+ const addInputHistory: Handler<{ text: string }, { ok: boolean }> = async ({ text }, ctx) => {
27
+ const history = ctx.workspaceState.get<string[]>(INPUT_HISTORY_KEY, []);
28
+ // 避免重复添加相同的最近一条
29
+ if (history[history.length - 1] !== text) {
30
+ history.push(text);
31
+ if (history.length > MAX_HISTORY_SIZE) {
32
+ history.shift();
33
+ }
34
+ await ctx.workspaceState.update(INPUT_HISTORY_KEY, history);
35
+ }
36
+ return { ok: true };
37
+ };
38
+
39
+ export const workspaceHandlers: Record<string, Handler<any, any>> = {
40
+ [Methods.CheckWorkspace]: checkWorkspace,
41
+ [Methods.OpenFolder]: openFolder,
42
+ [Methods.GetInputHistory]: getInputHistory,
43
+ [Methods.AddInputHistory]: addInputHistory,
44
+ };
apps/vscode/src/managers/baseline.manager.ts ADDED
@@ -0,0 +1,864 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { readFileSync, statSync } from 'node:fs';
3
+ import {
4
+ lstat,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ realpath,
9
+ rename,
10
+ rm,
11
+ stat,
12
+ unlink,
13
+ writeFile,
14
+ } from 'node:fs/promises';
15
+ import * as path from 'node:path';
16
+
17
+ import type { FileChange } from '../../shared/types';
18
+ import { relativeFsPath } from '../utils/fs-path';
19
+
20
+ const MANIFEST_VERSION = 1;
21
+ const SNAPSHOT_HASH = /^[a-f0-9]{64}$/;
22
+
23
+ export interface BaselineSession {
24
+ readonly id: string;
25
+ readonly workDir: string;
26
+ readonly metadata?: Readonly<Record<string, unknown>>;
27
+ }
28
+
29
+ interface ManifestEntry {
30
+ readonly snapshot: string;
31
+ readonly existedBefore: boolean;
32
+ }
33
+
34
+ interface BaselineManifestV1 {
35
+ readonly version: 1;
36
+ readonly sessionId: string;
37
+ readonly entries: Readonly<Record<string, ManifestEntry>>;
38
+ readonly acceptedLegacyPaths: readonly string[];
39
+ }
40
+
41
+ interface MutableManifest {
42
+ version: 1;
43
+ sessionId: string;
44
+ entries: Record<string, ManifestEntry>;
45
+ acceptedLegacyPaths: string[];
46
+ }
47
+
48
+ interface ResolvedFile {
49
+ readonly absolutePath: string;
50
+ readonly relativePath: string;
51
+ }
52
+
53
+ interface BaselineValue {
54
+ readonly content: string;
55
+ readonly existedBefore: boolean;
56
+ }
57
+
58
+ export class BaselineError extends Error {
59
+ constructor(message: string, options?: ErrorOptions) {
60
+ super(message, options);
61
+ this.name = 'BaselineError';
62
+ }
63
+ }
64
+
65
+ export class BaselineManager {
66
+ private readonly baselinesRoot: string;
67
+ private readonly updates = new Map<string, Promise<void>>();
68
+
69
+ constructor(globalStorageRoot: string, homeNamespace = 'default') {
70
+ if (globalStorageRoot.length === 0) {
71
+ throw new BaselineError('The VSCode global storage path is empty');
72
+ }
73
+ if (homeNamespace.length === 0) {
74
+ throw new BaselineError('The Kimi home namespace is empty');
75
+ }
76
+ this.baselinesRoot = path.join(globalStorageRoot, 'baselines', hash(homeNamespace));
77
+ }
78
+
79
+ /**
80
+ * Capture the file synchronously before returning control to the caller.
81
+ * Persistence is serialized per session and completes through the returned
82
+ * promise, but no `await` occurs before the original file has been read.
83
+ */
84
+ async capture(session: BaselineSession, filePath: string): Promise<void> {
85
+ const resolved = resolveSessionFile(session, filePath);
86
+ const captured = captureOriginal(resolved.absolutePath);
87
+
88
+ await this.serialize([session.id], async () => {
89
+ const manifest = await this.readManifest(session);
90
+ const localPath = equivalentPath(
91
+ session,
92
+ Object.keys(manifest.entries),
93
+ resolved.relativePath,
94
+ );
95
+ if (localPath !== undefined) return;
96
+
97
+ const accepted = new Set(manifest.acceptedLegacyPaths);
98
+ const acceptedPath = equivalentPath(session, accepted, resolved.relativePath);
99
+ if (acceptedPath === undefined) {
100
+ const legacyExists = await this.hasLegacyBaseline(session, resolved.relativePath);
101
+ if (legacyExists) return;
102
+ }
103
+
104
+ const snapshot = hash(captured.content);
105
+ await this.writeSnapshot(session.id, snapshot, captured.content);
106
+ if (acceptedPath !== undefined) accepted.delete(acceptedPath);
107
+
108
+ const next = mutableManifest(manifest);
109
+ next.entries[resolved.relativePath] = {
110
+ snapshot,
111
+ existedBefore: captured.existedBefore,
112
+ };
113
+ next.acceptedLegacyPaths = uniquePaths(session, accepted);
114
+ await this.writeManifest(next);
115
+ });
116
+ }
117
+
118
+ async getChanges(session: BaselineSession): Promise<FileChange[]> {
119
+ await this.waitForUpdates([session.id]);
120
+ const manifest = await this.readManifest(session);
121
+ const relativePaths = await this.effectivePaths(session, manifest);
122
+ const changes: FileChange[] = [];
123
+
124
+ for (const relativePath of relativePaths) {
125
+ const baseline = await this.readEffectiveBaseline(session, relativePath, manifest);
126
+ if (baseline === undefined) continue;
127
+
128
+ const resolved = resolveSessionFile(session, relativePath);
129
+ const currentContent = await readCurrentFile(resolved.absolutePath);
130
+ if (currentContent === undefined) {
131
+ if (baseline.existedBefore) {
132
+ changes.push({
133
+ path: relativePath,
134
+ status: 'Deleted',
135
+ additions: 0,
136
+ deletions: countLines(baseline.content),
137
+ });
138
+ }
139
+ continue;
140
+ }
141
+
142
+ if (!baseline.existedBefore) {
143
+ changes.push({
144
+ path: relativePath,
145
+ status: 'Added',
146
+ additions: countLines(currentContent),
147
+ deletions: 0,
148
+ });
149
+ continue;
150
+ }
151
+
152
+ if (currentContent !== baseline.content) {
153
+ const diff = computeLineDiff(baseline.content, currentContent);
154
+ changes.push({
155
+ path: relativePath,
156
+ status: 'Modified',
157
+ additions: diff.additions,
158
+ deletions: diff.deletions,
159
+ });
160
+ }
161
+ }
162
+
163
+ return changes;
164
+ }
165
+
166
+ async getContent(session: BaselineSession, filePath: string): Promise<string> {
167
+ await this.waitForUpdates([session.id]);
168
+ const resolved = resolveSessionFile(session, filePath);
169
+ const manifest = await this.readManifest(session);
170
+ const baseline = await this.readEffectiveBaseline(session, resolved.relativePath, manifest);
171
+ if (baseline === undefined) {
172
+ throw new BaselineError(
173
+ `No baseline exists for "${resolved.relativePath}" in session "${session.id}"`,
174
+ );
175
+ }
176
+ return baseline.content;
177
+ }
178
+
179
+ async undo(session: BaselineSession, filePath: string): Promise<void> {
180
+ const resolved = resolveSessionFile(session, filePath);
181
+ await this.serialize([session.id], async () => {
182
+ const manifest = await this.readManifest(session);
183
+ const baseline = await this.readEffectiveBaseline(session, resolved.relativePath, manifest);
184
+ if (baseline === undefined) {
185
+ throw new BaselineError(
186
+ `No baseline exists for "${resolved.relativePath}" in session "${session.id}"`,
187
+ );
188
+ }
189
+ await restoreFile(session.workDir, resolved.absolutePath, baseline);
190
+ });
191
+ }
192
+
193
+ async undoAll(session: BaselineSession): Promise<void> {
194
+ await this.serialize([session.id], async () => {
195
+ const manifest = await this.readManifest(session);
196
+ const relativePaths = await this.effectivePaths(session, manifest);
197
+ for (const relativePath of relativePaths) {
198
+ const baseline = await this.readEffectiveBaseline(session, relativePath, manifest);
199
+ if (baseline === undefined) continue;
200
+ await restoreFile(
201
+ session.workDir,
202
+ resolveSessionFile(session, relativePath).absolutePath,
203
+ baseline,
204
+ );
205
+ }
206
+ });
207
+ }
208
+
209
+ async keep(session: BaselineSession, filePath: string): Promise<void> {
210
+ const resolved = resolveSessionFile(session, filePath);
211
+ await this.serialize([session.id], async () => {
212
+ const manifest = await this.readManifest(session);
213
+ const localPath = equivalentPath(
214
+ session,
215
+ Object.keys(manifest.entries),
216
+ resolved.relativePath,
217
+ );
218
+ const hadLocal = localPath !== undefined;
219
+ const hasLegacy = await this.hasLegacyBaseline(session, resolved.relativePath);
220
+ if (!hadLocal && !hasLegacy) return;
221
+
222
+ const next = mutableManifest(manifest);
223
+ if (localPath !== undefined) delete next.entries[localPath];
224
+ const accepted = new Set(next.acceptedLegacyPaths);
225
+ const acceptedPath = equivalentPath(session, accepted, resolved.relativePath);
226
+ if (acceptedPath !== undefined) accepted.delete(acceptedPath);
227
+ if (hasLegacy) accepted.add(resolved.relativePath);
228
+ next.acceptedLegacyPaths = uniquePaths(session, accepted);
229
+
230
+ await this.writeManifest(next);
231
+ await this.removeUnreferencedSnapshots(session.id, next);
232
+ });
233
+ }
234
+
235
+ async keepAll(session: BaselineSession): Promise<void> {
236
+ await this.serialize([session.id], async () => {
237
+ const manifest = await this.readManifest(session);
238
+ const legacyPaths = await this.listLegacyPaths(session);
239
+ const next = mutableManifest(manifest);
240
+ next.entries = {};
241
+ next.acceptedLegacyPaths = uniquePaths(session, [
242
+ ...next.acceptedLegacyPaths,
243
+ ...legacyPaths,
244
+ ]);
245
+
246
+ await this.writeManifest(next);
247
+ await this.removeUnreferencedSnapshots(session.id, next);
248
+ });
249
+ }
250
+
251
+ async materializeToFork(source: BaselineSession, target: BaselineSession): Promise<void> {
252
+ if (source.id === target.id) {
253
+ throw new BaselineError('Cannot materialize a baseline fork onto the source session');
254
+ }
255
+
256
+ await this.serialize([source.id, target.id], async () => {
257
+ const sourceManifest = await this.readManifest(source);
258
+ const sourcePaths = await this.effectivePaths(source, sourceManifest);
259
+ const values = new Map<string, BaselineValue>();
260
+ for (const relativePath of sourcePaths) {
261
+ const baseline = await this.readEffectiveBaseline(source, relativePath, sourceManifest);
262
+ if (baseline !== undefined) values.set(relativePath, baseline);
263
+ }
264
+
265
+ const targetManifest = await this.readManifest(target);
266
+ const next = mutableManifest(targetManifest);
267
+ const accepted = uniquePaths(target, [
268
+ ...next.acceptedLegacyPaths,
269
+ ...sourceManifest.acceptedLegacyPaths,
270
+ ]);
271
+
272
+ for (const [relativePath, baseline] of values) {
273
+ const existingPath = equivalentPath(target, Object.keys(next.entries), relativePath);
274
+ if (existingPath !== undefined) continue;
275
+ const snapshot = hash(baseline.content);
276
+ await this.writeSnapshot(target.id, snapshot, baseline.content);
277
+ next.entries[relativePath] = {
278
+ snapshot,
279
+ existedBefore: baseline.existedBefore,
280
+ };
281
+ }
282
+
283
+ next.acceptedLegacyPaths = accepted;
284
+ await this.writeManifest(next);
285
+ });
286
+ }
287
+
288
+ async deleteSession(sessionId: string): Promise<void> {
289
+ requireSessionId(sessionId);
290
+ await this.serialize([sessionId], async () => {
291
+ await rm(this.sessionRoot(sessionId), { recursive: true, force: true });
292
+ });
293
+ }
294
+
295
+ private async effectivePaths(
296
+ session: BaselineSession,
297
+ manifest: BaselineManifestV1,
298
+ ): Promise<string[]> {
299
+ const paths = new Map<string, string>();
300
+ for (const relativePath of Object.keys(manifest.entries)) {
301
+ paths.set(pathComparisonKey(session, relativePath), relativePath);
302
+ }
303
+ const accepted = new Set(
304
+ manifest.acceptedLegacyPaths.map((relativePath) =>
305
+ pathComparisonKey(session, relativePath),
306
+ ),
307
+ );
308
+ for (const relativePath of await this.listLegacyPaths(session)) {
309
+ const key = pathComparisonKey(session, relativePath);
310
+ if (!accepted.has(key) && !paths.has(key)) paths.set(key, relativePath);
311
+ }
312
+ return [...paths.values()].toSorted();
313
+ }
314
+
315
+ private async readEffectiveBaseline(
316
+ session: BaselineSession,
317
+ relativePath: string,
318
+ manifest: BaselineManifestV1,
319
+ ): Promise<BaselineValue | undefined> {
320
+ const localPath = equivalentPath(session, Object.keys(manifest.entries), relativePath);
321
+ const local = localPath === undefined ? undefined : manifest.entries[localPath];
322
+ if (localPath !== undefined && local !== undefined) {
323
+ const content = await this.readSnapshot(session.id, local.snapshot, localPath);
324
+ return { content, existedBefore: local.existedBefore };
325
+ }
326
+
327
+ if (equivalentPath(session, manifest.acceptedLegacyPaths, relativePath) !== undefined) {
328
+ return undefined;
329
+ }
330
+ return this.readLegacyBaseline(session, relativePath);
331
+ }
332
+
333
+ private async readManifest(session: BaselineSession): Promise<BaselineManifestV1> {
334
+ requireSession(session);
335
+ let text: string;
336
+ try {
337
+ text = await readFile(this.manifestPath(session.id), 'utf-8');
338
+ } catch (error) {
339
+ if (isErrorCode(error, 'ENOENT')) return emptyManifest(session.id);
340
+ throw new BaselineError(`Unable to read baseline manifest for session "${session.id}"`, {
341
+ cause: error,
342
+ });
343
+ }
344
+
345
+ let parsed: unknown;
346
+ try {
347
+ parsed = JSON.parse(text) as unknown;
348
+ } catch (error) {
349
+ throw new BaselineError(`Baseline manifest for session "${session.id}" is invalid JSON`, {
350
+ cause: error,
351
+ });
352
+ }
353
+ return parseManifest(parsed, session);
354
+ }
355
+
356
+ private async writeManifest(manifest: BaselineManifestV1): Promise<void> {
357
+ if (
358
+ Object.keys(manifest.entries).length === 0 &&
359
+ manifest.acceptedLegacyPaths.length === 0
360
+ ) {
361
+ await rm(this.sessionRoot(manifest.sessionId), { recursive: true, force: true });
362
+ return;
363
+ }
364
+
365
+ const text = `${JSON.stringify(manifest, null, 2)}\n`;
366
+ await atomicWrite(this.manifestPath(manifest.sessionId), text);
367
+ }
368
+
369
+ private async writeSnapshot(sessionId: string, snapshot: string, content: string): Promise<void> {
370
+ const snapshotPath = this.snapshotPath(sessionId, snapshot);
371
+ try {
372
+ const existing = await readFile(snapshotPath, 'utf-8');
373
+ if (hash(existing) !== snapshot) {
374
+ throw new BaselineError(
375
+ `Baseline snapshot "${snapshot}" for session "${sessionId}" is corrupt`,
376
+ );
377
+ }
378
+ return;
379
+ } catch (error) {
380
+ if (!isErrorCode(error, 'ENOENT')) {
381
+ if (error instanceof BaselineError) throw error;
382
+ throw new BaselineError(
383
+ `Unable to inspect baseline snapshot "${snapshot}" for session "${sessionId}"`,
384
+ { cause: error },
385
+ );
386
+ }
387
+ }
388
+ await atomicWrite(snapshotPath, content);
389
+ }
390
+
391
+ private async readSnapshot(
392
+ sessionId: string,
393
+ snapshot: string,
394
+ relativePath: string,
395
+ ): Promise<string> {
396
+ let content: string;
397
+ try {
398
+ content = await readFile(this.snapshotPath(sessionId, snapshot), 'utf-8');
399
+ } catch (error) {
400
+ throw new BaselineError(
401
+ `Unable to read baseline snapshot for "${relativePath}" in session "${sessionId}"`,
402
+ { cause: error },
403
+ );
404
+ }
405
+ if (hash(content) !== snapshot) {
406
+ throw new BaselineError(
407
+ `Baseline snapshot for "${relativePath}" in session "${sessionId}" is corrupt`,
408
+ );
409
+ }
410
+ return content;
411
+ }
412
+
413
+ private async removeUnreferencedSnapshots(
414
+ sessionId: string,
415
+ manifest: BaselineManifestV1,
416
+ ): Promise<void> {
417
+ const snapshotsDir = this.snapshotsRoot(sessionId);
418
+ let names: string[];
419
+ try {
420
+ names = await readdir(snapshotsDir);
421
+ } catch (error) {
422
+ if (isErrorCode(error, 'ENOENT')) return;
423
+ throw new BaselineError(`Unable to clean baseline snapshots for session "${sessionId}"`, {
424
+ cause: error,
425
+ });
426
+ }
427
+
428
+ const referenced = new Set(Object.values(manifest.entries).map((entry) => entry.snapshot));
429
+ await Promise.all(
430
+ names.map(async (name) => {
431
+ if (referenced.has(name)) return;
432
+ await rm(path.join(snapshotsDir, name), { force: true });
433
+ }),
434
+ );
435
+ }
436
+
437
+ private async listLegacyPaths(session: BaselineSession): Promise<string[]> {
438
+ const root = legacyBaselineRoot(session);
439
+ if (root === undefined) return [];
440
+
441
+ const result: string[] = [];
442
+ await walkLegacyBaselines(root, '', result);
443
+ return result.toSorted();
444
+ }
445
+
446
+ private async hasLegacyBaseline(
447
+ session: BaselineSession,
448
+ relativePath: string,
449
+ ): Promise<boolean> {
450
+ const legacyPath = legacyBaselinePath(session, relativePath);
451
+ if (legacyPath === undefined) return false;
452
+ try {
453
+ const info = await stat(legacyPath);
454
+ if (!info.isFile()) {
455
+ throw new BaselineError(`Legacy baseline "${relativePath}" is not a regular file`);
456
+ }
457
+ return true;
458
+ } catch (error) {
459
+ if (isErrorCode(error, 'ENOENT')) return false;
460
+ if (error instanceof BaselineError) throw error;
461
+ throw new BaselineError(`Unable to inspect legacy baseline "${relativePath}"`, {
462
+ cause: error,
463
+ });
464
+ }
465
+ }
466
+
467
+ private async readLegacyBaseline(
468
+ session: BaselineSession,
469
+ relativePath: string,
470
+ ): Promise<BaselineValue | undefined> {
471
+ const legacyPath = legacyBaselinePath(session, relativePath);
472
+ if (legacyPath === undefined) return undefined;
473
+
474
+ let info;
475
+ try {
476
+ info = await stat(legacyPath);
477
+ } catch (error) {
478
+ if (isErrorCode(error, 'ENOENT')) return undefined;
479
+ throw new BaselineError(`Unable to inspect legacy baseline "${relativePath}"`, {
480
+ cause: error,
481
+ });
482
+ }
483
+ if (!info.isFile()) {
484
+ throw new BaselineError(`Legacy baseline "${relativePath}" is not a regular file`);
485
+ }
486
+
487
+ try {
488
+ const content = await readFile(legacyPath, 'utf-8');
489
+ return { content, existedBefore: content.length > 0 };
490
+ } catch (error) {
491
+ throw new BaselineError(`Unable to read legacy baseline "${relativePath}"`, {
492
+ cause: error,
493
+ });
494
+ }
495
+ }
496
+
497
+ private async serialize<T>(sessionIds: readonly string[], operation: () => Promise<T>): Promise<T> {
498
+ const ids = [...new Set(sessionIds)].toSorted();
499
+ for (const id of ids) requireSessionId(id);
500
+
501
+ const previous = ids.map((id) => this.updates.get(id) ?? Promise.resolve());
502
+ const run = Promise.all(previous).then(operation);
503
+ const settled = run.then(
504
+ () => undefined,
505
+ () => undefined,
506
+ );
507
+ for (const id of ids) this.updates.set(id, settled);
508
+ void settled.then(() => {
509
+ for (const id of ids) {
510
+ if (this.updates.get(id) === settled) this.updates.delete(id);
511
+ }
512
+ });
513
+ return run;
514
+ }
515
+
516
+ private async waitForUpdates(sessionIds: readonly string[]): Promise<void> {
517
+ await Promise.all(sessionIds.map((id) => this.updates.get(id) ?? Promise.resolve()));
518
+ }
519
+
520
+ private sessionRoot(sessionId: string): string {
521
+ return path.join(this.baselinesRoot, hash(sessionId));
522
+ }
523
+
524
+ private manifestPath(sessionId: string): string {
525
+ return path.join(this.sessionRoot(sessionId), 'manifest.json');
526
+ }
527
+
528
+ private snapshotsRoot(sessionId: string): string {
529
+ return path.join(this.sessionRoot(sessionId), 'snapshots');
530
+ }
531
+
532
+ private snapshotPath(sessionId: string, snapshot: string): string {
533
+ if (!SNAPSHOT_HASH.test(snapshot)) {
534
+ throw new BaselineError(`Invalid baseline snapshot hash "${snapshot}"`);
535
+ }
536
+ return path.join(this.snapshotsRoot(sessionId), snapshot);
537
+ }
538
+ }
539
+
540
+ function emptyManifest(sessionId: string): BaselineManifestV1 {
541
+ return { version: MANIFEST_VERSION, sessionId, entries: {}, acceptedLegacyPaths: [] };
542
+ }
543
+
544
+ function mutableManifest(manifest: BaselineManifestV1): MutableManifest {
545
+ return {
546
+ version: MANIFEST_VERSION,
547
+ sessionId: manifest.sessionId,
548
+ entries: { ...manifest.entries },
549
+ acceptedLegacyPaths: [...manifest.acceptedLegacyPaths],
550
+ };
551
+ }
552
+
553
+ function parseManifest(value: unknown, session: BaselineSession): BaselineManifestV1 {
554
+ if (!isRecord(value) || value['version'] !== MANIFEST_VERSION) {
555
+ throw new BaselineError(`Unsupported baseline manifest for session "${session.id}"`);
556
+ }
557
+ if (value['sessionId'] !== session.id) {
558
+ throw new BaselineError(`Baseline manifest does not belong to session "${session.id}"`);
559
+ }
560
+
561
+ const rawEntries = value['entries'];
562
+ const rawAccepted = value['acceptedLegacyPaths'];
563
+ if (!isRecord(rawEntries) || !Array.isArray(rawAccepted)) {
564
+ throw new BaselineError(`Invalid baseline manifest for session "${session.id}"`);
565
+ }
566
+
567
+ const entries: Record<string, ManifestEntry> = {};
568
+ const entryKeys = new Set<string>();
569
+ for (const [rawPath, rawEntry] of Object.entries(rawEntries)) {
570
+ if (
571
+ !isRecord(rawEntry) ||
572
+ typeof rawEntry['snapshot'] !== 'string' ||
573
+ !SNAPSHOT_HASH.test(rawEntry['snapshot']) ||
574
+ typeof rawEntry['existedBefore'] !== 'boolean'
575
+ ) {
576
+ throw new BaselineError(`Invalid baseline entry "${rawPath}" in session "${session.id}"`);
577
+ }
578
+ const relativePath = resolveSessionFile(session, rawPath).relativePath;
579
+ const comparisonKey = pathComparisonKey(session, relativePath);
580
+ if (relativePath !== rawPath || entryKeys.has(comparisonKey)) {
581
+ throw new BaselineError(`Unsafe baseline path "${rawPath}" in session "${session.id}"`);
582
+ }
583
+ entryKeys.add(comparisonKey);
584
+ entries[relativePath] = {
585
+ snapshot: rawEntry['snapshot'],
586
+ existedBefore: rawEntry['existedBefore'],
587
+ };
588
+ }
589
+
590
+ const acceptedLegacyPaths: string[] = [];
591
+ for (const rawPath of rawAccepted) {
592
+ if (typeof rawPath !== 'string') {
593
+ throw new BaselineError(`Invalid accepted legacy path in session "${session.id}"`);
594
+ }
595
+ const relativePath = resolveSessionFile(session, rawPath).relativePath;
596
+ if (relativePath !== rawPath) {
597
+ throw new BaselineError(`Unsafe accepted legacy path "${rawPath}" in session "${session.id}"`);
598
+ }
599
+ if (equivalentPath(session, acceptedLegacyPaths, relativePath) === undefined) {
600
+ acceptedLegacyPaths.push(relativePath);
601
+ }
602
+ }
603
+
604
+ return {
605
+ version: MANIFEST_VERSION,
606
+ sessionId: session.id,
607
+ entries,
608
+ acceptedLegacyPaths: uniquePaths(session, acceptedLegacyPaths),
609
+ };
610
+ }
611
+
612
+ function equivalentPath(
613
+ session: BaselineSession,
614
+ paths: Iterable<string>,
615
+ candidate: string,
616
+ ): string | undefined {
617
+ const candidateKey = pathComparisonKey(session, candidate);
618
+ for (const existing of paths) {
619
+ if (pathComparisonKey(session, existing) === candidateKey) return existing;
620
+ }
621
+ return undefined;
622
+ }
623
+
624
+ function uniquePaths(session: BaselineSession, paths: Iterable<string>): string[] {
625
+ const unique = new Map<string, string>();
626
+ for (const relativePath of paths) {
627
+ const key = pathComparisonKey(session, relativePath);
628
+ if (!unique.has(key)) unique.set(key, relativePath);
629
+ }
630
+ return [...unique.values()].toSorted();
631
+ }
632
+
633
+ function pathComparisonKey(session: BaselineSession, relativePath: string): string {
634
+ return isWindowsAbsolute(session.workDir) ? relativePath.toLowerCase() : relativePath;
635
+ }
636
+
637
+ function resolveSessionFile(session: BaselineSession, filePath: string): ResolvedFile {
638
+ requireSession(session);
639
+ if (filePath.length === 0) throw new BaselineError('The baseline file path is empty');
640
+
641
+ const windows = isWindowsAbsolute(session.workDir);
642
+ if (!windows && isWindowsAbsolute(filePath)) {
643
+ throw new BaselineError(`File "${filePath}" is outside workspace "${session.workDir}"`);
644
+ }
645
+
646
+ const paths = windows ? path.win32 : path;
647
+ const root = paths.resolve(session.workDir);
648
+ const absolutePath = paths.resolve(root, filePath);
649
+ const relativePath = paths.relative(root, absolutePath);
650
+ const parentPrefix = `..${paths.sep}`;
651
+ if (
652
+ relativePath.length === 0 ||
653
+ relativePath === '..' ||
654
+ relativePath.startsWith(parentPrefix) ||
655
+ paths.isAbsolute(relativePath)
656
+ ) {
657
+ throw new BaselineError(`File "${filePath}" is outside workspace "${session.workDir}"`);
658
+ }
659
+
660
+ return {
661
+ absolutePath,
662
+ relativePath: windows ? relativePath.replaceAll('\\', '/') : relativePath,
663
+ };
664
+ }
665
+
666
+ function isWindowsAbsolute(value: string): boolean {
667
+ return /^[a-zA-Z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value);
668
+ }
669
+
670
+ function legacyBaselineRoot(session: BaselineSession): string | undefined {
671
+ const source = session.metadata?.['kimi_cli_source_path'];
672
+ if (typeof source !== 'string' || source.length === 0) return undefined;
673
+
674
+ const sourceIsWindows = isWindowsAbsolute(source);
675
+ if (sourceIsWindows !== (process.platform === 'win32')) return undefined;
676
+ if (!path.isAbsolute(source)) return undefined;
677
+ return path.join(source, 'baseline');
678
+ }
679
+
680
+ function legacyBaselinePath(
681
+ session: BaselineSession,
682
+ relativePath: string,
683
+ ): string | undefined {
684
+ const root = legacyBaselineRoot(session);
685
+ if (root === undefined) return undefined;
686
+ const resolved = resolveSessionFile(session, relativePath);
687
+ return path.join(root, ...resolved.relativePath.split('/'));
688
+ }
689
+
690
+ async function walkLegacyBaselines(
691
+ directory: string,
692
+ relativeDirectory: string,
693
+ result: string[],
694
+ ): Promise<void> {
695
+ let entries;
696
+ try {
697
+ entries = await readdir(directory, { withFileTypes: true });
698
+ } catch (error) {
699
+ if (isErrorCode(error, 'ENOENT') && relativeDirectory.length === 0) return;
700
+ throw new BaselineError(`Unable to list legacy baseline directory "${directory}"`, {
701
+ cause: error,
702
+ });
703
+ }
704
+
705
+ for (const entry of entries) {
706
+ const relativePath = relativeDirectory
707
+ ? `${relativeDirectory}/${entry.name}`
708
+ : entry.name;
709
+ if (entry.isDirectory()) {
710
+ await walkLegacyBaselines(path.join(directory, entry.name), relativePath, result);
711
+ } else if (entry.isFile()) {
712
+ result.push(relativePath);
713
+ }
714
+ }
715
+ }
716
+
717
+ function captureOriginal(absolutePath: string): BaselineValue {
718
+ let info;
719
+ try {
720
+ info = statSync(absolutePath);
721
+ } catch (error) {
722
+ if (isErrorCode(error, 'ENOENT')) return { content: '', existedBefore: false };
723
+ throw new BaselineError(`Unable to inspect original file "${absolutePath}"`, {
724
+ cause: error,
725
+ });
726
+ }
727
+ if (!info.isFile()) {
728
+ throw new BaselineError(`Original path "${absolutePath}" is not a regular file`);
729
+ }
730
+
731
+ try {
732
+ return { content: readFileSync(absolutePath, 'utf-8'), existedBefore: true };
733
+ } catch (error) {
734
+ throw new BaselineError(`Unable to capture original file "${absolutePath}"`, {
735
+ cause: error,
736
+ });
737
+ }
738
+ }
739
+
740
+ async function readCurrentFile(absolutePath: string): Promise<string | undefined> {
741
+ try {
742
+ return await readFile(absolutePath, 'utf-8');
743
+ } catch (error) {
744
+ if (isErrorCode(error, 'ENOENT')) return undefined;
745
+ throw new BaselineError(`Unable to read current file "${absolutePath}"`, { cause: error });
746
+ }
747
+ }
748
+
749
+ async function restoreFile(
750
+ workDir: string,
751
+ absolutePath: string,
752
+ baseline: BaselineValue,
753
+ ): Promise<void> {
754
+ await requireContainedRestorePath(workDir, absolutePath);
755
+ if (!baseline.existedBefore) {
756
+ try {
757
+ await unlink(absolutePath);
758
+ } catch (error) {
759
+ if (!isErrorCode(error, 'ENOENT')) {
760
+ throw new BaselineError(`Unable to remove newly created file "${absolutePath}"`, {
761
+ cause: error,
762
+ });
763
+ }
764
+ }
765
+ return;
766
+ }
767
+
768
+ try {
769
+ await mkdir(path.dirname(absolutePath), { recursive: true });
770
+ await writeFile(absolutePath, baseline.content, 'utf-8');
771
+ } catch (error) {
772
+ throw new BaselineError(`Unable to restore file "${absolutePath}"`, { cause: error });
773
+ }
774
+ }
775
+
776
+ async function requireContainedRestorePath(workDir: string, absolutePath: string): Promise<void> {
777
+ try {
778
+ const [realWorkDir, realTarget] = await Promise.all([
779
+ realpath(workDir),
780
+ realExistingPath(absolutePath),
781
+ ]);
782
+ if (relativeFsPath(realWorkDir, realTarget) === undefined) {
783
+ throw new BaselineError(`Refusing to restore path outside the session workspace: "${absolutePath}"`);
784
+ }
785
+ } catch (error) {
786
+ if (error instanceof BaselineError) throw error;
787
+ throw new BaselineError(`Unable to validate restore path "${absolutePath}"`, { cause: error });
788
+ }
789
+ }
790
+
791
+ async function realExistingPath(candidate: string): Promise<string> {
792
+ let current = candidate;
793
+ while (true) {
794
+ try {
795
+ return await realpath(current);
796
+ } catch (error) {
797
+ if (!isErrorCode(error, 'ENOENT')) throw error;
798
+ let isDanglingSymlink = false;
799
+ try {
800
+ isDanglingSymlink = (await lstat(current)).isSymbolicLink();
801
+ } catch (lstatError) {
802
+ if (!isErrorCode(lstatError, 'ENOENT')) throw lstatError;
803
+ }
804
+ if (isDanglingSymlink) throw error;
805
+ const parent = path.dirname(current);
806
+ if (parent === current) throw error;
807
+ current = parent;
808
+ }
809
+ }
810
+ }
811
+
812
+ async function atomicWrite(targetPath: string, content: string): Promise<void> {
813
+ await mkdir(path.dirname(targetPath), { recursive: true, mode: 0o700 });
814
+ const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`;
815
+ try {
816
+ await writeFile(temporaryPath, content, { encoding: 'utf-8', mode: 0o600 });
817
+ await rename(temporaryPath, targetPath);
818
+ } catch (error) {
819
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
820
+ throw new BaselineError(`Unable to atomically write "${targetPath}"`, { cause: error });
821
+ }
822
+ }
823
+
824
+ function requireSession(session: BaselineSession): void {
825
+ requireSessionId(session.id);
826
+ if (session.workDir.length === 0) throw new BaselineError('The session workspace path is empty');
827
+ }
828
+
829
+ function requireSessionId(sessionId: string): void {
830
+ if (sessionId.length === 0) throw new BaselineError('The baseline session id is empty');
831
+ }
832
+
833
+ function hash(value: string): string {
834
+ return createHash('sha256').update(value, 'utf-8').digest('hex');
835
+ }
836
+
837
+ function isRecord(value: unknown): value is Record<string, unknown> {
838
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
839
+ }
840
+
841
+ function isErrorCode(error: unknown, code: string): boolean {
842
+ return isRecord(error) && error['code'] === code;
843
+ }
844
+
845
+ function countLines(content: string): number {
846
+ if (content.length === 0) return 0;
847
+ return content.replaceAll('\r\n', '\n').split('\n').length;
848
+ }
849
+
850
+ function computeLineDiff(
851
+ oldContent: string,
852
+ newContent: string,
853
+ ): { additions: number; deletions: number } {
854
+ const lines = (content: string): string[] =>
855
+ content.length === 0 ? [] : content.replaceAll('\r\n', '\n').split('\n');
856
+ const oldLines = lines(oldContent);
857
+ const newLines = lines(newContent);
858
+ const oldSet = new Set(oldLines);
859
+ const newSet = new Set(newLines);
860
+ return {
861
+ additions: newLines.filter((line) => !oldSet.has(line)).length,
862
+ deletions: oldLines.filter((line) => !newSet.has(line)).length,
863
+ };
864
+ }
apps/vscode/src/managers/file.manager.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+ import * as path from "node:path";
3
+ import { BaselineManager, type BaselineSession } from "./baseline.manager";
4
+ import { Events } from "../../shared/bridge";
5
+ import type { ProjectFile } from "../../shared/types";
6
+ import { buildCaseInsensitiveGlobLiteral } from "../utils/string";
7
+ import {
8
+ isWorkspacePathContained,
9
+ relativeWorkspacePath,
10
+ } from "../utils/workspace-path";
11
+
12
+ export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void;
13
+
14
+ const IGNORE_DIRS = new Set([
15
+ "node_modules",
16
+ ".git",
17
+ ".svn",
18
+ ".hg",
19
+ "dist",
20
+ "build",
21
+ "out",
22
+ ".next",
23
+ ".nuxt",
24
+ "__pycache__",
25
+ ".cache",
26
+ ".venv",
27
+ "venv",
28
+ ".gradle",
29
+ ".idea",
30
+ ".DS_Store",
31
+ "Thumbs.db",
32
+ "coverage",
33
+ ".nyc_output",
34
+ ".pytest_cache",
35
+ ".mypy_cache",
36
+ ".tox",
37
+ ".eggs",
38
+ ".sass-cache",
39
+ ".parcel-cache",
40
+ "bower_components",
41
+ "jspm_packages",
42
+ ".turbo",
43
+ ]);
44
+
45
+ const SEARCH_EXCLUDE = `{${[...IGNORE_DIRS].map((d) => `**/${d}`).join(",")}}`;
46
+
47
+ interface ViewState {
48
+ session: BaselineSession | null;
49
+ trackedFiles: Set<string>;
50
+ }
51
+
52
+ export class FileManager {
53
+ private viewStates = new Map<string, ViewState>();
54
+ private disposables: vscode.Disposable[] = [];
55
+
56
+ constructor(
57
+ private readonly baselineManager: BaselineManager,
58
+ private broadcast: BroadcastFn,
59
+ ) {
60
+ // Watch for file changes
61
+ const watcher = vscode.workspace.createFileSystemWatcher("**/*");
62
+
63
+ const refresh = (uri: vscode.Uri) => {
64
+ void this.onFileChange(uri).catch((error) => {
65
+ console.error("[kimi-vscode] Unable to refresh file changes", error);
66
+ });
67
+ };
68
+ watcher.onDidChange(refresh);
69
+ watcher.onDidCreate(refresh);
70
+ watcher.onDidDelete(refresh);
71
+
72
+ this.disposables.push(watcher);
73
+ }
74
+
75
+ private getViewState(webviewId: string): ViewState {
76
+ let state = this.viewStates.get(webviewId);
77
+ if (!state) {
78
+ state = { session: null, trackedFiles: new Set() };
79
+ this.viewStates.set(webviewId, state);
80
+ }
81
+ return state;
82
+ }
83
+
84
+ setSession(webviewId: string, session: BaselineSession): void {
85
+ this.getViewState(webviewId).session = session;
86
+ }
87
+
88
+ clearSession(webviewId: string): void {
89
+ const state = this.getViewState(webviewId);
90
+ state.session = null;
91
+ state.trackedFiles.clear();
92
+ }
93
+
94
+ getSessionId(webviewId: string): string | null {
95
+ return this.getViewState(webviewId).session?.id ?? null;
96
+ }
97
+
98
+ getSession(webviewId: string): BaselineSession | null {
99
+ return this.getViewState(webviewId).session;
100
+ }
101
+
102
+ trackFile(webviewId: string, absolutePath: string): void {
103
+ this.getViewState(webviewId).trackedFiles.add(absolutePath);
104
+ }
105
+
106
+ getTracked(webviewId: string): Set<string> {
107
+ return this.getViewState(webviewId).trackedFiles;
108
+ }
109
+
110
+ clearTracked(webviewId: string): void {
111
+ this.getViewState(webviewId).trackedFiles.clear();
112
+ }
113
+
114
+ disposeView(webviewId: string): void {
115
+ this.viewStates.delete(webviewId);
116
+ }
117
+
118
+ private async onFileChange(uri: vscode.Uri): Promise<void> {
119
+ const absolutePath = uri.fsPath;
120
+
121
+ for (const [webviewId, state] of this.viewStates) {
122
+ if (!state.session || !state.trackedFiles.has(absolutePath)) {
123
+ continue;
124
+ }
125
+
126
+ await this.refreshChanges(webviewId);
127
+ }
128
+ }
129
+
130
+ async refreshChanges(webviewId: string): Promise<void> {
131
+ const state = this.getViewState(webviewId);
132
+ if (state.session === null) {
133
+ this.broadcast(Events.FileChangesUpdated, [], webviewId);
134
+ return;
135
+ }
136
+ const changes = await this.baselineManager.getChanges(state.session);
137
+ this.broadcast(Events.FileChangesUpdated, changes, webviewId);
138
+ }
139
+
140
+ async searchFiles(workDirUri: vscode.Uri, query?: string): Promise<ProjectFile[]> {
141
+ query = query ? buildCaseInsensitiveGlobLiteral(query) : "";
142
+ const pattern = query ? `**/*${query}*` : "**/*";
143
+ const files = await vscode.workspace.findFiles(
144
+ new vscode.RelativePattern(workDirUri, pattern),
145
+ new vscode.RelativePattern(workDirUri, SEARCH_EXCLUDE),
146
+ 200,
147
+ );
148
+ const results = await Promise.all(
149
+ files.map(async (uri): Promise<ProjectFile | undefined> => {
150
+ const relativePath = relativeWorkspacePath(workDirUri, uri);
151
+ if (relativePath === undefined || !(await isWorkspacePathContained(workDirUri, uri))) return undefined;
152
+ return {
153
+ path: relativePath,
154
+ name: path.posix.basename(relativePath),
155
+ isDirectory: false,
156
+ };
157
+ }),
158
+ );
159
+ return results.filter((result): result is ProjectFile => result !== undefined);
160
+ }
161
+
162
+ dispose(): void {
163
+ for (const d of this.disposables) {
164
+ d.dispose();
165
+ }
166
+ this.disposables = [];
167
+ this.viewStates.clear();
168
+ }
169
+ }
apps/vscode/src/migration/index.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ export * from "./legacy-migration.manager";
apps/vscode/src/migration/legacy-migration.manager.ts ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, join, resolve, win32 } from "node:path";
4
+
5
+ import {
6
+ detectMigration,
7
+ runMigration,
8
+ defaultPlansSourceDir,
9
+ shouldSuppressMigration,
10
+ type MigrationPlan,
11
+ type MigrationReport,
12
+ type MigrationScope,
13
+ } from "@moonshot-ai/migration-legacy";
14
+
15
+ const FULL_MIGRATION_SCOPE = {
16
+ config: true,
17
+ mcp: true,
18
+ userHistory: true,
19
+ skills: true,
20
+ sessions: true,
21
+ } satisfies MigrationScope;
22
+
23
+ export type LegacyMigrationSourceOrigin = "default" | "legacy-vscode-setting";
24
+
25
+ export type LegacyMigrationWarningCode =
26
+ | "invalid-share-dir"
27
+ | "relative-share-dir"
28
+ | "source-equals-target"
29
+ | "source-not-directory"
30
+ | "source-unreadable"
31
+ | "legacy-session-unreadable"
32
+ | "detection-failed";
33
+
34
+ export interface LegacyMigrationWarning {
35
+ readonly code: LegacyMigrationWarningCode;
36
+ readonly message: string;
37
+ readonly sourceHome?: string;
38
+ }
39
+
40
+ export interface LegacyMigrationReauthItem {
41
+ readonly sourceHome: string;
42
+ readonly name: string;
43
+ }
44
+
45
+ export interface LegacyMigrationNotices {
46
+ readonly oauthLoginsRequiringRelogin: readonly LegacyMigrationReauthItem[];
47
+ readonly mcpOauthServersRequiringReauth: readonly LegacyMigrationReauthItem[];
48
+ }
49
+
50
+ export interface LegacyMigrationSourcePreview {
51
+ readonly sourceHome: string;
52
+ readonly origin: LegacyMigrationSourceOrigin;
53
+ readonly hasConfig: boolean;
54
+ readonly hasMcp: boolean;
55
+ readonly hasUserHistory: boolean;
56
+ readonly hasSkills: boolean;
57
+ readonly hasPlans: boolean;
58
+ readonly totalSessions: number;
59
+ readonly sessionIssues: number;
60
+ }
61
+
62
+ export interface LegacyMigrationPromptModel {
63
+ readonly kind: "legacy-migration";
64
+ readonly message: string;
65
+ readonly actions: readonly [
66
+ { readonly id: "now"; readonly label: "Migrate Now" },
67
+ { readonly id: "later"; readonly label: "Later" },
68
+ ];
69
+ readonly sources: readonly LegacyMigrationSourcePreview[];
70
+ readonly notices: LegacyMigrationNotices;
71
+ }
72
+
73
+ export interface LegacyMigrationDiscovery {
74
+ readonly prompt: LegacyMigrationPromptModel | null;
75
+ readonly suppressedSources: readonly LegacyMigrationSourcePreview[];
76
+ readonly warnings: readonly LegacyMigrationWarning[];
77
+ readonly notices: LegacyMigrationNotices;
78
+ }
79
+
80
+ export interface LegacyMigrationManagerOptions {
81
+ /** Harness-resolved homeDir. */
82
+ readonly targetHome: string;
83
+ /** Defaults to the legacy kimi-cli home (`~/.kimi`). Injectable for isolated tests. */
84
+ readonly defaultSourceHome?: string;
85
+ /** Defaults to the legacy kimi-cli plans dir (`~/.kimi/plans`). Injectable for isolated tests. */
86
+ readonly plansSourceDir?: string;
87
+ /** First workspace root. Used only to resolve a relative legacy KIMI_SHARE_DIR. */
88
+ readonly workspaceRoot?: string | null;
89
+ /** The removed `kimi.environmentVariables` VS Code setting, read once for migration. */
90
+ readonly legacyEnvironmentVariables?: unknown;
91
+ }
92
+
93
+ export type LegacyMigrationFailureCode =
94
+ | "run-failed"
95
+ | "legacy-config-unreadable"
96
+ | "legacy-mcp-unreadable"
97
+ | "session-failed";
98
+
99
+ export interface LegacyMigrationFailure {
100
+ readonly code: LegacyMigrationFailureCode;
101
+ readonly sourceHome: string;
102
+ readonly item?: string;
103
+ readonly message: string;
104
+ }
105
+
106
+ export interface LegacyMigrationTotals {
107
+ readonly configFiles: number;
108
+ readonly mcpServers: number;
109
+ readonly userHistoryEntries: number;
110
+ readonly skills: number;
111
+ readonly planFiles: number;
112
+ readonly sessions: number;
113
+ readonly alreadyMigratedSessions: number;
114
+ readonly skippedItems: number;
115
+ readonly conflicts: number;
116
+ readonly failures: number;
117
+ }
118
+
119
+ export interface LegacyMigrationSourceResult {
120
+ readonly source: LegacyMigrationSourcePreview;
121
+ readonly status: "completed" | "partial" | "failed";
122
+ readonly report?: MigrationReport;
123
+ readonly failures: readonly LegacyMigrationFailure[];
124
+ readonly error?: unknown;
125
+ }
126
+
127
+ export interface LegacyMigrationRunResult {
128
+ readonly status: "completed" | "partial" | "failed" | "nothing-to-migrate";
129
+ readonly message: string;
130
+ readonly sources: readonly LegacyMigrationSourceResult[];
131
+ readonly suppressedSources: readonly LegacyMigrationSourcePreview[];
132
+ readonly totals: LegacyMigrationTotals;
133
+ readonly warnings: readonly LegacyMigrationWarning[];
134
+ readonly notices: LegacyMigrationNotices;
135
+ readonly manualActions: readonly string[];
136
+ }
137
+
138
+ interface InspectedSource {
139
+ readonly preview: LegacyMigrationSourcePreview;
140
+ readonly plan: MigrationPlan;
141
+ }
142
+
143
+ interface InspectionResult {
144
+ readonly pending: readonly InspectedSource[];
145
+ readonly suppressed: readonly LegacyMigrationSourcePreview[];
146
+ readonly warnings: readonly LegacyMigrationWarning[];
147
+ readonly notices: LegacyMigrationNotices;
148
+ }
149
+
150
+ interface SourceCandidate {
151
+ readonly sourceHome: string;
152
+ readonly origin: LegacyMigrationSourceOrigin;
153
+ }
154
+
155
+ /**
156
+ * Coordinates legacy kimi-cli migration for the VS Code host while keeping all
157
+ * data translation inside the shared migration package.
158
+ */
159
+ export class LegacyMigrationManager {
160
+ readonly targetHome: string;
161
+
162
+ private readonly defaultSourceHome: string;
163
+ private readonly workspaceRoot: string | null;
164
+ private readonly legacyEnvironmentVariables: unknown;
165
+ private readonly plansSourceDir: string;
166
+
167
+ constructor(options: LegacyMigrationManagerOptions) {
168
+ if (options.targetHome.trim().length === 0) {
169
+ throw new Error("LegacyMigrationManager requires a non-empty targetHome.");
170
+ }
171
+ this.targetHome = resolve(options.targetHome);
172
+ this.defaultSourceHome = resolve(options.defaultSourceHome ?? join(homedir(), ".kimi"));
173
+ this.workspaceRoot =
174
+ options.workspaceRoot === undefined || options.workspaceRoot === null
175
+ ? null
176
+ : resolve(options.workspaceRoot);
177
+ this.legacyEnvironmentVariables = options.legacyEnvironmentVariables;
178
+ this.plansSourceDir = options.plansSourceDir ?? defaultPlansSourceDir();
179
+ }
180
+
181
+ /** Detect first-launch work without changing the source or target. */
182
+ async discover(): Promise<LegacyMigrationDiscovery> {
183
+ const inspection = await this.inspect(false);
184
+ const sources = inspection.pending.map((source) => source.preview);
185
+ return {
186
+ prompt:
187
+ sources.length === 0
188
+ ? null
189
+ : {
190
+ kind: "legacy-migration",
191
+ message:
192
+ "Legacy Kimi data was found. Migrate config, MCP servers, history, skills, and sessions into Kimi Code? Your old data will be kept.",
193
+ actions: [
194
+ { id: "now", label: "Migrate Now" },
195
+ { id: "later", label: "Later" },
196
+ ],
197
+ sources,
198
+ notices: inspection.notices,
199
+ },
200
+ suppressedSources: inspection.suppressed,
201
+ warnings: inspection.warnings,
202
+ notices: inspection.notices,
203
+ };
204
+ }
205
+
206
+ /** Run the migration selected from the first-launch prompt. */
207
+ async migrateNow(): Promise<LegacyMigrationRunResult> {
208
+ return this.execute(false);
209
+ }
210
+
211
+ /**
212
+ * Explicit command-palette retry. Marker suppression is intentionally
213
+ * bypassed because a completed marker may also describe a partially failed
214
+ * run, while the shared migrator itself remains idempotent.
215
+ */
216
+ async retry(): Promise<LegacyMigrationRunResult> {
217
+ return this.execute(true);
218
+ }
219
+
220
+ private async execute(ignoreMarker: boolean): Promise<LegacyMigrationRunResult> {
221
+ const inspection = await this.inspect(ignoreMarker);
222
+ const sourceResults: LegacyMigrationSourceResult[] = [];
223
+
224
+ for (const source of inspection.pending) {
225
+ try {
226
+ const report = await runMigration({
227
+ // The shared package owns its migration/schema version. It must not
228
+ // be coupled to the VS Code extension's release version.
229
+ plan: source.plan,
230
+ scope: FULL_MIGRATION_SCOPE,
231
+ source: source.preview.sourceHome,
232
+ target: this.targetHome,
233
+ plansSourceDir: this.plansSourceDir,
234
+ });
235
+ const failures = failuresFromReport(source, report);
236
+ sourceResults.push({
237
+ source: source.preview,
238
+ status: failures.length === 0 ? "completed" : "partial",
239
+ report,
240
+ failures,
241
+ });
242
+ } catch (error) {
243
+ sourceResults.push({
244
+ source: source.preview,
245
+ status: "failed",
246
+ failures: [
247
+ {
248
+ code: "run-failed",
249
+ sourceHome: source.preview.sourceHome,
250
+ message: formatError(error),
251
+ },
252
+ ],
253
+ error,
254
+ });
255
+ }
256
+ }
257
+
258
+ const status = runStatus(sourceResults);
259
+ const totals = aggregateTotals(sourceResults);
260
+ const manualActions = aggregateManualActions(sourceResults);
261
+ return {
262
+ status,
263
+ message: runMessage(status, totals),
264
+ sources: sourceResults,
265
+ suppressedSources: inspection.suppressed,
266
+ totals,
267
+ warnings: inspection.warnings,
268
+ notices: mergeRunNotices(inspection.notices, sourceResults),
269
+ manualActions,
270
+ };
271
+ }
272
+
273
+ private async inspect(ignoreMarker: boolean): Promise<InspectionResult> {
274
+ const { candidates, warnings } = this.sourceCandidates();
275
+ const pending: InspectedSource[] = [];
276
+ const suppressed: LegacyMigrationSourcePreview[] = [];
277
+ const oauthLoginsRequiringRelogin: LegacyMigrationReauthItem[] = [];
278
+ const mcpOauthServersRequiringReauth: LegacyMigrationReauthItem[] = [];
279
+
280
+ for (const candidate of candidates) {
281
+ const sourceCheck = await checkSourceDirectory(candidate.sourceHome);
282
+ if (sourceCheck === "missing") continue;
283
+ if (sourceCheck === "not-directory") {
284
+ warnings.push({
285
+ code: "source-not-directory",
286
+ sourceHome: candidate.sourceHome,
287
+ message: `Legacy migration source is not a directory: ${candidate.sourceHome}`,
288
+ });
289
+ continue;
290
+ }
291
+ if (sourceCheck === "unreadable") {
292
+ warnings.push({
293
+ code: "source-unreadable",
294
+ sourceHome: candidate.sourceHome,
295
+ message: `Legacy migration source cannot be read: ${candidate.sourceHome}`,
296
+ });
297
+ continue;
298
+ }
299
+
300
+ let plan: MigrationPlan;
301
+ try {
302
+ plan = await detectMigration({
303
+ sourcePath: candidate.sourceHome,
304
+ plansSourcePath: this.plansSourceDir,
305
+ });
306
+ } catch (error) {
307
+ warnings.push({
308
+ code: "detection-failed",
309
+ sourceHome: candidate.sourceHome,
310
+ message: `Unable to inspect legacy data at ${candidate.sourceHome}: ${formatError(error)}`,
311
+ });
312
+ continue;
313
+ }
314
+
315
+ oauthLoginsRequiringRelogin.push(
316
+ ...plan.oauthCredentials.map((name) => ({ sourceHome: candidate.sourceHome, name })),
317
+ );
318
+ mcpOauthServersRequiringReauth.push(
319
+ ...plan.detectedMcpOauthServers.map((name) => ({
320
+ sourceHome: candidate.sourceHome,
321
+ name,
322
+ })),
323
+ );
324
+
325
+ const hasSkills = plan.hasSkills;
326
+ const sessionScanFailures = plan.sessionScanFailures ?? [];
327
+ warnings.push(
328
+ ...sessionScanFailures.map((failure) => ({
329
+ code: "legacy-session-unreadable" as const,
330
+ sourceHome: candidate.sourceHome,
331
+ message: `${failure.reason} Source: ${failure.sourcePath}`,
332
+ })),
333
+ );
334
+ const preview: LegacyMigrationSourcePreview = {
335
+ sourceHome: candidate.sourceHome,
336
+ origin: candidate.origin,
337
+ hasConfig: plan.hasConfig,
338
+ hasMcp: plan.hasMcp,
339
+ hasUserHistory: plan.hasUserHistory,
340
+ hasSkills,
341
+ hasPlans: plan.hasPlans,
342
+ totalSessions: plan.totalSessions,
343
+ sessionIssues: sessionScanFailures.length,
344
+ };
345
+ if (!hasMigratableData(preview)) continue;
346
+
347
+ if (
348
+ !ignoreMarker &&
349
+ shouldSuppressMigration({
350
+ sourceHome: candidate.sourceHome,
351
+ targetHome: this.targetHome,
352
+ })
353
+ ) {
354
+ suppressed.push(preview);
355
+ continue;
356
+ }
357
+
358
+ pending.push({
359
+ preview,
360
+ plan,
361
+ });
362
+ }
363
+
364
+ return {
365
+ pending,
366
+ suppressed,
367
+ warnings,
368
+ notices: {
369
+ oauthLoginsRequiringRelogin: dedupeReauthItems(oauthLoginsRequiringRelogin),
370
+ mcpOauthServersRequiringReauth: dedupeReauthItems(
371
+ mcpOauthServersRequiringReauth,
372
+ ),
373
+ },
374
+ };
375
+ }
376
+
377
+ private sourceCandidates(): {
378
+ candidates: SourceCandidate[];
379
+ warnings: LegacyMigrationWarning[];
380
+ } {
381
+ const warnings: LegacyMigrationWarning[] = [];
382
+ const candidates: SourceCandidate[] = [
383
+ { sourceHome: this.defaultSourceHome, origin: "default" },
384
+ ];
385
+ const shareDir = readLegacyShareDir(this.legacyEnvironmentVariables);
386
+
387
+ if (shareDir.kind === "invalid") {
388
+ warnings.push({
389
+ code: "invalid-share-dir",
390
+ message: shareDir.message,
391
+ });
392
+ } else if (shareDir.kind === "value") {
393
+ let sourceHome: string | undefined;
394
+ if (isAbsolute(shareDir.value)) {
395
+ sourceHome = resolve(shareDir.value);
396
+ } else if (this.workspaceRoot === null) {
397
+ warnings.push({
398
+ code: "invalid-share-dir",
399
+ message:
400
+ "The legacy KIMI_SHARE_DIR is relative, but no workspace is open; this migration source was ignored.",
401
+ });
402
+ } else {
403
+ sourceHome = resolve(this.workspaceRoot, shareDir.value);
404
+ warnings.push({
405
+ code: "relative-share-dir",
406
+ sourceHome,
407
+ message: `The legacy relative KIMI_SHARE_DIR was resolved against the workspace: ${sourceHome}`,
408
+ });
409
+ }
410
+
411
+ if (sourceHome !== undefined && samePath(sourceHome, this.targetHome)) {
412
+ warnings.push({
413
+ code: "source-equals-target",
414
+ sourceHome,
415
+ message: "The legacy KIMI_SHARE_DIR resolves to the Kimi Code home and was ignored.",
416
+ });
417
+ } else if (
418
+ sourceHome !== undefined &&
419
+ !candidates.some((candidate) => samePath(candidate.sourceHome, sourceHome))
420
+ ) {
421
+ candidates.push({ sourceHome, origin: "legacy-vscode-setting" });
422
+ }
423
+ }
424
+
425
+ return { candidates, warnings };
426
+ }
427
+ }
428
+
429
+ function readLegacyShareDir(
430
+ environmentVariables: unknown,
431
+ ): { readonly kind: "missing" } | { readonly kind: "value"; readonly value: string } | {
432
+ readonly kind: "invalid";
433
+ readonly message: string;
434
+ } {
435
+ if (environmentVariables === undefined) return { kind: "missing" };
436
+ if (
437
+ typeof environmentVariables !== "object" ||
438
+ environmentVariables === null ||
439
+ Array.isArray(environmentVariables)
440
+ ) {
441
+ return {
442
+ kind: "invalid",
443
+ message: "The legacy kimi.environmentVariables setting is invalid and was ignored.",
444
+ };
445
+ }
446
+
447
+ const value = (environmentVariables as Record<string, unknown>)["KIMI_SHARE_DIR"];
448
+ if (value === undefined) return { kind: "missing" };
449
+ if (typeof value !== "string" || value.trim().length === 0) {
450
+ return {
451
+ kind: "invalid",
452
+ message: "The legacy KIMI_SHARE_DIR must be a non-empty string and was ignored.",
453
+ };
454
+ }
455
+ return { kind: "value", value };
456
+ }
457
+
458
+ async function checkSourceDirectory(
459
+ sourceHome: string,
460
+ ): Promise<"ok" | "missing" | "not-directory" | "unreadable"> {
461
+ try {
462
+ const sourceStat = await stat(sourceHome);
463
+ if (!sourceStat.isDirectory()) return "not-directory";
464
+ } catch (error) {
465
+ return isMissingError(error) ? "missing" : "unreadable";
466
+ }
467
+
468
+ try {
469
+ await readdir(sourceHome);
470
+ return "ok";
471
+ } catch {
472
+ return "unreadable";
473
+ }
474
+ }
475
+
476
+ function isMissingError(error: unknown): boolean {
477
+ return (
478
+ typeof error === "object" &&
479
+ error !== null &&
480
+ "code" in error &&
481
+ (error as { readonly code?: unknown }).code === "ENOENT"
482
+ );
483
+ }
484
+
485
+ function hasMigratableData(source: LegacyMigrationSourcePreview): boolean {
486
+ return (
487
+ source.hasConfig ||
488
+ source.hasMcp ||
489
+ source.hasUserHistory ||
490
+ source.hasSkills ||
491
+ source.hasPlans ||
492
+ source.totalSessions > 0 ||
493
+ source.sessionIssues > 0
494
+ );
495
+ }
496
+
497
+ function failuresFromReport(
498
+ source: InspectedSource,
499
+ report: MigrationReport,
500
+ ): LegacyMigrationFailure[] {
501
+ const failures: LegacyMigrationFailure[] = [];
502
+ if (report.summary.config.sourceUnreadable) {
503
+ failures.push({
504
+ code: "legacy-config-unreadable",
505
+ sourceHome: source.preview.sourceHome,
506
+ item: "config.toml",
507
+ message: "The legacy config could not be read or parsed; review it manually.",
508
+ });
509
+ }
510
+ if (report.summary.mcp.sourceUnreadable) {
511
+ failures.push({
512
+ code: "legacy-mcp-unreadable",
513
+ sourceHome: source.preview.sourceHome,
514
+ item: "mcp.json",
515
+ message: "The legacy mcp.json could not be parsed; review it manually.",
516
+ });
517
+ }
518
+ failures.push(
519
+ ...report.summary.sessions.sessionsFailed.map((failure) => ({
520
+ code: "session-failed" as const,
521
+ sourceHome: source.preview.sourceHome,
522
+ item: failure.sourcePath,
523
+ message: failure.reason,
524
+ })),
525
+ );
526
+ return failures;
527
+ }
528
+
529
+ function runStatus(
530
+ sources: readonly LegacyMigrationSourceResult[],
531
+ ): LegacyMigrationRunResult["status"] {
532
+ if (sources.length === 0) return "nothing-to-migrate";
533
+ if (sources.every((source) => source.status === "failed")) return "failed";
534
+ if (sources.some((source) => source.status !== "completed")) return "partial";
535
+ return "completed";
536
+ }
537
+
538
+ function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): LegacyMigrationTotals {
539
+ let configFiles = 0;
540
+ let mcpServers = 0;
541
+ let userHistoryEntries = 0;
542
+ let skills = 0;
543
+ let planFiles = 0;
544
+ let sessions = 0;
545
+ let alreadyMigratedSessions = 0;
546
+ let skippedItems = 0;
547
+ let conflicts = 0;
548
+ let failures = 0;
549
+
550
+ for (const source of sources) {
551
+ failures += source.failures.length;
552
+ const summary = source.report?.summary;
553
+ if (summary === undefined) continue;
554
+ configFiles += summary.config.migrated ? 1 : 0;
555
+ mcpServers += summary.mcp.mergedServers.length;
556
+ userHistoryEntries += summary.userHistory.copied;
557
+ skills += summary.skills.copied;
558
+ planFiles += summary.plans.copied;
559
+ sessions += summary.sessions.sessionsMigrated;
560
+ alreadyMigratedSessions += summary.sessions.sessionsAlreadyMigrated;
561
+ skippedItems +=
562
+ summary.userHistory.skippedExisting +
563
+ summary.skills.skippedExisting +
564
+ summary.sessions.sessionsSkippedPlaceholder +
565
+ summary.sessions.sessionsSkippedEmpty +
566
+ summary.sessions.sessionsSkippedMalformed;
567
+ conflicts +=
568
+ summary.config.configConflicts.length +
569
+ summary.mcp.keptNewForConflicts.length +
570
+ summary.sessions.sessionsConflicts.length;
571
+ }
572
+
573
+ return {
574
+ configFiles,
575
+ mcpServers,
576
+ userHistoryEntries,
577
+ skills,
578
+ planFiles,
579
+ sessions,
580
+ alreadyMigratedSessions,
581
+ skippedItems,
582
+ conflicts,
583
+ failures,
584
+ };
585
+ }
586
+
587
+ function aggregateManualActions(
588
+ sources: readonly LegacyMigrationSourceResult[],
589
+ ): readonly string[] {
590
+ const actions: string[] = [];
591
+ for (const source of sources) {
592
+ for (const failure of source.failures) {
593
+ if (failure.code === "run-failed") {
594
+ actions.push(
595
+ `Fix access to ${source.source.sourceHome} or the Kimi Code home, then run “Kimi Code: Migrate Legacy Data” again.`,
596
+ );
597
+ } else {
598
+ actions.push(
599
+ `${failure.message} Source: ${failure.item ?? source.source.sourceHome}`,
600
+ );
601
+ }
602
+ }
603
+
604
+ const summary = source.report?.summary;
605
+ if (summary === undefined) continue;
606
+ if (summary.config.wroteSiblingDueToConflict) {
607
+ actions.push("Review and merge config.migrated-from-kimi-cli.toml.");
608
+ }
609
+ if (summary.config.wroteTuiSibling) {
610
+ actions.push("Review and merge tui.migrated-from-kimi-cli.toml.");
611
+ }
612
+ if (summary.mcp.wroteSiblingDueToConflict) {
613
+ actions.push("Review and merge mcp.migrated-from-kimi-cli.json.");
614
+ }
615
+ if (summary.sessions.sessionsConflicts.length > 0) {
616
+ actions.push(
617
+ `${summary.sessions.sessionsConflicts.length} legacy session(s) conflicted with existing target sessions and were kept unchanged.`,
618
+ );
619
+ }
620
+ }
621
+ return [...new Set(actions)];
622
+ }
623
+
624
+ function mergeRunNotices(
625
+ detectionNotices: LegacyMigrationNotices,
626
+ sources: readonly LegacyMigrationSourceResult[],
627
+ ): LegacyMigrationNotices {
628
+ const oauth = [...detectionNotices.oauthLoginsRequiringRelogin];
629
+ const mcpOauth = [...detectionNotices.mcpOauthServersRequiringReauth];
630
+ for (const source of sources) {
631
+ const notices = source.report?.notices;
632
+ if (notices === undefined) continue;
633
+ oauth.push(
634
+ ...notices.oauthLoginsRequiringRelogin.map((name) => ({
635
+ sourceHome: source.source.sourceHome,
636
+ name,
637
+ })),
638
+ );
639
+ mcpOauth.push(
640
+ ...notices.mcpOauthServersRequiringReauth.map((name) => ({
641
+ sourceHome: source.source.sourceHome,
642
+ name,
643
+ })),
644
+ );
645
+ }
646
+ return {
647
+ oauthLoginsRequiringRelogin: dedupeReauthItems(oauth),
648
+ mcpOauthServersRequiringReauth: dedupeReauthItems(mcpOauth),
649
+ };
650
+ }
651
+
652
+ function dedupeReauthItems(
653
+ items: readonly LegacyMigrationReauthItem[],
654
+ ): readonly LegacyMigrationReauthItem[] {
655
+ const seen = new Set<string>();
656
+ return items.filter((item) => {
657
+ const key = `${pathKey(item.sourceHome)}\0${item.name}`;
658
+ if (seen.has(key)) return false;
659
+ seen.add(key);
660
+ return true;
661
+ });
662
+ }
663
+
664
+ function runMessage(
665
+ status: LegacyMigrationRunResult["status"],
666
+ totals: LegacyMigrationTotals,
667
+ ): string {
668
+ if (status === "nothing-to-migrate") return "No legacy Kimi data needs migration.";
669
+ if (status === "failed") {
670
+ return "Legacy migration failed. Fix the reported path or data error, then retry from the command palette.";
671
+ }
672
+ 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)`;
673
+ if (status === "partial") {
674
+ return `Legacy migration completed with ${totals.failures} failure(s): ${migrated}. Review the details and retry from the command palette.`;
675
+ }
676
+ return `Legacy migration complete: ${migrated}. Old data was kept.`;
677
+ }
678
+
679
+ function formatError(error: unknown): string {
680
+ if (!(error instanceof Error)) return String(error);
681
+ const messages: string[] = [];
682
+ let current: unknown = error;
683
+ while (current instanceof Error) {
684
+ messages.push(current.message || current.name);
685
+ current = current.cause;
686
+ }
687
+ return messages.join(": ");
688
+ }
689
+
690
+ function samePath(left: string, right: string): boolean {
691
+ return pathKey(left) === pathKey(right);
692
+ }
693
+
694
+ function pathKey(path: string): string {
695
+ if (process.platform === "win32") return win32.resolve(path).toLowerCase();
696
+ const windowsAbsolute = win32.isAbsolute(path);
697
+ return windowsAbsolute ? win32.resolve(path).toLowerCase() : resolve(path);
698
+ }
apps/vscode/src/raw-modules.d.ts ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ declare module "*?raw" {
2
+ const text: string;
3
+ export default text;
4
+ }
apps/vscode/src/runtime/event-adapter.ts ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Event } from '@moonshot-ai/kimi-code-sdk';
2
+
3
+ import type {
4
+ DisplayBlock,
5
+ LegacyWireEvent,
6
+ StatusUpdate,
7
+ TokenUsage,
8
+ TurnBegin,
9
+ } from '../../shared/legacy-sdk';
10
+ import type { ErrorPhase, UIStreamEvent } from '../../shared/types';
11
+ import { toLegacyDisplay } from './tool-display';
12
+
13
+ const DEFAULT_MAIN_AGENT_ID = 'main';
14
+
15
+ export interface AdapterTokenUsage {
16
+ readonly inputOther: number;
17
+ readonly output: number;
18
+ readonly inputCacheRead: number;
19
+ readonly inputCacheCreation: number;
20
+ }
21
+
22
+ export interface SubagentParent {
23
+ readonly parentAgentId: string;
24
+ readonly parentToolCallId: string;
25
+ }
26
+
27
+ export interface EventAdapterState {
28
+ readonly subagentParents: Readonly<Record<string, SubagentParent>>;
29
+ readonly turnUsageByAgent: Readonly<Record<string, AdapterTokenUsage>>;
30
+ readonly toolDisplays: Readonly<Record<string, readonly DisplayBlock[]>>;
31
+ }
32
+
33
+ export interface AdaptedToolCallPartEvent {
34
+ readonly type: 'ToolCallPart';
35
+ readonly payload: {
36
+ /** Lets the Webview update the right call when tool arguments interleave. */
37
+ readonly tool_call_id: string;
38
+ readonly arguments_part?: string | null;
39
+ };
40
+ readonly _sessionId?: string;
41
+ }
42
+
43
+ export type AdaptedUIStreamEvent = UIStreamEvent | AdaptedToolCallPartEvent;
44
+
45
+ type SdkTurnEndedEvent = Extract<Event, { type: 'turn.ended' }>;
46
+
47
+ export interface TurnTerminalMetadata {
48
+ /** Stable within one adapter stream and suitable for terminal-event de-duplication. */
49
+ readonly key: string;
50
+ readonly sessionId: string;
51
+ readonly agentId: string;
52
+ readonly turnId: number;
53
+ readonly reason: SdkTurnEndedEvent['reason'];
54
+ readonly error?: NonNullable<SdkTurnEndedEvent['error']>;
55
+ }
56
+
57
+ export interface EventAdapterResult {
58
+ readonly state: EventAdapterState;
59
+ readonly event?: AdaptedUIStreamEvent;
60
+ /** SessionRuntime owns conversion of this metadata to exactly one complete/error event. */
61
+ readonly terminal?: TurnTerminalMetadata;
62
+ }
63
+
64
+ export interface AdaptSdkEventOptions {
65
+ /** The SDK turn-start event intentionally does not repeat prompt content. */
66
+ readonly pendingInput?: TurnBegin['user_input'];
67
+ readonly mainAgentId?: string;
68
+ readonly errorPhase?: ErrorPhase;
69
+ }
70
+
71
+ export function createEventAdapterState(): EventAdapterState {
72
+ return {
73
+ subagentParents: {},
74
+ turnUsageByAgent: {},
75
+ toolDisplays: {},
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Purely projects one public Node SDK event into the released Webview protocol.
81
+ * The returned state must be passed into the next call; the input state is never mutated.
82
+ */
83
+ export function adaptSdkEvent(
84
+ state: EventAdapterState,
85
+ sdkEvent: Event,
86
+ options: AdaptSdkEventOptions = {},
87
+ ): EventAdapterResult {
88
+ const mainAgentId = options.mainAgentId ?? DEFAULT_MAIN_AGENT_ID;
89
+
90
+ if (sdkEvent.type === 'subagent.spawned') {
91
+ const parentAgentId = sdkEvent.parentAgentId ?? sdkEvent.callerAgentId ?? sdkEvent.agentId;
92
+ return {
93
+ state: {
94
+ ...state,
95
+ subagentParents: {
96
+ ...state.subagentParents,
97
+ [sdkEvent.subagentId]: {
98
+ parentAgentId,
99
+ parentToolCallId: scopedToolCallId(
100
+ parentAgentId,
101
+ sdkEvent.parentToolCallId,
102
+ mainAgentId,
103
+ ),
104
+ },
105
+ },
106
+ },
107
+ };
108
+ }
109
+
110
+ if (sdkEvent.type === 'turn.started') {
111
+ const nextState = resetTurnUsage(state, sdkEvent.agentId);
112
+ if (sdkEvent.agentId !== mainAgentId || options.pendingInput === undefined) {
113
+ return { state: nextState };
114
+ }
115
+ return {
116
+ state: nextState,
117
+ event: withSessionId(
118
+ {
119
+ type: 'TurnBegin',
120
+ payload: { user_input: options.pendingInput },
121
+ },
122
+ sdkEvent.sessionId,
123
+ ),
124
+ };
125
+ }
126
+
127
+ if (sdkEvent.type === 'turn.ended') {
128
+ if (sdkEvent.agentId !== mainAgentId) return { state };
129
+ return {
130
+ state,
131
+ terminal: {
132
+ key: `${sdkEvent.sessionId}:${sdkEvent.agentId}:${sdkEvent.turnId}`,
133
+ sessionId: sdkEvent.sessionId,
134
+ agentId: sdkEvent.agentId,
135
+ turnId: sdkEvent.turnId,
136
+ reason: sdkEvent.reason,
137
+ error: sdkEvent.error,
138
+ },
139
+ };
140
+ }
141
+
142
+ if (sdkEvent.type === 'error') {
143
+ if (sdkEvent.agentId !== mainAgentId) return { state };
144
+ return {
145
+ state,
146
+ event: {
147
+ type: 'error',
148
+ code: sdkEvent.code,
149
+ message: sdkEvent.message,
150
+ detail: serializeDetails(sdkEvent.details),
151
+ phase: options.errorPhase ?? 'runtime',
152
+ _sessionId: sdkEvent.sessionId,
153
+ },
154
+ };
155
+ }
156
+
157
+ const mapped = mapLegacyWireEvent(state, sdkEvent, mainAgentId);
158
+ if (mapped.event === undefined) return { state: mapped.state };
159
+
160
+ const routed = routeSubagentEvent(
161
+ mapped.state,
162
+ sdkEvent.agentId,
163
+ mapped.event,
164
+ mainAgentId,
165
+ );
166
+ if (routed === undefined) return { state: mapped.state };
167
+
168
+ return {
169
+ state: mapped.state,
170
+ event: withSessionId(routed, sdkEvent.sessionId),
171
+ };
172
+ }
173
+
174
+ export function toLegacyToolName(name: string): string {
175
+ switch (name) {
176
+ case 'Bash':
177
+ return 'Shell';
178
+ case 'Read':
179
+ return 'ReadFile';
180
+ case 'Write':
181
+ return 'WriteFile';
182
+ case 'Edit':
183
+ return 'StrReplaceFile';
184
+ case 'TodoList':
185
+ return 'SetTodoList';
186
+ default:
187
+ return name;
188
+ }
189
+ }
190
+
191
+ interface MappedLegacyWireEvent {
192
+ readonly state: EventAdapterState;
193
+ readonly event?: LegacyWireEvent;
194
+ }
195
+
196
+ function mapLegacyWireEvent(
197
+ state: EventAdapterState,
198
+ sdkEvent: Event,
199
+ mainAgentId: string,
200
+ ): MappedLegacyWireEvent {
201
+ switch (sdkEvent.type) {
202
+ case 'turn.step.started':
203
+ return {
204
+ state,
205
+ event: { type: 'StepBegin', payload: { n: sdkEvent.step } },
206
+ };
207
+ case 'turn.step.retrying':
208
+ return {
209
+ state,
210
+ event: {
211
+ type: 'StatusUpdate',
212
+ payload: {
213
+ retrying: {
214
+ next_attempt: sdkEvent.nextAttempt,
215
+ max_attempts: sdkEvent.maxAttempts,
216
+ delay_ms: sdkEvent.delayMs,
217
+ message: sdkEvent.errorMessage,
218
+ },
219
+ },
220
+ },
221
+ };
222
+ case 'turn.step.interrupted':
223
+ return {
224
+ state,
225
+ event: { type: 'StepInterrupted', payload: {} },
226
+ };
227
+ case 'assistant.delta':
228
+ return {
229
+ state,
230
+ event: { type: 'ContentPart', payload: { type: 'text', text: sdkEvent.delta } },
231
+ };
232
+ case 'hook.result':
233
+ return {
234
+ state,
235
+ event: { type: 'ContentPart', payload: { type: 'text', text: sdkEvent.content } },
236
+ };
237
+ case 'thinking.delta':
238
+ return {
239
+ state,
240
+ event: { type: 'ContentPart', payload: { type: 'think', think: sdkEvent.delta } },
241
+ };
242
+ case 'tool.call.started': {
243
+ const toolCallId = scopedToolCallId(
244
+ sdkEvent.agentId,
245
+ sdkEvent.toolCallId,
246
+ mainAgentId,
247
+ );
248
+ const display = sdkEvent.display === undefined ? undefined : toLegacyDisplay(sdkEvent.display);
249
+ return {
250
+ state: display === undefined
251
+ ? state
252
+ : {
253
+ ...state,
254
+ toolDisplays: { ...state.toolDisplays, [toolCallId]: display },
255
+ },
256
+ event: {
257
+ type: 'ToolCall',
258
+ payload: {
259
+ type: 'function',
260
+ id: toolCallId,
261
+ function: {
262
+ name: toLegacyToolName(sdkEvent.name),
263
+ arguments: serializeArguments(sdkEvent.args),
264
+ },
265
+ },
266
+ },
267
+ };
268
+ }
269
+ case 'tool.call.delta': {
270
+ const event: AdaptedToolCallPartEvent = {
271
+ type: 'ToolCallPart',
272
+ payload: {
273
+ tool_call_id: scopedToolCallId(
274
+ sdkEvent.agentId,
275
+ sdkEvent.toolCallId,
276
+ mainAgentId,
277
+ ),
278
+ arguments_part: sdkEvent.argumentsPart,
279
+ },
280
+ };
281
+ return { state, event: event as LegacyWireEvent };
282
+ }
283
+ case 'tool.result': {
284
+ const toolCallId = scopedToolCallId(
285
+ sdkEvent.agentId,
286
+ sdkEvent.toolCallId,
287
+ mainAgentId,
288
+ );
289
+ const display = state.toolDisplays[toolCallId] ?? [];
290
+ const toolDisplays = { ...state.toolDisplays };
291
+ delete toolDisplays[toolCallId];
292
+ const output = serializeToolOutput(sdkEvent.output);
293
+ return {
294
+ state: { ...state, toolDisplays },
295
+ event: {
296
+ type: 'ToolResult',
297
+ payload: {
298
+ tool_call_id: toolCallId,
299
+ return_value: {
300
+ is_error: sdkEvent.isError === true,
301
+ output,
302
+ message: '',
303
+ display: [...display],
304
+ },
305
+ },
306
+ },
307
+ };
308
+ }
309
+ case 'agent.status.updated':
310
+ return mapStatusUpdate(state, sdkEvent);
311
+ case 'compaction.started':
312
+ return {
313
+ state,
314
+ event: { type: 'CompactionBegin', payload: {} },
315
+ };
316
+ case 'compaction.blocked':
317
+ case 'compaction.cancelled':
318
+ case 'compaction.completed':
319
+ return {
320
+ state,
321
+ event: { type: 'CompactionEnd', payload: {} },
322
+ };
323
+ default:
324
+ return { state };
325
+ }
326
+ }
327
+
328
+ function mapStatusUpdate(
329
+ state: EventAdapterState,
330
+ sdkEvent: Extract<Event, { type: 'agent.status.updated' }>,
331
+ ): MappedLegacyWireEvent {
332
+ const payload: StatusUpdate = {};
333
+ const contextUsage = contextUsageRatio(sdkEvent);
334
+ if (contextUsage !== undefined) payload.context_usage = contextUsage;
335
+ if (sdkEvent.planMode !== undefined) payload.plan_mode = sdkEvent.planMode;
336
+ if (sdkEvent.model !== undefined) payload.model = sdkEvent.model;
337
+ if (sdkEvent.thinkingEffort !== undefined) payload.thinking_effort = sdkEvent.thinkingEffort;
338
+
339
+ const currentTurn = sdkEvent.usage?.currentTurn;
340
+ if (currentTurn === undefined) {
341
+ return Object.keys(payload).length === 0
342
+ ? { state }
343
+ : { state, event: { type: 'StatusUpdate', payload } };
344
+ }
345
+
346
+ const previous = state.turnUsageByAgent[sdkEvent.agentId];
347
+ payload.token_usage = usageDelta(currentTurn, previous);
348
+ return {
349
+ state: {
350
+ ...state,
351
+ turnUsageByAgent: {
352
+ ...state.turnUsageByAgent,
353
+ [sdkEvent.agentId]: currentTurn,
354
+ },
355
+ },
356
+ event: { type: 'StatusUpdate', payload },
357
+ };
358
+ }
359
+
360
+ function contextUsageRatio(
361
+ sdkEvent: Extract<Event, { type: 'agent.status.updated' }>,
362
+ ): number | undefined {
363
+ if (sdkEvent.contextUsage !== undefined) return sdkEvent.contextUsage;
364
+ const { contextTokens, maxContextTokens } = sdkEvent;
365
+ if (
366
+ typeof contextTokens !== 'number' ||
367
+ typeof maxContextTokens !== 'number' ||
368
+ !Number.isFinite(contextTokens) ||
369
+ !Number.isFinite(maxContextTokens)
370
+ ) {
371
+ return undefined;
372
+ }
373
+ return maxContextTokens > 0 ? contextTokens / maxContextTokens : undefined;
374
+ }
375
+
376
+ function usageDelta(current: AdapterTokenUsage, previous: AdapterTokenUsage | undefined): TokenUsage {
377
+ return {
378
+ input_other: delta(current.inputOther, previous?.inputOther),
379
+ output: delta(current.output, previous?.output),
380
+ input_cache_read: delta(current.inputCacheRead, previous?.inputCacheRead),
381
+ input_cache_creation: delta(
382
+ current.inputCacheCreation,
383
+ previous?.inputCacheCreation,
384
+ ),
385
+ };
386
+ }
387
+
388
+ function delta(current: number, previous: number | undefined): number {
389
+ if (previous === undefined || current < previous) return current;
390
+ return current - previous;
391
+ }
392
+
393
+ function resetTurnUsage(state: EventAdapterState, agentId: string): EventAdapterState {
394
+ if (state.turnUsageByAgent[agentId] === undefined) return state;
395
+ const nextUsage = { ...state.turnUsageByAgent };
396
+ delete nextUsage[agentId];
397
+ return { ...state, turnUsageByAgent: nextUsage };
398
+ }
399
+
400
+ function routeSubagentEvent(
401
+ state: EventAdapterState,
402
+ agentId: string,
403
+ event: LegacyWireEvent,
404
+ mainAgentId: string,
405
+ ): LegacyWireEvent | undefined {
406
+ if (agentId === mainAgentId) return event;
407
+
408
+ let currentAgentId = agentId;
409
+ let routed = event;
410
+ const visited = new Set<string>();
411
+
412
+ while (currentAgentId !== mainAgentId) {
413
+ if (visited.has(currentAgentId)) return undefined;
414
+ visited.add(currentAgentId);
415
+
416
+ const parent = state.subagentParents[currentAgentId];
417
+ if (parent === undefined) return undefined;
418
+ routed = {
419
+ type: 'SubagentEvent',
420
+ payload: {
421
+ parent_tool_call_id: parent.parentToolCallId,
422
+ event: routed,
423
+ },
424
+ };
425
+ currentAgentId = parent.parentAgentId;
426
+ }
427
+
428
+ return routed;
429
+ }
430
+
431
+ function scopedToolCallId(agentId: string, toolCallId: string, mainAgentId: string): string {
432
+ return agentId === mainAgentId ? toolCallId : `${agentId}:${toolCallId}`;
433
+ }
434
+
435
+ function withSessionId(event: LegacyWireEvent, sessionId: string): AdaptedUIStreamEvent {
436
+ return { ...event, _sessionId: sessionId } as AdaptedUIStreamEvent;
437
+ }
438
+
439
+ function serializeArguments(args: unknown): string {
440
+ try {
441
+ return JSON.stringify(args) ?? '{}';
442
+ } catch {
443
+ return '{}';
444
+ }
445
+ }
446
+
447
+ function serializeToolOutput(output: unknown): string {
448
+ if (typeof output === 'string') return output;
449
+ try {
450
+ return JSON.stringify(output, null, 2) ?? '';
451
+ } catch {
452
+ return String(output);
453
+ }
454
+ }
455
+
456
+ function serializeDetails(details: Record<string, unknown> | undefined): string | undefined {
457
+ if (details === undefined) return undefined;
458
+ try {
459
+ return JSON.stringify(details, null, 2);
460
+ } catch {
461
+ return "[Unable to serialize error details]";
462
+ }
463
+ }
apps/vscode/src/runtime/kimi-runtime.ts ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ createKimiHarness,
3
+ type KimiHarness,
4
+ type Session,
5
+ type SessionSummary,
6
+ type ThinkingEffort,
7
+ } from "@moonshot-ai/kimi-code-sdk";
8
+
9
+ import type { RuntimeBroadcast } from "./session-runtime";
10
+ import {
11
+ corePermissionForLegacyApproval,
12
+ legacyApprovalMetadata,
13
+ readLegacyApprovalFlags,
14
+ readMigratedLegacyApprovalFlags,
15
+ withGlobalYoloMode,
16
+ type LegacyApprovalFlags,
17
+ } from "./legacy-approval";
18
+ import { SessionRuntime } from "./session-runtime";
19
+ import { areSameFsPath } from "../utils/fs-path";
20
+
21
+ export interface KimiRuntimeOptions {
22
+ readonly version: string;
23
+ readonly broadcast: RuntimeBroadcast;
24
+ readonly captureBaseline: (
25
+ session: Pick<SessionSummary, "id" | "workDir" | "metadata">,
26
+ filePath: string,
27
+ webviewIds: readonly string[],
28
+ ) => void;
29
+ readonly log: (message: string, error?: unknown) => void;
30
+ readonly homeDir?: string;
31
+ readonly harness?: KimiHarness;
32
+ }
33
+
34
+ export interface OpenSessionOptions {
35
+ readonly webviewId: string;
36
+ readonly workDir: string;
37
+ readonly sessionId?: string;
38
+ readonly model: string;
39
+ readonly effort: string;
40
+ readonly yoloMode: boolean;
41
+ }
42
+
43
+ /** Extension-host owner for one in-process Node SDK harness. */
44
+ export class KimiRuntime {
45
+ readonly harness: KimiHarness;
46
+
47
+ private readonly broadcast: RuntimeBroadcast;
48
+ private readonly captureBaseline: KimiRuntimeOptions["captureBaseline"];
49
+ private readonly log: KimiRuntimeOptions["log"];
50
+ private readonly sessions = new Map<string, SessionRuntime>();
51
+ private readonly sessionByView = new Map<string, string>();
52
+ private readonly viewChains = new Map<string, Promise<void>>();
53
+ private closed = false;
54
+
55
+ constructor(options: KimiRuntimeOptions) {
56
+ this.broadcast = options.broadcast;
57
+ this.captureBaseline = options.captureBaseline;
58
+ this.log = options.log;
59
+ this.harness =
60
+ options.harness ??
61
+ createKimiHarness({
62
+ homeDir: options.homeDir,
63
+ identity: {
64
+ productName: "kimi-code-vscode",
65
+ version: options.version,
66
+ platform: "kimi_code_vscode",
67
+ },
68
+ uiMode: "vscode",
69
+ });
70
+ }
71
+
72
+ getSessionForView(webviewId: string): SessionRuntime | undefined {
73
+ const id = this.sessionByView.get(webviewId);
74
+ return id === undefined ? undefined : this.sessions.get(id);
75
+ }
76
+
77
+ getSession(id: string): SessionRuntime | undefined {
78
+ return this.sessions.get(id);
79
+ }
80
+
81
+ async openSession(options: OpenSessionOptions): Promise<SessionRuntime> {
82
+ return this.serializeView(options.webviewId, () => this.openSessionInner(options));
83
+ }
84
+
85
+ private async openSessionInner(options: OpenSessionOptions): Promise<SessionRuntime> {
86
+ this.ensureOpen();
87
+ const current = this.getSessionForView(options.webviewId);
88
+ const requestedId = options.sessionId ?? current?.id;
89
+
90
+ if (
91
+ current !== undefined &&
92
+ requestedId === current.id &&
93
+ areSameFsPath(current.session.workDir, options.workDir)
94
+ ) {
95
+ await applySessionSettings(current.session, options, current.legacyApprovalFlags);
96
+ await current.announceStatus(options.webviewId);
97
+ return current;
98
+ }
99
+
100
+ let runtime = requestedId === undefined ? undefined : this.sessions.get(requestedId);
101
+ if (runtime !== undefined) {
102
+ assertSessionWorkDir(runtime.session, options.workDir);
103
+ await applySessionSettings(runtime.session, options, runtime.legacyApprovalFlags);
104
+ await this.detachViewInner(options.webviewId);
105
+ } else {
106
+ const defaultApproval: LegacyApprovalFlags = { yolo: options.yoloMode, afk: false };
107
+ const session =
108
+ requestedId === undefined
109
+ ? await this.harness.createSession({
110
+ workDir: options.workDir,
111
+ model: options.model || undefined,
112
+ thinking: normalizeEffort(options.effort),
113
+ permission: corePermissionForLegacyApproval(defaultApproval),
114
+ metadata: legacyApprovalMetadata(defaultApproval),
115
+ })
116
+ : await this.harness.resumeSession({ id: requestedId, includeSubagents: true });
117
+ try {
118
+ assertSessionWorkDir(session, options.workDir);
119
+ const storedApproval = readLegacyApprovalFlags(session.summary?.metadata);
120
+ const restoredApproval =
121
+ storedApproval ?? (await this.readMigratedLegacyApproval(session)) ?? defaultApproval;
122
+ const approval = withGlobalYoloMode(restoredApproval, options.yoloMode);
123
+ if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) {
124
+ await session.updateMetadata(legacyApprovalMetadata(approval));
125
+ }
126
+ await applySessionSettings(session, options, approval);
127
+ await this.detachViewInner(options.webviewId);
128
+ runtime = this.wrapSession(session, approval);
129
+ } catch (error) {
130
+ await session.close().catch((closeError: unknown) => {
131
+ this.log("Failed to close a rejected session", closeError);
132
+ });
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ runtime.subscribe(options.webviewId);
138
+ this.sessionByView.set(options.webviewId, runtime.id);
139
+ await runtime.announceStatus(options.webviewId);
140
+ return runtime;
141
+ }
142
+
143
+ async attachResumedSession(
144
+ webviewId: string,
145
+ session: Session,
146
+ defaultYoloMode = false,
147
+ ): Promise<SessionRuntime> {
148
+ return this.serializeView(webviewId, () =>
149
+ this.attachResumedSessionInner(webviewId, session, defaultYoloMode),
150
+ );
151
+ }
152
+
153
+ private async attachResumedSessionInner(
154
+ webviewId: string,
155
+ session: Session,
156
+ defaultYoloMode: boolean,
157
+ ): Promise<SessionRuntime> {
158
+ const existing = this.sessions.get(session.id);
159
+ if (existing !== undefined && this.sessionByView.get(webviewId) === session.id) {
160
+ existing.subscribe(webviewId);
161
+ await existing.announceStatus(webviewId);
162
+ return existing;
163
+ }
164
+ await this.detachViewInner(webviewId);
165
+ let runtime = existing ?? this.sessions.get(session.id);
166
+ if (runtime === undefined) {
167
+ try {
168
+ const storedApproval = readLegacyApprovalFlags(session.summary?.metadata);
169
+ const restoredApproval =
170
+ storedApproval ??
171
+ (await this.readMigratedLegacyApproval(session)) ??
172
+ { yolo: defaultYoloMode, afk: false };
173
+ const approval = withGlobalYoloMode(restoredApproval, defaultYoloMode);
174
+ if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) {
175
+ await session.updateMetadata(legacyApprovalMetadata(approval));
176
+ }
177
+ const status = await session.getStatus();
178
+ const permission = corePermissionForLegacyApproval(approval);
179
+ if (status.permission !== permission) await session.setPermission(permission);
180
+ runtime = this.wrapSession(session, approval);
181
+ } catch (error) {
182
+ await session.close().catch((closeError: unknown) => {
183
+ this.log("Failed to close a rejected session", closeError);
184
+ });
185
+ throw error;
186
+ }
187
+ }
188
+ runtime.subscribe(webviewId);
189
+ this.sessionByView.set(webviewId, runtime.id);
190
+ await runtime.announceStatus(webviewId);
191
+ return runtime;
192
+ }
193
+
194
+ async detachView(webviewId: string): Promise<void> {
195
+ return this.serializeView(webviewId, () => this.detachViewInner(webviewId));
196
+ }
197
+
198
+ private async detachViewInner(webviewId: string): Promise<void> {
199
+ const id = this.sessionByView.get(webviewId);
200
+ if (id === undefined) return;
201
+ this.sessionByView.delete(webviewId);
202
+ const runtime = this.sessions.get(id);
203
+ if (runtime === undefined) return;
204
+ runtime.unsubscribeView(webviewId);
205
+ if (runtime.subscribers.length === 0) {
206
+ this.sessions.delete(id);
207
+ await runtime.close();
208
+ }
209
+ }
210
+
211
+ // A view attaches to at most one session, so opens/detaches for one view
212
+ // must never overlap: concurrent callers that both miss `this.sessions`
213
+ // would wrap the same SDK session twice and double every streamed event.
214
+ private serializeView<T>(webviewId: string, work: () => Promise<T>): Promise<T> {
215
+ const prev = this.viewChains.get(webviewId) ?? Promise.resolve();
216
+ const run = prev.then(work, work);
217
+ const next = run.then(
218
+ () => undefined,
219
+ () => undefined,
220
+ );
221
+ this.viewChains.set(webviewId, next);
222
+ void next.finally(() => {
223
+ if (this.viewChains.get(webviewId) === next) this.viewChains.delete(webviewId);
224
+ });
225
+ return run;
226
+ }
227
+
228
+ async closeSession(id: string): Promise<void> {
229
+ const runtime = this.sessions.get(id);
230
+ if (runtime === undefined) {
231
+ await this.harness.closeSession(id);
232
+ return;
233
+ }
234
+ this.sessions.delete(id);
235
+ for (const webviewId of runtime.subscribers) {
236
+ this.sessionByView.delete(webviewId);
237
+ }
238
+ await runtime.close();
239
+ }
240
+
241
+ async deleteSession(id: string): Promise<void> {
242
+ await this.closeSession(id);
243
+ await this.harness.deleteSession(id);
244
+ }
245
+
246
+ async setYoloModeForActiveSessions(enabled: boolean): Promise<void> {
247
+ await Promise.all(
248
+ [...this.sessions.values()].map((session) => session.setLegacyYoloMode(enabled)),
249
+ );
250
+ }
251
+
252
+ async dispose(): Promise<void> {
253
+ if (this.closed) return;
254
+ this.closed = true;
255
+ await Promise.all([...this.sessions.values()].map((session) => session.close()));
256
+ this.sessions.clear();
257
+ this.sessionByView.clear();
258
+ await this.harness.close();
259
+ }
260
+
261
+ private wrapSession(session: Session, legacyApproval: LegacyApprovalFlags): SessionRuntime {
262
+ const runtime = new SessionRuntime({
263
+ session,
264
+ legacyApproval,
265
+ broadcast: this.broadcast,
266
+ captureBaseline: this.captureBaseline,
267
+ log: this.log,
268
+ });
269
+ this.sessions.set(session.id, runtime);
270
+ return runtime;
271
+ }
272
+
273
+ private async readMigratedLegacyApproval(
274
+ session: Session,
275
+ ): Promise<LegacyApprovalFlags | undefined> {
276
+ const metadata = session.summary?.metadata;
277
+ try {
278
+ return await readMigratedLegacyApprovalFlags(metadata);
279
+ } catch (error) {
280
+ this.log("Unable to restore legacy session approval settings", error);
281
+ return undefined;
282
+ }
283
+ }
284
+
285
+ private ensureOpen(): void {
286
+ if (this.closed) throw new Error("Kimi runtime is closed.");
287
+ }
288
+ }
289
+
290
+ async function applySessionSettings(
291
+ session: Session,
292
+ options: OpenSessionOptions,
293
+ legacyApproval: LegacyApprovalFlags,
294
+ ): Promise<void> {
295
+ const status = await session.getStatus();
296
+ // Model and thinking effort are applied only when the session is created
297
+ // (see openSession). An existing session keeps its own — the global config
298
+ // values are defaults for new sessions, matching CLI/TUI resume semantics.
299
+ // Changes made in the pickers reach the active session through the
300
+ // SaveConfig handler instead.
301
+ const permission = corePermissionForLegacyApproval(legacyApproval);
302
+ if (status.permission !== permission) {
303
+ await session.setPermission(permission);
304
+ }
305
+ }
306
+
307
+ export function normalizeEffort(effort: string): ThinkingEffort {
308
+ return (effort.trim() || "off") as ThinkingEffort;
309
+ }
310
+
311
+ function flagsDiffer(a: LegacyApprovalFlags, b: LegacyApprovalFlags): boolean {
312
+ return a.yolo !== b.yolo || a.afk !== b.afk;
313
+ }
314
+
315
+ function assertSessionWorkDir(session: Pick<Session, "workDir">, expectedWorkDir: string): void {
316
+ if (!areSameFsPath(session.workDir, expectedWorkDir)) {
317
+ throw new Error("The selected session belongs to a different working directory.");
318
+ }
319
+ }
apps/vscode/src/runtime/legacy-approval.ts ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import type { JsonObject, PermissionMode } from "@moonshot-ai/kimi-code-sdk";
5
+
6
+ export const LEGACY_APPROVAL_METADATA_KEY = "vscode_legacy_approval";
7
+
8
+ export interface LegacyApprovalFlags {
9
+ readonly yolo: boolean;
10
+ readonly afk: boolean;
11
+ }
12
+
13
+ export function readLegacyApprovalFlags(
14
+ metadata: Readonly<Record<string, unknown>> | undefined,
15
+ ): LegacyApprovalFlags | undefined {
16
+ const value = metadata?.[LEGACY_APPROVAL_METADATA_KEY];
17
+ return parseLegacyApprovalFlags(value);
18
+ }
19
+
20
+ export async function readMigratedLegacyApprovalFlags(
21
+ metadata: Readonly<Record<string, unknown>> | undefined,
22
+ ): Promise<LegacyApprovalFlags | undefined> {
23
+ const sourcePath = metadata?.["kimi_cli_source_path"];
24
+ if (typeof sourcePath !== "string" || sourcePath.length === 0) return undefined;
25
+ let text: string;
26
+ try {
27
+ text = await readFile(join(sourcePath, "state.json"), "utf8");
28
+ } catch (error) {
29
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
30
+ throw error;
31
+ }
32
+ const state = JSON.parse(text) as { readonly approval?: unknown };
33
+ return parseLegacyApprovalFlags(state.approval);
34
+ }
35
+
36
+ function parseLegacyApprovalFlags(value: unknown): LegacyApprovalFlags | undefined {
37
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
38
+ const yolo = Reflect.get(value, "yolo");
39
+ const afk = Reflect.get(value, "afk");
40
+ if (typeof yolo !== "boolean" && typeof afk !== "boolean") return undefined;
41
+ return {
42
+ yolo: typeof yolo === "boolean" ? yolo : false,
43
+ afk: typeof afk === "boolean" ? afk : false,
44
+ };
45
+ }
46
+
47
+ export function legacyApprovalMetadata(flags: LegacyApprovalFlags): JsonObject {
48
+ return {
49
+ [LEGACY_APPROVAL_METADATA_KEY]: {
50
+ yolo: flags.yolo,
51
+ afk: flags.afk,
52
+ },
53
+ };
54
+ }
55
+
56
+ export function corePermissionForLegacyApproval(flags: LegacyApprovalFlags): PermissionMode {
57
+ if (flags.afk) return "auto";
58
+ return flags.yolo ? "yolo" : "manual";
59
+ }
60
+
61
+ /**
62
+ * The global `kimi.yoloMode` setting is authoritative whenever a session
63
+ * attaches to the runtime; afk stays per-session because it has no global
64
+ * setting counterpart.
65
+ */
66
+ export function withGlobalYoloMode(
67
+ flags: LegacyApprovalFlags,
68
+ yoloMode: boolean,
69
+ ): LegacyApprovalFlags {
70
+ return flags.yolo === yoloMode ? flags : { yolo: yoloMode, afk: flags.afk };
71
+ }
apps/vscode/src/runtime/replay-adapter.ts ADDED
@@ -0,0 +1,594 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ AgentReplayRecord,
3
+ ContentPart,
4
+ PromptOrigin,
5
+ ResumedAgentState,
6
+ ResumedSessionState,
7
+ } from "@moonshot-ai/kimi-code-sdk";
8
+
9
+ import type {
10
+ ContentPart as LegacyContentPart,
11
+ DisplayBlock,
12
+ LegacyWireEvent,
13
+ RunResult,
14
+ TokenUsage,
15
+ ToolCall,
16
+ } from "../../shared/legacy-sdk";
17
+ import type { UIStreamEvent } from "../../shared/types";
18
+ import { toLegacyToolName } from "./event-adapter";
19
+ import { toLegacyDisplay } from "./tool-display";
20
+
21
+ interface SubagentReplayInvocation {
22
+ readonly parentAgentId: string;
23
+ readonly parentToolCallId: string;
24
+ readonly childAgentId: string;
25
+ readonly startedAt: number;
26
+ readonly order: number;
27
+ records: readonly AgentReplayRecord[];
28
+ }
29
+
30
+ interface SubagentReplayIndex {
31
+ readonly byParentCall: ReadonlyMap<string, readonly SubagentReplayInvocation[]>;
32
+ }
33
+
34
+ /** Projects a complete resumed SDK session, including persisted subagent steps. */
35
+ export function replaySessionToWebviewEvents(
36
+ state: ResumedSessionState,
37
+ sessionId: string,
38
+ ): UIStreamEvent[] {
39
+ const main = state.agents["main"];
40
+ if (main === undefined) throw new Error("Session history is unavailable.");
41
+ return replayAgentToWebviewEvents(main, sessionId, buildSubagentReplayIndex(state));
42
+ }
43
+
44
+ /** Projects the public SDK resume replay into the released Webview protocol. */
45
+ export function replayToWebviewEvents(
46
+ agent: ResumedAgentState,
47
+ sessionId: string,
48
+ ): UIStreamEvent[] {
49
+ return replayAgentToWebviewEvents(agent, sessionId);
50
+ }
51
+
52
+ function replayAgentToWebviewEvents(
53
+ agent: ResumedAgentState,
54
+ sessionId: string,
55
+ subagents?: SubagentReplayIndex,
56
+ ): UIStreamEvent[] {
57
+ const events: UIStreamEvent[] = [];
58
+ let turnOpen = false;
59
+ let step = 0;
60
+ const toolDisplays = new Map<string, readonly DisplayBlock[]>();
61
+
62
+ events.push(
63
+ withSession(
64
+ {
65
+ type: "StatusUpdate",
66
+ payload: {
67
+ ...(agent.config.modelAlias === undefined ? {} : { model: agent.config.modelAlias }),
68
+ thinking_effort: agent.config.thinkingEffort,
69
+ plan_mode: agent.plan !== null,
70
+ },
71
+ },
72
+ sessionId,
73
+ ),
74
+ );
75
+
76
+ const completeTurn = () => {
77
+ if (!turnOpen) return;
78
+ const result: RunResult = { status: "finished" };
79
+ events.push({ type: "stream_complete", result, _sessionId: sessionId });
80
+ turnOpen = false;
81
+ };
82
+
83
+ const ensureStep = () => {
84
+ if (!turnOpen) return;
85
+ if (step === 0) {
86
+ step = 1;
87
+ events.push(withSession({ type: "StepBegin", payload: { n: step } }, sessionId));
88
+ }
89
+ };
90
+
91
+ for (const record of agent.replay) {
92
+ switch (record.type) {
93
+ case "message": {
94
+ const message = record.message;
95
+ if (message.role === "user") {
96
+ if (!isVisibleUserMessage(message.origin)) break;
97
+ const imported = importedContextReplay(message.content);
98
+ completeTurn();
99
+ step = 0;
100
+ turnOpen = true;
101
+ events.push(
102
+ withSession(
103
+ {
104
+ type: "TurnBegin",
105
+ payload: {
106
+ user_input: imported?.input ?? replayUserInput(message.content, message.origin),
107
+ },
108
+ },
109
+ sessionId,
110
+ ),
111
+ );
112
+ if (imported !== undefined) {
113
+ ensureStep();
114
+ events.push(
115
+ withSession(
116
+ { type: "ContentPart", payload: { type: "text", text: imported.confirmation } },
117
+ sessionId,
118
+ ),
119
+ );
120
+ }
121
+ break;
122
+ }
123
+
124
+ if (message.role === "assistant") {
125
+ if (!turnOpen) break;
126
+ ensureStep();
127
+ for (const part of toLegacyContent(message.content)) {
128
+ events.push(withSession({ type: "ContentPart", payload: part }, sessionId));
129
+ }
130
+ for (const call of message.toolCalls) {
131
+ const display = message.toolCallDisplays?.[call.id];
132
+ if (display !== undefined) {
133
+ toolDisplays.set(call.id, toLegacyDisplay(display));
134
+ }
135
+ const toolCall: ToolCall = {
136
+ type: "function",
137
+ id: call.id,
138
+ function: {
139
+ name: toLegacyToolName(call.name),
140
+ arguments: call.arguments,
141
+ },
142
+ };
143
+ events.push(withSession({ type: "ToolCall", payload: toolCall }, sessionId));
144
+ if (subagents !== undefined) {
145
+ for (const nested of renderSubagentInvocations(
146
+ subagents,
147
+ "main",
148
+ call.id,
149
+ [],
150
+ new Set(),
151
+ )) {
152
+ events.push(withSession(nested, sessionId));
153
+ }
154
+ }
155
+ }
156
+ break;
157
+ }
158
+
159
+ if (message.role === "tool" && turnOpen && message.toolCallId !== undefined) {
160
+ ensureStep();
161
+ const display = toolDisplays.get(message.toolCallId) ?? [];
162
+ toolDisplays.delete(message.toolCallId);
163
+ events.push(
164
+ withSession(
165
+ {
166
+ type: "ToolResult",
167
+ payload: {
168
+ tool_call_id: message.toolCallId,
169
+ return_value: {
170
+ is_error: message.isError === true,
171
+ output: toLegacyContent(message.content),
172
+ message: "",
173
+ display: [...display],
174
+ },
175
+ },
176
+ },
177
+ sessionId,
178
+ ),
179
+ );
180
+ }
181
+ break;
182
+ }
183
+ case "compaction":
184
+ if (!turnOpen) break;
185
+ ensureStep();
186
+ events.push(withSession({ type: "CompactionBegin", payload: {} }, sessionId));
187
+ if (record.result !== undefined) {
188
+ events.push(withSession({ type: "CompactionEnd", payload: {} }, sessionId));
189
+ }
190
+ break;
191
+ case "plan_updated":
192
+ if (turnOpen) {
193
+ ensureStep();
194
+ events.push(
195
+ withSession(
196
+ { type: "StatusUpdate", payload: { plan_mode: record.enabled } },
197
+ sessionId,
198
+ ),
199
+ );
200
+ }
201
+ break;
202
+ case "config_updated":
203
+ case "permission_updated":
204
+ case "approval_result":
205
+ case "goal_updated":
206
+ break;
207
+ }
208
+ }
209
+
210
+ completeTurn();
211
+ const resumedStatus = resumedStatusPayload(agent);
212
+ if (Object.keys(resumedStatus).length > 0) {
213
+ events.push(withSession({ type: "StatusUpdate", payload: resumedStatus }, sessionId));
214
+ }
215
+ return events;
216
+ }
217
+
218
+ function buildSubagentReplayIndex(state: ResumedSessionState): SubagentReplayIndex {
219
+ const invocations: SubagentReplayInvocation[] = [];
220
+ let order = 0;
221
+
222
+ for (const [parentAgentId, parent] of Object.entries(state.agents)) {
223
+ const calls = new Map<
224
+ string,
225
+ { readonly name: string; readonly startedAt: number; readonly order: number }
226
+ >();
227
+ for (const record of parent.replay) {
228
+ if (record.type !== "message") continue;
229
+ const { message } = record;
230
+ if (message.role === "assistant") {
231
+ for (const call of message.toolCalls) {
232
+ calls.set(call.id, { name: call.name, startedAt: record.time, order: order++ });
233
+ }
234
+ continue;
235
+ }
236
+ if (message.role !== "tool" || message.toolCallId === undefined) continue;
237
+ const call = calls.get(message.toolCallId);
238
+ if (call === undefined || (call.name !== "Agent" && call.name !== "AgentSwarm")) continue;
239
+ for (const childAgentId of subagentIdsFromResult(call.name, message.content)) {
240
+ const metadata = state.sessionMetadata.agents[childAgentId];
241
+ if (metadata?.parentAgentId !== parentAgentId || state.agents[childAgentId] === undefined) {
242
+ continue;
243
+ }
244
+ invocations.push({
245
+ parentAgentId,
246
+ parentToolCallId: message.toolCallId,
247
+ childAgentId,
248
+ startedAt: call.startedAt,
249
+ order: call.order,
250
+ records: [],
251
+ });
252
+ }
253
+ }
254
+ }
255
+
256
+ const byChild = new Map<string, SubagentReplayInvocation[]>();
257
+ for (const invocation of invocations) {
258
+ const entries = byChild.get(invocation.childAgentId) ?? [];
259
+ entries.push(invocation);
260
+ byChild.set(invocation.childAgentId, entries);
261
+ }
262
+ for (const [childAgentId, entries] of byChild) {
263
+ entries.sort(compareInvocation);
264
+ const replay = state.agents[childAgentId]?.replay ?? [];
265
+ for (const [index, invocation] of entries.entries()) {
266
+ const next = entries[index + 1];
267
+ invocation.records = replay.filter(
268
+ (record) =>
269
+ record.time >= invocation.startedAt &&
270
+ (next === undefined || record.time < next.startedAt),
271
+ );
272
+ }
273
+ }
274
+
275
+ const byParentCall = new Map<string, SubagentReplayInvocation[]>();
276
+ for (const invocation of invocations) {
277
+ const key = parentCallKey(invocation.parentAgentId, invocation.parentToolCallId);
278
+ const entries = byParentCall.get(key) ?? [];
279
+ entries.push(invocation);
280
+ byParentCall.set(key, entries);
281
+ }
282
+ for (const entries of byParentCall.values()) entries.sort(compareInvocation);
283
+ return { byParentCall };
284
+ }
285
+
286
+ function compareInvocation(a: SubagentReplayInvocation, b: SubagentReplayInvocation): number {
287
+ return a.startedAt - b.startedAt || a.order - b.order;
288
+ }
289
+
290
+ function subagentIdsFromResult(
291
+ toolName: "Agent" | "AgentSwarm",
292
+ content: readonly ContentPart[],
293
+ ): readonly string[] {
294
+ const text = content
295
+ .filter((part): part is Extract<ContentPart, { type: "text" }> => part.type === "text")
296
+ .map((part) => part.text)
297
+ .join("");
298
+ if (toolName === "Agent") {
299
+ const header = text.split("\n\n", 1)[0] ?? text;
300
+ const match = /(?:^|\n)agent_id:\s*([^\s]+)\s*(?=\n|$)/.exec(header);
301
+ return match === null ? [] : [match[1]!];
302
+ }
303
+ const pattern = /<subagent\b[^>]*\bagent_id="([^"]+)"[^>]*\boutcome="[^"]+">/g;
304
+ return [...text.matchAll(pattern)].map((match) => match[1]!).filter(uniqueString);
305
+ }
306
+
307
+ function uniqueString(value: string, index: number, values: readonly string[]): boolean {
308
+ return values.indexOf(value) === index;
309
+ }
310
+
311
+ function renderSubagentInvocations(
312
+ index: SubagentReplayIndex,
313
+ parentAgentId: string,
314
+ parentToolCallId: string,
315
+ parentChain: readonly SubagentReplayInvocation[],
316
+ visited: ReadonlySet<string>,
317
+ ): LegacyWireEvent[] {
318
+ const result: LegacyWireEvent[] = [];
319
+ const invocations = index.byParentCall.get(parentCallKey(parentAgentId, parentToolCallId)) ?? [];
320
+ for (const invocation of invocations) {
321
+ const invocationKey = `${invocation.parentAgentId}\u0000${invocation.parentToolCallId}\u0000${invocation.childAgentId}\u0000${String(invocation.startedAt)}`;
322
+ if (visited.has(invocationKey)) continue;
323
+ const nextVisited = new Set([...visited, invocationKey]);
324
+ result.push(
325
+ ...renderSubagentInvocation(index, invocation, [invocation, ...parentChain], nextVisited),
326
+ );
327
+ }
328
+ return result;
329
+ }
330
+
331
+ function renderSubagentInvocation(
332
+ index: SubagentReplayIndex,
333
+ invocation: SubagentReplayInvocation,
334
+ chain: readonly SubagentReplayInvocation[],
335
+ visited: ReadonlySet<string>,
336
+ ): LegacyWireEvent[] {
337
+ const events: LegacyWireEvent[] = [];
338
+ const toolDisplays = new Map<string, readonly DisplayBlock[]>();
339
+ let step = 0;
340
+
341
+ const emit = (event: LegacyWireEvent) => {
342
+ events.push(wrapSubagentEvent(event, chain));
343
+ };
344
+
345
+ for (const record of invocation.records) {
346
+ switch (record.type) {
347
+ case "message": {
348
+ const { message } = record;
349
+ if (message.role === "user") {
350
+ step = 0;
351
+ break;
352
+ }
353
+ if (message.role === "assistant") {
354
+ step += 1;
355
+ emit({ type: "StepBegin", payload: { n: step } });
356
+ for (const part of toLegacyContent(message.content)) {
357
+ emit({ type: "ContentPart", payload: part });
358
+ }
359
+ for (const call of message.toolCalls) {
360
+ const toolCallId = scopedReplayToolCallId(invocation.childAgentId, call.id);
361
+ const display = message.toolCallDisplays?.[call.id];
362
+ if (display !== undefined) toolDisplays.set(toolCallId, toLegacyDisplay(display));
363
+ emit({
364
+ type: "ToolCall",
365
+ payload: {
366
+ type: "function",
367
+ id: toolCallId,
368
+ function: {
369
+ name: toLegacyToolName(call.name),
370
+ arguments: call.arguments,
371
+ },
372
+ },
373
+ });
374
+ events.push(
375
+ ...renderSubagentInvocations(
376
+ index,
377
+ invocation.childAgentId,
378
+ call.id,
379
+ chain,
380
+ visited,
381
+ ),
382
+ );
383
+ }
384
+ break;
385
+ }
386
+ if (message.role === "tool" && message.toolCallId !== undefined) {
387
+ const toolCallId = scopedReplayToolCallId(
388
+ invocation.childAgentId,
389
+ message.toolCallId,
390
+ );
391
+ const display = toolDisplays.get(toolCallId) ?? [];
392
+ toolDisplays.delete(toolCallId);
393
+ emit({
394
+ type: "ToolResult",
395
+ payload: {
396
+ tool_call_id: toolCallId,
397
+ return_value: {
398
+ is_error: message.isError === true,
399
+ output: toLegacyContent(message.content),
400
+ message: "",
401
+ display: [...display],
402
+ },
403
+ },
404
+ });
405
+ }
406
+ break;
407
+ }
408
+ case "compaction":
409
+ emit({ type: "CompactionBegin", payload: {} });
410
+ if (record.result !== undefined) emit({ type: "CompactionEnd", payload: {} });
411
+ break;
412
+ case "plan_updated":
413
+ emit({ type: "StatusUpdate", payload: { plan_mode: record.enabled } });
414
+ break;
415
+ case "config_updated":
416
+ case "permission_updated":
417
+ case "approval_result":
418
+ case "goal_updated":
419
+ break;
420
+ }
421
+ }
422
+ return events;
423
+ }
424
+
425
+ function wrapSubagentEvent(
426
+ event: LegacyWireEvent,
427
+ chain: readonly SubagentReplayInvocation[],
428
+ ): LegacyWireEvent {
429
+ let routed = event;
430
+ for (const invocation of chain) {
431
+ routed = {
432
+ type: "SubagentEvent",
433
+ payload: {
434
+ parent_tool_call_id: scopedReplayToolCallId(
435
+ invocation.parentAgentId,
436
+ invocation.parentToolCallId,
437
+ ),
438
+ event: routed,
439
+ },
440
+ };
441
+ }
442
+ return routed;
443
+ }
444
+
445
+ function scopedReplayToolCallId(agentId: string, toolCallId: string): string {
446
+ return agentId === "main" ? toolCallId : `${agentId}:${toolCallId}`;
447
+ }
448
+
449
+ function parentCallKey(agentId: string, toolCallId: string): string {
450
+ return `${agentId}\u0000${toolCallId}`;
451
+ }
452
+
453
+ function resumedStatusPayload(agent: ResumedAgentState): {
454
+ context_usage?: number;
455
+ token_usage?: TokenUsage;
456
+ } {
457
+ const maxContextTokens = agent.config.modelCapabilities.max_context_tokens;
458
+ const totalUsage = agent.usage.total ?? sumUsage(agent.usage.byModel) ?? agent.usage.currentTurn;
459
+ return {
460
+ ...(maxContextTokens > 0
461
+ ? { context_usage: agent.context.tokenCount / maxContextTokens }
462
+ : {}),
463
+ ...(totalUsage === undefined
464
+ ? {}
465
+ : {
466
+ token_usage: {
467
+ input_other: totalUsage.inputOther,
468
+ output: totalUsage.output,
469
+ input_cache_read: totalUsage.inputCacheRead,
470
+ input_cache_creation: totalUsage.inputCacheCreation,
471
+ },
472
+ }),
473
+ };
474
+ }
475
+
476
+ function sumUsage(
477
+ byModel: ResumedAgentState["usage"]["byModel"],
478
+ ): ResumedAgentState["usage"]["total"] {
479
+ if (byModel === undefined || Object.keys(byModel).length === 0) return undefined;
480
+ return Object.values(byModel).reduce(
481
+ (total, usage) => ({
482
+ inputOther: total.inputOther + usage.inputOther,
483
+ output: total.output + usage.output,
484
+ inputCacheRead: total.inputCacheRead + usage.inputCacheRead,
485
+ inputCacheCreation: total.inputCacheCreation + usage.inputCacheCreation,
486
+ }),
487
+ { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 },
488
+ );
489
+ }
490
+
491
+ function toLegacyContent(content: readonly ContentPart[]): LegacyContentPart[] {
492
+ const result: LegacyContentPart[] = [];
493
+ for (const part of content) {
494
+ switch (part.type) {
495
+ case "text":
496
+ result.push({ type: "text", text: part.text });
497
+ break;
498
+ case "think":
499
+ result.push({
500
+ type: "think",
501
+ think: part.think,
502
+ ...(part.encrypted === undefined ? {} : { encrypted: part.encrypted }),
503
+ });
504
+ break;
505
+ case "image_url":
506
+ result.push({ type: "image_url", image_url: { ...part.imageUrl } });
507
+ break;
508
+ case "audio_url":
509
+ result.push({ type: "audio_url", audio_url: { ...part.audioUrl } });
510
+ break;
511
+ case "video_url":
512
+ result.push({ type: "video_url", video_url: { ...part.videoUrl } });
513
+ break;
514
+ }
515
+ }
516
+ return result;
517
+ }
518
+
519
+ function isVisibleUserMessage(origin: PromptOrigin | undefined): boolean {
520
+ if (origin === undefined || origin.kind === "user") return true;
521
+ if (origin.kind === "skill_activation" || origin.kind === "plugin_command") {
522
+ return origin.trigger === "user-slash";
523
+ }
524
+ return origin.kind === "shell_command" && origin.phase === "input";
525
+ }
526
+
527
+ function replayUserInput(
528
+ content: readonly ContentPart[],
529
+ origin: PromptOrigin | undefined,
530
+ ): LegacyContentPart[] {
531
+ if (origin?.kind === "skill_activation" && origin.trigger === "user-slash") {
532
+ const args = origin.skillArgs?.trim();
533
+ return [{
534
+ type: "text",
535
+ text: `/skill:${origin.skillName}${args ? ` ${args}` : ""}`,
536
+ }];
537
+ }
538
+ if (origin?.kind === "plugin_command") {
539
+ const args = origin.commandArgs?.trim();
540
+ return [{
541
+ type: "text",
542
+ text: `/${origin.pluginId}:${origin.commandName}${args ? ` ${args}` : ""}`,
543
+ }];
544
+ }
545
+ return toLegacyContent(content);
546
+ }
547
+
548
+ function importedContextReplay(
549
+ content: readonly ContentPart[],
550
+ ): { input: LegacyContentPart[]; confirmation: string } | undefined {
551
+ const importedPart = content.find(
552
+ (part): part is Extract<ContentPart, { type: "text" }> =>
553
+ part.type === "text" && part.text.startsWith("<imported_context "),
554
+ );
555
+ if (importedPart === undefined) return undefined;
556
+ const match = /^<imported_context source="([^"]*)">\n([\s\S]*)\n<\/imported_context>$/.exec(
557
+ importedPart.text,
558
+ );
559
+ if (match === null) return undefined;
560
+ const source = decodeXml(match[1]!);
561
+ const importedText = match[2]!;
562
+ const target = importTarget(source);
563
+ return {
564
+ input: [{ type: "text", text: `/import ${target}` }],
565
+ confirmation: `Imported context from ${source} (${String(importedText.length)} chars).`,
566
+ };
567
+ }
568
+
569
+ function importTarget(source: string): string {
570
+ const quoted = /^(?:file|session) '([\s\S]*)'$/.exec(source);
571
+ return quoted?.[1] ?? source;
572
+ }
573
+
574
+ function decodeXml(value: string): string {
575
+ return value
576
+ .replaceAll("&quot;", '"')
577
+ .replaceAll("&apos;", "'")
578
+ .replaceAll("&lt;", "<")
579
+ .replaceAll("&gt;", ">")
580
+ .replaceAll("&amp;", "&");
581
+ }
582
+
583
+ function withSession<T extends LegacyWireEvent>(event: T, sessionId: string): T & { _sessionId: string } {
584
+ return { ...event, _sessionId: sessionId };
585
+ }
586
+
587
+ export function replayRecordTurnCount(records: readonly AgentReplayRecord[]): number {
588
+ return records.filter(
589
+ (record) =>
590
+ record.type === "message" &&
591
+ record.message.role === "user" &&
592
+ isVisibleUserMessage(record.message.origin),
593
+ ).length;
594
+ }
apps/vscode/src/runtime/reverse-rpc.ts ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type {
4
+ ApprovalRequest,
5
+ ApprovalResponse as CoreApprovalResponse,
6
+ QuestionRequest,
7
+ QuestionResult,
8
+ } from "@moonshot-ai/kimi-code-sdk";
9
+
10
+ import type { ApprovalResponse, QuestionRequest as LegacyQuestionRequest } from "../../shared/legacy-sdk";
11
+ import { describeToolDisplay, toLegacyDisplay } from "./tool-display";
12
+
13
+ export type ReverseRpcEvent =
14
+ | { type: "ApprovalRequest"; payload: ReturnType<typeof approvalPayload> }
15
+ | { type: "QuestionRequest"; payload: LegacyQuestionRequest };
16
+
17
+ export class ReverseRpcController {
18
+ private readonly approvals = new Map<string, (response: CoreApprovalResponse) => void>();
19
+ private readonly questions = new Map<string, (result: QuestionResult) => void>();
20
+
21
+ constructor(private readonly emit: (event: ReverseRpcEvent) => void) {}
22
+
23
+ requestApproval(request: ApprovalRequest): Promise<CoreApprovalResponse> {
24
+ const id = randomUUID();
25
+ return new Promise((resolve) => {
26
+ this.approvals.set(id, resolve);
27
+ this.emit({ type: "ApprovalRequest", payload: approvalPayload(id, request) });
28
+ });
29
+ }
30
+
31
+ requestQuestion(request: QuestionRequest): Promise<QuestionResult> {
32
+ const id = randomUUID();
33
+ return new Promise((resolve) => {
34
+ this.questions.set(id, resolve);
35
+ this.emit({
36
+ type: "QuestionRequest",
37
+ payload: {
38
+ id,
39
+ tool_call_id: request.toolCallId ?? "",
40
+ questions: request.questions.map((question) => ({
41
+ question: question.question,
42
+ header: question.header,
43
+ options: question.options.map((option) => ({
44
+ label: option.label,
45
+ description: option.description,
46
+ })),
47
+ multi_select: question.multiSelect,
48
+ })),
49
+ },
50
+ });
51
+ });
52
+ }
53
+
54
+ respondApproval(id: string, response: ApprovalResponse): boolean {
55
+ const resolve = this.approvals.get(id);
56
+ if (!resolve) return false;
57
+ this.approvals.delete(id);
58
+ if (response === "approve_for_session") {
59
+ resolve({ decision: "approved", scope: "session" });
60
+ } else if (response === "approve") {
61
+ resolve({ decision: "approved" });
62
+ } else {
63
+ resolve({ decision: "rejected" });
64
+ }
65
+ return true;
66
+ }
67
+
68
+ respondQuestion(id: string, answers: Record<string, string>): boolean {
69
+ const resolve = this.questions.get(id);
70
+ if (!resolve) return false;
71
+ this.questions.delete(id);
72
+ resolve({ answers });
73
+ return true;
74
+ }
75
+
76
+ cancelAll(reason: string): void {
77
+ for (const resolve of this.approvals.values()) {
78
+ resolve({ decision: "cancelled", feedback: reason });
79
+ }
80
+ for (const resolve of this.questions.values()) {
81
+ resolve(null);
82
+ }
83
+ this.approvals.clear();
84
+ this.questions.clear();
85
+ }
86
+ }
87
+
88
+ function approvalPayload(id: string, request: ApprovalRequest) {
89
+ return {
90
+ id,
91
+ tool_call_id: request.toolCallId,
92
+ sender: request.toolName,
93
+ action: request.action,
94
+ description: describeToolDisplay(request.display),
95
+ display: toLegacyDisplay(request.display),
96
+ };
97
+ }
apps/vscode/src/runtime/session-runtime.ts ADDED
@@ -0,0 +1,656 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ isKimiError,
3
+ type ContentPart as SdkContentPart,
4
+ type Event,
5
+ type PromptInput,
6
+ type Session,
7
+ type SessionSummary,
8
+ } from "@moonshot-ai/kimi-code-sdk";
9
+
10
+ import type { ContentPart as LegacyContentPart, ApprovalResponse } from "../../shared/legacy-sdk";
11
+ import { Events } from "../../shared/bridge";
12
+ import { getUserMessage } from "../../shared/errors";
13
+ import type { ErrorPhase, UIStreamEvent } from "../../shared/types";
14
+ import {
15
+ adaptSdkEvent,
16
+ createEventAdapterState,
17
+ type EventAdapterState,
18
+ type TurnTerminalMetadata,
19
+ } from "./event-adapter";
20
+ import {
21
+ corePermissionForLegacyApproval,
22
+ legacyApprovalMetadata,
23
+ type LegacyApprovalFlags,
24
+ } from "./legacy-approval";
25
+ import { ReverseRpcController } from "./reverse-rpc";
26
+
27
+ export type RuntimeBroadcast = (event: string, data: unknown, webviewId?: string) => void;
28
+
29
+ export interface SessionRuntimeOptions {
30
+ readonly session: Session;
31
+ readonly legacyApproval: LegacyApprovalFlags;
32
+ readonly broadcast: RuntimeBroadcast;
33
+ readonly captureBaseline: (
34
+ session: Pick<SessionSummary, "id" | "workDir" | "metadata">,
35
+ filePath: string,
36
+ webviewIds: readonly string[],
37
+ ) => void;
38
+ readonly log: (message: string, error?: unknown) => void;
39
+ }
40
+
41
+ interface ActivePrompt {
42
+ readonly input: LegacyContentPart[] | string;
43
+ started: boolean;
44
+ settled: boolean;
45
+ resolve: (result: PromptResult) => void;
46
+ }
47
+
48
+ const ALREADY_GENERATING_MESSAGE = "A response is already being generated for this session.";
49
+
50
+ export interface PromptResult {
51
+ readonly status: "finished" | "cancelled" | "failed";
52
+ }
53
+
54
+ interface SuppressedError {
55
+ readonly code: string;
56
+ readonly message: string;
57
+ }
58
+
59
+ interface PendingHostCompaction {
60
+ readonly actionId: number;
61
+ readonly resolve: (result: "completed" | "cancelled") => void;
62
+ readonly reject: (error: unknown) => void;
63
+ }
64
+
65
+ /**
66
+ * Owns the one SDK event subscription and reverse-RPC handlers for a session.
67
+ * Any number of Webviews may subscribe without replacing each other's approval
68
+ * handler or duplicating streamed events.
69
+ */
70
+ export class SessionRuntime {
71
+ readonly session: Session;
72
+
73
+ private readonly broadcast: RuntimeBroadcast;
74
+ private readonly captureBaseline: SessionRuntimeOptions["captureBaseline"];
75
+ private readonly log: SessionRuntimeOptions["log"];
76
+ private readonly webviewIds = new Set<string>();
77
+ private readonly reverseRpc: ReverseRpcController;
78
+ private readonly unsubscribe: () => void;
79
+ private adapterState: EventAdapterState = createEventAdapterState();
80
+ private activePrompt: ActivePrompt | undefined;
81
+ private hostActionActive = false;
82
+ private hostActionSequence = 0;
83
+ private activeHostActionId: number | undefined;
84
+ private readonly cancelledHostActions = new Set<number>();
85
+ private pendingHostCompaction: PendingHostCompaction | undefined;
86
+ private readonly activeWorkSettledWaiters = new Set<() => void>();
87
+ private exclusiveActionActive = false;
88
+ private readonly terminalKeys = new Set<string>();
89
+ private suppressedError: SuppressedError | undefined;
90
+ private legacyApproval: LegacyApprovalFlags;
91
+ private closed = false;
92
+
93
+ constructor(options: SessionRuntimeOptions) {
94
+ this.session = options.session;
95
+ this.broadcast = options.broadcast;
96
+ this.captureBaseline = options.captureBaseline;
97
+ this.log = options.log;
98
+ this.legacyApproval = options.legacyApproval;
99
+ this.reverseRpc = new ReverseRpcController((event) => this.emitStreamEvent(event));
100
+
101
+ // Forward every approval request to the user. The engine permission mode
102
+ // (mapped from the legacy flags) already auto-approves what yolo/auto
103
+ // allow internally; anything that reaches this handler is an exception
104
+ // (sensitive file, plan review, ask rule) the user must decide on.
105
+ this.session.setApprovalHandler((request) => this.reverseRpc.requestApproval(request));
106
+ this.session.setQuestionHandler((request) => this.reverseRpc.requestQuestion(request));
107
+ this.unsubscribe = this.session.onEvent((event) => this.onSdkEvent(event));
108
+ }
109
+
110
+ get id(): string {
111
+ return this.session.id;
112
+ }
113
+
114
+ get summary(): SessionSummary | undefined {
115
+ return this.session.summary;
116
+ }
117
+
118
+ get subscribers(): readonly string[] {
119
+ return [...this.webviewIds];
120
+ }
121
+
122
+ get isBusy(): boolean {
123
+ return this.hasActiveWork || this.exclusiveActionActive;
124
+ }
125
+
126
+ get legacyApprovalFlags(): LegacyApprovalFlags {
127
+ return this.legacyApproval;
128
+ }
129
+
130
+ async toggleLegacyApproval(kind: keyof LegacyApprovalFlags): Promise<LegacyApprovalFlags> {
131
+ const next = { ...this.legacyApproval, [kind]: !this.legacyApproval[kind] };
132
+ await this.applyLegacyApproval(next);
133
+ return next;
134
+ }
135
+
136
+ async setLegacyYoloMode(enabled: boolean): Promise<void> {
137
+ if (this.legacyApproval.yolo === enabled) return;
138
+ await this.applyLegacyApproval({ ...this.legacyApproval, yolo: enabled });
139
+ }
140
+
141
+ subscribe(webviewId: string): void {
142
+ this.ensureOpen();
143
+ this.webviewIds.add(webviewId);
144
+ }
145
+
146
+ /**
147
+ * Push the session's current status to a view. Called whenever a view
148
+ * opens or re-enters a session so the display (model, thinking effort,
149
+ * plan mode) matches engine truth instead of the global defaults.
150
+ */
151
+ async announceStatus(webviewId: string): Promise<void> {
152
+ this.ensureOpen();
153
+ const status = await this.session.getStatus();
154
+ if (this.closed || !this.webviewIds.has(webviewId)) return;
155
+ this.broadcast(
156
+ Events.StreamEvent,
157
+ {
158
+ type: "StatusUpdate",
159
+ payload: {
160
+ model: status.model,
161
+ thinking_effort: status.thinkingEffort,
162
+ plan_mode: status.planMode,
163
+ context_usage: status.contextUsage,
164
+ },
165
+ _sessionId: this.id,
166
+ },
167
+ webviewId,
168
+ );
169
+ }
170
+
171
+ unsubscribeView(webviewId: string): void {
172
+ this.webviewIds.delete(webviewId);
173
+ }
174
+
175
+ async prompt(input: string | LegacyContentPart[]): Promise<PromptResult> {
176
+ return this.runTurnAction(input, () => this.session.prompt(toSdkPromptInput(input)));
177
+ }
178
+
179
+ async runTurnAction(
180
+ input: string | LegacyContentPart[],
181
+ action: () => Promise<void>,
182
+ ): Promise<PromptResult> {
183
+ this.ensureOpen();
184
+ if (this.isBusy) {
185
+ // A re-entrant turn request must never disturb the active turn — it fails
186
+ // only itself. When a turn or host action is running, its later terminal
187
+ // stream event unlocks every subscribed view, so a non-terminal warning
188
+ // is enough. An exclusive operation (e.g. fork materialization) emits no
189
+ // such terminal event, so reject terminally: the caller's composer must
190
+ // unlock rather than hang until the handshake timeout.
191
+ this.emitError(
192
+ new Error(ALREADY_GENERATING_MESSAGE),
193
+ "runtime",
194
+ { terminal: this.hasActiveWork ? false : undefined },
195
+ );
196
+ return { status: "failed" };
197
+ }
198
+
199
+ let resolveCompletion!: (result: PromptResult) => void;
200
+ const completion = new Promise<PromptResult>((resolve) => {
201
+ resolveCompletion = resolve;
202
+ });
203
+ const active: ActivePrompt = {
204
+ input,
205
+ started: false,
206
+ settled: false,
207
+ resolve: resolveCompletion,
208
+ };
209
+ this.activePrompt = active;
210
+
211
+ try {
212
+ await action();
213
+ } catch (error) {
214
+ // Only settle the prompt this call created. Once the event pipeline or a
215
+ // cancel has settled it, the failure was already reported — settling it
216
+ // again would misreport the active turn and could emit a duplicate error.
217
+ if (!active.settled) {
218
+ this.emitError(error, active.started ? "runtime" : "preflight");
219
+ this.settlePrompt({ status: "failed" });
220
+ }
221
+ }
222
+
223
+ return completion;
224
+ }
225
+
226
+ beginHostAction(input: string | LegacyContentPart[], forkable = false): number {
227
+ this.ensureOpen();
228
+ if (this.isBusy) {
229
+ throw new Error(ALREADY_GENERATING_MESSAGE);
230
+ }
231
+ const actionId = ++this.hostActionSequence;
232
+ this.hostActionActive = true;
233
+ this.activeHostActionId = actionId;
234
+ this.emitStreamEvent({
235
+ type: "TurnBegin",
236
+ payload: { user_input: input, forkable },
237
+ _sessionId: this.id,
238
+ });
239
+ this.emitStreamEvent({
240
+ type: "StepBegin",
241
+ payload: { n: 1 },
242
+ _sessionId: this.id,
243
+ });
244
+ return actionId;
245
+ }
246
+
247
+ emitHostText(text: string, actionId = this.activeHostActionId): void {
248
+ if (!this.hostActionActive || actionId !== this.activeHostActionId || text.length === 0) return;
249
+ this.emitStreamEvent({
250
+ type: "ContentPart",
251
+ payload: { type: "text", text },
252
+ _sessionId: this.id,
253
+ });
254
+ }
255
+
256
+ announceSessionStart(model?: string): void {
257
+ this.emitStreamEvent({
258
+ type: "session_start",
259
+ sessionId: this.id,
260
+ ...(model === undefined ? {} : { model }),
261
+ _sessionId: this.id,
262
+ });
263
+ }
264
+
265
+ completeHostAction(
266
+ status: "finished" | "cancelled" = "finished",
267
+ actionId = this.activeHostActionId,
268
+ ): void {
269
+ if (!this.hostActionActive || actionId !== this.activeHostActionId) return;
270
+ this.hostActionActive = false;
271
+ this.activeHostActionId = undefined;
272
+ this.emitStreamEvent({
273
+ type: "stream_complete",
274
+ result: { status },
275
+ _sessionId: this.id,
276
+ });
277
+ this.notifyActiveWorkSettled();
278
+ }
279
+
280
+ failHostAction(actionId: number): void {
281
+ if (!this.hostActionActive || actionId !== this.activeHostActionId) return;
282
+ this.hostActionActive = false;
283
+ this.activeHostActionId = undefined;
284
+ this.notifyActiveWorkSettled();
285
+ }
286
+
287
+ wasHostActionCancelled(actionId: number): boolean {
288
+ return this.cancelledHostActions.has(actionId);
289
+ }
290
+
291
+ releaseHostAction(actionId: number): void {
292
+ this.cancelledHostActions.delete(actionId);
293
+ }
294
+
295
+ async compactHostAction(actionId: number, instruction?: string): Promise<void> {
296
+ if (!this.hostActionActive || actionId !== this.activeHostActionId) {
297
+ throw new Error("The host action is no longer active.");
298
+ }
299
+ if (this.pendingHostCompaction !== undefined) {
300
+ throw new Error("A context compaction is already running.");
301
+ }
302
+
303
+ let resolveCompletion!: (result: "completed" | "cancelled") => void;
304
+ let rejectCompletion!: (error: unknown) => void;
305
+ const completion = new Promise<"completed" | "cancelled">((resolve, reject) => {
306
+ resolveCompletion = resolve;
307
+ rejectCompletion = reject;
308
+ });
309
+ this.pendingHostCompaction = {
310
+ actionId,
311
+ resolve: resolveCompletion,
312
+ reject: rejectCompletion,
313
+ };
314
+
315
+ try {
316
+ await this.session.compact(instruction === undefined ? {} : { instruction });
317
+ } catch (error) {
318
+ if (this.pendingHostCompaction?.actionId === actionId) {
319
+ this.pendingHostCompaction = undefined;
320
+ rejectCompletion(error);
321
+ }
322
+ }
323
+
324
+ const result = await completion;
325
+ if (result === "cancelled") {
326
+ throw new Error("Context compaction was cancelled.");
327
+ }
328
+ }
329
+
330
+ async cancel(): Promise<void> {
331
+ // Always reach the engine, even when the host believes nothing is active.
332
+ // The host-side bookkeeping can drift from engine truth after an abnormal
333
+ // error path; session.cancel() is a harmless no-op when the engine is
334
+ // idle, but it is the only way to recover a turn the host lost track of.
335
+ if (this.closed) return;
336
+ this.reverseRpc.cancelAll("Turn cancelled");
337
+ const cancellingHostAction = this.hostActionActive;
338
+ const hostActionId = this.activeHostActionId;
339
+ if (cancellingHostAction && hostActionId !== undefined) {
340
+ this.cancelledHostActions.add(hostActionId);
341
+ this.completeHostAction("cancelled", hostActionId);
342
+ }
343
+ // A manual compaction is not a model turn, so Session.cancel() alone does
344
+ // not stop it. Calling both public cancellation surfaces is harmless when
345
+ // the other operation is idle and keeps the Stop button correct for both
346
+ // normal turns and host-side slash commands such as /compact and /init.
347
+ const results = await Promise.allSettled([
348
+ this.session.cancel(),
349
+ ...(cancellingHostAction ? [this.session.cancelCompaction()] : []),
350
+ ]);
351
+ const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
352
+ if (failure !== undefined) throw failure.reason;
353
+ }
354
+
355
+ /**
356
+ * Stop any in-flight work, wait for its terminal event, and keep new turns
357
+ * out until the supplied operation finishes. Forking uses this so it reads a
358
+ * fully settled session instead of racing the asynchronous cancel event.
359
+ */
360
+ async runExclusiveAfterCancelling<T>(action: () => Promise<T>): Promise<T> {
361
+ this.ensureOpen();
362
+ if (this.exclusiveActionActive) {
363
+ throw new Error("Another session operation is already in progress.");
364
+ }
365
+
366
+ this.exclusiveActionActive = true;
367
+ try {
368
+ const settled = this.waitForActiveWorkToSettle();
369
+ await this.cancel();
370
+ await settled;
371
+ this.ensureOpen();
372
+ return await action();
373
+ } finally {
374
+ this.exclusiveActionActive = false;
375
+ }
376
+ }
377
+
378
+ async steer(input: string | LegacyContentPart[]): Promise<void> {
379
+ this.ensureOpen();
380
+ await this.session.steer(toSdkPromptInput(input));
381
+ this.emitStreamEvent({
382
+ type: "SteerInput",
383
+ payload: { user_input: input },
384
+ _sessionId: this.id,
385
+ });
386
+ }
387
+
388
+ respondApproval(id: string, response: ApprovalResponse): boolean {
389
+ return this.reverseRpc.respondApproval(id, response);
390
+ }
391
+
392
+ respondQuestion(id: string, answers: Record<string, string>): boolean {
393
+ return this.reverseRpc.respondQuestion(id, answers);
394
+ }
395
+
396
+ async close(): Promise<void> {
397
+ if (this.closed) return;
398
+ this.closed = true;
399
+ this.pendingHostCompaction?.reject(new Error("Session closed during context compaction."));
400
+ this.pendingHostCompaction = undefined;
401
+ this.reverseRpc.cancelAll("Session closed");
402
+ this.unsubscribe();
403
+ this.session.setApprovalHandler(undefined);
404
+ this.session.setQuestionHandler(undefined);
405
+ if (this.activePrompt !== undefined || this.hostActionActive) {
406
+ try {
407
+ await this.session.cancel();
408
+ } catch (error) {
409
+ this.log("Failed to cancel the active turn while closing a session", error);
410
+ }
411
+ if (this.activePrompt !== undefined) this.settlePrompt({ status: "cancelled" });
412
+ this.hostActionActive = false;
413
+ this.activeHostActionId = undefined;
414
+ this.notifyActiveWorkSettled();
415
+ }
416
+ this.cancelledHostActions.clear();
417
+ await this.session.close();
418
+ this.webviewIds.clear();
419
+ }
420
+
421
+ private async applyLegacyApproval(flags: LegacyApprovalFlags): Promise<void> {
422
+ this.ensureOpen();
423
+ const permission = corePermissionForLegacyApproval(flags);
424
+ const status = await this.session.getStatus();
425
+ const permissionChanged = status.permission !== permission;
426
+ if (permissionChanged) await this.session.setPermission(permission);
427
+ try {
428
+ await this.session.updateMetadata(legacyApprovalMetadata(flags));
429
+ } catch (error) {
430
+ if (permissionChanged) {
431
+ await this.session.setPermission(status.permission).catch((rollbackError: unknown) => {
432
+ this.log("Failed to restore session permission after a metadata error", rollbackError);
433
+ });
434
+ }
435
+ throw error;
436
+ }
437
+ this.legacyApproval = flags;
438
+ }
439
+
440
+ private onSdkEvent(event: Event): void {
441
+ if (this.closed) return;
442
+
443
+ if (event.type === "compaction.completed" || event.type === "compaction.cancelled") {
444
+ const pending = this.pendingHostCompaction;
445
+ if (pending !== undefined) {
446
+ this.pendingHostCompaction = undefined;
447
+ pending.resolve(event.type === "compaction.completed" ? "completed" : "cancelled");
448
+ }
449
+ }
450
+
451
+ if (event.type === "turn.started" && event.agentId === "main" && this.activePrompt !== undefined) {
452
+ this.activePrompt.started = true;
453
+ }
454
+
455
+ if (event.type === "tool.call.started") {
456
+ this.captureFileBaseline(event);
457
+ }
458
+
459
+ if (event.type === "turn.step.retrying") {
460
+ this.log(
461
+ `Provider retry ${event.nextAttempt}/${event.maxAttempts} in ${event.delayMs}ms`,
462
+ new Error(event.errorMessage),
463
+ );
464
+ }
465
+
466
+ if (event.type === "error" && this.consumeSuppressedError(event.code, event.message)) {
467
+ return;
468
+ }
469
+
470
+ const pendingInput = this.activePrompt?.input;
471
+ const adapted = adaptSdkEvent(this.adapterState, event, {
472
+ pendingInput,
473
+ errorPhase: this.activePrompt?.started === false ? "preflight" : "runtime",
474
+ });
475
+ this.adapterState = adapted.state;
476
+
477
+ if (adapted.terminal !== undefined) {
478
+ this.emitTerminal(adapted.terminal);
479
+ return;
480
+ }
481
+
482
+ if (adapted.event !== undefined) {
483
+ // Errors the core reports while the active turn keeps running (they are
484
+ // not followed by a terminal turn.ended) must not look turn-ending to the
485
+ // Webview — otherwise the UI unlocks mid-turn and the next send collides
486
+ // with the still-active prompt.
487
+ const wireEvent =
488
+ adapted.event.type === "error" && this.activePrompt?.started === true
489
+ ? { ...adapted.event, terminal: false as const }
490
+ : adapted.event;
491
+ this.emitStreamEvent(wireEvent);
492
+ if (adapted.event.type === "error" && this.activePrompt !== undefined && !this.activePrompt.started) {
493
+ this.settlePrompt({ status: "failed" });
494
+ }
495
+ }
496
+ }
497
+
498
+ private captureFileBaseline(event: Extract<Event, { type: "tool.call.started" }>): void {
499
+ if (event.name !== "Write" && event.name !== "Edit") return;
500
+ if (!isRecord(event.args)) return;
501
+ const filePath = event.args["path"];
502
+ if (typeof filePath !== "string" || filePath.length === 0) return;
503
+
504
+ const summary = this.session.summary;
505
+ this.captureBaseline(
506
+ {
507
+ id: this.session.id,
508
+ workDir: this.session.workDir,
509
+ metadata: summary?.metadata,
510
+ },
511
+ filePath,
512
+ this.subscribers,
513
+ );
514
+ }
515
+
516
+ private emitTerminal(terminal: TurnTerminalMetadata): void {
517
+ if (this.terminalKeys.has(terminal.key)) return;
518
+ this.terminalKeys.add(terminal.key);
519
+
520
+ if (terminal.reason === "completed") {
521
+ this.emitStreamEvent({
522
+ type: "stream_complete",
523
+ result: { status: "finished" },
524
+ _sessionId: terminal.sessionId,
525
+ });
526
+ this.settlePrompt({ status: "finished" });
527
+ return;
528
+ }
529
+
530
+ if (terminal.reason === "cancelled") {
531
+ this.reverseRpc.cancelAll("Turn cancelled");
532
+ this.emitStreamEvent({
533
+ type: "stream_complete",
534
+ result: { status: "cancelled" },
535
+ _sessionId: terminal.sessionId,
536
+ });
537
+ this.settlePrompt({ status: "cancelled" });
538
+ return;
539
+ }
540
+
541
+ const code = terminal.error?.code ?? `turn.${terminal.reason}`;
542
+ this.reverseRpc.cancelAll("Turn ended");
543
+ const detail = terminal.error?.message ?? `Turn ended with reason: ${terminal.reason}`;
544
+ const message = getUserMessage(code, detail);
545
+ this.log("Session turn failed", new Error(`${code}: ${detail}`));
546
+ this.emitStreamEvent({
547
+ type: "error",
548
+ code,
549
+ message,
550
+ detail,
551
+ phase: "runtime",
552
+ _sessionId: terminal.sessionId,
553
+ });
554
+ if (terminal.error !== undefined) {
555
+ this.suppressedError = { code: terminal.error.code, message: terminal.error.message };
556
+ }
557
+ this.settlePrompt({ status: "failed" });
558
+ }
559
+
560
+ private consumeSuppressedError(code: string, message: string): boolean {
561
+ const suppressed = this.suppressedError;
562
+ if (suppressed === undefined) return false;
563
+ this.suppressedError = undefined;
564
+ return suppressed.code === code && suppressed.message === message;
565
+ }
566
+
567
+ private emitError(error: unknown, phase: ErrorPhase, options?: { readonly terminal?: boolean }): void {
568
+ const code = isKimiError(error) ? error.code : "internal";
569
+ const detail = error instanceof Error ? error.message : String(error);
570
+ this.log(`Session ${phase} error`, error);
571
+ this.emitStreamEvent({
572
+ type: "error",
573
+ code,
574
+ message: getUserMessage(code, detail),
575
+ detail,
576
+ phase,
577
+ _sessionId: this.session.id,
578
+ terminal: options?.terminal,
579
+ });
580
+ }
581
+
582
+ private emitStreamEvent(event: UIStreamEvent | { type: string; payload: unknown }): void {
583
+ for (const webviewId of this.webviewIds) {
584
+ this.broadcast(Events.StreamEvent, event, webviewId);
585
+ }
586
+ }
587
+
588
+ private settlePrompt(result: PromptResult): void {
589
+ const active = this.activePrompt;
590
+ if (active === undefined || active.settled) return;
591
+ active.settled = true;
592
+ this.activePrompt = undefined;
593
+ active.resolve(result);
594
+ this.notifyActiveWorkSettled();
595
+ }
596
+
597
+ private get hasActiveWork(): boolean {
598
+ return this.activePrompt !== undefined || this.hostActionActive;
599
+ }
600
+
601
+ private waitForActiveWorkToSettle(): Promise<void> {
602
+ if (!this.hasActiveWork) return Promise.resolve();
603
+ return new Promise((resolve) => {
604
+ this.activeWorkSettledWaiters.add(resolve);
605
+ });
606
+ }
607
+
608
+ private notifyActiveWorkSettled(): void {
609
+ if (this.hasActiveWork) return;
610
+ for (const resolve of this.activeWorkSettledWaiters) resolve();
611
+ this.activeWorkSettledWaiters.clear();
612
+ }
613
+
614
+ private ensureOpen(): void {
615
+ if (this.closed) throw new Error("Session is closed.");
616
+ }
617
+ }
618
+
619
+ export function toSdkPromptInput(input: string | LegacyContentPart[]): string | PromptInput {
620
+ if (typeof input === "string") return input;
621
+ const parts: SdkContentPart[] = [];
622
+ for (const part of input) {
623
+ switch (part.type) {
624
+ case "text":
625
+ parts.push({ type: "text", text: part.text });
626
+ break;
627
+ case "image_url":
628
+ parts.push({
629
+ type: "image_url",
630
+ imageUrl: {
631
+ url: part.image_url.url,
632
+ ...(part.image_url.id === null || part.image_url.id === undefined ? {} : { id: part.image_url.id }),
633
+ },
634
+ });
635
+ break;
636
+ case "video_url":
637
+ parts.push({
638
+ type: "video_url",
639
+ videoUrl: {
640
+ url: part.video_url.url,
641
+ ...(part.video_url.id === null || part.video_url.id === undefined ? {} : { id: part.video_url.id }),
642
+ },
643
+ });
644
+ break;
645
+ case "audio_url":
646
+ case "think":
647
+ // PromptInput intentionally accepts user text/images/videos only.
648
+ break;
649
+ }
650
+ }
651
+ return parts as PromptInput;
652
+ }
653
+
654
+ function isRecord(value: unknown): value is Record<string, unknown> {
655
+ return typeof value === "object" && value !== null && !Array.isArray(value);
656
+ }
apps/vscode/src/runtime/tool-display.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { ToolInputDisplay } from "@moonshot-ai/kimi-code-sdk";
2
+
3
+ import type { DisplayBlock } from "../../shared/legacy-sdk";
4
+
5
+ export function describeToolDisplay(display: ToolInputDisplay): string {
6
+ switch (display.kind) {
7
+ case "command":
8
+ return display.command;
9
+ case "file_io":
10
+ return `${display.operation} ${display.path}`;
11
+ case "diff":
12
+ return `Edit ${display.path}`;
13
+ case "search":
14
+ return `Search for ${display.query}`;
15
+ case "url_fetch":
16
+ return display.url;
17
+ case "agent_call":
18
+ return display.prompt;
19
+ case "skill_call":
20
+ return display.args ? `${display.skill_name} ${display.args}` : display.skill_name;
21
+ case "todo_list":
22
+ return "Update the task list";
23
+ case "task":
24
+ return display.description;
25
+ case "task_stop":
26
+ return display.task_description;
27
+ case "plan_review":
28
+ return display.plan;
29
+ case "goal_start":
30
+ return display.objective;
31
+ case "generic":
32
+ return display.summary;
33
+ }
34
+ }
35
+
36
+ export function toLegacyDisplay(display: ToolInputDisplay): DisplayBlock[] {
37
+ switch (display.kind) {
38
+ case "command":
39
+ return [{ type: "shell", language: display.language ?? "bash", command: display.command }];
40
+ case "diff":
41
+ return [{ type: "diff", path: display.path, old_text: display.before, new_text: display.after }];
42
+ case "file_io":
43
+ if (
44
+ display.before !== undefined ||
45
+ display.after !== undefined ||
46
+ display.content !== undefined
47
+ ) {
48
+ return [{
49
+ type: "diff",
50
+ path: display.path,
51
+ old_text: display.before ?? "",
52
+ new_text: display.after ?? display.content ?? "",
53
+ }];
54
+ }
55
+ return [{ type: "brief", text: describeToolDisplay(display) }];
56
+ case "todo_list":
57
+ return [{
58
+ type: "todo",
59
+ items: display.items.map((item) => ({
60
+ title: item.title,
61
+ status: item.status === "done" || item.status === "in_progress" ? item.status : "pending",
62
+ })),
63
+ }];
64
+ case "search":
65
+ case "url_fetch":
66
+ case "agent_call":
67
+ case "skill_call":
68
+ case "task":
69
+ case "task_stop":
70
+ case "plan_review":
71
+ case "goal_start":
72
+ case "generic":
73
+ return [{ type: "brief", text: describeToolDisplay(display) }];
74
+ }
75
+ }
apps/vscode/src/utils/context.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import * as vscode from "vscode";
2
+ import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk";
3
+
4
+ export async function updateLoginContext(harness: KimiHarness): Promise<boolean> {
5
+ const status = await harness.auth.status();
6
+ const loggedIn = status.providers.some((provider) => provider.hasToken);
7
+ await vscode.commands.executeCommand("setContext", "kimi.isLoggedIn", loggedIn);
8
+ return loggedIn;
9
+ }
apps/vscode/src/utils/fs-path.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as path from "node:path";
2
+
3
+ /** Return a forward-slash relative path when `candidate` is inside `root`. */
4
+ export function relativeFsPath(root: string, candidate: string): string | undefined {
5
+ const paths = isWindowsPath(root) || isWindowsPath(candidate) ? path.win32 : path;
6
+ const relativePath = paths.relative(paths.resolve(root), paths.resolve(candidate));
7
+ if (
8
+ relativePath === "" ||
9
+ (!relativePath.startsWith(`..${paths.sep}`) && relativePath !== ".." && !paths.isAbsolute(relativePath))
10
+ ) {
11
+ return relativePath.split(paths.sep).join("/");
12
+ }
13
+ return undefined;
14
+ }
15
+
16
+ /** Whether two native filesystem paths identify the same location. */
17
+ export function areSameFsPath(left: string, right: string): boolean {
18
+ return relativeFsPath(left, right) === "";
19
+ }
20
+
21
+ /** Whether `candidate` is `root` or a descendant using native path rules. */
22
+ export function isFsPathInsideOrEqual(root: string, candidate: string): boolean {
23
+ return relativeFsPath(root, candidate) !== undefined;
24
+ }
25
+
26
+ function isWindowsPath(value: string): boolean {
27
+ return /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value);
28
+ }
apps/vscode/src/utils/session-context.ts ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {
2
+ ContentPart,
3
+ ContextMessage,
4
+ PromptOrigin,
5
+ ToolCall,
6
+ } from "@moonshot-ai/kimi-code-sdk";
7
+
8
+ const INTERNAL_ORIGINS = new Set<PromptOrigin["kind"]>([
9
+ "injection",
10
+ "system_trigger",
11
+ "compaction_summary",
12
+ "hook_result",
13
+ "cron_job",
14
+ "cron_missed",
15
+ ]);
16
+
17
+ const TOOL_HINT_KEYS = ["path", "file_path", "command", "query", "url", "name", "pattern"];
18
+
19
+ export function buildExportMarkdown(input: {
20
+ readonly sessionId: string;
21
+ readonly workDir: string;
22
+ readonly history: readonly ContextMessage[];
23
+ readonly tokenCount: number;
24
+ readonly now: Date;
25
+ }): string {
26
+ const turns = groupIntoTurns(input.history);
27
+ const firstUser = input.history.find(
28
+ (message) => message.role === "user" && !isInternalMessage(message),
29
+ );
30
+ const topic = firstUser === undefined ? "" : shorten(stringifyParts(firstUser.content), 80);
31
+ const toolCalls = input.history.reduce((count, message) => count + message.toolCalls.length, 0);
32
+ const lines = [
33
+ "---",
34
+ `session_id: ${input.sessionId}`,
35
+ `exported_at: ${input.now.toISOString()}`,
36
+ `work_dir: ${input.workDir}`,
37
+ `message_count: ${String(input.history.length)}`,
38
+ `token_count: ${String(input.tokenCount)}`,
39
+ "---",
40
+ "",
41
+ "# Kimi Session Export",
42
+ "",
43
+ "## Overview",
44
+ "",
45
+ topic ? `- **Topic**: ${topic}` : "- **Topic**: (empty)",
46
+ `- **Conversation**: ${String(turns.length)} turns | ${String(toolCalls)} tool calls`,
47
+ "",
48
+ "---",
49
+ "",
50
+ ];
51
+
52
+ for (let index = 0; index < turns.length; index += 1) {
53
+ lines.push(formatTurn(turns[index]!, index + 1));
54
+ }
55
+ return lines.join("\n");
56
+ }
57
+
58
+ export function stringifyContextHistory(history: readonly ContextMessage[]): string {
59
+ const messages: string[] = [];
60
+ for (const message of history) {
61
+ if (isInternalMessage(message)) continue;
62
+ const sections: string[] = [];
63
+ const content = stringifyParts(message.content);
64
+ if (content.trim()) sections.push(content);
65
+ if (message.toolCalls.length > 0) {
66
+ sections.push(message.toolCalls.map(stringifyToolCall).join("\n"));
67
+ }
68
+ if (sections.length === 0) continue;
69
+ const callId = message.role === "tool" && message.toolCallId
70
+ ? ` (call_id: ${message.toolCallId})`
71
+ : "";
72
+ messages.push(`[${message.role.toUpperCase()}]${callId}\n${sections.join("\n")}`);
73
+ }
74
+ return messages.join("\n\n");
75
+ }
76
+
77
+ export function isImportableTextFile(fileName: string): boolean {
78
+ const dot = fileName.lastIndexOf(".");
79
+ if (dot <= 0) return true;
80
+ return IMPORTABLE_EXTENSIONS.has(fileName.slice(dot).toLowerCase());
81
+ }
82
+
83
+ export function isSensitiveFile(fileName: string): boolean {
84
+ const normalized = fileName.toLowerCase();
85
+ return [".env", "credentials", "secrets", ".pem", ".key", ".p12", ".pfx", ".keystore"]
86
+ .some((pattern) => normalized.includes(pattern));
87
+ }
88
+
89
+ function isInternalMessage(message: ContextMessage): boolean {
90
+ return message.origin !== undefined && INTERNAL_ORIGINS.has(message.origin.kind);
91
+ }
92
+
93
+ function groupIntoTurns(history: readonly ContextMessage[]): ContextMessage[][] {
94
+ const turns: ContextMessage[][] = [];
95
+ let current: ContextMessage[] = [];
96
+ for (const message of history) {
97
+ if (isInternalMessage(message)) continue;
98
+ if (message.role === "user" && current.length > 0) {
99
+ turns.push(current);
100
+ current = [];
101
+ }
102
+ current.push(message);
103
+ }
104
+ if (current.length > 0) turns.push(current);
105
+ return turns;
106
+ }
107
+
108
+ function formatTurn(messages: readonly ContextMessage[], turnNumber: number): string {
109
+ const lines = [`## Turn ${String(turnNumber)}`, ""];
110
+ const toolInfo = new Map<string, { name: string; hint: string }>();
111
+ let assistantHeading = false;
112
+ for (const message of messages) {
113
+ if (message.role === "user") {
114
+ lines.push("### User", "", stringifyParts(message.content), "");
115
+ continue;
116
+ }
117
+ if (message.role === "assistant") {
118
+ if (!assistantHeading) {
119
+ lines.push("### Assistant", "");
120
+ assistantHeading = true;
121
+ }
122
+ const content = formatPartsMarkdown(message.content);
123
+ if (content) lines.push(content, "");
124
+ for (const call of message.toolCalls) {
125
+ const hint = toolCallHint(call);
126
+ toolInfo.set(call.id, { name: call.name, hint });
127
+ lines.push(formatToolCallMarkdown(call, hint), "");
128
+ }
129
+ continue;
130
+ }
131
+ if (message.role === "tool") {
132
+ const info = toolInfo.get(message.toolCallId ?? "") ?? { name: "unknown", hint: "" };
133
+ const hint = info.hint ? ` (\`${info.hint}\`)` : "";
134
+ lines.push(
135
+ `<details><summary>Tool Result: ${info.name}${hint}</summary>`,
136
+ "",
137
+ `<!-- call_id: ${message.toolCallId ?? "unknown"} -->`,
138
+ formatPartsMarkdown(message.content),
139
+ "",
140
+ "</details>",
141
+ "",
142
+ );
143
+ continue;
144
+ }
145
+ lines.push(`### ${capitalize(message.role)}`, "", formatPartsMarkdown(message.content), "");
146
+ }
147
+ return lines.join("\n");
148
+ }
149
+
150
+ function formatToolCallMarkdown(call: ToolCall, hint: string): string {
151
+ let args = call.arguments ?? "{}";
152
+ try {
153
+ args = JSON.stringify(JSON.parse(args), null, 2);
154
+ } catch {
155
+ // Preserve malformed arguments exactly as recorded.
156
+ }
157
+ const suffix = hint ? ` (\`${hint}\`)` : "";
158
+ return `#### Tool Call: ${call.name}${suffix}\n<!-- call_id: ${call.id} -->\n\`\`\`json\n${args}\n\`\`\``;
159
+ }
160
+
161
+ function toolCallHint(call: ToolCall): string {
162
+ let args: unknown;
163
+ try {
164
+ args = JSON.parse(call.arguments ?? "{}");
165
+ } catch {
166
+ return "";
167
+ }
168
+ if (!isRecord(args)) return "";
169
+ for (const key of TOOL_HINT_KEYS) {
170
+ const value = args[key];
171
+ if (typeof value === "string" && value.trim()) return shorten(value, 60);
172
+ }
173
+ return "";
174
+ }
175
+
176
+ function stringifyToolCall(call: ToolCall): string {
177
+ let args = call.arguments ?? "{}";
178
+ try {
179
+ args = JSON.stringify(JSON.parse(args));
180
+ } catch {
181
+ // Preserve malformed arguments exactly as recorded.
182
+ }
183
+ return `Tool Call: ${call.name}(${args})`;
184
+ }
185
+
186
+ function formatPartsMarkdown(parts: readonly ContentPart[]): string {
187
+ return parts.map(formatPartMarkdown).filter(Boolean).join("\n");
188
+ }
189
+
190
+ function formatPartMarkdown(part: ContentPart): string {
191
+ switch (part.type) {
192
+ case "text": return part.text;
193
+ case "think": return part.think.trim()
194
+ ? `<details><summary>Thinking</summary>\n\n${part.think}\n\n</details>`
195
+ : "";
196
+ case "image_url": return "[image]";
197
+ case "audio_url": return "[audio]";
198
+ case "video_url": return "[video]";
199
+ }
200
+ }
201
+
202
+ function stringifyParts(parts: readonly ContentPart[]): string {
203
+ return parts.map((part) => {
204
+ if (part.type === "text") return part.text;
205
+ if (part.type === "think") return part.think.trim() ? `<thinking>\n${part.think}\n</thinking>` : "";
206
+ if (part.type === "image_url") return "[image]";
207
+ if (part.type === "audio_url") return "[audio]";
208
+ return "[video]";
209
+ }).filter(Boolean).join("\n");
210
+ }
211
+
212
+ function capitalize(value: string): string {
213
+ return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1);
214
+ }
215
+
216
+ function shorten(value: string, width: number): string {
217
+ return value.length <= width ? value : `${value.slice(0, width)}…`;
218
+ }
219
+
220
+ function isRecord(value: unknown): value is Record<string, unknown> {
221
+ return typeof value === "object" && value !== null && !Array.isArray(value);
222
+ }
223
+
224
+ const IMPORTABLE_EXTENSIONS = new Set([
225
+ ".md", ".markdown", ".txt", ".text", ".rst", ".json", ".jsonl", ".yaml", ".yml",
226
+ ".toml", ".ini", ".cfg", ".conf", ".csv", ".tsv", ".xml", ".env", ".properties",
227
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".kt", ".go", ".rs", ".c", ".cpp",
228
+ ".h", ".hpp", ".cs", ".rb", ".php", ".swift", ".scala", ".sh", ".bash", ".zsh",
229
+ ".fish", ".ps1", ".bat", ".cmd", ".r", ".lua", ".pl", ".pm", ".ex", ".exs",
230
+ ".erl", ".hs", ".ml", ".sql", ".graphql", ".proto", ".html", ".htm", ".css",
231
+ ".scss", ".sass", ".less", ".svg", ".log", ".tex", ".bib", ".org", ".adoc", ".wiki",
232
+ ]);
apps/vscode/src/utils/string.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function buildCaseInsensitiveGlobLiteral(input: string) {
2
+ let out = "";
3
+ for (const ch of input) {
4
+ if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")) {
5
+ const l = ch.toLowerCase();
6
+ const u = ch.toUpperCase();
7
+ out += `[${l}${u}]`;
8
+ } else {
9
+ if ("*?[]{}\\".includes(ch)) {
10
+ out += "\\" + ch;
11
+ } else {
12
+ out += ch;
13
+ }
14
+ }
15
+ }
16
+ return out;
17
+ }
apps/vscode/src/utils/workspace-path.ts ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { lstat, realpath } from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import * as vscode from "vscode";
5
+
6
+ import { relativeFsPath } from "./fs-path";
7
+
8
+ export interface WorkspacePath {
9
+ readonly uri: vscode.Uri;
10
+ readonly relativePath: string;
11
+ }
12
+
13
+ export function workDirUriFromPath(
14
+ workspaceRootUri: vscode.Uri,
15
+ workspaceRoot: string,
16
+ workDir: string,
17
+ ): vscode.Uri | undefined {
18
+ const relativePath = relativeFsPath(workspaceRoot, workDir);
19
+ if (relativePath === undefined) return undefined;
20
+ return relativePath === ""
21
+ ? workspaceRootUri
22
+ : vscode.Uri.joinPath(workspaceRootUri, ...toPathSegments(relativePath));
23
+ }
24
+
25
+ export function resolveWorkspacePath(
26
+ workDirUri: vscode.Uri,
27
+ input: string,
28
+ options: { allowRoot?: boolean } = {},
29
+ ): WorkspacePath | undefined {
30
+ const normalized = input.replaceAll("\\", "/");
31
+ if (
32
+ path.posix.isAbsolute(normalized) ||
33
+ path.win32.isAbsolute(input) ||
34
+ /^[A-Za-z]:/.test(input)
35
+ ) return undefined;
36
+
37
+ const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== ".");
38
+ if (segments.includes("..")) return undefined;
39
+ if (segments.length === 0) {
40
+ return options.allowRoot === true ? { uri: workDirUri, relativePath: "" } : undefined;
41
+ }
42
+
43
+ return {
44
+ uri: vscode.Uri.joinPath(workDirUri, ...segments),
45
+ relativePath: segments.join("/"),
46
+ };
47
+ }
48
+
49
+ export function relativeWorkspacePath(rootUri: vscode.Uri, candidateUri: vscode.Uri): string | undefined {
50
+ if (rootUri.scheme !== candidateUri.scheme) return undefined;
51
+
52
+ // VS Code exposes native Windows file paths through `fsPath`, while URI
53
+ // paths always use `/`. Comparing the URI strings makes the same drive or
54
+ // UNC path look different when only separator or drive/share casing differs.
55
+ // Native filesystem semantics are the right contract for `file:` URIs.
56
+ if (rootUri.scheme === "file") {
57
+ const relativePath = relativeFsPath(rootUri.fsPath, candidateUri.fsPath);
58
+ return relativePath === "" ? undefined : relativePath;
59
+ }
60
+
61
+ if (rootUri.authority !== candidateUri.authority) return undefined;
62
+
63
+ const relativePath = path.posix.relative(normalizeUriPath(rootUri.path), normalizeUriPath(candidateUri.path));
64
+ if (
65
+ relativePath === "" ||
66
+ relativePath === ".." ||
67
+ relativePath.startsWith("../") ||
68
+ path.posix.isAbsolute(relativePath)
69
+ ) {
70
+ return undefined;
71
+ }
72
+ return relativePath;
73
+ }
74
+
75
+ export async function isWorkspacePathContained(
76
+ rootUri: vscode.Uri,
77
+ candidateUri: vscode.Uri,
78
+ options: { allowMissing?: boolean } = {},
79
+ ): Promise<boolean> {
80
+ if (rootUri.toString() !== candidateUri.toString() && relativeWorkspacePath(rootUri, candidateUri) === undefined) {
81
+ return false;
82
+ }
83
+ if (rootUri.scheme !== "file" || candidateUri.scheme !== "file") return true;
84
+
85
+ try {
86
+ const [realRoot, realCandidate] = await Promise.all([
87
+ realpath(rootUri.fsPath),
88
+ options.allowMissing === true ? realExistingPath(candidateUri.fsPath) : realpath(candidateUri.fsPath),
89
+ ]);
90
+ return relativeFsPath(realRoot, realCandidate) !== undefined;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ export function isWorkspacePathContainedSync(
97
+ rootUri: vscode.Uri,
98
+ candidateUri: vscode.Uri,
99
+ options: { allowMissing?: boolean } = {},
100
+ ): boolean {
101
+ if (rootUri.toString() !== candidateUri.toString() && relativeWorkspacePath(rootUri, candidateUri) === undefined) {
102
+ return false;
103
+ }
104
+ if (rootUri.scheme !== "file" || candidateUri.scheme !== "file") return true;
105
+
106
+ try {
107
+ const realRoot = realpathSync(rootUri.fsPath);
108
+ const realCandidate =
109
+ options.allowMissing === true
110
+ ? realExistingPathSync(candidateUri.fsPath)
111
+ : realpathSync(candidateUri.fsPath);
112
+ return relativeFsPath(realRoot, realCandidate) !== undefined;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ async function realExistingPath(candidate: string): Promise<string> {
119
+ let current = candidate;
120
+ while (true) {
121
+ try {
122
+ return await realpath(current);
123
+ } catch (error) {
124
+ if (!isMissingPathError(error)) throw error;
125
+ let isDanglingSymlink = false;
126
+ try {
127
+ isDanglingSymlink = (await lstat(current)).isSymbolicLink();
128
+ } catch (lstatError) {
129
+ if (!isMissingPathError(lstatError)) throw lstatError;
130
+ }
131
+ if (isDanglingSymlink) throw error;
132
+ const parent = path.dirname(current);
133
+ if (parent === current) throw error;
134
+ current = parent;
135
+ }
136
+ }
137
+ }
138
+
139
+ function realExistingPathSync(candidate: string): string {
140
+ let current = candidate;
141
+ while (true) {
142
+ try {
143
+ return realpathSync(current);
144
+ } catch (error) {
145
+ if (!isMissingPathError(error)) throw error;
146
+ let isDanglingSymlink = false;
147
+ try {
148
+ isDanglingSymlink = lstatSync(current).isSymbolicLink();
149
+ } catch (lstatError) {
150
+ if (!isMissingPathError(lstatError)) throw lstatError;
151
+ }
152
+ if (isDanglingSymlink) throw error;
153
+ const parent = path.dirname(current);
154
+ if (parent === current) throw error;
155
+ current = parent;
156
+ }
157
+ }
158
+ }
159
+
160
+ function isMissingPathError(error: unknown): boolean {
161
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
162
+ }
163
+
164
+ function toPathSegments(value: string): string[] {
165
+ return value.replaceAll("\\", "/").split("/").filter(Boolean);
166
+ }
167
+
168
+ function normalizeUriPath(value: string): string {
169
+ const normalized = path.posix.normalize(value);
170
+ return normalized.length > 1 ? normalized.replace(/\/$/, "") : normalized;
171
+ }
apps/vscode/webview-ui/public/kimi-banner-dark.svg ADDED
apps/vscode/webview-ui/public/kimi-banner-light.svg ADDED
apps/vscode/webview-ui/public/kimi-logo.png ADDED
apps/vscode/webview-ui/src/App.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // node/vscode_extension/webview-ui/src/App.tsx
2
+ import { useEffect, useState, useCallback } from "react";
3
+ import { useQueryClient } from "@tanstack/react-query";
4
+ import { Header } from "./components/Header";
5
+ import { ChatArea } from "./components/ChatArea";
6
+ import { InputArea } from "./components/inputarea/InputArea";
7
+ import { MCPServersModal } from "./components/MCPServersModal";
8
+ import { WorkDirModal } from "./components/WorkDirModal";
9
+ import { ConfigErrorScreen } from "./components/ConfigErrorScreen";
10
+ import { LoginScreen } from "./components/LoginScreen";
11
+ import { Toaster, toast } from "./components/ui/sonner";
12
+ import { useChatStore, useSettingsStore } from "./stores";
13
+ import { bridge, Events } from "./services";
14
+ import { useAppInit, resolveAppView } from "./hooks/useAppInit";
15
+ import { isPreflightError } from "shared/errors";
16
+ import type { UIStreamEvent, StreamError, ExtensionConfig } from "shared/types";
17
+ import "./styles/index.css";
18
+
19
+ function MainContent({ onAuthAction }: { onAuthAction: () => void }) {
20
+ const { processEvent, startNewConversation, sessionId } = useChatStore();
21
+ const { setExtensionConfig, extensionConfig } = useSettingsStore();
22
+ const queryClient = useQueryClient();
23
+
24
+ useEffect(() => {
25
+ return bridge.on(Events.StreamEvent, (event: UIStreamEvent) => {
26
+ // 只有当前已有 session 时才过滤,确保 session_start 能正常处理
27
+ if (sessionId && "_sessionId" in event && event._sessionId && event._sessionId !== sessionId) {
28
+ console.log("Ignored stream event from another session:", event._sessionId);
29
+ return;
30
+ }
31
+ processEvent(event);
32
+ if (event.type === "error") {
33
+ const streamError = event as StreamError;
34
+ if (isPreflightError(streamError.code || "UNKNOWN")) {
35
+ toast.error(streamError.message);
36
+ }
37
+ }
38
+ });
39
+ }, [processEvent, sessionId]);
40
+
41
+ useEffect(() => {
42
+ const unsubs = [
43
+ bridge.on(Events.MCPServersChanged, () => void queryClient.invalidateQueries({ queryKey: ["mcpServers"] })),
44
+ bridge.on(Events.ExtensionConfigChanged, ({ config }: { config: ExtensionConfig }) => setExtensionConfig(config)),
45
+ bridge.on(Events.FocusInput, () => document.querySelector<HTMLTextAreaElement>("textarea")?.focus()),
46
+ bridge.on(Events.NewConversation, () => {
47
+ void startNewConversation().catch((error: unknown) => {
48
+ toast.error(error instanceof Error ? error.message : String(error));
49
+ });
50
+ }),
51
+ ];
52
+ return () => unsubs.forEach((u) => u());
53
+ }, [queryClient, setExtensionConfig, startNewConversation]);
54
+
55
+ useEffect(() => {
56
+ if (!extensionConfig.enableNewConversationShortcut) return;
57
+ const handler = (e: KeyboardEvent) => {
58
+ if ((e.metaKey || e.ctrlKey) && e.key === "n") {
59
+ e.preventDefault();
60
+ void startNewConversation().catch((error: unknown) => {
61
+ toast.error(error instanceof Error ? error.message : String(error));
62
+ });
63
+ }
64
+ };
65
+ window.addEventListener("keydown", handler);
66
+ return () => window.removeEventListener("keydown", handler);
67
+ }, [extensionConfig.enableNewConversationShortcut, startNewConversation]);
68
+
69
+ return (
70
+ <>
71
+ <div className="flex-1 min-h-0 relative group/chat">
72
+ <ChatArea />
73
+ </div>
74
+ <div className="shrink-0 max-h-[80vh] flex flex-col min-h-0">
75
+ <InputArea onAuthAction={onAuthAction} />
76
+ </div>
77
+ <MCPServersModal />
78
+ <WorkDirModal />
79
+ </>
80
+ );
81
+ }
82
+
83
+ export default function App() {
84
+ const { status, errorMessage, modelsCount, refresh } = useAppInit();
85
+ const [skippedLogin, setSkippedLogin] = useState(false);
86
+ const [showLogin, setShowLogin] = useState(false);
87
+
88
+ const handleLoginSuccess = useCallback(() => {
89
+ setShowLogin(false);
90
+ setSkippedLogin(false);
91
+ refresh();
92
+ }, [refresh]);
93
+
94
+ const handleSkip = useCallback(() => {
95
+ setShowLogin(false);
96
+ setSkippedLogin(true);
97
+ }, []);
98
+
99
+ const handleShowLogin = useCallback(() => {
100
+ setSkippedLogin(false);
101
+ setShowLogin(true);
102
+ }, []);
103
+
104
+ const handleAuthAction = useCallback(() => {
105
+ setSkippedLogin(false);
106
+ setShowLogin(false);
107
+ refresh();
108
+ }, [refresh]);
109
+
110
+ const resolution = resolveAppView({ status, modelsCount, skippedLogin, showLogin });
111
+
112
+ // 登录界面:未登录且未跳过,或用户从其他界面主动选择登录
113
+ if (resolution.view === "login") {
114
+ return (
115
+ <div className="flex flex-col h-screen text-foreground overflow-hidden">
116
+ <Header />
117
+ <LoginScreen onLoginSuccess={handleLoginSuccess} onSkip={handleSkip} />
118
+ <Toaster position="top-center" />
119
+ </div>
120
+ );
121
+ }
122
+
123
+ // 错误与设置状态界面;no-models 必须保留回到登录界面的入口
124
+ if (resolution.view === "status") {
125
+ return (
126
+ <div className="flex flex-col h-screen text-foreground overflow-hidden">
127
+ <Header />
128
+ <ConfigErrorScreen
129
+ type={resolution.status}
130
+ errorMessage={errorMessage}
131
+ onRefresh={refresh}
132
+ onBackToLogin={resolution.canGoToLogin ? handleShowLogin : undefined}
133
+ />
134
+ <Toaster position="top-center" />
135
+ </div>
136
+ );
137
+ }
138
+
139
+ // 正常状态
140
+ return (
141
+ <div className="flex flex-col h-screen text-foreground overflow-hidden">
142
+ <Header />
143
+ <MainContent onAuthAction={handleAuthAction} />
144
+ <Toaster position="top-center" />
145
+ </div>
146
+ );
147
+ }
apps/vscode/webview-ui/src/components/ActionMenu.tsx ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { IconSettings, IconServer, IconLogout, IconLogin, IconLoader2, IconRefresh, IconFileText, IconFolder } from "@tabler/icons-react";
3
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Separator } from "@/components/ui/separator";
6
+ import { useSettingsStore } from "@/stores";
7
+ import { bridge } from "@/services";
8
+ import { toast } from "@/components/ui/sonner";
9
+ import { cn } from "@/lib/utils";
10
+
11
+ interface ActionMenuProps {
12
+ className?: string;
13
+ onAuthAction?: () => void;
14
+ }
15
+
16
+ function MenuSection({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
17
+ return (
18
+ <div className="py-1">
19
+ <div className="px-2.5 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider flex items-center justify-between">
20
+ <span>{title}</span>
21
+ {subtitle && <span className="normal-case tracking-normal">{subtitle}</span>}
22
+ </div>
23
+ {children}
24
+ </div>
25
+ );
26
+ }
27
+
28
+ function MenuItem({ onClick, disabled, danger, children }: { onClick: () => void; disabled?: boolean; danger?: boolean; children: React.ReactNode }) {
29
+ return (
30
+ <button
31
+ onClick={onClick}
32
+ disabled={disabled}
33
+ className={cn(
34
+ "w-full flex items-center gap-2 px-2.5 py-1.5 text-xs hover:bg-accent transition-colors text-left cursor-pointer",
35
+ disabled && "opacity-50 cursor-not-allowed",
36
+ danger && "text-red-500 hover:text-red-600",
37
+ )}
38
+ >
39
+ {children}
40
+ </button>
41
+ );
42
+ }
43
+
44
+ export function ActionMenu({ className, onAuthAction }: ActionMenuProps) {
45
+ const [open, setOpen] = useState(false);
46
+ const [loading, setLoading] = useState(false);
47
+ const { setMCPModalOpen, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore();
48
+
49
+ const handleOpenSettings = () => {
50
+ void bridge.openSettings();
51
+ setOpen(false);
52
+ };
53
+
54
+ const handleOpenMCPServers = () => {
55
+ setMCPModalOpen(true);
56
+ setOpen(false);
57
+ };
58
+
59
+ const handleChangeWorkDir = () => {
60
+ useSettingsStore.getState().setWorkDirModalOpen(true);
61
+ setOpen(false);
62
+ };
63
+
64
+ const handleReset = () => {
65
+ setOpen(false);
66
+ void bridge.reloadWebview();
67
+ };
68
+
69
+ const handleShowLogs = () => {
70
+ void bridge.showLogs();
71
+ setOpen(false);
72
+ };
73
+
74
+ const handleAuthAction = async () => {
75
+ setLoading(true);
76
+ try {
77
+ if (isLoggedIn) {
78
+ await bridge.logout();
79
+ setIsLoggedIn(false);
80
+ } else {
81
+ const result = await bridge.login();
82
+ if (result.success) {
83
+ setIsLoggedIn(true);
84
+ } else {
85
+ toast.error(result.error ?? "Sign-in failed. Check the logs for details.");
86
+ }
87
+ }
88
+ } finally {
89
+ setLoading(false);
90
+ setOpen(false);
91
+ }
92
+ onAuthAction?.();
93
+ };
94
+
95
+ return (
96
+ <Popover open={open} onOpenChange={setOpen}>
97
+ <PopoverTrigger asChild>
98
+ <Button variant="ghost" size="icon-xs" className={cn("text-muted-foreground", className)}>
99
+ <IconSettings className="size-3.5" />
100
+ </Button>
101
+ </PopoverTrigger>
102
+ <PopoverContent className="w-[calc(100vw-1rem)] max-w-72 p-1.5 gap-0!" align="end" side="top">
103
+ <MenuSection title="Settings">
104
+ <MenuItem onClick={handleChangeWorkDir}>
105
+ <IconFolder className="size-4 text-muted-foreground" />
106
+ <span className="flex-1">Working Directory</span>
107
+ </MenuItem>
108
+ <MenuItem onClick={handleOpenMCPServers}>
109
+ <IconServer className="size-4 text-muted-foreground" />
110
+ <span className="flex-1">MCP Servers</span>
111
+ </MenuItem>
112
+ <MenuItem onClick={handleOpenSettings}>
113
+ <IconSettings className="size-4 text-muted-foreground" />
114
+ <span className="flex-1">General Config</span>
115
+ <span className="text-[10px] text-muted-foreground">↗</span>
116
+ </MenuItem>
117
+ </MenuSection>
118
+
119
+ <Separator className="my-px" />
120
+
121
+ <MenuSection title="Support" subtitle={extensionConfig.version ? `v${extensionConfig.version}` : undefined}>
122
+ <MenuItem onClick={handleShowLogs}>
123
+ <IconFileText className="size-4 text-muted-foreground" />
124
+ <span className="flex-1">Show Logs</span>
125
+ </MenuItem>
126
+ <MenuItem onClick={handleReset}>
127
+ <IconRefresh className="size-4 text-muted-foreground" />
128
+ <span className="flex-1">Reset Kimi</span>
129
+ </MenuItem>
130
+ </MenuSection>
131
+
132
+ <Separator className="my-px" />
133
+
134
+ <MenuSection title="Account">
135
+ <MenuItem
136
+ onClick={() => {
137
+ void handleAuthAction();
138
+ }}
139
+ disabled={loading}
140
+ danger={isLoggedIn}
141
+ >
142
+ {loading ? <IconLoader2 className="size-4 animate-spin" /> : isLoggedIn ? <IconLogout className="size-4" /> : <IconLogin className="size-4 text-muted-foreground" />}
143
+ <span className="flex-1">{loading ? "Processing…" : isLoggedIn ? "Sign out" : "Sign in"}</span>
144
+ </MenuItem>
145
+ </MenuSection>
146
+ </PopoverContent>
147
+ </Popover>
148
+ );
149
+ }
apps/vscode/webview-ui/src/components/ApprovalDialog.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { IconChevronDown, IconChevronUp } from "@tabler/icons-react";
3
+ import { useApprovalStore } from "@/stores";
4
+ import { DisplayBlocks } from "./DisplayBlocks";
5
+ import { cn } from "@/lib/utils";
6
+ import type { ApprovalResponse } from "shared/legacy-sdk";
7
+
8
+ export function ApprovalDialog() {
9
+ const { pending, respondToRequest } = useApprovalStore();
10
+ const [selectedIndex, setSelectedIndex] = useState(1);
11
+ const [expanded, setExpanded] = useState(false);
12
+
13
+ const req = pending[0];
14
+
15
+ // Auto-expand if there's a diff block (code change)
16
+ useEffect(() => {
17
+ if (req) {
18
+ const hasDiff = req.display?.some((b) => b.type === "diff") ?? false;
19
+ setExpanded(hasDiff);
20
+ }
21
+ }, [req?.id]);
22
+
23
+ if (!req) return null;
24
+ const hasDisplay = req.display && req.display.length > 0;
25
+
26
+ const handleResponse = async (response: ApprovalResponse) => {
27
+ await respondToRequest(req.id, response);
28
+ setSelectedIndex(1);
29
+ setExpanded(false);
30
+ };
31
+
32
+ const options = [
33
+ { key: "approve", label: "Yes", index: 1 },
34
+ { key: "approve_for_session", label: "Yes, for this session", index: 2 },
35
+ { key: "reject", label: "No", index: 3 },
36
+ ] as const;
37
+
38
+ return (
39
+ <div className={cn("mb-0.5 border border-blue-200 dark:border-blue-800 rounded-lg overflow-hidden bg-background flex flex-col shrink")}>
40
+ <div className="p-2 space-y-2 flex-1 min-h-0 overflow-hidden flex flex-col">
41
+ <div className="flex items-center justify-between shrink-0">
42
+ <div className="text-xs font-semibold text-foreground">Allow this {req.action.toLowerCase()}?</div>
43
+ {hasDisplay && (
44
+ <button onClick={() => setExpanded(!expanded)} className="p-1 hover:bg-muted rounded transition-colors">
45
+ {expanded ? <IconChevronDown className="size-4 text-muted-foreground" /> : <IconChevronUp className="size-4 text-muted-foreground" />}
46
+ </button>
47
+ )}
48
+ </div>
49
+
50
+ <div className="text-xs text-foreground/90 break-all leading-relaxed bg-muted/30 py-2 px-2 rounded shrink-0 max-h-20 overflow-y-auto font-mono">{req.description}</div>
51
+
52
+ {hasDisplay && (
53
+ <div className={cn("overflow-y-auto", expanded ? "flex-1 min-h-0" : "max-h-24")}>
54
+ <DisplayBlocks blocks={req.display} maxHeight={expanded ? "max-h-none" : "max-h-20"} />
55
+ </div>
56
+ )}
57
+
58
+ <div className="text-xs text-muted-foreground shrink-0">{req.sender}</div>
59
+
60
+ <div className="space-y-1.5 pt-1 shrink-0">
61
+ {options.map((opt) => (
62
+ <button
63
+ key={opt.key}
64
+ onClick={() => {
65
+ void handleResponse(opt.key);
66
+ }}
67
+ onMouseEnter={() => setSelectedIndex(opt.index)}
68
+ className={cn(
69
+ "w-full text-left px-2 py-1 rounded-md text-xs transition-colors",
70
+ "border border-border cursor-pointer",
71
+ selectedIndex === opt.index ? "bg-blue-500 text-white border-blue-500" : "bg-background hover:bg-muted/50",
72
+ )}
73
+ >
74
+ <span className={cn("mr-2", selectedIndex === opt.index ? "text-blue-200" : "text-muted-foreground")}>{opt.index}</span>
75
+ <span className="font-medium">{opt.label}</span>
76
+ </button>
77
+ ))}
78
+ </div>
79
+ </div>
80
+ </div>
81
+ );
82
+ }
apps/vscode/webview-ui/src/components/BottomToolbar.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from "react";
2
+ import { IconChevronUp, IconStack2, IconFileCode } from "@tabler/icons-react";
3
+ import { useChatStore } from "@/stores";
4
+ import { bridge, Events } from "@/services";
5
+ import { cn } from "@/lib/utils";
6
+ import { FileChangesPanel } from "./FileChangesPanel";
7
+ import { QueuedMessagesPanel } from "./QueuedMessagesPanel";
8
+ import { ApprovalDialog } from "./ApprovalDialog";
9
+ import { QuestionDialog } from "./QuestionDialog";
10
+ import type { FileChange } from "shared/types";
11
+
12
+ type TabId = "queue" | "changes" | null;
13
+
14
+ export function BottomToolbar() {
15
+ const { queue } = useChatStore();
16
+ const [activeTab, setActiveTab] = useState<TabId>(null);
17
+ const [fileChanges, setFileChanges] = useState<FileChange[]>([]);
18
+
19
+ useEffect(() => {
20
+ return bridge.on<FileChange[]>(Events.FileChangesUpdated, setFileChanges);
21
+ }, []);
22
+
23
+ // Auto-close tab when data becomes empty
24
+ useEffect(() => {
25
+ if (activeTab === "queue" && queue.length === 0) {
26
+ setActiveTab(null);
27
+ }
28
+ if (activeTab === "changes" && fileChanges.length === 0) {
29
+ setActiveTab(null);
30
+ }
31
+ }, [activeTab, queue.length, fileChanges.length]);
32
+
33
+ const hasQueue = queue.length > 0;
34
+ const hasChanges = fileChanges.length > 0;
35
+ const hasTabs = hasQueue || hasChanges;
36
+
37
+ const toggleTab = (tab: TabId) => {
38
+ setActiveTab((prev) => (prev === tab ? null : tab));
39
+ };
40
+
41
+ const fileStats = fileChanges.reduce((a, c) => ({ additions: a.additions + c.additions, deletions: a.deletions + c.deletions }), { additions: 0, deletions: 0 });
42
+
43
+ return (
44
+ <div className="flex flex-col min-h-0 flex-1 overflow-hidden">
45
+ {/* ApprovalDialog and QuestionDialog - priority, shrink-0 */}
46
+ <ApprovalDialog />
47
+ <QuestionDialog />
48
+
49
+ {/* Queue/Changes panel - takes remaining space */}
50
+ {activeTab && (
51
+ <div className="flex-1 min-h-16 max-h-32 overflow-y-auto mb-0.5 border border-border/60 rounded-md bg-card">
52
+ {activeTab === "queue" && <QueuedMessagesPanel />}
53
+ {activeTab === "changes" && <FileChangesPanel changes={fileChanges} />}
54
+ </div>
55
+ )}
56
+
57
+ {/* Tab bar - always at bottom, shrink-0 */}
58
+ {hasTabs && (
59
+ <div className="shrink-0 mb-0.5 rounded-md overflow-hidden bg-card">
60
+ <div className="flex items-center gap-1 py-0.5 min-h-7">
61
+ {hasQueue && (
62
+ <button
63
+ onClick={() => toggleTab("queue")}
64
+ className={cn(
65
+ "flex items-center gap-1.5 px-2 py-0.5 rounded text-xs transition-colors",
66
+ activeTab === "queue" ? "bg-accent text-accent-foreground" : "hover:bg-muted/50 text-muted-foreground",
67
+ )}
68
+ >
69
+ <IconStack2 className="size-3.5" />
70
+ <span>{queue.length} Queued</span>
71
+ <IconChevronUp className={cn("size-3 transition-transform", activeTab === "queue" && "rotate-180")} />
72
+ </button>
73
+ )}
74
+
75
+ {hasChanges && (
76
+ <button
77
+ onClick={() => toggleTab("changes")}
78
+ className={cn(
79
+ "flex items-center gap-1.5 px-2 py-0.5 rounded text-xs transition-colors",
80
+ activeTab === "changes" ? "bg-accent text-accent-foreground" : "hover:bg-muted/50 text-muted-foreground",
81
+ )}
82
+ >
83
+ <IconFileCode className="size-3.5" />
84
+ <span>{fileChanges.length} Changed</span>
85
+ <span className="text-[10px] tabular-nums">
86
+ <span className="text-green-600 dark:text-green-400">+{fileStats.additions}</span> <span className="text-red-600 dark:text-red-400">-{fileStats.deletions}</span>
87
+ </span>
88
+ <IconChevronUp className={cn("size-3 transition-transform", activeTab === "changes" && "rotate-180")} />
89
+ </button>
90
+ )}
91
+ </div>
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
apps/vscode/webview-ui/src/components/ChatArea.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ScrollToBottom, { useScrollToBottom, useSticky } from "react-scroll-to-bottom";
2
+ import { IconArrowDown } from "@tabler/icons-react";
3
+ import { ChatMessage } from "./ChatMessage";
4
+ import { WelcomeScreen } from "./WelcomeScreen";
5
+ import { useChatStore } from "@/stores";
6
+ import { cn } from "@/lib/utils";
7
+ import { getForkTurnIndex } from "shared/fork-turn-index";
8
+
9
+ function ScrollButton() {
10
+ const scrollToBottom = useScrollToBottom();
11
+ const [sticky] = useSticky();
12
+
13
+ if (sticky) return null;
14
+
15
+ return (
16
+ <button
17
+ onClick={() => scrollToBottom()}
18
+ className={cn("absolute bottom-4 right-4 p-2 rounded-full z-10", "bg-blue-400 text-white shadow-lg", "hover:bg-blue-600 transition-all")}
19
+ >
20
+ <IconArrowDown className="size-4" />
21
+ </button>
22
+ );
23
+ }
24
+
25
+ function MessageList() {
26
+ const messages = useChatStore((s) => s.messages);
27
+ const isStreaming = useChatStore((s) => s.isStreaming);
28
+
29
+ return (
30
+ <>
31
+ <div className="">
32
+ {messages.map((message, idx) => (
33
+ <ChatMessage
34
+ key={message.id}
35
+ message={message}
36
+ turnIndex={getForkTurnIndex(messages, idx)}
37
+ isStreaming={isStreaming && idx === messages.length - 1 && message.role === "assistant"}
38
+ />
39
+ ))}
40
+ </div>
41
+ <ScrollButton />
42
+ </>
43
+ );
44
+ }
45
+
46
+ export function ChatArea() {
47
+ const messageCount = useChatStore((s) => s.messages.length);
48
+
49
+ if (messageCount === 0) {
50
+ return (
51
+ <div className="h-full flex items-center justify-center relative">
52
+ <WelcomeScreen />
53
+ </div>
54
+ );
55
+ }
56
+
57
+ return (
58
+ <div className="h-full relative">
59
+ <ScrollToBottom className="h-full" scrollViewClassName="h-full overflow-y-auto overflow-x-hidden" followButtonClassName="hidden" initialScrollBehavior="auto">
60
+ <MessageList />
61
+ </ScrollToBottom>
62
+ </div>
63
+ );
64
+ }
apps/vscode/webview-ui/src/components/ChatMessage.tsx ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, Fragment, memo } from "react";
2
+ import { IconLoader3, IconGitFork } from "@tabler/icons-react";
3
+ import { cn } from "@/lib/utils";
4
+ import { Content } from "@/lib/content";
5
+ import { Markdown } from "./Markdown";
6
+ import { ToolCallCard } from "./ToolRenderers";
7
+ import { CopyButton } from "./CopyButton";
8
+ import { ThinkingBlock } from "./ThinkingBlock";
9
+ import { CompactionCard } from "./CompactionCard";
10
+ import { MediaThumbnail } from "./MediaThumbnail";
11
+ import { MediaPreviewModal } from "./MediaPreviewModal";
12
+ import { InlineError } from "./InlineError";
13
+ import { PlanCard } from "./PlanCard";
14
+ import { StreamingConfirmDialog } from "./StreamingConfirmDialog";
15
+ import { Button } from "@/components/ui/button";
16
+ import { toast } from "@/components/ui/sonner";
17
+ import { useChatStore } from "@/stores";
18
+ import { bridge } from "@/services";
19
+ import type { ChatMessage as ChatMessageType, UIStep, UIStepItem } from "@/stores/chat.store";
20
+ import type { ContentPart } from "shared/legacy-sdk";
21
+
22
+ interface ChatMessageProps {
23
+ message: ChatMessageType;
24
+ /** 0-indexed turn number for this message */
25
+ turnIndex?: number;
26
+ isStreaming?: boolean;
27
+ }
28
+
29
+ function ThinkingIndicator() {
30
+ return (
31
+ <div className="flex items-center gap-2 mt-1 text-blue-500/80 py-1">
32
+ <IconLoader3 className="size-3.5 animate-spin" />
33
+ <span className="text-[11px] font-medium tracking-wide">Processing…</span>
34
+ </div>
35
+ );
36
+ }
37
+
38
+ function SteerBubble({ content }: { content: string | ContentPart[] }) {
39
+ const text = typeof content === "string" ? content : Content.getText(content);
40
+ return (
41
+ <div className="flex justify-end my-1">
42
+ <div className="max-w-[85%] px-3 py-1 rounded-2xl rounded-br-md bg-zinc-100 dark:bg-zinc-800 text-foreground">
43
+ <p className="text-xs leading-relaxed">{text}</p>
44
+ </div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ function StepItemRenderer({ item }: { item: UIStepItem }) {
50
+ switch (item.type) {
51
+ case "thinking":
52
+ return <ThinkingBlock content={item.content} finished={item.finished} />;
53
+ case "text":
54
+ return <Markdown content={item.content} className="text-xs leading-relaxed" enableEnrichment={item.finished === true} />;
55
+ case "tool_use":
56
+ return <ToolCallCard call={item.call} result={item.result} subagentSteps={item.subagent_steps} />;
57
+ case "compaction":
58
+ return <CompactionCard />;
59
+ case "steer":
60
+ return <SteerBubble content={item.content} />;
61
+ default:
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function StepContent({ step, showConnector }: { step: UIStep; showConnector?: boolean }) {
67
+ const hasItems = step.items.length > 0;
68
+ const hasToolOrThinking = step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction");
69
+ const showIndicator = hasToolOrThinking;
70
+ const hasActiveItem = step.items.some((item) => (item.type === "text" || item.type === "thinking") && !item.finished);
71
+
72
+ if (!hasItems) {
73
+ return null;
74
+ }
75
+
76
+ return (
77
+ <div className="flex gap-2">
78
+ {showIndicator ? (
79
+ <div className="hidden @[420px]:flex shrink-0 w-5 flex-col items-center relative">
80
+ <div
81
+ className={cn("size-1.5 rounded-full mt-2 shrink-0 relative z-10", hasActiveItem ? "bg-blue-400 shadow-[0_0_8px_rgba(59,130,246,0.6)] animate-pulse" : "bg-blue-400")}
82
+ />
83
+ {showConnector && (
84
+ <div
85
+ className={cn(
86
+ "absolute left-1/2 w-px",
87
+ hasActiveItem ? "bg-gradient-to-b from-zinc-300 to-transparent dark:from-zinc-600 dark:to-transparent" : "bg-zinc-300 dark:bg-zinc-600",
88
+ )}
89
+ style={{ top: "calc(0.5rem + 0.1875rem)", bottom: "calc(-0.75rem - 0.5rem - 0.1875rem)", transform: "translateX(-50%)" }}
90
+ />
91
+ )}
92
+ </div>
93
+ ) : (
94
+ <div className="hidden @[420px]:block shrink-0 w-5" />
95
+ )}
96
+ <div className="flex-1 min-w-0 space-y-2">
97
+ {step.items.map((item, idx) => (
98
+ <StepItemRenderer key={`${step.n}-${idx}`} item={item} />
99
+ ))}
100
+ </div>
101
+ </div>
102
+ );
103
+ }
104
+
105
+ function MessageMedia({ images, videos, onPreview }: { images: string[]; videos: string[]; onPreview: (src: string) => void }) {
106
+ if (images.length === 0 && videos.length === 0) {
107
+ return null;
108
+ }
109
+ return (
110
+ <div className="flex flex-wrap gap-2 my-2">
111
+ {images.map((src, idx) => (
112
+ <MediaThumbnail key={`img-${idx}`} src={src} size="md" onClick={() => onPreview(src)} />
113
+ ))}
114
+ {videos.map((src, idx) => (
115
+ <MediaThumbnail key={`vid-${idx}`} src={src} size="md" onClick={() => onPreview(src)} />
116
+ ))}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ interface StepGroup {
122
+ planMode: boolean;
123
+ steps: UIStep[];
124
+ startIndex: number;
125
+ }
126
+
127
+ function groupStepsByPlanMode(steps: UIStep[]): StepGroup[] {
128
+ const groups: StepGroup[] = [];
129
+ for (let i = 0; i < steps.length; i++) {
130
+ const isPlan = steps[i].planMode === true;
131
+ const last = groups.at(-1);
132
+ if (last && last.planMode === isPlan) {
133
+ last.steps.push(steps[i]);
134
+ } else {
135
+ groups.push({ planMode: isPlan, steps: [steps[i]], startIndex: i });
136
+ }
137
+ }
138
+ return groups;
139
+ }
140
+
141
+ interface ForkButtonProps {
142
+ turnIndex: number;
143
+ className?: string;
144
+ }
145
+
146
+ function ForkButton({ turnIndex, className }: ForkButtonProps) {
147
+ const [showConfirm, setShowConfirm] = useState(false);
148
+ const [isForking, setIsForking] = useState(false);
149
+ const sessionId = useChatStore((s) => s.sessionId);
150
+ const isStreaming = useChatStore((s) => s.isStreaming);
151
+ const loadSession = useChatStore((s) => s.loadSession);
152
+
153
+ const handleFork = () => {
154
+ if (!sessionId || turnIndex < 0) return;
155
+ setShowConfirm(true);
156
+ };
157
+
158
+ const doFork = async () => {
159
+ if (!sessionId) return;
160
+
161
+ setIsForking(true);
162
+ try {
163
+ const result = await bridge.forkSession(sessionId, turnIndex);
164
+ if (result) {
165
+ // Load the forked session
166
+ const events = await bridge.loadSessionHistory(result.sessionId);
167
+ await loadSession(result.sessionId, events);
168
+ }
169
+ } catch (error) {
170
+ toast.error(`Failed to fork conversation: ${error instanceof Error ? error.message : String(error)}`);
171
+ } finally {
172
+ setIsForking(false);
173
+ setShowConfirm(false);
174
+ }
175
+ };
176
+
177
+ return (
178
+ <>
179
+ <Button
180
+ variant="ghost"
181
+ size="icon-xs"
182
+ className={cn(
183
+ "h-5 w-5 text-muted-foreground hover:text-foreground transition-all border-0! hover:bg-zinc-200 dark:hover:bg-zinc-800 cursor-pointer",
184
+ isForking && "opacity-50 pointer-events-none",
185
+ className,
186
+ )}
187
+ onClick={handleFork}
188
+ disabled={isForking || !sessionId}
189
+ title="Fork conversation from this point"
190
+ >
191
+ <IconGitFork className="size-3.5" />
192
+ </Button>
193
+
194
+ <StreamingConfirmDialog
195
+ open={showConfirm}
196
+ onOpenChange={setShowConfirm}
197
+ title="Fork Conversation"
198
+ description={
199
+ isStreaming
200
+ ? "The current conversation is still generating a response. Forking will stop the generation and create a new conversation from this point. Continue?"
201
+ : "This will create a new conversation branching from this point. All messages after this turn will be removed in the forked conversation. Continue?"
202
+ }
203
+ confirmLabel="Fork"
204
+ onConfirm={() => { void doFork(); }}
205
+ confirmLoading={isForking}
206
+ confirmDisabled={isForking}
207
+ cancelDisabled={isForking}
208
+ />
209
+ </>
210
+ );
211
+ }
212
+
213
+ function UserMessage({ message }: { message: ChatMessageType }) {
214
+ const [previewMedia, setPreviewMedia] = useState<string | null>(null);
215
+ const displayContent = Content.getText(message.content);
216
+ const images = Content.getImages(message.content);
217
+ const videos = Content.getVideos(message.content);
218
+
219
+ return (
220
+ <div className="px-3 pt-3 pb-1 flex justify-end">
221
+ <div className={cn("max-w-[85%] px-3.5 py-1.5 rounded-2xl rounded-br-md", "bg-zinc-100 dark:bg-zinc-800", "text-foreground")}>
222
+ {displayContent && (
223
+ // FIX: removed whitespace-pre-wrap — it conflicted with ReactMarkdown's
224
+ // block-level elements (<p>, <ol>, <li>), doubling vertical spacing.
225
+ // ReactMarkdown already handles paragraph breaks from \n\n.
226
+ <div className="text-xs leading-relaxed wrap-break-word">
227
+ <Markdown content={displayContent} enableEnrichment enableLocalImageRender={false} />
228
+ </div>
229
+ )}
230
+ <MessageMedia images={images} videos={videos} onPreview={setPreviewMedia} />
231
+ </div>
232
+ <MediaPreviewModal src={previewMedia} onClose={() => setPreviewMedia(null)} />
233
+ </div>
234
+ );
235
+ }
236
+
237
+ function AssistantMessage({ message, turnIndex, isStreaming }: { message: ChatMessageType; turnIndex?: number; isStreaming?: boolean }) {
238
+ const [previewMedia, setPreviewMedia] = useState<string | null>(null);
239
+ const isCompacting = useChatStore((s) => s.isCompacting);
240
+
241
+ const steps = message.steps || [];
242
+ const hasSteps = steps.length > 0;
243
+ const images = Content.getImages(message.content);
244
+ const videos = Content.getVideos(message.content);
245
+
246
+ const stepHasIndicator = steps.map((step) => step.items.some((item) => item.type === "tool_use" || item.type === "thinking" || item.type === "compaction"));
247
+
248
+ const contentToCopy = (() => {
249
+ if (!hasSteps) {
250
+ return typeof message.content === "string" ? message.content : "";
251
+ }
252
+ const lastStep = steps[steps.length - 1];
253
+ const textItems = lastStep.items.filter((item) => item.type === "text");
254
+ if (textItems.length > 0) {
255
+ return textItems.map((item) => (item as { type: "text"; content: string }).content).join("\n");
256
+ }
257
+ return typeof message.content === "string" ? message.content : "";
258
+ })();
259
+
260
+ if (!isStreaming && !hasMessageContent(message) && !message.inlineError) {
261
+ return null;
262
+ }
263
+
264
+ const displayContent = typeof message.content === "string" ? message.content : "";
265
+ const isShowingInlineError = message.inlineError && !isStreaming;
266
+
267
+ return (
268
+ <div className="@container px-3 py-3 group/message">
269
+ <div className="flex gap-3 flex-col">
270
+ <div className="flex flex-row items-center justify-start gap-2">
271
+ <div className="shrink-0 size-5 rounded flex items-center justify-center text-[10px] font-medium bg-blue-500 text-white">K</div>
272
+ <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">Kimi</div>
273
+ </div>
274
+
275
+ <div className="flex-1 min-w-0">
276
+ <div className="flex flex-col">
277
+ <div className="[&>*:not(:last-child)]:mb-3">
278
+ {hasSteps &&
279
+ groupStepsByPlanMode(steps).map((group, gi) => {
280
+ const totalSteps = steps.length;
281
+ const stepsContent = group.steps.map((step, i) => {
282
+ const globalIndex = group.startIndex + i;
283
+ const isLastInGroup = i === group.steps.length - 1;
284
+ const isLastOverall = globalIndex === totalSteps - 1;
285
+ const hasIndicator = stepHasIndicator[globalIndex];
286
+ const hasNextIndicator = stepHasIndicator.slice(globalIndex + 1).some(Boolean);
287
+ const showConnector = hasIndicator && hasNextIndicator && !isLastInGroup && !isLastOverall;
288
+ return <StepContent key={step.n} step={step} showConnector={showConnector} />;
289
+ });
290
+
291
+ if (group.planMode) {
292
+ return <PlanCard key={`plan-${gi}`}>{stepsContent}</PlanCard>;
293
+ }
294
+ return <Fragment key={`normal-${gi}`}>{stepsContent}</Fragment>;
295
+ })}
296
+ {!hasSteps && displayContent && <Markdown content={displayContent} className="text-xs leading-relaxed @[420px]:pl-5" enableEnrichment={!isStreaming} />}
297
+ {(images.length > 0 || videos.length > 0) && (
298
+ <div className="@[420px]:pl-5">
299
+ <MessageMedia images={images} videos={videos} onPreview={setPreviewMedia} />
300
+ </div>
301
+ )}
302
+ </div>
303
+
304
+ {/* 内嵌错误显示 */}
305
+ {isShowingInlineError && message.inlineError && (
306
+ <div className="@[420px]:pl-5">
307
+ <InlineError error={message.inlineError} />
308
+ </div>
309
+ )}
310
+ <div className="flex flex-row items-center space-between">
311
+ <div className="inline-flex flex-1">{isStreaming && !isShowingInlineError && !isCompacting && <ThinkingIndicator />}</div>
312
+ <div className="inline-flex flex-1" />
313
+ {!isStreaming && contentToCopy.trim().length > 0 && (
314
+ <div className="flex justify-start pt-1 gap-1 opacity-0 group-hover/message:opacity-100 transition-opacity duration-100">
315
+ <CopyButton content={contentToCopy} />
316
+ {message.forkable !== false && turnIndex !== undefined && turnIndex >= 0 && <ForkButton turnIndex={turnIndex} />}
317
+ </div>
318
+ )}
319
+ </div>
320
+ </div>
321
+ </div>
322
+ </div>
323
+ <MediaPreviewModal src={previewMedia} onClose={() => setPreviewMedia(null)} />
324
+ </div>
325
+ );
326
+ }
327
+
328
+ function hasMessageContent(message: ChatMessageType): boolean {
329
+ if (!Content.isEmpty(message.content)) {
330
+ return true;
331
+ }
332
+ return message.steps?.some((s) => s.items.length > 0) ?? false;
333
+ }
334
+
335
+ export const ChatMessage = memo(function ChatMessage({ message, turnIndex, isStreaming }: ChatMessageProps) {
336
+ if (message.role === "user") {
337
+ return <UserMessage message={message} />;
338
+ }
339
+ return <AssistantMessage message={message} turnIndex={turnIndex} isStreaming={isStreaming} />;
340
+ });
apps/vscode/webview-ui/src/components/ChatStatus.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useChatStore } from "@/stores";
2
+ import { cn } from "@/lib/utils";
3
+ import { IconArrowUp, IconArrowDown, IconBrandSpeedtest, IconRefresh } from "@tabler/icons-react";
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
5
+
6
+ export function TokenInfo() {
7
+ const { lastStatus, tokenUsage, activeTokenUsage } = useChatStore();
8
+
9
+ const inputTotal =
10
+ tokenUsage.input_other +
11
+ tokenUsage.input_cache_read +
12
+ tokenUsage.input_cache_creation +
13
+ activeTokenUsage.input_other +
14
+ activeTokenUsage.input_cache_read +
15
+ activeTokenUsage.input_cache_creation;
16
+
17
+ const outputTotal = tokenUsage.output + activeTokenUsage.output;
18
+
19
+ const contextPercent = lastStatus?.context_usage ? Math.round(lastStatus.context_usage * 1000) / 10 : 0;
20
+
21
+ return (
22
+ <div className="space-y-2">
23
+ <div className="text-[10px] text-muted-foreground uppercase tracking-wider">Token Usage</div>
24
+ <div className="grid grid-cols-3 gap-2 text-xs">
25
+ <div className="flex flex-col">
26
+ <span className="text-muted-foreground text-[10px]">Context</span>
27
+ <span className={cn(contextPercent > 80 && "text-amber-500", contextPercent > 95 && "text-destructive")}>{contextPercent}%</span>
28
+ </div>
29
+ <div className="flex flex-col">
30
+ <span className="text-muted-foreground text-[10px]">Input</span>
31
+ <span>{inputTotal.toLocaleString()}</span>
32
+ </div>
33
+ <div className="flex flex-col">
34
+ <span className="text-muted-foreground text-[10px]">Output</span>
35
+ <span>{outputTotal.toLocaleString()}</span>
36
+ </div>
37
+ </div>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export function ChatStatus() {
43
+ const { lastStatus, tokenUsage, activeTokenUsage } = useChatStore();
44
+
45
+ if (!lastStatus) {
46
+ return null;
47
+ }
48
+
49
+ const { context_usage } = lastStatus;
50
+ const retrying = lastStatus.retrying;
51
+
52
+ const inputTotal =
53
+ tokenUsage.input_other +
54
+ tokenUsage.input_cache_read +
55
+ tokenUsage.input_cache_creation +
56
+ activeTokenUsage.input_other +
57
+ activeTokenUsage.input_cache_read +
58
+ activeTokenUsage.input_cache_creation;
59
+
60
+ const outputTotal = tokenUsage.output + activeTokenUsage.output;
61
+
62
+ const contextPercent = context_usage ? Math.round(context_usage * 1000) / 10 : 0;
63
+
64
+ return (
65
+ <div className="flex items-center gap-3 text-[10px] text-muted-foreground border border-border/40 rounded-full px-2 py-0.5 select-none h-6 box-border mr-2 @max-[240px]:hidden">
66
+ {retrying && (
67
+ <Tooltip>
68
+ <TooltipTrigger asChild>
69
+ <span className="flex items-center gap-1 text-amber-500">
70
+ <IconRefresh className="size-3" />
71
+ Retry {retrying.next_attempt}/{retrying.max_attempts}
72
+ </span>
73
+ </TooltipTrigger>
74
+ <TooltipContent>
75
+ Retrying in {Math.ceil(retrying.delay_ms / 1000)}s: {retrying.message}
76
+ </TooltipContent>
77
+ </Tooltip>
78
+ )}
79
+ {retrying && <div className="w-px h-3 bg-border/50" />}
80
+ <div className="flex items-center gap-1.5" title="Context Window Usage">
81
+ <Tooltip>
82
+ <TooltipTrigger asChild>
83
+ <span className="flex items-center gap-1.5">
84
+ <IconBrandSpeedtest className="size-3 opacity-70" />
85
+ <span className={cn(contextPercent > 80 && "text-amber-500", contextPercent > 95 && "text-destructive")}>{contextPercent}%</span>
86
+ </span>
87
+ </TooltipTrigger>
88
+ <TooltipContent>Context Window Usage</TooltipContent>
89
+ </Tooltip>
90
+ </div>
91
+ <div className="w-px h-3 bg-border/50 @max-[440px]:hidden" />
92
+ <div className="flex items-center gap-1.5 @max-[440px]:hidden" title="Total Input Tokens">
93
+ <Tooltip>
94
+ <TooltipTrigger asChild>
95
+ <span className="flex items-center gap-1.5">
96
+ <IconArrowUp className="size-3 opacity-70" />
97
+ <span>{inputTotal.toLocaleString()}</span>
98
+ </span>
99
+ </TooltipTrigger>
100
+ <TooltipContent>Total Input Tokens</TooltipContent>
101
+ </Tooltip>
102
+ </div>
103
+ <div className="w-px h-3 bg-border/50 @max-[440px]:hidden" />
104
+ <div className="flex items-center gap-1.5 @max-[440px]:hidden" title="Output Tokens">
105
+ <Tooltip>
106
+ <TooltipTrigger asChild>
107
+ <span className="flex items-center gap-1.5">
108
+ <IconArrowDown className="size-3 opacity-70" />
109
+ <span>{outputTotal.toLocaleString()}</span>
110
+ </span>
111
+ </TooltipTrigger>
112
+ <TooltipContent>Total Output Tokens</TooltipContent>
113
+ </Tooltip>
114
+ </div>
115
+ </div>
116
+ );
117
+ }
apps/vscode/webview-ui/src/components/CompactionCard.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IconLoader2 } from "@tabler/icons-react";
2
+ import { useChatStore } from "@/stores";
3
+
4
+ export function CompactionCard() {
5
+ const isCompacting = useChatStore((s) => s.isCompacting);
6
+
7
+ return (
8
+ <div className="rounded-lg border border-border bg-muted/20 overflow-hidden">
9
+ <div className="flex items-center gap-3 px-3 py-2.5">
10
+ {isCompacting ? (
11
+ <IconLoader2 className="size-4 text-blue-500 animate-spin" />
12
+ ) : (
13
+ <div className="size-4 flex items-center justify-center">
14
+ <div className="size-2 rounded-full bg-emerald-500" />
15
+ </div>
16
+ )}
17
+ <div className="flex-1 min-w-0">
18
+ <div className="text-xs font-medium text-foreground">{isCompacting ? "Compacting context…" : "Context compacted"}</div>
19
+ </div>
20
+ </div>
21
+ </div>
22
+ );
23
+ }
apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import {
3
+ IconAlertTriangle,
4
+ IconArrowLeft,
5
+ IconCheck,
6
+ IconCopy,
7
+ IconFileSettings,
8
+ IconFolderOpen,
9
+ IconLoader2,
10
+ IconRefresh,
11
+ IconTerminal2,
12
+ } from "@tabler/icons-react";
13
+
14
+ import { bridge } from "@/services";
15
+ import { Button } from "@/components/ui/button";
16
+ import { KimiMascot } from "./KimiMascot";
17
+
18
+ interface Props {
19
+ type: "loading" | "runtime-error" | "no-models" | "no-workspace";
20
+ errorMessage?: string | null;
21
+ onRefresh?: () => void;
22
+ onBackToLogin?: () => void;
23
+ }
24
+
25
+ function ErrorDetails({ message }: { message?: string | null }) {
26
+ const [copied, setCopied] = useState(false);
27
+
28
+ if (!message) return null;
29
+
30
+ const copyError = async () => {
31
+ await navigator.clipboard.writeText(message);
32
+ setCopied(true);
33
+ window.setTimeout(() => setCopied(false), 2_000);
34
+ };
35
+
36
+ return (
37
+ <div className="bg-muted/50 rounded-lg p-4 text-left space-y-2">
38
+ <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
39
+ <div className="flex items-center gap-2 min-w-0">
40
+ <IconTerminal2 className="size-4" />
41
+ <span>Error details</span>
42
+ </div>
43
+ <Button
44
+ onClick={() => {
45
+ void copyError();
46
+ }}
47
+ variant="ghost"
48
+ size="xs"
49
+ className="h-6 px-1.5 gap-1 shrink-0"
50
+ >
51
+ {copied ? <IconCheck className="size-3" /> : <IconCopy className="size-3" />}
52
+ {copied ? "Copied" : "Copy"}
53
+ </Button>
54
+ </div>
55
+ <pre className="max-h-36 overflow-auto whitespace-pre-wrap break-words text-xs bg-background rounded px-3 py-2 font-mono text-foreground">{message}</pre>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ function NoModelsContent({ onRefresh, onBackToLogin }: Pick<Props, "onRefresh" | "onBackToLogin">) {
61
+ return (
62
+ <>
63
+ <div className="space-y-2">
64
+ <div className="inline-flex items-center gap-2 text-amber-500">
65
+ <IconAlertTriangle className="size-5" />
66
+ <span className="text-sm font-medium">Model setup required</span>
67
+ </div>
68
+ <p className="text-xs text-muted-foreground">
69
+ Sign in with a Kimi account, or configure a provider and model in your shared Kimi Code <code className="bg-muted px-1 rounded">config.toml</code>.
70
+ </p>
71
+ </div>
72
+
73
+ <div className="bg-muted/50 rounded-lg p-4 text-left space-y-2">
74
+ <div className="flex items-center gap-2 text-xs font-medium">
75
+ <IconFileSettings className="size-4" />
76
+ Shared Kimi Code configuration
77
+ </div>
78
+ <p className="text-xs text-muted-foreground">
79
+ VS Code and the terminal UI use the same Kimi Code home, configuration, credentials, and sessions.
80
+ </p>
81
+ </div>
82
+
83
+ <div className="flex flex-col min-[400px]:flex-row min-[400px]:justify-between gap-2 w-full">
84
+ {onBackToLogin && (
85
+ <Button onClick={onBackToLogin} variant="ghost" size="sm" className="gap-1 text-muted-foreground">
86
+ <IconArrowLeft className="size-3" />
87
+ Back to sign in
88
+ </Button>
89
+ )}
90
+ {onRefresh && (
91
+ <Button onClick={onRefresh} variant="ghost" size="sm" className="gap-1 text-muted-foreground">
92
+ <IconRefresh className="size-3" />
93
+ Reload
94
+ </Button>
95
+ )}
96
+ </div>
97
+ </>
98
+ );
99
+ }
100
+
101
+ export function ConfigErrorScreen({ type, errorMessage, onRefresh, onBackToLogin }: Props) {
102
+ if (type === "loading") {
103
+ return (
104
+ <div className="h-full flex items-center justify-center p-6">
105
+ <div className="text-center space-y-4">
106
+ <KimiMascot className="h-10 mx-auto opacity-50" />
107
+ <div className="inline-flex items-center gap-2 text-muted-foreground">
108
+ <IconLoader2 className="size-4 animate-spin" />
109
+ <span className="text-sm">Starting Kimi Code…</span>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ if (type === "no-workspace") {
117
+ return (
118
+ <div className="h-full flex items-center justify-center p-6">
119
+ <div className="max-w-sm text-center space-y-6">
120
+ <KimiMascot className="h-10 mx-auto opacity-50" />
121
+ <div className="space-y-2">
122
+ <div className="inline-flex items-center gap-2 text-amber-500">
123
+ <IconFolderOpen className="size-5" />
124
+ <span className="text-sm font-medium">No workspace open</span>
125
+ </div>
126
+ <p className="text-xs text-muted-foreground">Open a folder to start using Kimi Code.</p>
127
+ </div>
128
+ <Button
129
+ onClick={() => {
130
+ void bridge.openFolder();
131
+ }}
132
+ className="gap-2"
133
+ >
134
+ <IconFolderOpen className="size-4" />
135
+ Open Folder
136
+ </Button>
137
+ </div>
138
+ </div>
139
+ );
140
+ }
141
+
142
+ if (type === "no-models") {
143
+ return (
144
+ <div className="h-full flex items-center justify-center p-6">
145
+ <div className="max-w-sm text-center space-y-6">
146
+ <KimiMascot className="h-10 mx-auto opacity-50" />
147
+ <NoModelsContent onRefresh={onRefresh} onBackToLogin={onBackToLogin} />
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ return (
154
+ <div className="flex-1 min-h-0 overflow-y-auto p-6">
155
+ <div className="max-w-sm mx-auto text-center space-y-6">
156
+ <KimiMascot className="h-10 mx-auto opacity-50" />
157
+ <div className="space-y-2">
158
+ <div className="inline-flex items-center gap-2 text-red-500">
159
+ <IconAlertTriangle className="size-5" />
160
+ <span className="text-sm font-medium">Kimi Code could not start</span>
161
+ </div>
162
+ <p className="text-xs text-muted-foreground">Check the error below. Full diagnostics are available in the Kimi Code output channel.</p>
163
+ </div>
164
+ <ErrorDetails message={errorMessage} />
165
+ <div className="flex gap-2 justify-center">
166
+ <Button
167
+ onClick={() => {
168
+ void bridge.showLogs();
169
+ }}
170
+ variant="outline"
171
+ size="sm"
172
+ >
173
+ Show Logs
174
+ </Button>
175
+ {onRefresh && (
176
+ <Button onClick={onRefresh} size="sm" className="gap-1">
177
+ <IconRefresh className="size-3" />
178
+ Retry
179
+ </Button>
180
+ )}
181
+ </div>
182
+ </div>
183
+ </div>
184
+ );
185
+ }
apps/vscode/webview-ui/src/components/CopyButton.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { IconCopy, IconCheck } from "@tabler/icons-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { cn } from "@/lib/utils";
5
+
6
+ interface CopyButtonProps {
7
+ content: string;
8
+ className?: string;
9
+ }
10
+
11
+ export function CopyButton({ content, className }: CopyButtonProps) {
12
+ const [isCopied, setIsCopied] = useState(false);
13
+
14
+ const handleCopy = async () => {
15
+ if (!content) return;
16
+
17
+ try {
18
+ await navigator.clipboard.writeText(content);
19
+ setIsCopied(true);
20
+ setTimeout(() => setIsCopied(false), 2000);
21
+ } catch (err) {
22
+ console.error("Failed to copy:", err);
23
+ }
24
+ };
25
+
26
+ if (!content) return null;
27
+
28
+ return (
29
+ <Button
30
+ variant="ghost"
31
+ size="icon-xs"
32
+ className={cn(
33
+ "h-5 w-5 text-muted-foreground hover:text-foreground transition-all border-0! hover:bg-zinc-200 dark:hover:bg-zinc-800 cursor-pointer",
34
+ isCopied && "text-emerald-500 hover:text-emerald-600",
35
+ className,
36
+ )}
37
+ onClick={() => {
38
+ void handleCopy();
39
+ }}
40
+ title="Copy message"
41
+ >
42
+ {isCopied ? <IconCheck className="size-3.5" /> : <IconCopy className="size-3.5" />}
43
+ </Button>
44
+ );
45
+ }
apps/vscode/webview-ui/src/components/DisplayBlocks.tsx ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react";
2
+ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
3
+ import { oneDark, oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
4
+ import type { DisplayBlock, DiffBlock, TodoBlock, BriefBlock, ShellBlock } from "shared/legacy-sdk";
5
+ import { cn } from "@/lib/utils";
6
+ import * as Diff from "diff";
7
+
8
+ function useIsDark(): boolean {
9
+ return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
10
+ }
11
+
12
+ interface DiffBlockProps {
13
+ block: DiffBlock;
14
+ maxHeight?: string;
15
+ }
16
+
17
+ interface DiffPart {
18
+ value: string;
19
+ added?: boolean;
20
+ removed?: boolean;
21
+ }
22
+
23
+ function renderDiffLine(parts: DiffPart[], type: "added" | "removed"): React.ReactNode {
24
+ return parts.map((part, i) => {
25
+ if (type === "removed") {
26
+ if (part.added) return null;
27
+ return (
28
+ <span key={i} className={cn(part.removed && "bg-red-300/50 dark:bg-red-700/50 rounded-sm")}>
29
+ {part.value}
30
+ </span>
31
+ );
32
+ } else {
33
+ if (part.removed) return null;
34
+ return (
35
+ <span key={i} className={cn(part.added && "bg-emerald-300/50 dark:bg-emerald-700/50 rounded-sm")}>
36
+ {part.value}
37
+ </span>
38
+ );
39
+ }
40
+ });
41
+ }
42
+
43
+ // Extract diff computation to a pure function for memoization
44
+ function computeDiffLines(oldText: string, newText: string): { oldLines: DiffPart[][]; newLines: DiffPart[][] } {
45
+ const diffParts = Diff.diffWords(oldText, newText);
46
+
47
+ const oldLines: DiffPart[][] = [];
48
+ const newLines: DiffPart[][] = [];
49
+ let currentOldLine: DiffPart[] = [];
50
+ let currentNewLine: DiffPart[] = [];
51
+
52
+ for (const part of diffParts) {
53
+ const lines = part.value.split("\n");
54
+ lines.forEach((line, lineIndex) => {
55
+ const isLastLine = lineIndex === lines.length - 1;
56
+ const partForLine: DiffPart = {
57
+ value: line,
58
+ added: part.added,
59
+ removed: part.removed,
60
+ };
61
+
62
+ if (!part.added) {
63
+ currentOldLine.push(partForLine);
64
+ }
65
+ if (!part.removed) {
66
+ currentNewLine.push(partForLine);
67
+ }
68
+
69
+ if (!isLastLine) {
70
+ if (!part.added) {
71
+ oldLines.push(currentOldLine);
72
+ currentOldLine = [];
73
+ }
74
+ if (!part.removed) {
75
+ newLines.push(currentNewLine);
76
+ currentNewLine = [];
77
+ }
78
+ }
79
+ });
80
+ }
81
+
82
+ if (currentOldLine.length > 0) oldLines.push(currentOldLine);
83
+ if (currentNewLine.length > 0) newLines.push(currentNewLine);
84
+
85
+ return { oldLines, newLines };
86
+ }
87
+
88
+ export function DiffBlockView({ block, maxHeight = "max-h-40" }: DiffBlockProps) {
89
+ const fileName = block.path.split("/").pop() || block.path;
90
+ const hasOld = block.old_text.length > 0;
91
+ const hasNew = block.new_text.length > 0;
92
+
93
+ const { oldLines, newLines } = useMemo(() => computeDiffLines(block.old_text, block.new_text), [block.old_text, block.new_text]);
94
+
95
+ return (
96
+ <div className="text-[11px] border border-border rounded-md overflow-hidden">
97
+ <div className="px-2 py-1 bg-muted/50 border-b border-border text-muted-foreground truncate">{fileName}</div>
98
+ <div className="flex flex-col md:flex-row">
99
+ {hasOld && (
100
+ <div className="bg-red-500/5 dark:bg-red-500/10 border-b md:border-b-0 md:border-r border-border/50 flex-1 min-w-0">
101
+ <div className={cn("px-2 py-1.5 overflow-auto", maxHeight)}>
102
+ {oldLines.map((lineParts, i) => (
103
+ <div key={i} className="flex">
104
+ <span className="text-red-500 select-none mr-2 shrink-0">-</span>
105
+ <span className="text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">{renderDiffLine(lineParts, "removed") || " "}</span>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ </div>
110
+ )}
111
+ {hasNew && (
112
+ <div className="bg-emerald-500/5 dark:bg-emerald-500/10 flex-1 min-w-0">
113
+ <div className={cn("px-2 py-1.5 overflow-auto", maxHeight)}>
114
+ {newLines.map((lineParts, i) => (
115
+ <div key={i} className="flex">
116
+ <span className="text-emerald-500 select-none mr-2 shrink-0">+</span>
117
+ <span className="text-emerald-600 dark:text-emerald-400 whitespace-pre-wrap break-all">{renderDiffLine(lineParts, "added") || " "}</span>
118
+ </div>
119
+ ))}
120
+ </div>
121
+ </div>
122
+ )}
123
+ </div>
124
+ </div>
125
+ );
126
+ }
127
+
128
+ interface TodoBlockProps {
129
+ block: TodoBlock;
130
+ }
131
+
132
+ export function TodoBlockView({ block }: TodoBlockProps) {
133
+ return (
134
+ <div className="text-xs border border-border rounded-md overflow-hidden">
135
+ {block.items.map((item, i) => (
136
+ <div key={i} className={cn("px-2 py-1.5 flex items-center gap-2", i !== block.items.length - 1 && "border-b border-border/50")}>
137
+ <span
138
+ className={cn(
139
+ "size-2 rounded-full shrink-0",
140
+ item.status === "done" && "bg-emerald-500",
141
+ item.status === "in_progress" && "bg-amber-500",
142
+ item.status === "pending" && "bg-zinc-400",
143
+ )}
144
+ />
145
+ <span className="truncate">{item.title}</span>
146
+ </div>
147
+ ))}
148
+ </div>
149
+ );
150
+ }
151
+
152
+ interface BriefBlockProps {
153
+ block: BriefBlock;
154
+ }
155
+
156
+ export function BriefBlockView({ block }: BriefBlockProps) {
157
+ return <div className="text-xs text-muted-foreground bg-muted/30 rounded-md px-2 py-1.5">{block.text}</div>;
158
+ }
159
+
160
+ interface ShellBlockProps {
161
+ block: ShellBlock;
162
+ maxHeight?: string;
163
+ }
164
+
165
+ export function ShellBlockView({ block, maxHeight = "max-h-40" }: ShellBlockProps) {
166
+ console.log("ShellBlockView render", { block });
167
+ const isDark = useIsDark();
168
+ const language = block.language || "bash";
169
+
170
+ return (
171
+ <div className="text-[11px] border border-border rounded-md overflow-hidden">
172
+ <div className="px-2 py-1 bg-muted/50 border-b border-border text-muted-foreground flex items-center gap-2">
173
+ <span className="font-mono">$</span>
174
+ <span>Shell Command</span>
175
+ </div>
176
+ <div className={cn("overflow-auto", maxHeight)}>
177
+ <SyntaxHighlighter
178
+ style={isDark ? oneDark : oneLight}
179
+ language={language}
180
+ PreTag="div"
181
+ customStyle={{ margin: 0, padding: "0.5rem", fontSize: "11px", background: "transparent" }}
182
+ codeTagProps={{ style: { backgroundColor: "transparent" } }}
183
+ >
184
+ {block.command}
185
+ </SyntaxHighlighter>
186
+ </div>
187
+ </div>
188
+ );
189
+ }
190
+
191
+ interface DisplayBlockViewProps {
192
+ block: DisplayBlock;
193
+ maxHeight?: string;
194
+ }
195
+
196
+ export function DisplayBlockView({ block, maxHeight }: DisplayBlockViewProps) {
197
+ switch (block.type) {
198
+ case "diff":
199
+ return <DiffBlockView block={block as DiffBlock} maxHeight={maxHeight} />;
200
+ case "todo":
201
+ return <TodoBlockView block={block as TodoBlock} />;
202
+ case "brief":
203
+ return <BriefBlockView block={block as BriefBlock} />;
204
+ default:
205
+ return null;
206
+ }
207
+ }
208
+
209
+ interface DisplayBlocksProps {
210
+ blocks: DisplayBlock[];
211
+ maxHeight?: string;
212
+ className?: string;
213
+ }
214
+
215
+ export function DisplayBlocks({ blocks, maxHeight, className }: DisplayBlocksProps) {
216
+ if (!blocks || blocks.length === 0) {
217
+ return null;
218
+ }
219
+
220
+ return (
221
+ <div className={cn("space-y-2", className)}>
222
+ {blocks.map((block, i) => (
223
+ <DisplayBlockView key={i} block={block} maxHeight={maxHeight} />
224
+ ))}
225
+ </div>
226
+ );
227
+ }
apps/vscode/webview-ui/src/components/FileChangesPanel.tsx ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import { IconFilePlus, IconFileMinus, IconFileX, IconArrowBackUp, IconCheck, IconGitCompare } from "@tabler/icons-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
5
+ import { useChatStore } from "@/stores";
6
+ import { bridge } from "@/services";
7
+ import { cn } from "@/lib/utils";
8
+ import { FileChange } from "shared/types";
9
+ import { toast } from "./ui/sonner";
10
+
11
+ const STATUS_CONFIG = {
12
+ Added: { icon: IconFilePlus, color: "text-green-600 dark:text-green-400" },
13
+ Deleted: { icon: IconFileX, color: "text-red-600 dark:text-red-400" },
14
+ Modified: { icon: IconFileMinus, color: "text-yellow-600 dark:text-yellow-400" },
15
+ } as const;
16
+
17
+ function getTotalStats(changes: FileChange[]) {
18
+ return changes.reduce(
19
+ (a, c) => ({
20
+ additions: a.additions + c.additions,
21
+ deletions: a.deletions + c.deletions,
22
+ }),
23
+ { additions: 0, deletions: 0 },
24
+ );
25
+ }
26
+
27
+ interface FileItemProps {
28
+ file: FileChange;
29
+ onRevert: () => void;
30
+ onKeep: () => void;
31
+ onViewDiff: () => void;
32
+ disabled: boolean;
33
+ isStreaming?: boolean;
34
+ }
35
+
36
+ function FileItem({ file, onRevert, onKeep, onViewDiff, disabled, isStreaming }: FileItemProps) {
37
+ const { icon: Icon, color } = STATUS_CONFIG[file.status];
38
+ const name = file.path.split("/").pop() || file.path;
39
+ const dir = file.path.includes("/") ? file.path.slice(0, file.path.lastIndexOf("/")) : "";
40
+
41
+ return (
42
+ <div className="group flex items-center gap-1.5 px-2.5 py-1.5 hover:bg-accent/50 text-xs">
43
+ <Icon className={cn("size-3.5 shrink-0", color)} />
44
+ <div className="flex-1 min-w-0 text-left truncate hover:underline cursor-pointer" onClick={onViewDiff} title={file.path}>
45
+ <span className="font-medium">{name}</span>
46
+ {dir && <span className="ml-1 text-muted-foreground text-[10px]">{dir}</span>}
47
+ </div>
48
+ <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
49
+ <Tooltip>
50
+ <TooltipTrigger asChild>
51
+ <Button variant="ghost" size="icon" className="size-5 border-0! cursor-pointer hover:bg-accent" onClick={onViewDiff}>
52
+ <IconGitCompare className="size-3" />
53
+ </Button>
54
+ </TooltipTrigger>
55
+ <TooltipContent>View Changes</TooltipContent>
56
+ </Tooltip>
57
+ {!isStreaming && (
58
+ <>
59
+ <Tooltip>
60
+ <TooltipTrigger asChild>
61
+ <Button variant="ghost" size="icon" className="size-5 border-0! cursor-pointer hover:bg-accent" onClick={onRevert} disabled={disabled}>
62
+ <IconArrowBackUp className="size-3" />
63
+ </Button>
64
+ </TooltipTrigger>
65
+ <TooltipContent>Undo Changes</TooltipContent>
66
+ </Tooltip>
67
+ <Tooltip>
68
+ <TooltipTrigger asChild>
69
+ <Button variant="ghost" size="icon" className="size-5 border-0! cursor-pointer hover:bg-accent" onClick={onKeep} disabled={disabled}>
70
+ <IconCheck className="size-3" />
71
+ </Button>
72
+ </TooltipTrigger>
73
+ <TooltipContent>Keep Changes</TooltipContent>
74
+ </Tooltip>
75
+ </>
76
+ )}
77
+ </div>
78
+ <div className="flex items-center gap-1 text-[10px] tabular-nums shrink-0">
79
+ <span className="text-green-600 dark:text-green-400">+{file.additions}</span>
80
+ <span className="text-red-600 dark:text-red-400">-{file.deletions}</span>
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ interface FileChangesPanelProps {
87
+ changes: FileChange[];
88
+ }
89
+
90
+ export function FileChangesPanel({ changes }: FileChangesPanelProps) {
91
+ const { isStreaming } = useChatStore();
92
+ const [loading, setLoading] = useState(false);
93
+
94
+ const handleRevert = async (filePath?: string) => {
95
+ setLoading(true);
96
+ try {
97
+ await bridge.revertFiles(filePath);
98
+ } catch (error) {
99
+ toast.error(`Unable to undo changes: ${error instanceof Error ? error.message : String(error)}`);
100
+ } finally {
101
+ setLoading(false);
102
+ }
103
+ };
104
+
105
+ const handleKeep = async (filePath?: string) => {
106
+ setLoading(true);
107
+ try {
108
+ await bridge.keepChanges(filePath);
109
+ } catch (error) {
110
+ toast.error(`Unable to keep changes: ${error instanceof Error ? error.message : String(error)}`);
111
+ } finally {
112
+ setLoading(false);
113
+ }
114
+ };
115
+
116
+ const stats = getTotalStats(changes);
117
+
118
+ if (!changes.length) {
119
+ return <div className="px-2.5 py-3 text-xs text-muted-foreground text-center">No file changes</div>;
120
+ }
121
+
122
+ return (
123
+ <div className="flex flex-col bg-card shrink">
124
+ {/* Header with actions */}
125
+ <div className="flex items-center justify-between px-2.5 py-1.5 text-xs bg-muted/30">
126
+ <div className="flex items-center gap-2">
127
+ <span className="text-muted-foreground">
128
+ {changes.length} file{changes.length !== 1 ? "s" : ""}
129
+ </span>
130
+ <div className="flex items-center gap-1 text-[10px] tabular-nums">
131
+ <span className="text-green-600 dark:text-green-400">+{stats.additions}</span>
132
+ <span className="text-red-600 dark:text-red-400">-{stats.deletions}</span>
133
+ </div>
134
+ </div>
135
+ {!isStreaming && (
136
+ <div className="flex gap-1">
137
+ <Button
138
+ variant="default"
139
+ size="sm"
140
+ className="h-5 px-2 text-[10px] cursor-pointer"
141
+ onClick={() => {
142
+ void handleKeep();
143
+ }}
144
+ disabled={loading}
145
+ >
146
+ Keep All
147
+ </Button>
148
+ <Button
149
+ variant="secondary"
150
+ size="sm"
151
+ className="h-5 px-2 text-[10px] cursor-pointer"
152
+ onClick={() => {
153
+ void handleRevert();
154
+ }}
155
+ disabled={loading}
156
+ >
157
+ Undo All
158
+ </Button>
159
+ </div>
160
+ )}
161
+ </div>
162
+
163
+ {/* File list */}
164
+ <div className="overflow-y-auto flex-1">
165
+ {changes.map((file) => (
166
+ <FileItem
167
+ key={file.path}
168
+ file={file}
169
+ onRevert={() => {
170
+ void handleRevert(file.path);
171
+ }}
172
+ onKeep={() => {
173
+ void handleKeep(file.path);
174
+ }}
175
+ onViewDiff={() => {
176
+ void bridge.openFileDiff(file.path);
177
+ }}
178
+ disabled={loading}
179
+ isStreaming={isStreaming}
180
+ />
181
+ ))}
182
+ </div>
183
+ </div>
184
+ );
185
+ }
apps/vscode/webview-ui/src/components/FilePickerMenu.tsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Fragment, useEffect, useRef } from "react";
2
+ import { IconFolder, IconFile, IconPhoto } from "@tabler/icons-react";
3
+ import { cn } from "@/lib/utils";
4
+ import { mentionMatchSpans, type MentionMatchSpan } from "@/lib/mention-match";
5
+
6
+ export interface FileItem {
7
+ name: string;
8
+ path: string;
9
+ isDirectory: boolean;
10
+ matchPositions?: number[];
11
+ }
12
+
13
+ interface FilePickerMenuProps {
14
+ items: FileItem[];
15
+ selectedIndex: number;
16
+ isLoading?: boolean;
17
+ isStale?: boolean;
18
+ showMediaOption?: boolean;
19
+ onSelectMedia?: () => void;
20
+ onSelectItem: (item: FileItem) => void;
21
+ onHover: (index: number) => void;
22
+ }
23
+
24
+ function parentDir(path: string): string {
25
+ const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
26
+ const idx = trimmed.lastIndexOf("/");
27
+ return idx === -1 ? "" : trimmed.slice(0, idx);
28
+ }
29
+
30
+ function nameSpans(item: FileItem): MentionMatchSpan[] {
31
+ const path = item.path.endsWith("/") ? item.path.slice(0, -1) : item.path;
32
+ return mentionMatchSpans(item.name, item.matchPositions, Math.max(0, path.length - item.name.length));
33
+ }
34
+
35
+ function dirSpans(item: FileItem): MentionMatchSpan[] {
36
+ return mentionMatchSpans(parentDir(item.path), item.matchPositions, 0);
37
+ }
38
+
39
+ export function FilePickerMenu({
40
+ items,
41
+ selectedIndex,
42
+ isLoading,
43
+ isStale = false,
44
+ showMediaOption = true,
45
+ onSelectMedia,
46
+ onSelectItem,
47
+ onHover,
48
+ }: FilePickerMenuProps) {
49
+ const selectedRef = useRef<HTMLButtonElement>(null);
50
+ const hoverSelectionRef = useRef<number | null>(null);
51
+
52
+ useEffect(() => {
53
+ if (hoverSelectionRef.current === selectedIndex) {
54
+ hoverSelectionRef.current = null;
55
+ return;
56
+ }
57
+ hoverSelectionRef.current = null;
58
+ selectedRef.current?.scrollIntoView({ block: "nearest" });
59
+ }, [selectedIndex]);
60
+
61
+ const handleHover = (index: number) => {
62
+ if (isStale) return;
63
+ hoverSelectionRef.current = index;
64
+ onHover(index);
65
+ };
66
+
67
+ const headerCount = showMediaOption ? 1 : 0;
68
+
69
+ return (
70
+ <div className="rounded-md border bg-popover shadow-md overflow-hidden">
71
+ {showMediaOption && onSelectMedia && (
72
+ <button
73
+ ref={selectedIndex === 0 ? selectedRef : null}
74
+ onMouseDown={(e) => e.preventDefault()}
75
+ onClick={onSelectMedia}
76
+ onMouseMove={() => handleHover(0)}
77
+ className={cn("w-full px-2 py-1.5 text-left flex items-center gap-2 border-b border-border", selectedIndex === 0 ? "bg-accent" : "hover:bg-accent/50")}
78
+ >
79
+ <IconPhoto className="size-3.5 text-muted-foreground" />
80
+ <span className="text-xs">Select images or videos…</span>
81
+ </button>
82
+ )}
83
+ <div className={cn("max-h-64 overflow-y-auto", isStale && "opacity-60")}>
84
+ {isLoading ? (
85
+ <div className="px-2 py-4 text-center text-xs text-muted-foreground">Loading…</div>
86
+ ) : items.length === 0 ? (
87
+ <div className="px-2 py-4 text-center text-xs text-muted-foreground">No files found</div>
88
+ ) : (
89
+ items.map((item, idx) => {
90
+ const itemIndex = idx + headerCount;
91
+ const dir = parentDir(item.path);
92
+ return (
93
+ <button
94
+ key={item.path}
95
+ ref={itemIndex === selectedIndex ? selectedRef : null}
96
+ onMouseDown={(e) => e.preventDefault()}
97
+ onClick={() => onSelectItem(item)}
98
+ onMouseMove={() => handleHover(itemIndex)}
99
+ className={cn("w-full px-2 py-1.5 text-left flex items-center justify-between gap-3", itemIndex === selectedIndex ? "bg-accent" : "hover:bg-accent/50")}
100
+ >
101
+ <span className="flex items-center gap-1.5 text-xs shrink-0">
102
+ {item.isDirectory ? <IconFolder className="size-3 text-muted-foreground" /> : <IconFile className="size-3 text-muted-foreground" />}
103
+ <span className={cn(item.isDirectory && "font-medium")}>
104
+ {nameSpans(item).map((span, spanIdx) =>
105
+ span.hit ? (
106
+ <span key={spanIdx} className="text-foreground font-semibold">{span.text}</span>
107
+ ) : (
108
+ <Fragment key={spanIdx}>{span.text}</Fragment>
109
+ ),
110
+ )}
111
+ {item.isDirectory && "/"}
112
+ </span>
113
+ </span>
114
+ {dir && (
115
+ <span className="text-[10px] text-muted-foreground truncate max-w-32">
116
+ {dirSpans(item).map((span, spanIdx) =>
117
+ span.hit ? (
118
+ <span key={spanIdx} className="text-foreground">{span.text}</span>
119
+ ) : (
120
+ <Fragment key={spanIdx}>{span.text}</Fragment>
121
+ ),
122
+ )}
123
+ </span>
124
+ )}
125
+ </button>
126
+ );
127
+ })
128
+ )}
129
+ </div>
130
+ </div>
131
+ );
132
+ }