Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- apps/vscode/.vscode/launch.json +29 -0
- apps/vscode/.vscode/tasks.json +77 -0
- apps/vscode/scripts/extension-host-smoke.mjs +180 -0
- apps/vscode/src/KimiWebviewProvider.ts +182 -0
- apps/vscode/src/bridge-handler.ts +350 -0
- apps/vscode/src/config/vscode-settings.ts +64 -0
- apps/vscode/src/extension.ts +280 -0
- apps/vscode/src/handlers/auth.handler.ts +56 -0
- apps/vscode/src/handlers/chat.handler.ts +226 -0
- apps/vscode/src/handlers/config.handler.ts +205 -0
- apps/vscode/src/handlers/file.handler.ts +247 -0
- apps/vscode/src/handlers/index.ts +20 -0
- apps/vscode/src/handlers/mcp.handler.ts +322 -0
- apps/vscode/src/handlers/session.handler.ts +273 -0
- apps/vscode/src/handlers/slash-command.ts +314 -0
- apps/vscode/src/handlers/types.ts +41 -0
- apps/vscode/src/handlers/workspace.handler.ts +44 -0
- apps/vscode/src/managers/baseline.manager.ts +864 -0
- apps/vscode/src/managers/file.manager.ts +169 -0
- apps/vscode/src/migration/index.ts +1 -0
- apps/vscode/src/migration/legacy-migration.manager.ts +698 -0
- apps/vscode/src/raw-modules.d.ts +4 -0
- apps/vscode/src/runtime/event-adapter.ts +463 -0
- apps/vscode/src/runtime/kimi-runtime.ts +319 -0
- apps/vscode/src/runtime/legacy-approval.ts +71 -0
- apps/vscode/src/runtime/replay-adapter.ts +594 -0
- apps/vscode/src/runtime/reverse-rpc.ts +97 -0
- apps/vscode/src/runtime/session-runtime.ts +656 -0
- apps/vscode/src/runtime/tool-display.ts +75 -0
- apps/vscode/src/utils/context.ts +9 -0
- apps/vscode/src/utils/fs-path.ts +28 -0
- apps/vscode/src/utils/session-context.ts +232 -0
- apps/vscode/src/utils/string.ts +17 -0
- apps/vscode/src/utils/workspace-path.ts +171 -0
- apps/vscode/webview-ui/public/kimi-banner-dark.svg +6 -0
- apps/vscode/webview-ui/public/kimi-banner-light.svg +6 -0
- apps/vscode/webview-ui/public/kimi-logo.png +0 -0
- apps/vscode/webview-ui/src/App.tsx +147 -0
- apps/vscode/webview-ui/src/components/ActionMenu.tsx +149 -0
- apps/vscode/webview-ui/src/components/ApprovalDialog.tsx +82 -0
- apps/vscode/webview-ui/src/components/BottomToolbar.tsx +96 -0
- apps/vscode/webview-ui/src/components/ChatArea.tsx +64 -0
- apps/vscode/webview-ui/src/components/ChatMessage.tsx +340 -0
- apps/vscode/webview-ui/src/components/ChatStatus.tsx +117 -0
- apps/vscode/webview-ui/src/components/CompactionCard.tsx +23 -0
- apps/vscode/webview-ui/src/components/ConfigErrorScreen.tsx +185 -0
- apps/vscode/webview-ui/src/components/CopyButton.tsx +45 -0
- apps/vscode/webview-ui/src/components/DisplayBlocks.tsx +227 -0
- apps/vscode/webview-ui/src/components/FileChangesPanel.tsx +185 -0
- 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(""", '"')
|
| 577 |
+
.replaceAll("'", "'")
|
| 578 |
+
.replaceAll("<", "<")
|
| 579 |
+
.replaceAll(">", ">")
|
| 580 |
+
.replaceAll("&", "&");
|
| 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 |
+
}
|