diff --git a/.changeset/legacy-migration-targets.md b/.changeset/legacy-migration-targets.md new file mode 100644 index 0000000000..e5f765443f --- /dev/null +++ b/.changeset/legacy-migration-targets.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/migration-legacy": patch +"@moonshot-ai/kimi-code": patch +--- + +Keep legacy migrations idempotent across multiple Kimi homes and report damaged or unmapped sessions instead of silently skipping them. diff --git a/.changeset/vscode-node-sdk-host.md b/.changeset/vscode-node-sdk-host.md new file mode 100644 index 0000000000..ab7f3f0028 --- /dev/null +++ b/.changeset/vscode-node-sdk-host.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code-sdk": patch +"@moonshot-ai/kimi-code": patch +--- + +Support in-process editor hosts with session lifecycle, context, MCP configuration, and cross-platform session storage APIs. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99d74a5398..3ff91c3037 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,48 @@ jobs: - name: Smoke test CLI bundle run: pnpm -C apps/kimi-code run smoke + vscode-vsix-package: + name: VSIX package audit (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: all + - os: macos-latest + target: darwin-arm64 + - os: windows-latest + target: win32-x64 + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + - name: Build and audit target VSIX + run: pnpm --filter kimi-code run package:platform -- --target "${{ matrix.target }}" + - name: Run installed VSIX Extension Host smoke (Linux) + if: runner.os == 'Linux' + run: xvfb-run -a pnpm --filter kimi-code run test:extension-host -- --version 1.100.0 + - name: Run installed VSIX Extension Host smoke on stable (Linux) + if: runner.os == 'Linux' + run: xvfb-run -a pnpm --filter kimi-code run test:extension-host -- --version stable + - name: Run installed VSIX Extension Host smoke + if: runner.os != 'Linux' + run: pnpm --filter kimi-code run test:extension-host -- --version 1.100.0 + - uses: actions/upload-artifact@v4 + with: + name: vscode-vsix-${{ matrix.target }} + path: apps/vscode/artifacts/vsix/*.vsix + if-no-files-found: error + test: runs-on: ubuntu-latest strategy: @@ -125,6 +167,8 @@ jobs: echo "Typechecking ${config}" pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit done + - name: Typecheck VS Code extension + run: pnpm --filter kimi-code run typecheck - name: Typecheck kimi-web (vue-tsc) run: pnpm --filter @moonshot-ai/kimi-web run typecheck - name: Typecheck vis-server diff --git a/.gitignore b/.gitignore index 4bd389b824..e62f42128f 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ plugins/cdn/ .kimi-code/local.toml .kimi-sandbox/ .vscode/ +!apps/vscode/.vscode/ +!apps/vscode/.vscode/*.json +apps/vscode/artifacts/ Dockerfile docker-compose.yml diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts index 48435d2bc4..cf121bf604 100644 --- a/apps/kimi-code/src/migration/detect-pending.ts +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -3,10 +3,13 @@ * shown. Cheap, synchronous-ish, no TTY required. Returns the MigrationPlan to * drive the screen, or null when there is nothing to offer. */ -import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { existsSync } from 'node:fs'; -import { detectMigration, type MigrationPlan } from '@moonshot-ai/migration-legacy'; +import { + detectMigration, + shouldSuppressMigration, + type MigrationPlan, +} from '@moonshot-ai/migration-legacy'; export interface DetectPendingInput { readonly sourceHome: string; @@ -24,11 +27,11 @@ export async function detectPendingMigration( ): Promise { const { sourceHome, targetHome } = input; if (!existsSync(sourceHome)) return null; - if (input.ignoreMarker !== true) { - if (migrationAlreadyTargeted(join(sourceHome, '.migrated-to-kimi-code'), targetHome)) { - return null; - } - if (existsSync(join(targetHome, '.skip-migration-from-kimi-cli'))) return null; + if ( + input.ignoreMarker !== true && + shouldSuppressMigration({ sourceHome, targetHome }) + ) { + return null; } let plan: MigrationPlan; @@ -52,21 +55,3 @@ export async function detectPendingMigration( return plan; } - -/** - * True when the legacy `.migrated-to-kimi-code` marker records a migration - * into *this* target home. A marker written for a different `KIMI_CODE_HOME` - * must not suppress the prompt — that target has never received migrated data. - * An unreadable/old marker without `target_path` is treated as "matches" - * (conservative: do not re-prompt when the marker exists but is ambiguous). - */ -function migrationAlreadyTargeted(markerPath: string, targetHome: string): boolean { - if (!existsSync(markerPath)) return false; - try { - const parsed = JSON.parse(readFileSync(markerPath, 'utf-8')) as { target_path?: unknown }; - if (typeof parsed.target_path !== 'string') return true; - return resolve(parsed.target_path) === resolve(targetHome); - } catch { - return true; - } -} diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index df9271b3a1..76cdec4587 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -217,6 +217,9 @@ export function projectContext( } break; } + case 'context.update_token_count': + contextTokens = rec.tokenCount; + break; case 'context.clear': if (mode === 'model') { messages = []; diff --git a/apps/vis/server/test/lib/context-projector.test.ts b/apps/vis/server/test/lib/context-projector.test.ts index 0a5ded18fe..1268753aac 100644 --- a/apps/vis/server/test/lib/context-projector.test.ts +++ b/apps/vis/server/test/lib/context-projector.test.ts @@ -785,6 +785,16 @@ describe('context-projector', () => { expect(proj.contextTokens).toBe(20); // 10+5+2+3, absolute (not summed across usage.record) }); + it('uses context.update_token_count as the latest absolute context-token snapshot', () => { + const entries = [ + { lineNo: 1, data: { type: 'context.update_token_count' as const, tokenCount: 42 }, raw: {} }, + ]; + + const proj = projectContext(entries as any); + + expect(proj.contextTokens).toBe(42); + }); + // ---- Fix ②: contextTokens updates on clear / compaction lifecycle events --- // agent-core ContextMemory sets _tokenCount on clear() (→ 0) and // applyCompaction(result) (→ result.tokensAfter), not only on step.end. These diff --git a/apps/vis/web/src/components/wire/renderers.tsx b/apps/vis/web/src/components/wire/renderers.tsx index 6ec3eb67fd..126ac3bded 100644 --- a/apps/vis/web/src/components/wire/renderers.tsx +++ b/apps/vis/web/src/components/wire/renderers.tsx @@ -217,6 +217,12 @@ export const WIRE_RENDERERS: RendererMap = { detail: (r) => , }, + 'context.update_token_count': { + tone: 'meta', + label: 'tokens', + headline: (r) => ({ main: context {r.tokenCount} tok }), + }, + 'context.clear': { tone: 'warning', label: 'clear', diff --git a/apps/vis/web/src/lib/analysis.ts b/apps/vis/web/src/lib/analysis.ts index c1798ecf66..24a83e7ae3 100644 --- a/apps/vis/web/src/lib/analysis.ts +++ b/apps/vis/web/src/lib/analysis.ts @@ -279,6 +279,17 @@ export function analyzeWire(entries: readonly WireEntry[]): Analysis { if (current) current.cancelled = true; break; + case 'context.update_token_count': + contextTokens = rec.tokenCount; + contextSeries.push({ + lineNo: entry.lineNo, + time: t, + turnIndex: current?.index ?? -1, + step: -1, + contextTokens, + }); + if (contextTokens > peakContext) peakContext = contextTokens; + break; case 'context.clear': contextTokens = 0; break; diff --git a/apps/vis/web/test/analysis.test.ts b/apps/vis/web/test/analysis.test.ts index 225e3ee921..f8986674d5 100644 --- a/apps/vis/web/test/analysis.test.ts +++ b/apps/vis/web/test/analysis.test.ts @@ -131,4 +131,15 @@ describe('analyzeWire', () => { expect(a.contextSeries.map((p) => p.contextTokens)).toEqual([200, 200]); expect(a.summary.peakContextTokens).toBe(200); }); + + it('uses context.update_token_count as the absolute context-window fill', () => { + line = 0; + const a = analyzeWire([ + e({ type: 'context.update_token_count', tokenCount: 42 }, 1), + ]); + + expect(a.summary.contextTokens).toBe(42); + expect(a.summary.peakContextTokens).toBe(42); + expect(a.contextSeries.map((point) => point.contextTokens)).toEqual([42]); + }); }); diff --git a/apps/vscode/.vscode-test.mjs b/apps/vscode/.vscode-test.mjs new file mode 100644 index 0000000000..f728f012c6 --- /dev/null +++ b/apps/vscode/.vscode-test.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from "@vscode/test-cli"; + +export default defineConfig({ + files: "out/test/**/*.test.js", +}); diff --git a/apps/vscode/.vscode/launch.json b/apps/vscode/.vscode/launch.json new file mode 100644 index 0000000000..6a674b7ea9 --- /dev/null +++ b/apps/vscode/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Kimi Code: Extension Development Host (isolated)", + "type": "extensionHost", + "request": "launch", + "preLaunchTask": "vscode: dev", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--user-data-dir=${workspaceFolder}/../../.tmp/vscode-extension-dev/user-data", + "--extensions-dir=${workspaceFolder}/../../.tmp/vscode-extension-dev/extensions", + "--disable-workspace-trust", + "--skip-welcome", + "--skip-release-notes", + "${workspaceFolder}/../../.tmp/vscode-extension-dev/workspace" + ], + "env": { + "KIMI_CODE_HOME": "${workspaceFolder}/../../.tmp/vscode-extension-dev/kimi-home" + }, + "outFiles": ["${workspaceFolder}/dist/**/*.js", "${workspaceFolder}/dist/**/*.mjs"], + "sourceMaps": true, + "resolveSourceMapLocations": [ + "${workspaceFolder}/**", + "!**/node_modules/**" + ] + } + ] +} diff --git a/apps/vscode/.vscode/tasks.json b/apps/vscode/.vscode/tasks.json new file mode 100644 index 0000000000..948c22a2b8 --- /dev/null +++ b/apps/vscode/.vscode/tasks.json @@ -0,0 +1,77 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "vscode: prepare isolated dev", + "type": "process", + "command": "pnpm", + "args": ["run", "dev:prepare"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] + }, + { + "label": "vscode: watch extension", + "type": "process", + "command": "pnpm", + "args": ["run", "dev:extension"], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "problemMatcher": { + "owner": "typescript", + "fileLocation": "absolute", + "pattern": { + "regexp": "^(.*?):(\\d+):(\\d+):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*", + "endsPattern": "Extension dev build complete; watching for changes" + } + } + }, + { + "label": "vscode: watch webview", + "type": "process", + "command": "pnpm", + "args": ["run", "dev:webview"], + "options": { + "cwd": "${workspaceFolder}" + }, + "isBackground": true, + "problemMatcher": { + "owner": "vite", + "fileLocation": "absolute", + "pattern": { + "regexp": "^(.*?):(\\d+):(\\d+):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + }, + "background": { + "activeOnStart": true, + "beginsPattern": ".*", + "endsPattern": "built in \\d+.*ms" + } + } + }, + { + "label": "vscode: dev", + "dependsOrder": "parallel", + "dependsOn": [ + "vscode: prepare isolated dev", + "vscode: watch extension", + "vscode: watch webview" + ], + "problemMatcher": [] + } + ] +} diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore new file mode 100644 index 0000000000..726951204a --- /dev/null +++ b/apps/vscode/.vscodeignore @@ -0,0 +1,50 @@ +# Source files +src/** +webview-ui/** +shared/** +**/*.ts +**/*.tsx + +# Node modules (bundled via esbuild/vite) +**/node_modules/** + +# Build configs +.vscode/** +tsconfig.json +tsdown.config.ts +vitest.config.ts +.vscode-test.mjs +eslint.config.mjs +webview-ui/tsconfig.json +webview-ui/vite.config.ts +webview-ui/components.json + +# Scripts +scripts/** +test/** +runtime/** +artifacts/** + +# Never package generated test/debug state +**/.kimi/** +**/.kimi-code/** +**/cache/** +**/caches/** +**/credentials/** +**/logs/** +**/profile/** +**/profiles/** +**/session/** +**/sessions/** +**/tokens/** +**/*.jsonl +**/*.log + +# Other +.gitignore +**/*.map +*.vsix +**/.DS_Store + +# Prevent parent directory traversal via symlinks +../** diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md new file mode 100644 index 0000000000..073a2e4593 --- /dev/null +++ b/apps/vscode/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +## 0.6.0 + +### Breaking + +- Raised the minimum supported editor version to VS Code 1.100.0. +- Legacy Kimi Code OAuth credentials and MCP OAuth credentials are deliberately + not migrated. Sign in to Kimi Code again and re-authorize affected MCP + servers after upgrading. +- Removed the `kimi.executablePath` and `kimi.environmentVariables` settings. + The old `kimi.environmentVariables.KIMI_SHARE_DIR` value is consulted only to + discover legacy data during migration; it is not applied to the new runtime. + The system-level `KIMI_CODE_HOME` environment variable remains supported. + +### Changed + +- Replaced the legacy Python/stdio runtime with the in-process Kimi Code Node + SDK. The extension no longer downloads or starts a separate Kimi executable. +- The in-process engine is the same one that powers the Kimi Code CLI, so the + agent gains CLI-parity capabilities beyond the legacy runtime, including + parallel subagent swarms, background tasks, and long-running goal runs. +- Added an opt-in legacy migration prompt on the first launch that detects data + from version 0.5.x. The migration copies or merges supported data into the + current Kimi Code home and does not delete the legacy source. If migration is + skipped or needs to be retried, run **Kimi Code: Migrate Legacy Data** from the + Command Palette. +- When VS Code and the Kimi Code terminal app resolve to the same + `KIMI_CODE_HOME`, they use the same configuration and session storage. Running + the same session concurrently from multiple processes is not supported or + protected by cross-process locking. +- The model picker groups models by provider when multiple providers are + configured, keeps provider identity when display names match, and recognizes + adaptive-thinking metadata. A configured custom default provider no longer + requires dismissing the Kimi account login screen on every launch. +- The file changes panel and Undo actions use extension-maintained baselines. + Files changed through Kimi's Write and Edit operations are tracked on a + best-effort basis. File deletions performed inside Bash are not tracked by + this baseline and therefore cannot be restored by the panel's Undo action. + +### Fixed + +- The `kimi.yoloMode` setting now reaches the permission engine: enabling it + maps to the core `yolo` permission mode and takes effect when a session + attaches, including sessions that previously stored a disabled auto-approve + state. +- Kept the chat header and input toolbar readable when the sidebar is narrow: + controls wrap and shrink instead of being clipped. + +### Distribution boundary + +Release packaging produces target-specific VSIX files for `darwin-x64`, +`darwin-arm64`, `linux-x64`, `linux-arm64`, `win32-x64`, and `win32-arm64`. +Archive and static verification for a target does not by itself prove that the +extension has run successfully in that target's Extension Host; runtime test +results must be recorded separately for each operating system and architecture. diff --git a/apps/vscode/LICENSE b/apps/vscode/LICENSE new file mode 100644 index 0000000000..7a4a3ea242 --- /dev/null +++ b/apps/vscode/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/apps/vscode/README.md b/apps/vscode/README.md new file mode 100644 index 0000000000..ef73e42490 --- /dev/null +++ b/apps/vscode/README.md @@ -0,0 +1,43 @@ +# Kimi Code + +AI coding assistant for VS Code, built for long-context workflows and complex coding tasks. + +## Features + +- **Works alongside you**: Kimi autonomously explores your codebase, reads and writes code, and runs terminal commands with your permission +- **Thinking controls**: Toggle reasoning or choose a model-supported thinking effort +- **Provider-aware models**: Distinguish and select same-named models across configured providers +- **Native editor integration**: Review AI-proposed changes directly in VS Code's diff viewer +- **MCP support**: Extend capabilities with Model Context Protocol servers +- **Slash commands**: Quick actions like `/init` to analyze your project and `/compact` to manage context + +## Install + +Kimi Code requires VS Code 1.100.0 or later. + +1. Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=moonshot-ai.kimi-code) +2. Open a folder in VS Code +3. Click the Kimi icon in the Activity Bar +4. Sign in with a [kimi.com/code](https://www.kimi.com/code) subscription, or use a provider already configured in the shared `config.toml` + +The extension runs the Kimi Code Node SDK in the VS Code Extension Host. When +the extension and the Kimi Code terminal app resolve to the same +`KIMI_CODE_HOME`, they share `config.toml`, MCP configuration, login state, and +sessions. The system-level `KIMI_CODE_HOME` environment variable is supported; +there is no separate VS Code setting for it. Do not run the same session from +both applications at the same time, because cross-process session locking is +not guaranteed. + +After upgrading from version 0.5.x, the extension prompts before migrating any +legacy data it finds. Migration copies or merges data into the current Kimi Code +home and does not delete the legacy source. Legacy Kimi Code OAuth and MCP OAuth +credentials are not copied, so those connections must be authorized again. +See [the changelog](CHANGELOG.md) for the full compatibility notes. + +## Docs + +Official doc for Kimi Code can be found at [www.kimi.com/code/docs](https://www.kimi.com/code/docs/en/kimi-code-for-vscode/guides/getting-started.html) + +## License + +[Apache-2.0](LICENSE) diff --git a/apps/vscode/docs/node-sdk-migration.md b/apps/vscode/docs/node-sdk-migration.md new file mode 100644 index 0000000000..92ba893d09 --- /dev/null +++ b/apps/vscode/docs/node-sdk-migration.md @@ -0,0 +1,396 @@ +# VS Code Node SDK Migration Design + +Status: accepted and implemented for extension `0.6.0` + +Last updated: 2026-07-16 + +## Context + +The `0.5.x` VS Code extension launched a separately installed Python Kimi CLI +and communicated with it over stdio. That architecture duplicated runtime +installation, configuration, authentication, and session behavior between the +editor and Kimi Code. + +Version `0.6.0` moves the extension into this monorepo under `apps/vscode` and +runs the stable TypeScript v1 engine through `@moonshot-ai/kimi-code-sdk` in the +VS Code Extension Host. The migration preserves the existing extension ID, +commands, Webview, and user-visible workflows. It does not redesign the UI or +introduce unrelated TUI features. + +This document records the durable design decisions behind that migration. It +is not a release checklist or a transcript of the implementation process. + +## Goals + +- Keep the extension ID `moonshot-ai.kimi-code` so `0.6.0` upgrades existing + installations. +- Preserve the existing VS Code commands, shortcuts, Webview workflows, editor + integration, session management, MCP management, and file changes panel. +- Replace the Python/stdio host with the in-process v1 Node SDK. +- Share Kimi Code configuration, authentication, MCP configuration, and + sessions with the TUI when both processes resolve the same Kimi Code home. +- Reuse the shared legacy migration package instead of maintaining a VS + Code-specific session translator. +- Add only the smallest SDK/core APIs needed to preserve existing VS Code + behavior. +- Package and test platform-targeted VSIX artifacts for macOS, Linux, and + Windows. + +The only intentional UI capability added during the migration is +model-aware thinking effort selection. It is necessary to represent the model +capabilities exposed by the v1 configuration. + +## Non-goals + +- Replacing the React Webview with VS Code native views or Chat Participants. +- Migrating to the v2 engine. +- Adding TUI-only features such as goals, cron, swarm, or BTW. +- Keeping a Python CLI fallback or a custom executable setting. +- Adding cross-process locks for concurrent access to one session. +- Making the core wait for VS Code before file tools execute. +- Parsing arbitrary shell commands to infer file changes. +- Copying legacy OAuth or MCP OAuth credentials. +- Publishing an extension as part of the build or package commands. + +## Runtime architecture + +```mermaid +flowchart LR + UI["React Webview
browser sandbox"] + Host["VS Code Extension Host
Node process"] + SDK["@moonshot-ai/kimi-code-sdk
KimiHarness and Session"] + Core["v1 agent-core"] + Home["Kimi Code home
config, auth, MCP, sessions"] + + UI <-->|"postMessage RPC and events"| Host + Host -->|"in-process calls"| SDK + SDK -->|"in-process calls"| Core + SDK <--> Home +``` + +There is no Python process, secondary Node process, or local HTTP server in the +production extension path. + +The Webview remains because it is the existing product UI. It cannot import the +Node SDK directly: the Webview is a browser sandbox and must not access the file +system, credentials, process environment, or session storage. Those operations +stay in the trusted Extension Host. + +### Extension identity + +The runtime constructs the SDK client with: + +- `userAgentProduct: "kimi-code-vscode"` +- `version` from `apps/vscode/package.json` +- `uiMode: "vscode"` + +For `0.6.0`, the normal HTTP User-Agent product is therefore +`kimi-code-vscode/0.6.0`. The version has one source of truth and is not copied +into runtime code or packaging scripts. + +### Package boundaries + +- `apps/vscode` depends on `@moonshot-ai/kimi-code-sdk`. +- `apps/vscode` must not depend directly on `@moonshot-ai/agent-core`. +- Core capabilities needed by released clients are exposed through the Node SDK + and tested at that public boundary. +- The Webview communicates only through the typed bridge in + `apps/vscode/shared`. + +## Main components + +| Area | Primary implementation | +|---|---| +| Activation and VS Code commands | `apps/vscode/src/extension.ts` | +| Webview lifecycle | `apps/vscode/src/KimiWebviewProvider.ts` | +| Webview RPC boundary | `apps/vscode/src/bridge-handler.ts`, `apps/vscode/src/handlers` | +| SDK host | `apps/vscode/src/runtime/kimi-runtime.ts` | +| Session lifecycle and event routing | `apps/vscode/src/runtime/session-runtime.ts` | +| SDK-to-Webview event conversion | `apps/vscode/src/runtime/event-adapter.ts` | +| Session replay | `apps/vscode/src/runtime/replay-adapter.ts` | +| File changes and baselines | `apps/vscode/src/managers` | +| Legacy migration coordination | `apps/vscode/src/migration` | +| React UI | `apps/vscode/webview-ui` | +| Packaging and smoke tests | `apps/vscode/scripts`, `.github/workflows/ci.yml` | + +## Data ownership + +### Shared Kimi Code home + +The SDK resolves the home directory using the normal Kimi Code rules: + +1. system-level `KIMI_CODE_HOME`, when set; +2. otherwise `~/.kimi-code`. + +The extension does not add a separate `kimi.homeDir` setting and does not pass +its own default home to the SDK. VS Code and the TUI share the following data +only when they resolve the same home: + +- `config.toml` +- `mcp.json` +- authentication state +- `sessions/` +- `session_index.jsonl` +- other SDK-owned Kimi Code data + +Remote SSH, WSL, and Dev Container installations use the environment and home +of the remote Extension Host. They do not automatically share the local +machine's Kimi Code home. + +### VS Code-owned state + +VS Code settings and Extension Host storage own editor-specific behavior: + +- autosave and keyboard behavior; +- thinking display preferences; +- editor context injection mode; +- Webview state; +- file change baselines and legacy baseline acceptance markers. + +These values are not written into the shared core configuration unless they +are already a shared product setting, such as the selected model or thinking +effort. + +### Environment variables + +The old `kimi.environmentVariables` setting existed to populate the environment +of the Python child process. It was removed with that process model. + +- Provider-specific environment variables remain in `config.toml`. +- MCP server environment variables remain in `mcp.json`. +- proxy and other process-level variables are inherited from the Extension + Host environment. +- a legacy `KIMI_SHARE_DIR` in the removed setting is consulted only as an + additional migration source. +- other values from the removed global environment map are not migrated. + +## Webview bridge + +The bridge keeps the request/response and event-broadcast shape of the previous +extension so the React UI did not require a product redesign. The Extension +Host validates method names, payloads, workspace containment, and file paths. +The Webview uses a nonce-based content security policy and does not receive +tokens, complete configuration files, or unnecessary absolute paths. + +The event adapter maps v1 SDK events such as `turn.started`, +`assistant.delta`, `tool.call.started`, approvals, questions, compaction, and +errors into the UI state expected by the migrated Webview. Session replay uses +the SDK/core replay surface rather than parsing current core storage directly +from the UI. + +Provider errors may arrive after a failed turn-end event. Session subscriptions +therefore remain alive long enough to deliver the final error instead of being +cancelled immediately when a turn ends. + +## Sessions + +The Node SDK exposes the session operations required by the existing editor UI: + +- create, resume, list, rename, and export; +- permanent delete; +- fork from a selected historical turn; +- replayed context and records needed to restore the UI; +- approval, question, stop, steer, plan mode, model, and thinking controls. + +Permanent delete keeps the old VS Code meaning; it is not silently mapped to +archive. A running target session is stopped and closed before its persisted +data and index references are removed. + +Historical fork keeps the selected turn and its preceding context, then removes +later conversation state. Baselines are materialized into the fork so Keep or +Undo actions in one session cannot affect the other. + +The TUI and VS Code may resume sessions created by each other. They must not run +the same session concurrently because the v1 store has no cross-process write +lock. + +## Provider-aware models and thinking + +Model aliases retain their provider identity. The picker groups models by +provider when multiple providers are configured, so same-named models remain +distinct. Media fallback prefers a compatible model from the current provider +before considering another provider. + +Thinking controls follow each model's declared capabilities: + +- models with `support_efforts` expose those effort values; +- boolean-thinking models expose on/off; +- `always_thinking` models do not expose an invalid off state; +- adaptive-thinking capability is preserved when applying the selection. + +Changing the model or thinking effort updates the active session and the shared +default configuration used by new TUI and VS Code sessions. + +## MCP + +Home-level MCP operations are exposed by the SDK harness rather than by direct +file access from the extension. They cover configuration CRUD, OAuth/reset, +connection testing, and user-global `mcp.json` entries. Session-level status and +reconnect behavior remain on the Session surface. + +The extension supports stdio and HTTP servers, including their environment, +headers, and authorization flows. Legacy MCP OAuth credentials are not copied +and may require authorization after upgrade. + +## Legacy migration + +Migration is opt-in and uses `@moonshot-ai/migration-legacy` for detection and +translation. The extension coordinates prompts and reports but does not +maintain another config/session translator. + +### Sources and target + +- default source: `~/.kimi`; +- optional additional source: a valid legacy `KIMI_SHARE_DIR` from the removed + VS Code setting; +- target: the SDK-resolved Kimi Code home. + +Migration covers the shared config, MCP config, user history, supported skills, +and sessions. Existing target data wins according to the shared migration +package's conflict rules. Migration is repeatable and does not delete the +legacy source. + +On first launch, the extension detects work without mutating either home and +offers **Migrate now** or **Later**. The command +`Kimi Code: Migrate Legacy Data` remains available for manual runs and retries. + +The shared marker `.migrated-to-kimi-code` can contain multiple target homes. +This prevents duplicate migration when the TUI migrated the same source first, +while still allowing a different `KIMI_CODE_HOME` to be migrated later. + +Migrated sessions keep source metadata in `state.json.custom`, including the +legacy source path and session identity. This metadata also supports legacy +baseline fallback. + +OAuth and MCP OAuth credentials are intentionally not copied. Refresh tokens +may rotate, so copying them can invalidate one installation or create ambiguous +ownership. The upgrade flow and release notes must tell users to authorize +again when needed. + +## File changes and baselines + +The baseline feature exists only to support the VS Code File Changes panel, +diff view, Keep Changes, and Undo. It is not core session state and is not a TUI +feature. + +### New sessions + +Baselines are stored under the extension's `globalStorage`, namespaced by the +resolved Kimi Code home and session ID. The home namespace prevents sessions +with the same ID in different homes from sharing baseline state. + +The session runtime observes `tool.call.started` for the explicit `Write` and +`Edit` tools. It captures the first pre-change content for each file and +refreshes the File Changes panel after tool results. A missing original file is +represented as a newly created file. + +- **Keep** removes the effective baseline entry from the panel. +- **Undo** restores the original content or deletes a file that did not exist. +- keeping a file and editing it again starts a new baseline period. +- all paths are resolved against the session work directory and checked for + traversal and symlink escape. + +This is intentionally best-effort. The core does not pause tool execution for a +VS Code callback, and arbitrary Bash file mutations are not tracked. + +### Migrated sessions + +Legacy baselines are not copied in bulk. A migrated session uses its recorded +legacy source path and reads `/baseline` as a read-only +fallback. + +The lookup order is: + +1. extension-owned baseline; +2. an extension-owned marker saying a legacy path was accepted; +3. the legacy baseline. + +Keeping a legacy change writes the acceptance marker instead of deleting old +data. Forking materializes the currently effective baseline into the target +session's extension storage. If the user deletes the legacy home, the migrated +conversation remains usable but legacy diff/Undo information is no longer +available. + +## Packaging and CI + +The Webview and Extension Host are separate build products: + +- Vite builds browser assets for the Webview. +- the Extension Host bundle includes the Node SDK and v1 runtime; only VS Code + host modules remain external. + +The production VSIX excludes tests, fixtures, source maps, local profiles, +sessions, caches, logs, tokens, and the old Python/stdio runtime. Package audit +checks the manifest, assets, unresolved runtime imports, and sensitive files +after test state has been created. + +The extension produces six target artifacts: + +- `darwin-x64`, `darwin-arm64` +- `linux-x64`, `linux-arm64` +- `win32-x64`, `win32-arm64` + +The CI matrix builds and audits the target VSIX files. Installed-extension smoke +tests run on Linux, macOS, and Windows x64 runners. Linux checks the declared +minimum VS Code version and stable; macOS and Windows check the declared +minimum. Architecture targets without a matching runner remain package/audit +evidence rather than runtime E2E evidence. + +Packaging never publishes. Marketplace and Open VSX publication require a +separate authorized release action. + +## Validation snapshot + +The migration was validated with: + +- package-local tests for the extension, Node SDK, v1 core, and legacy + migration; +- real temporary homes and workspaces for migration and baseline behavior; +- provider-aware model, identity, MCP, error, session replay, delete, and fork + contract tests; +- production Extension Host and Webview builds; +- six target VSIX package audits; +- an upgrade from the released `0.5.10` extension to local `0.6.0` in an + isolated profile; +- installed VSIX Extension Host smoke on Linux, macOS, and Windows x64 CI; +- local installed `darwin-arm64` VSIX smoke on VS Code `1.100.0`; +- repository lint, typecheck, tests, Nix build, workspace sync, changeset + status, and whitespace checks. + +All automated CI checks for the migration branch were green when this document +was finalized. + +## Known limitations and release gates + +- Remote SSH, WSL, and Dev Container behavior follows `extensionKind: + ["workspace"]` and remote home resolution, but still needs representative + release-candidate smoke in a real remote environment. +- Cursor installation, activation, Webview, and basic chat require a usable + Cursor environment; VS Code smoke is not a substitute. +- A release candidate should receive one real-account auth/provider smoke in a + workspace containing no sensitive files. +- Migrated nested subagents preserve the parent call/summary but cannot recreate + every child prompt, step, tool, and approval detail from the legacy format. +- Legacy OAuth and MCP OAuth require reauthorization. +- Bash-based deletes and arbitrary external file edits are outside baseline + tracking. +- Concurrent writes to one session from multiple processes are unsupported. +- The package script uses a pinned `@vscode/vsce` programmatic `pack()` entry; + upgrading that package requires revalidating the packaging contract. + +## Maintenance invariants + +Future changes must preserve these boundaries unless a new design explicitly +replaces them: + +1. The Webview never imports the Node SDK or gains direct Node/file/auth access. +2. `apps/vscode` never imports v1 agent-core directly. +3. Shared config and sessions live in the SDK-resolved Kimi Code home; editor + preferences and baselines remain VS Code-owned. +4. Legacy migration translation stays in `packages/migration-legacy`. +5. Session storage is accessed through SDK/core APIs, not parsed or mutated by + the Webview. +6. Baselines remain an extension compatibility layer and do not make core tool + execution wait for VS Code. +7. Package-only evidence is not reported as runtime E2E evidence. +8. Build/package commands do not publish artifacts. diff --git a/apps/vscode/package.json b/apps/vscode/package.json new file mode 100644 index 0000000000..3d1bfd33e3 --- /dev/null +++ b/apps/vscode/package.json @@ -0,0 +1,312 @@ +{ + "name": "kimi-code", + "publisher": "moonshot-ai", + "displayName": "Kimi Code", + "description": "Official Kimi Code plugin for VS Code", + "version": "0.6.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/MoonshotAI/kimi-code.git", + "directory": "apps/vscode" + }, + "engines": { + "vscode": "^1.100.0" + }, + "extensionKind": [ + "workspace" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": false + }, + "virtualWorkspaces": false + }, + "categories": [ + "AI", + "Chat", + "Programming Languages" + ], + "keywords": [ + "ai", + "assistant", + "code", + "chat", + "agent", + "mcp", + "skills", + "json", + "autocomplete", + "moonshot", + "kimi", + "moonshot-ai" + ], + "pricing": "Free", + "activationEvents": [], + "main": "./dist/extension.js", + "icon": "resources/kimi-icon-storefront.png", + "contributes": { + "configuration": { + "title": "Kimi Code", + "properties": { + "kimi.yoloMode": { + "type": "boolean", + "default": false, + "description": "Auto-approve all tool calls" + }, + "kimi.autosave": { + "type": "boolean", + "default": true, + "description": "Automatically save files before Kimi reads or writes them" + }, + "kimi.enableNewConversationShortcut": { + "type": "boolean", + "default": false, + "description": "Use Cmd/Ctrl+N to start a new conversation when Kimi is focused" + }, + "kimi.useCtrlEnterToSend": { + "type": "boolean", + "default": false, + "description": "Use Ctrl/Cmd+Enter to send prompts instead of Enter" + }, + "kimi.showThinkingContent": { + "type": "boolean", + "default": true, + "description": "Show thinking/reasoning content in the chat UI" + }, + "kimi.showThinkingExpanded": { + "type": "boolean", + "default": false, + "description": "Auto-expand thinking/reasoning sections when shown (requires 'Show Thinking Content' to be enabled)" + }, + "kimi.editorContext": { + "type": "string", + "default": "never", + "enum": ["never", "onConversationStart", "onFileChange"], + "enumDescriptions": [ + "Never share editor context", + "Share once when conversation starts", + "Share when active file changes" + ], + "description": "Control when to share the active editor's file and cursor position with Kimi" + } + } + }, + "commands": [ + { + "command": "kimi.clearAllState", + "title": "Kimi Code: [DEBUG] Clear All State" + }, + { + "command": "kimi.openInTab", + "title": "Kimi Code: Open in New Tab", + "icon": "$(link-external)" + }, + { + "command": "kimi.openInSideBar", + "title": "Kimi Code: Open in Side Panel", + "icon": "$(layout-sidebar-left)" + }, + { + "command": "kimi.focusInput", + "title": "Kimi Code: Focus Input", + "icon": "$(edit)" + }, + { + "command": "kimi.insertMention", + "title": "Kimi Code: Insert Current File", + "icon": "$(mention)" + }, + { + "command": "kimi.newConversation", + "title": "Kimi Code: New Conversation", + "icon": "$(add)" + }, + { + "command": "kimi.showLogs", + "title": "Kimi Code: Show Logs", + "icon": "$(output)" + }, + { + "command": "kimi.resetKimi", + "title": "Kimi Code: Reset Kimi (in case of any issues/no response)", + "icon": "$(refresh)" + }, + { + "command": "kimi.logout", + "title": "Kimi Code: Logout", + "icon": "$(sign-out)" + }, + { + "command": "kimi.migrateLegacyData", + "title": "Kimi Code: Migrate Legacy Data" + } + ], + "keybindings": [ + { + "command": "kimi.focusInput", + "key": "ctrl+shift+k", + "mac": "cmd+shift+k" + }, + { + "command": "kimi.insertMention", + "key": "alt+k", + "mac": "alt+k", + "when": "editorTextFocus" + } + ], + "viewsContainers": { + "activitybar": [ + { + "id": "kimi-sidebar", + "title": "Kimi Code", + "icon": "resources/kimi-icon.svg" + } + ] + }, + "views": { + "kimi-sidebar": [ + { + "type": "webview", + "id": "kimi.webview", + "name": "Kimi Code" + } + ] + }, + "menus": { + "view/title": [ + { + "command": "kimi.openInTab", + "when": "view == kimi.webview", + "group": "navigation" + }, + { + "command": "kimi.newConversation", + "when": "view == kimi.webview", + "group": "navigation" + } + ], + "commandPalette": [ + { + "command": "kimi.clearAllState", + "when": "isDevelopment" + }, + { + "command": "kimi.openInTab", + "when": "true" + }, + { + "command": "kimi.openInSideBar", + "when": "true" + }, + { + "command": "kimi.focusInput", + "when": "true" + }, + { + "command": "kimi.insertMention", + "when": "true" + }, + { + "command": "kimi.newConversation", + "when": "true" + }, + { + "command": "kimi.showLogs", + "when": "true" + }, + { + "command": "kimi.resetKimi", + "when": "true" + }, + { + "command": "kimi.logout", + "when": "kimi.isLoggedIn" + }, + { + "command": "kimi.migrateLegacyData", + "when": "true" + } + ], + "editor/context": [ + { + "command": "kimi.insertMention", + "group": "moonshot-ai", + "when": "editorTextFocus" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "pnpm run build", + "dev": "pnpm run dev:prepare && pnpm run \"/^dev:(extension|webview)$/\"", + "dev:prepare": "node scripts/prepare-dev.mjs", + "dev:extension": "node scripts/watch-extension.mjs", + "dev:webview": "vite build --config webview-ui/vite.config.ts --watch --sourcemap", + "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p webview-ui/tsconfig.json --noEmit", + "build": "pnpm run build:extension && pnpm run build:webview", + "build:webview": "vite build --config webview-ui/vite.config.ts", + "build:extension": "tsdown --config tsdown.config.ts", + "test": "vitest run --config vitest.config.ts", + "test:extension-host": "node scripts/extension-host-smoke.mjs", + "package:platform": "node scripts/vsix-package.mjs", + "package:verify": "node scripts/vsix-verify.mjs", + "publish:vsix": "node scripts/vsix-publish.mjs", + "publish:ovsx": "node scripts/ovsx-publish.mjs" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.4", + "@types/diff": "^8.0.0", + "@types/katex": "^0.16.8", + "@types/node": "^22.15.3", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@types/react-scroll-to-bottom": "^4.2.5", + "@types/react-syntax-highlighter": "^15.5.13", + "@types/vscode": "1.100.0", + "@vitejs/plugin-react": "^4.4.1", + "@vscode/test-cli": "^0.0.11", + "@vscode/test-electron": "^2.5.2", + "@vscode/vsce": "3.9.2", + "acorn": "8.17.0", + "ovsx": "1.0.2", + "tailwindcss": "^4.1.4", + "vite": "^6.3.3", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vitest": "4.1.4" + }, + "dependencies": { + "@base-ui/react": "^1.0.0", + "@fontsource-variable/inter": "5.2.8", + "@moonshot-ai/kimi-code-sdk": "workspace:^", + "@moonshot-ai/migration-legacy": "workspace:^", + "@radix-ui/react-accordion": "^1.2.12", + "@tabler/icons-react": "^3.36.0", + "ahooks": "^3.9.6", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "diff": "^8.0.2", + "fuse.js": "^7.1.0", + "heic-to": "^1.0.2", + "immer": "^11.1.0", + "katex": "^0.17.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "react-scroll-to-bottom": "^4.2.0", + "react-syntax-highlighter": "^16.1.0", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "shadcn": "^3.6.2", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tw-animate-css": "^1.4.0", + "zustand": "^5" + } +} diff --git a/apps/vscode/resources/kimi-icon-storefront.png b/apps/vscode/resources/kimi-icon-storefront.png new file mode 100644 index 0000000000..033c0f4fb2 Binary files /dev/null and b/apps/vscode/resources/kimi-icon-storefront.png differ diff --git a/apps/vscode/resources/kimi-icon.svg b/apps/vscode/resources/kimi-icon.svg new file mode 100644 index 0000000000..db4631b8e4 --- /dev/null +++ b/apps/vscode/resources/kimi-icon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + diff --git a/apps/vscode/scripts/extension-host-smoke.d.mts b/apps/vscode/scripts/extension-host-smoke.d.mts new file mode 100644 index 0000000000..eb723df5b0 --- /dev/null +++ b/apps/vscode/scripts/extension-host-smoke.d.mts @@ -0,0 +1,16 @@ +export interface ExtensionHostSmokeOptions { + version?: string; + vsixPath?: string; + cachePath?: string; +} + +export interface ExtensionHostSmokeResult { + version: string; + vscodeVersion: string; + vsixPath: string; + cachePath: string; +} + +export function runExtensionHostSmoke( + options?: ExtensionHostSmokeOptions, +): Promise; diff --git a/apps/vscode/scripts/extension-host-smoke.mjs b/apps/vscode/scripts/extension-host-smoke.mjs new file mode 100644 index 0000000000..03eeb2090d --- /dev/null +++ b/apps/vscode/scripts/extension-host-smoke.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runTests, runVSCodeCommand } from "@vscode/test-electron"; + +import { isMainModule } from "./vsix-targets.mjs"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const appDir = resolve(scriptDir, ".."); +const defaultCachePath = join(tmpdir(), "kimi-vscode-test-cache"); + +export async function runExtensionHostSmoke(options = {}) { + const version = options.version ?? "stable"; + const cacheRoot = resolve(options.cachePath ?? process.env.VSCODE_TEST_CACHE ?? defaultCachePath); + const vsixPath = resolve(options.vsixPath ?? defaultVsixPath()); + await access(vsixPath); + await mkdir(cacheRoot, { recursive: true }); + + // A stable request must never reuse an older stable download. @vscode/test-electron + // falls back to a cached build when version discovery fails, which would turn an + // offline run into a false green. Exact versions remain cached by version. + const disposableCache = version === "stable"; + const cachePath = disposableCache + ? await mkdtemp(join(cacheRoot, "stable-")) + : cacheRoot; + + const root = await mkdtemp(join(tmpdir(), "kvh-")); + const paths = { + root, + extensions: join(root, "ext"), + installUserData: join(root, "install"), + userData: join(root, "user"), + kimiHome: join(root, "home"), + osHome: join(root, "os-home"), + workspace: join(root, "ws"), + harness: join(root, "harness"), + report: join(root, "extension-host-report.json"), + }; + + try { + await Promise.all([ + paths.extensions, + paths.installUserData, + paths.userData, + paths.kimiHome, + paths.osHome, + paths.workspace, + paths.harness, + ].map((path) => mkdir(path, { recursive: true }))); + await writeFile(join(paths.workspace, "README.md"), "# Kimi VSIX Extension Host smoke\n", "utf8"); + await writeHarnessManifest(paths.harness); + + const installProfileArgs = [ + `--extensions-dir=${paths.extensions}`, + `--user-data-dir=${paths.installUserData}`, + ]; + const profileArgs = [ + `--extensions-dir=${paths.extensions}`, + `--user-data-dir=${paths.userData}`, + ]; + const downloadOptions = { version, cachePath }; + const install = await runVSCodeCommand( + ["--install-extension", vsixPath, "--force", ...installProfileArgs], + downloadOptions, + ); + const installOutput = `${install.stdout}\n${install.stderr}`; + if (!/successfully installed|was successfully installed/i.test(installOutput)) { + throw new Error(`VSIX installation did not report success:\n${installOutput.trim()}`); + } + + await runTests({ + ...downloadOptions, + extensionDevelopmentPath: paths.harness, + extensionTestsPath: join(appDir, "test", "extension-host", "index.cjs"), + launchArgs: [ + paths.workspace, + ...profileArgs, + "--disable-workspace-trust", + "--skip-welcome", + "--skip-release-notes", + ], + extensionTestsEnv: { + KIMI_CODE_HOME: paths.kimiHome, + KIMI_VSCODE_SMOKE_OS_HOME: paths.osHome, + KIMI_VSCODE_SMOKE_REPORT: paths.report, + KIMI_VSCODE_SMOKE_VSIX: basename(vsixPath), + }, + }); + + const report = JSON.parse(await readFile(paths.report, "utf8")); + if (typeof report.vscode !== "string" || report.vscode.length === 0) { + throw new Error("Extension Host smoke did not report its actual VS Code version"); + } + if (version !== "stable" && report.vscode !== version) { + throw new Error( + `Extension Host ran VS Code ${report.vscode}, expected requested version ${version}`, + ); + } + + return { version, vscodeVersion: report.vscode, vsixPath, cachePath }; + } finally { + await Promise.all([ + rm(root, { recursive: true, force: true }), + disposableCache ? rm(cachePath, { recursive: true, force: true }) : Promise.resolve(), + ]); + } +} + +function defaultVsixPath() { + const arch = process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "x64" : process.arch; + return join(appDir, "artifacts", "vsix", `kimi-code-${process.platform}-${arch}.vsix`); +} + +async function writeHarnessManifest(directory) { + await writeFile( + join(directory, "package.json"), + JSON.stringify({ + name: "kimi-vscode-extension-host-smoke", + displayName: "Kimi VSCode Extension Host Smoke", + publisher: "local-test", + version: "0.0.0", + engines: { vscode: "^1.70.0" }, + main: "./extension.cjs", + activationEvents: ["*"], + }), + "utf8", + ); + await writeFile( + join(directory, "extension.cjs"), + "exports.activate = function activate() {}; exports.deactivate = function deactivate() {};\n", + "utf8", + ); +} + +function parseArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--") continue; + if (argument === "--version") { + options.version = requiredValue(argv[++index], argument); + } else if (argument === "--vsix") { + options.vsixPath = requiredValue(argv[++index], argument); + } else if (argument === "--cache-path") { + options.cachePath = requiredValue(argv[++index], argument); + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else { + throw new Error(`Unknown option: ${argument}`); + } + } + return options; +} + +function requiredValue(value, flag) { + if (value === undefined || value.startsWith("-")) throw new Error(`${flag} requires a value`); + return value; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log("Usage: node scripts/extension-host-smoke.mjs [--version ] [--vsix ] [--cache-path ]"); + return; + } + const result = await runExtensionHostSmoke(options); + console.log( + `VSIX Extension Host smoke passed: ${result.vsixPath} on VS Code ${result.vscodeVersion} (requested ${result.version})`, + ); +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`VSIX Extension Host smoke failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/scripts/local-cli.mjs b/apps/vscode/scripts/local-cli.mjs new file mode 100644 index 0000000000..ccc29ae7e3 --- /dev/null +++ b/apps/vscode/scripts/local-cli.mjs @@ -0,0 +1,47 @@ +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +export function resolveLocalCli(packageName, executableName) { + let packageJsonPath; + try { + packageJsonPath = require.resolve(`${packageName}/package.json`); + } catch (error) { + throw new Error( + `Local CLI dependency ${packageName} is not installed. Run pnpm install; runtime CLI downloads are disabled.`, + { cause: error }, + ); + } + + const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const relativeBin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[executableName]; + if (typeof relativeBin !== 'string') { + throw new Error(`${packageName} does not declare the expected "${executableName}" binary.`); + } + return resolve(dirname(packageJsonPath), relativeBin); +} + +export function runLocalCli(packageName, executableName, args, options = {}) { + const cliPath = resolveLocalCli(packageName, executableName); + const result = spawnSync(process.execPath, [cliPath, ...args], { + cwd: options.cwd, + env: options.env ?? process.env, + encoding: options.encoding, + stdio: options.stdio ?? 'inherit', + }); + if (result.error !== undefined) { + throw new Error(`Unable to start local ${executableName}: ${result.error.message}`, { + cause: result.error, + }); + } + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join(''); + throw new Error( + `Local ${executableName} exited with code ${result.status ?? 'unknown'}${output ? `:\n${output}` : ''}`, + ); + } + return result; +} diff --git a/apps/vscode/scripts/ovsx-publish.mjs b/apps/vscode/scripts/ovsx-publish.mjs new file mode 100644 index 0000000000..05809c6bcc --- /dev/null +++ b/apps/vscode/scripts/ovsx-publish.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +import { existsSync } from 'node:fs'; + +import { runLocalCli } from './local-cli.mjs'; +import { parsePublishArguments, publishUsage } from './publish-args.mjs'; +import { extensionRoot, isMainModule } from './vsix-targets.mjs'; +import { verifyVsix } from './vsix-verify.mjs'; + +async function main() { + const options = parsePublishArguments(process.argv.slice(2)); + if (options.help) { + console.log(publishUsage('Open VSX')); + return; + } + if (!process.env.OVSX_PAT) throw new Error('OVSX_PAT is required to publish.'); + + await verifyInputs(options); + for (const file of options.files) { + console.log(`Publishing verified package ${file}...`); + try { + runLocalCli('ovsx', 'ovsx', ['publish', file], { + cwd: extensionRoot, + encoding: 'utf8', + stdio: 'pipe', + }); + } catch (error) { + if (/already exists/i.test(error instanceof Error ? error.message : String(error))) { + console.log(`Package already exists: ${file}`); + continue; + } + throw error; + } + } +} + +async function verifyInputs(options) { + for (let index = 0; index < options.targets.length; index += 1) { + const file = options.files[index]; + if (!existsSync(file)) { + throw new Error(`Missing VSIX ${file}. Run pnpm run package:platform first.`); + } + await verifyVsix(file, options.targets[index], { sourceRoot: extensionRoot }); + } +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`Open VSX publish failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/scripts/prepare-dev.mjs b/apps/vscode/scripts/prepare-dev.mjs new file mode 100644 index 0000000000..81f9fb8a96 --- /dev/null +++ b/apps/vscode/scripts/prepare-dev.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, parse, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { isMainModule } from './vsix-targets.mjs'; + +const SAFE_DIRECTORY_NAME = 'vscode-extension-dev'; +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const monorepoRoot = resolve(scriptDir, '../../..'); +const defaultBaseDir = join(monorepoRoot, '.tmp', SAFE_DIRECTORY_NAME); + +export async function prepareDevEnvironment(baseDir = defaultBaseDir) { + const root = resolve(baseDir); + assertSafeRoot(root); + await rm(root, { recursive: true, force: true }); + + const paths = { + root, + userData: join(root, 'user-data'), + extensions: join(root, 'extensions'), + kimiHome: join(root, 'kimi-home'), + workspace: join(root, 'workspace'), + }; + await Promise.all( + Object.values(paths) + .filter((path) => path !== root) + .map((path) => mkdir(path, { recursive: true })), + ); + await writeFile( + join(paths.workspace, 'README.md'), + '# Isolated Kimi Code extension development workspace\n', + ); + return paths; +} + +function assertSafeRoot(root) { + const parsed = parse(root); + if (root === parsed.root || basename(root) !== SAFE_DIRECTORY_NAME) { + throw new Error( + `Refusing to reset unsafe development directory "${root}"; it must end in ${SAFE_DIRECTORY_NAME}.`, + ); + } +} + +function parseArguments(argv) { + let baseDir = defaultBaseDir; + let help = false; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') { + continue; + } else if (argument === '--help' || argument === '-h') { + help = true; + } else if (argument === '--base-dir') { + const value = argv[++index]; + if (value === undefined || value.startsWith('-')) throw new Error('--base-dir requires a value.'); + baseDir = value; + } else { + throw new Error(`Unknown option: ${argument}`); + } + } + return { baseDir, help }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log('Usage: node scripts/prepare-dev.mjs [--base-dir <.../vscode-extension-dev>]'); + return; + } + const paths = await prepareDevEnvironment(options.baseDir); + console.log(`Prepared isolated VS Code profile: ${paths.root}`); + console.log(`KIMI_CODE_HOME=${paths.kimiHome}`); + console.log(`Workspace=${paths.workspace}`); +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`Development environment setup failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/scripts/publish-args.mjs b/apps/vscode/scripts/publish-args.mjs new file mode 100644 index 0000000000..ce2f095c25 --- /dev/null +++ b/apps/vscode/scripts/publish-args.mjs @@ -0,0 +1,54 @@ +import { join, resolve } from 'node:path'; + +import { + defaultVsixOutputDir, + normalizeVsixTargets, + vsixFileName, +} from './vsix-targets.mjs'; + +export function parsePublishArguments(argv) { + const targets = []; + let outputDir = defaultVsixOutputDir; + let help = false; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') { + continue; + } else if (argument === '--help' || argument === '-h') { + help = true; + } else if (argument === '--out-dir') { + outputDir = requireOptionValue(argv, ++index, '--out-dir'); + } else if (argument === '--target') { + targets.push(requireOptionValue(argv, ++index, '--target')); + } else if (argument.startsWith('-')) { + throw new Error(`Unknown option: ${argument}`); + } else { + targets.push(argument); + } + } + + const normalizedTargets = normalizeVsixTargets(targets); + const resolvedOutputDir = resolve(outputDir); + return { + help, + targets: normalizedTargets, + outputDir: resolvedOutputDir, + files: normalizedTargets.map((target) => join(resolvedOutputDir, vsixFileName(target))), + }; +} + +function requireOptionValue(argv, index, option) { + const value = argv[index]; + if (value !== undefined && !value.startsWith('-')) return value; + throw new Error(`${option} requires a value.`); +} + +export function publishUsage(marketplace) { + return [ + `Usage: node scripts/${marketplace === 'Open VSX' ? 'ovsx' : 'vsix'}-publish.mjs [targets...] [--out-dir ]`, + '', + `Publishes already-built, re-verified VSIX files to ${marketplace}.`, + 'This command never builds or downloads a CLI.', + ].join('\n'); +} diff --git a/apps/vscode/scripts/vsix-package.mjs b/apps/vscode/scripts/vsix-package.mjs new file mode 100644 index 0000000000..0558e1eb82 --- /dev/null +++ b/apps/vscode/scripts/vsix-package.mjs @@ -0,0 +1,141 @@ +#!/usr/bin/env node +import { createRequire } from 'node:module'; +import { mkdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { runLocalCli } from './local-cli.mjs'; +import { + defaultVsixOutputDir, + extensionRoot, + isMainModule, + normalizeVsixTargets, + vsixFileName, +} from './vsix-targets.mjs'; +import { verifyVsix } from './vsix-verify.mjs'; + +const require = createRequire(import.meta.url); + +function parseArguments(argv) { + const targets = []; + let outputDir = defaultVsixOutputDir; + let dryRun = false; + let help = false; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') { + continue; + } else if (argument === '--help' || argument === '-h') { + help = true; + } else if (argument === '--dry-run') { + dryRun = true; + } else if (argument === '--out-dir') { + outputDir = requireOptionValue(argv, ++index, '--out-dir'); + } else if (argument === '--target') { + targets.push(requireOptionValue(argv, ++index, '--target')); + } else if (argument.startsWith('-')) { + throw new Error(`Unknown option: ${argument}`); + } else { + targets.push(argument); + } + } + + return { + targets: normalizeVsixTargets(targets), + outputDir: resolve(outputDir), + dryRun, + help, + }; +} + +function requireOptionValue(argv, index, option) { + const value = argv[index]; + if (value !== undefined && !value.startsWith('-')) return value; + throw new Error(`${option} requires a value.`); +} + +function usage() { + return [ + 'Usage: node scripts/vsix-package.mjs [targets...] [--out-dir ]', + ' node scripts/vsix-package.mjs --target win32-x64 --dry-run', + '', + 'With no targets, all six supported VSIX targets are built and audited.', + 'This command never publishes an extension.', + ].join('\n'); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + await mkdir(options.outputDir, { recursive: true }); + console.log(`VSIX targets: ${options.targets.join(', ')}`); + console.log(`Output directory: ${options.outputDir}`); + + if (!options.dryRun) { + console.log('Building the extension once before packaging all targets...'); + buildExtension(); + } + + const packVsix = options.dryRun ? undefined : loadVscePack(); + + for (const target of options.targets) { + const outputPath = join(options.outputDir, vsixFileName(target)); + if (options.dryRun) { + console.log(`Would package ${target} -> ${outputPath}`); + continue; + } + + console.log(`\nPackaging ${target} with the workspace @vscode/vsce...`); + await packVsix({ + cwd: extensionRoot, + dependencies: false, + packagePath: outputPath, + target, + }); + const result = await verifyVsix(outputPath, target, { sourceRoot: extensionRoot }); + console.log( + `Verified ${target}: ${result.files} files, ${result.bytes} unpacked bytes; package/static checks passed.`, + ); + } + + if (!options.dryRun) { + console.log( + '\nVSIX packaging complete. These are package-only results until each target runs in its matching extension host.', + ); + } +} + +function buildExtension() { + runLocalCli('tsdown', 'tsdown', ['--config', 'tsdown.config.ts'], { cwd: extensionRoot }); + runLocalCli('vite', 'vite', ['build', '--config', 'webview-ui/vite.config.ts'], { + cwd: extensionRoot, + }); +} + +function loadVscePack() { + try { + // VSCE's public API always runs vscode:prepublish. We build once above and pin VSCE, + // so use its package phase directly instead of rebuilding for every target. + const module = require('@vscode/vsce/out/package.js'); + if (typeof module.pack !== 'function') { + throw new TypeError('the installed package does not expose pack()'); + } + return module.pack; + } catch (error) { + throw new Error( + 'The workspace @vscode/vsce package API is unavailable. Run pnpm install; runtime CLI downloads are disabled.', + { cause: error }, + ); + } +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`VSIX packaging failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/scripts/vsix-publish.mjs b/apps/vscode/scripts/vsix-publish.mjs new file mode 100644 index 0000000000..e795247860 --- /dev/null +++ b/apps/vscode/scripts/vsix-publish.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import { existsSync } from 'node:fs'; + +import { runLocalCli } from './local-cli.mjs'; +import { parsePublishArguments, publishUsage } from './publish-args.mjs'; +import { extensionRoot, isMainModule } from './vsix-targets.mjs'; +import { verifyVsix } from './vsix-verify.mjs'; + +async function main() { + const options = parsePublishArguments(process.argv.slice(2)); + if (options.help) { + console.log(publishUsage('Visual Studio Marketplace')); + return; + } + if (!process.env.VSCE_PAT) throw new Error('VSCE_PAT is required to publish.'); + + await verifyInputs(options); + for (const file of options.files) { + console.log(`Publishing verified package ${file}...`); + runLocalCli( + '@vscode/vsce', + 'vsce', + ['publish', '--packagePath', file, '--skip-duplicate'], + { cwd: extensionRoot }, + ); + } +} + +async function verifyInputs(options) { + for (let index = 0; index < options.targets.length; index += 1) { + const file = options.files[index]; + if (!existsSync(file)) { + throw new Error(`Missing VSIX ${file}. Run pnpm run package:platform first.`); + } + await verifyVsix(file, options.targets[index], { sourceRoot: extensionRoot }); + } +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`Marketplace publish failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/scripts/vsix-targets.mjs b/apps/vscode/scripts/vsix-targets.mjs new file mode 100644 index 0000000000..b3314ad419 --- /dev/null +++ b/apps/vscode/scripts/vsix-targets.mjs @@ -0,0 +1,45 @@ +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +export const VSIX_TARGETS = Object.freeze([ + 'darwin-x64', + 'darwin-arm64', + 'linux-x64', + 'linux-arm64', + 'win32-x64', + 'win32-arm64', +]); + +export const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const defaultVsixOutputDir = join(extensionRoot, 'artifacts', 'vsix'); + +export function vsixFileName(target) { + assertVsixTarget(target); + return `kimi-code-${target}.vsix`; +} + +export function normalizeVsixTargets(values) { + const requested = values.length === 0 || values.includes('all') ? VSIX_TARGETS : values; + if (values.includes('all') && values.length > 1) { + throw new Error('Target "all" cannot be combined with an explicit VSIX target.'); + } + + const targets = []; + for (const target of requested) { + assertVsixTarget(target); + if (!targets.includes(target)) targets.push(target); + } + return targets; +} + +export function assertVsixTarget(target) { + if (VSIX_TARGETS.includes(target)) return; + throw new Error( + `Unknown VSIX target "${target}". Expected one of: ${VSIX_TARGETS.join(', ')}`, + ); +} + +export function isMainModule(metaUrl) { + const argvPath = process.argv[1]; + return argvPath !== undefined && pathToFileURL(resolve(argvPath)).href === metaUrl; +} diff --git a/apps/vscode/scripts/vsix-verify.mjs b/apps/vscode/scripts/vsix-verify.mjs new file mode 100644 index 0000000000..b1e05219e1 --- /dev/null +++ b/apps/vscode/scripts/vsix-verify.mjs @@ -0,0 +1,487 @@ +#!/usr/bin/env node +import { builtinModules } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; +import { parse } from 'acorn'; + +import { extractZip } from './zip.mjs'; +import { + defaultVsixOutputDir, + extensionRoot, + isMainModule, + normalizeVsixTargets, + vsixFileName, +} from './vsix-targets.mjs'; + +const REQUIRED_WEBVIEW_FILES = [ + 'dist/webview.js', + 'dist/kimi-banner-dark.svg', + 'dist/kimi-banner-light.svg', + 'dist/kimi-logo.png', +]; +const FORBIDDEN_PATH_SEGMENTS = new Set([ + '.kimi', + '.kimi-code', + '.vscode', + '__tests__', + 'cache', + 'caches', + 'credentials', + 'logs', + 'node_modules', + 'profile', + 'profiles', + 'runtime', + 'scripts', + 'session', + 'sessions', + 'src', + 'state', + 'states', + 'test', + 'tests', + 'tokens', + 'webview-ui', +]); +const FORBIDDEN_EXTENSIONS = new Set(['.jsonl', '.log', '.map', '.py', '.pyc', '.ts', '.tsx']); +const TEXT_EXTENSIONS = new Set([ + '.cjs', + '.css', + '.html', + '.js', + '.json', + '.md', + '.mjs', + '.svg', + '.txt', + '.xml', +]); +const BUILTIN_IMPORTS = new Set( + builtinModules.flatMap((name) => [name, name.startsWith('node:') ? name.slice(5) : `node:${name}`]), +); +// `ws` probes these native accelerators inside try/catch and immediately uses +// its bundled JavaScript fallback when they are absent. They are not required +// runtime dependencies and must not be shipped as cross-platform native code. +const OPTIONAL_FALLBACK_IMPORTS = new Set(['bufferutil', 'canvas', 'utf-8-validate']); +const MANIFEST_FIELDS = [ + 'name', + 'publisher', + 'displayName', + 'version', + 'engines', + 'extensionKind', + 'capabilities', + 'activationEvents', + 'main', + 'icon', +]; +const CONTRIBUTE_FIELDS = [ + 'commands', + 'configuration', + 'keybindings', + 'menus', + 'views', + 'viewsContainers', +]; + +export async function verifyVsix(vsixPath, target, options = {}) { + const extractionRoot = await mkdtemp(join(tmpdir(), 'kimi-vsix-audit-')); + try { + await extractZip(vsixPath, extractionRoot); + return await auditExtractedVsix(extractionRoot, target, options); + } finally { + await rm(extractionRoot, { recursive: true, force: true }); + } +} + +export async function auditExtractedVsix(extractionRoot, target, options = {}) { + const sourceRoot = options.sourceRoot ?? extensionRoot; + const extensionDir = join(extractionRoot, 'extension'); + const files = await listFiles(extractionRoot); + const fileSet = new Set(files); + + requireFile(fileSet, 'extension.vsixmanifest'); + requireFile(fileSet, '[Content_Types].xml'); + requireFile(fileSet, 'extension/package.json'); + const packagedManifest = await readJson(join(extensionDir, 'package.json'), 'package.json'); + const sourceManifest = await readJson(join(sourceRoot, 'package.json'), 'source package.json'); + + await verifyTargetManifest(extractionRoot, target); + verifyPackageManifest(packagedManifest, sourceManifest); + verifyRequiredFiles(fileSet, packagedManifest); + verifyForbiddenFiles(files); + await verifyNoSensitiveContent(extractionRoot, files, sourceRoot, options.forbiddenText ?? []); + await verifyRuntimeImports(extensionDir, files); + await verifyEntryImport(extensionDir, packagedManifest.main); + + const bytes = await totalSize(extractionRoot, files); + return { target, files: files.length, bytes }; +} + +async function verifyTargetManifest(extractionRoot, target) { + const xml = await readFile(join(extractionRoot, 'extension.vsixmanifest'), 'utf8'); + const actual = xml.match(/\bTargetPlatform="([^"]+)"/)?.[1]; + if (actual !== target) { + throw new Error( + `VSIX manifest target is ${actual ?? 'missing'}, expected ${target}.`, + ); + } +} + +function verifyPackageManifest(packaged, source) { + if (typeof packaged.main !== 'string' || !packaged.main.endsWith('.js')) { + throw new Error(`Packaged extension main must be a .js entry, got ${String(packaged.main)}.`); + } + if (packaged.main !== './dist/extension.js') { + throw new Error(`Packaged extension main is ${packaged.main}, expected ./dist/extension.js.`); + } + + for (const field of MANIFEST_FIELDS) { + if (!isDeepStrictEqual(packaged[field], source[field])) { + throw new Error(`Packaged package.json field "${field}" does not match the source manifest.`); + } + } + for (const field of CONTRIBUTE_FIELDS) { + if (!isDeepStrictEqual(packaged.contributes?.[field], source.contributes?.[field])) { + throw new Error( + `Packaged package.json contributes.${field} does not match the source manifest.`, + ); + } + } +} + +function verifyRequiredFiles(fileSet, manifest) { + const required = [ + 'extension/LICENSE.txt', + `extension/${stripLeadingDotSlash(manifest.main)}`, + ...REQUIRED_WEBVIEW_FILES.map((file) => `extension/${file}`), + ]; + requireOneOf(fileSet, ['extension/README.md', 'extension/readme.md'], 'marketplace README'); + if (typeof manifest.icon === 'string') required.push(`extension/${manifest.icon}`); + + for (const container of Object.values(manifest.contributes?.viewsContainers ?? {})) { + if (!Array.isArray(container)) continue; + for (const view of container) { + if (typeof view?.icon === 'string') required.push(`extension/${view.icon}`); + } + } + for (const file of required) requireFile(fileSet, file); +} + +function verifyForbiddenFiles(files) { + for (const file of files) { + const normalized = file.replaceAll('\\', '/'); + const lower = normalized.toLowerCase(); + const extensionRelative = lower.startsWith('extension/') ? lower.slice('extension/'.length) : lower; + const segments = extensionRelative.split('/'); + const forbiddenSegment = segments.find((segment) => FORBIDDEN_PATH_SEGMENTS.has(segment)); + if (forbiddenSegment !== undefined) { + throw new Error(`Forbidden package path segment "${forbiddenSegment}" in ${normalized}.`); + } + if (FORBIDDEN_EXTENSIONS.has(extname(lower))) { + throw new Error(`Forbidden package file type in ${normalized}.`); + } + if ( + lower.includes('kimi-agent-sdk') || + lower.includes('download-cli') || + lower.includes('/bin/kimi/') || + /(^|\/)uv(?:\.exe)?$/.test(lower) + ) { + throw new Error(`Legacy CLI/runtime artifact found in ${normalized}.`); + } + } +} + +async function verifyNoSensitiveContent(extractionRoot, files, sourceRoot, extraForbiddenText) { + const secretValues = [process.env.VSCE_PAT, process.env.OVSX_PAT] + .filter((value) => typeof value === 'string' && value.length >= 8); + const forbidden = [sourceRoot, homedir(), ...extraForbiddenText, ...secretValues] + .filter((value) => typeof value === 'string' && value.length >= 4) + .flatMap((value) => [value, value.replaceAll('\\', '/'), value.replaceAll('/', '\\')]); + + for (const file of files) { + if (!TEXT_EXTENSIONS.has(extname(file).toLowerCase())) continue; + const content = await readFile(join(extractionRoot, file), 'utf8'); + const match = forbidden.find((value) => content.includes(value)); + if (match === undefined) continue; + const label = secretValues.includes(match) ? 'a marketplace token' : 'a local filesystem path'; + throw new Error(`Packaged text file ${file} contains ${label}.`); + } +} + +async function verifyRuntimeImports(extensionDir, files) { + const distFiles = files.filter( + (file) => file.startsWith('extension/dist/') && ['.cjs', '.js', '.mjs'].includes(extname(file)), + ); + if (distFiles.length === 0) throw new Error('No JavaScript extension bundle files were packaged.'); + + for (const archivePath of distFiles) { + const localPath = join(dirname(extensionDir), archivePath); + const source = await readFile(localPath, 'utf8'); + for (const specifier of collectLiteralImports(source)) { + if (specifier === 'vscode' || BUILTIN_IMPORTS.has(specifier)) continue; + if (OPTIONAL_FALLBACK_IMPORTS.has(specifier)) continue; + if (specifier.startsWith('node:')) continue; + if (specifier.startsWith('.') || specifier.startsWith('/')) { + if (specifier.startsWith('/')) { + throw new Error(`Absolute runtime import "${specifier}" in ${archivePath}.`); + } + const dependencyPath = resolve(dirname(localPath), stripImportSuffix(specifier)); + if (!runtimeImportExists(dependencyPath)) { + throw new Error(`Missing relative runtime import "${specifier}" in ${archivePath}.`); + } + continue; + } + if (specifier.startsWith('data:') || specifier.startsWith('file:')) continue; + throw new Error(`Bare runtime dependency "${specifier}" remains in ${archivePath}.`); + } + } +} + +function collectLiteralImports(source) { + const imports = new Set(); + const program = parse(source, { + allowHashBang: true, + ecmaVersion: 'latest', + sourceType: 'module', + }); + walkSyntax(program, (node) => { + if ( + node.type === 'ImportDeclaration' || + node.type === 'ExportAllDeclaration' || + node.type === 'ExportNamedDeclaration' + ) { + const specifier = literalString(node.source); + if (specifier !== undefined) imports.add(specifier); + return; + } + if (node.type === 'ImportExpression') { + const specifier = literalString(node.source); + if (specifier !== undefined) imports.add(specifier); + return; + } + if (node.type === 'CallExpression' && isRuntimeRequire(node.callee)) { + const specifier = literalString(node.arguments?.[0]); + if (specifier !== undefined) imports.add(specifier); + } + }); + return imports; +} + +function walkSyntax(value, visit) { + if (Array.isArray(value)) { + for (const item of value) walkSyntax(item, visit); + return; + } + if (typeof value !== 'object' || value === null) return; + if (typeof value.type === 'string') visit(value); + for (const [key, child] of Object.entries(value)) { + if (key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue; + walkSyntax(child, visit); + } +} + +function isRuntimeRequire(callee) { + if (callee?.type === 'Identifier') return /^(?:__)?require\d*$/.test(callee.name); + if (callee?.type !== 'MemberExpression' || callee.computed === true) return false; + return callee.property?.type === 'Identifier' && callee.property.name === 'require'; +} + +function literalString(node) { + if (node?.type === 'Literal' && typeof node.value === 'string') return node.value; + if (node?.type === 'TemplateLiteral' && node.expressions?.length === 0) { + return node.quasis?.[0]?.value?.cooked; + } + return undefined; +} + +async function verifyEntryImport(extensionDir, main) { + const mainPath = join(extensionDir, stripLeadingDotSlash(main)); + const stubDir = join(extensionDir, 'node_modules', 'vscode'); + await mkdir(stubDir, { recursive: true }); + await writeFile( + join(stubDir, 'package.json'), + `${JSON.stringify({ name: 'vscode', version: '0.0.0-test', type: 'module', exports: './index.js' }, null, 2)}\n`, + ); + await writeFile(join(stubDir, 'index.js'), 'export {};\n'); + + const script = [ + `const extension = await import(${JSON.stringify(pathToFileURL(mainPath).href)});`, + 'if (typeof extension.activate !== "function") {', + ' throw new Error("extension bundle does not export activate");', + '}', + ].join('\n'); + const env = { ...process.env }; + delete env.NODE_PATH; + const result = spawnSync(process.execPath, ['--input-type=module', '--eval', script], { + cwd: dirname(extensionDir), + env, + encoding: 'utf8', + timeout: 30_000, + }); + await rm(join(extensionDir, 'node_modules'), { recursive: true, force: true }); + if (result.error !== undefined) { + throw new Error(`Unable to import the unpacked extension entry: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = conciseProcessError(result.stderr || result.stdout); + throw new Error(`Unpacked extension entry import failed: ${detail}`); + } +} + +async function readJson(path, label) { + try { + return JSON.parse(await readFile(path, 'utf8')); + } catch (error) { + throw new Error(`${label} is not valid JSON: ${describeError(error)}`, { cause: error }); + } +} + +async function listFiles(root) { + const output = []; + async function visit(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const localPath = join(directory, entry.name); + if (entry.isDirectory()) { + await visit(localPath); + } else if (entry.isFile()) { + output.push(relative(root, localPath).split(sep).join('/')); + } else { + throw new Error(`Unsupported non-file entry in unpacked VSIX: ${localPath}`); + } + } + } + await visit(root); + return output.sort(); +} + +async function totalSize(root, files) { + let bytes = 0; + for (const file of files) bytes += (await stat(join(root, file))).size; + return bytes; +} + +function requireFile(fileSet, file) { + if (fileSet.has(file)) return; + throw new Error(`Required VSIX resource is missing: ${file}`); +} + +function requireOneOf(fileSet, files, label) { + if (files.some((file) => fileSet.has(file))) return; + throw new Error(`Required VSIX resource is missing: ${label} (${files.join(' or ')}).`); +} + +function runtimeImportExists(path) { + return [path, `${path}.js`, `${path}.mjs`, `${path}.cjs`, join(path, 'index.js')].some(existsSync); +} + +function stripImportSuffix(specifier) { + return specifier.split(/[?#]/, 1)[0]; +} + +function stripLeadingDotSlash(value) { + return String(value).replace(/^\.\//, ''); +} + +function describeError(error) { + return error instanceof Error ? error.message : String(error); +} + +function conciseProcessError(output) { + const lines = String(output).trim().split(/\r?\n/).filter(Boolean); + return lines.slice(-4).join('\n') || 'process exited without an error message'; +} + +function parseArguments(argv) { + const targets = []; + let outputDir = defaultVsixOutputDir; + let file; + let directory; + let help = false; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === '--') { + continue; + } else if (argument === '--help' || argument === '-h') { + help = true; + } else if (argument === '--out-dir') { + outputDir = requireOptionValue(argv, ++index, '--out-dir'); + } else if (argument === '--target') { + targets.push(requireOptionValue(argv, ++index, '--target')); + } else if (argument === '--file') { + file = requireOptionValue(argv, ++index, '--file'); + } else if (argument === '--directory') { + directory = requireOptionValue(argv, ++index, '--directory'); + } else if (argument.startsWith('-')) { + throw new Error(`Unknown option: ${argument}`); + } else { + targets.push(argument); + } + } + + if (file !== undefined && directory !== undefined) { + throw new Error('--file and --directory cannot be used together.'); + } + const normalizedTargets = normalizeVsixTargets(targets); + if ((file !== undefined || directory !== undefined) && normalizedTargets.length !== 1) { + throw new Error('--file and --directory require exactly one target.'); + } + return { targets: normalizedTargets, outputDir: resolve(outputDir), file, directory, help }; +} + +function requireOptionValue(argv, index, option) { + const value = argv[index]; + if (value !== undefined && !value.startsWith('-')) return value; + throw new Error(`${option} requires a value.`); +} + +function usage() { + return [ + 'Usage: node scripts/vsix-verify.mjs [targets...] [--out-dir ]', + ' node scripts/vsix-verify.mjs --target --file ', + ' node scripts/vsix-verify.mjs --target --directory ', + '', + 'The verifier performs a package-content audit and an entry import smoke only.', + 'It does not claim that a target passed a real operating-system E2E run.', + ].join('\n'); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + for (const target of options.targets) { + const input = options.directory ?? options.file ?? join(options.outputDir, vsixFileName(target)); + const result = options.directory === undefined + ? await verifyVsix(resolve(input), target, { sourceRoot: extensionRoot }) + : await auditExtractedVsix(resolve(input), target, { sourceRoot: extensionRoot }); + console.log( + `Verified ${target}: ${result.files} files, ${result.bytes} unpacked bytes; static audit and entry import smoke passed (package-only).`, + ); + } +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`VSIX verification failed: ${describeError(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/scripts/watch-extension.mjs b/apps/vscode/scripts/watch-extension.mjs new file mode 100644 index 0000000000..35ed284cdd --- /dev/null +++ b/apps/vscode/scripts/watch-extension.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import { existsSync } from 'node:fs'; +import { readdir, stat } from 'node:fs/promises'; +import { extname, join, resolve } from 'node:path'; + +import { runLocalCli } from './local-cli.mjs'; +import { extensionRoot, isMainModule } from './vsix-targets.mjs'; + +const sourceDirectories = [ + join(extensionRoot, 'src'), + join(extensionRoot, 'shared'), + ...[ + 'agent-core', + 'kaos', + 'kosong', + 'migration-legacy', + 'node-sdk', + 'oauth', + 'protocol', + 'telemetry', + ].map((name) => resolve(extensionRoot, `../../packages/${name}/src`)), +].filter(existsSync); +const rootConfigFiles = ['package.json', 'tsconfig.json', 'tsdown.config.ts'].map((name) => + join(extensionRoot, name), +); +const watchedExtensions = new Set(['.cts', '.js', '.json', '.md', '.mjs', '.mts', '.ts', '.tsx']); +const pollIntervalMs = 1_500; + +function buildExtension() { + runLocalCli( + 'tsdown', + 'tsdown', + ['--config', join(extensionRoot, 'tsdown.config.ts'), '--sourcemap'], + { cwd: extensionRoot }, + ); +} + +async function main() { + buildExtension(); + let snapshot = await sourceSnapshot(); + let checking = false; + const poll = async () => { + if (checking) return; + checking = true; + try { + const nextSnapshot = await sourceSnapshot(); + if (nextSnapshot === snapshot) return; + snapshot = nextSnapshot; + console.log('\nExtension source changed; rebuilding with sourcemaps...'); + buildExtension(); + console.log('Extension rebuild complete.'); + } catch (error) { + console.error(`Extension watch check failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + checking = false; + } + }; + const interval = setInterval(() => void poll(), pollIntervalMs); + + const close = () => { + clearInterval(interval); + process.exit(0); + }; + process.once('SIGINT', close); + process.once('SIGTERM', close); + console.log('Extension dev build complete; watching for changes.'); +} + +async function sourceSnapshot() { + const records = []; + for (const directory of sourceDirectories) await collectSourceRecords(directory, records); + for (const file of rootConfigFiles) await addFileRecord(file, records); + return records.sort().join('\n'); +} + +async function collectSourceRecords(directory, records) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + await collectSourceRecords(path, records); + } else if (entry.isFile() && watchedExtensions.has(extname(entry.name))) { + await addFileRecord(path, records); + } + } +} + +async function addFileRecord(path, records) { + const info = await stat(path); + records.push(`${path}:${info.size}:${info.mtimeMs}`); +} + +if (isMainModule(import.meta.url)) { + main().catch((error) => { + console.error(`Extension watch failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + }); +} diff --git a/apps/vscode/scripts/zip.mjs b/apps/vscode/scripts/zip.mjs new file mode 100644 index 0000000000..27df84d44a --- /dev/null +++ b/apps/vscode/scripts/zip.mjs @@ -0,0 +1,122 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve, sep } from 'node:path'; +import { inflateRawSync } from 'node:zlib'; + +const END_OF_CENTRAL_DIRECTORY = 0x06054b50; +const CENTRAL_DIRECTORY_ENTRY = 0x02014b50; +const LOCAL_FILE_HEADER = 0x04034b50; +const MAX_END_RECORD_SEARCH = 65_535 + 22; + +export async function extractZip(archivePath, destination) { + const archive = await readFile(archivePath); + const entries = readCentralDirectory(archive); + + for (const entry of entries) { + const relativePath = safeArchivePath(entry.name); + if (relativePath === '') continue; + const outputPath = resolve(destination, relativePath); + assertInside(destination, outputPath, entry.name); + + if (entry.name.endsWith('/')) { + await mkdir(outputPath, { recursive: true }); + continue; + } + + const content = readEntry(archive, entry); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, content); + } + + return entries.map((entry) => entry.name); +} + +function readCentralDirectory(archive) { + const endOffset = findEndRecord(archive); + const entryCount = archive.readUInt16LE(endOffset + 10); + const directorySize = archive.readUInt32LE(endOffset + 12); + const directoryOffset = archive.readUInt32LE(endOffset + 16); + + if (entryCount === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) { + throw new Error('ZIP64 VSIX archives are not supported by the verifier.'); + } + if (directoryOffset + directorySize > archive.length) { + throw new Error('VSIX central directory points outside the archive.'); + } + + const entries = []; + let offset = directoryOffset; + for (let index = 0; index < entryCount; index += 1) { + if (archive.readUInt32LE(offset) !== CENTRAL_DIRECTORY_ENTRY) { + throw new Error(`Invalid VSIX central-directory entry at byte ${offset}.`); + } + const flags = archive.readUInt16LE(offset + 8); + if ((flags & 0x1) !== 0) throw new Error('Encrypted VSIX entries are not supported.'); + + const compression = archive.readUInt16LE(offset + 10); + const compressedSize = archive.readUInt32LE(offset + 20); + const uncompressedSize = archive.readUInt32LE(offset + 24); + const nameLength = archive.readUInt16LE(offset + 28); + const extraLength = archive.readUInt16LE(offset + 30); + const commentLength = archive.readUInt16LE(offset + 32); + const localOffset = archive.readUInt32LE(offset + 42); + const nameStart = offset + 46; + const name = archive.subarray(nameStart, nameStart + nameLength).toString('utf8'); + entries.push({ name, compression, compressedSize, uncompressedSize, localOffset }); + offset = nameStart + nameLength + extraLength + commentLength; + } + return entries; +} + +function findEndRecord(archive) { + const lowerBound = Math.max(0, archive.length - MAX_END_RECORD_SEARCH); + for (let offset = archive.length - 22; offset >= lowerBound; offset -= 1) { + if (archive.readUInt32LE(offset) === END_OF_CENTRAL_DIRECTORY) return offset; + } + throw new Error('VSIX is not a readable ZIP archive: end record is missing.'); +} + +function readEntry(archive, entry) { + if (archive.readUInt32LE(entry.localOffset) !== LOCAL_FILE_HEADER) { + throw new Error(`Invalid local header for VSIX entry ${entry.name}.`); + } + const nameLength = archive.readUInt16LE(entry.localOffset + 26); + const extraLength = archive.readUInt16LE(entry.localOffset + 28); + const dataStart = entry.localOffset + 30 + nameLength + extraLength; + const dataEnd = dataStart + entry.compressedSize; + if (dataEnd > archive.length) throw new Error(`Truncated VSIX entry ${entry.name}.`); + + const compressed = archive.subarray(dataStart, dataEnd); + let content; + if (entry.compression === 0) { + content = compressed; + } else if (entry.compression === 8) { + content = inflateRawSync(compressed); + } else { + throw new Error(`Unsupported compression method ${entry.compression} for ${entry.name}.`); + } + if (content.length !== entry.uncompressedSize) { + throw new Error( + `Unexpected uncompressed size for ${entry.name}: ${content.length}, expected ${entry.uncompressedSize}.`, + ); + } + return content; +} + +function safeArchivePath(name) { + const normalized = name.replaceAll('\\', '/'); + const segments = normalized.split('/').filter((segment) => segment !== ''); + if ( + normalized.startsWith('/') || + /^[A-Za-z]:/.test(normalized) || + segments.some((segment) => segment === '..') + ) { + throw new Error(`Unsafe path in VSIX archive: ${name}`); + } + return segments.join(sep); +} + +function assertInside(destination, outputPath, archiveName) { + const root = resolve(destination); + if (outputPath === root || outputPath.startsWith(`${root}${sep}`)) return; + throw new Error(`Unsafe path in VSIX archive: ${archiveName}`); +} diff --git a/apps/vscode/shared/bridge.ts b/apps/vscode/shared/bridge.ts new file mode 100644 index 0000000000..805c1f32f6 --- /dev/null +++ b/apps/vscode/shared/bridge.ts @@ -0,0 +1,324 @@ +/** + * Bridge Protocol - Communication between VS Code extension and webview. + * + * Architecture: + * - Webview calls Methods via RPC (request/response) + * - Extension broadcasts Events to webview (one-way notifications) + * + * RPC flow: webview.call(method, params) -> extension.dispatch -> webview.resolve(result) + * Event flow: extension.broadcast(event, data) -> webview.on(event, handler) + */ + +export const Methods = { + CheckWorkspace: "checkWorkspace", + GetInputHistory: "getInputHistory", + AddInputHistory: "addInputHistory", + + GetSlashCommands: "getSlashCommands", + CheckLoginStatus: "checkLoginStatus", + Login: "login", + Logout: "logout", + SaveConfig: "saveConfig", + GetExtensionConfig: "getExtensionConfig", + OpenSettings: "openSettings", + OpenFolder: "openFolder", + GetModels: "getModels", + + GetMCPServers: "getMCPServers", + AddMCPServer: "addMCPServer", + UpdateMCPServer: "updateMCPServer", + RemoveMCPServer: "removeMCPServer", + AuthMCP: "authMCP", + ResetAuthMCP: "resetAuthMCP", + TestMCP: "testMCP", + + StreamChat: "streamChat", + AbortChat: "abortChat", + ResetSession: "resetSession", + SetPlanMode: "setPlanMode", + SteerChat: "steerChat", + RespondApproval: "respondApproval", + + GetKimiSessions: "getKimiSessions", + GetAllKimiSessions: "getAllKimiSessions", + GetRegisteredWorkDirs: "getRegisteredWorkDirs", + SetWorkDir: "setWorkDir", + BrowseWorkDir: "browseWorkDir", + LoadKimiSessionHistory: "loadKimiSessionHistory", + DeleteKimiSession: "deleteKimiSession", + ForkKimiSession: "forkKimiSession", + GetProjectFiles: "getProjectFiles", + PickMedia: "pickMedia", + OpenFile: "openFile", + CheckFileExists: "checkFileExists", + CheckFilesExist: "checkFilesExist", + OpenFileDiff: "openFileDiff", + TrackFiles: "trackFiles", + ClearTrackedFiles: "clearTrackedFiles", + RevertFiles: "revertFiles", + KeepChanges: "keepChanges", + GetImageDataUri: "getImageDataUri", + ShowLogs: "showLogs", + ReloadWebview: "reloadWebview", + RespondQuestion: "respondQuestion", +} as const; + +export type RpcMethod = (typeof Methods)[keyof typeof Methods]; + +export interface RpcMessage { + readonly id: string; + readonly method: RpcMethod; + readonly params?: unknown; +} + +export interface RpcResult { + readonly id: string; + readonly result?: unknown; + readonly error?: string; +} + +export type RpcMessageValidation = + | { readonly ok: true; readonly message: RpcMessage } + | { + readonly ok: false; + readonly id: string; + readonly method: string; + readonly error: string; + }; + +export const Events = { + ExtensionConfigChanged: "extensionConfigChanged", + MCPServersChanged: "mcpServersChanged", + StreamEvent: "streamEvent", + FocusInput: "focusInput", + InsertMention: "insertMention", + NewConversation: "newConversation", + FileChangesUpdated: "fileChangesUpdated", + RollbackInput: "rollbackInput", + LoginUrl: "loginUrl", +} as const; + +const rpcMethods = new Set(Object.values(Methods)); + +/** Validates the untrusted Webview message before any host-side handler runs. */ +export function validateRpcMessage(value: unknown): RpcMessageValidation { + if (!isPlainObject(value)) { + return invalidMessage("", "", "Invalid bridge request: expected a plain object."); + } + + const id = value["id"]; + if (!Object.hasOwn(value, "id") || typeof id !== "string" || id.trim().length === 0) { + return invalidMessage("", safeMethod(value["method"]), "Invalid bridge request: id must be a non-empty string."); + } + + const method = value["method"]; + if (!Object.hasOwn(value, "method") || typeof method !== "string" || method.trim().length === 0) { + return invalidMessage(id, "", "Invalid bridge request: method must be a non-empty string."); + } + if (!rpcMethods.has(method)) { + return invalidMessage(id, method, `Unknown bridge method: ${method}`); + } + const params = Object.hasOwn(value, "params") ? value["params"] : undefined; + if (!validateParams(method as RpcMethod, params)) { + return invalidMessage(id, method, `Invalid bridge params for method: ${method}`); + } + + return { ok: true, message: { id, method: method as RpcMethod, params } }; +} + +function validateParams(method: RpcMethod, params: unknown): boolean { + switch (method) { + case Methods.CheckWorkspace: + case Methods.GetInputHistory: + case Methods.GetSlashCommands: + case Methods.CheckLoginStatus: + case Methods.Login: + case Methods.Logout: + case Methods.GetExtensionConfig: + case Methods.OpenSettings: + case Methods.OpenFolder: + case Methods.GetModels: + case Methods.GetMCPServers: + case Methods.AbortChat: + case Methods.ResetSession: + case Methods.GetKimiSessions: + case Methods.GetAllKimiSessions: + case Methods.GetRegisteredWorkDirs: + case Methods.BrowseWorkDir: + case Methods.ClearTrackedFiles: + case Methods.ShowLogs: + case Methods.ReloadWebview: + return params === undefined; + + case Methods.AddInputHistory: + return hasString(params, "text"); + case Methods.SaveConfig: + return isPlainObject(params) + && typeof params["model"] === "string" + && isOptionalType(params["thinking"], "boolean") + && isOptionalType(params["effort"], "string"); + case Methods.AddMCPServer: + return isMcpServerConfig(params); + case Methods.UpdateMCPServer: + return isMcpUpdate(params); + case Methods.RemoveMCPServer: + case Methods.AuthMCP: + case Methods.ResetAuthMCP: + case Methods.TestMCP: + return hasNonEmptyString(params, "name"); + case Methods.StreamChat: + return isStreamChatParams(params); + case Methods.RespondApproval: + return isPlainObject(params) + && isNonEmptyString(params["requestId"]) + && (params["response"] === "approve" + || params["response"] === "approve_for_session" + || params["response"] === "reject"); + case Methods.RespondQuestion: + return isPlainObject(params) + && isNonEmptyString(params["rpcRequestId"]) + && isNonEmptyString(params["questionRequestId"]) + && isStringRecord(params["answers"]); + case Methods.SetPlanMode: + return hasBoolean(params, "enabled"); + case Methods.SteerChat: + return isPlainObject(params) && isContent(params["content"]); + case Methods.GetProjectFiles: + return params === undefined || ( + isPlainObject(params) + && isOptionalType(params["query"], "string") + && isOptionalType(params["directory"], "string") + ); + case Methods.SetWorkDir: + return isPlainObject(params) && (params["workDir"] === null || typeof params["workDir"] === "string"); + case Methods.LoadKimiSessionHistory: + return hasNonEmptyString(params, "kimiSessionId"); + case Methods.DeleteKimiSession: + return hasNonEmptyString(params, "sessionId"); + case Methods.ForkKimiSession: + return isPlainObject(params) + && isNonEmptyString(params["sessionId"]) + && Number.isInteger(params["turnIndex"]) + && (params["turnIndex"] as number) >= 0; + case Methods.PickMedia: + return isPlainObject(params) + && (params["maxCount"] === undefined + || (Number.isInteger(params["maxCount"]) && (params["maxCount"] as number) >= 0)) + && isOptionalType(params["includeVideo"], "boolean"); + case Methods.OpenFile: + case Methods.OpenFileDiff: + case Methods.CheckFileExists: + case Methods.GetImageDataUri: + return hasString(params, "filePath"); + case Methods.CheckFilesExist: + case Methods.TrackFiles: + return hasStringArray(params, "paths"); + case Methods.RevertFiles: + case Methods.KeepChanges: + return isPlainObject(params) && isOptionalType(params["filePath"], "string"); + } +} + +function isStreamChatParams(value: unknown): boolean { + return isPlainObject(value) + && isContent(value["content"]) + && typeof value["model"] === "string" + && isOptionalType(value["effort"], "string") + && isOptionalType(value["thinking"], "boolean") + && isOptionalType(value["planMode"], "boolean") + && isOptionalType(value["sessionId"], "string"); +} + +function isContent(value: unknown): boolean { + return typeof value === "string" || (Array.isArray(value) && value.every(isContentPart)); +} + +function isContentPart(value: unknown): boolean { + if (!isPlainObject(value) || typeof value["type"] !== "string") return false; + switch (value["type"]) { + case "text": + return typeof value["text"] === "string"; + case "think": + return typeof value["think"] === "string" + && (value["encrypted"] === undefined + || value["encrypted"] === null + || typeof value["encrypted"] === "string"); + case "image_url": + case "audio_url": + case "video_url": { + const media = value[value["type"]]; + return isPlainObject(media) + && typeof media["url"] === "string" + && (media["id"] === undefined || media["id"] === null || typeof media["id"] === "string"); + } + default: + return false; + } +} + +function isMcpUpdate(value: unknown): boolean { + if (!isPlainObject(value)) return false; + if (Object.hasOwn(value, "server")) { + return isNonEmptyString(value["originalName"]) && isMcpServerConfig(value["server"]); + } + return isMcpServerConfig(value); +} + +function isMcpServerConfig(value: unknown): boolean { + return isPlainObject(value) + && isNonEmptyString(value["name"]) + && (value["transport"] === "stdio" || value["transport"] === "http") + && isOptionalType(value["url"], "string") + && isOptionalType(value["command"], "string") + && (value["args"] === undefined || isStringArray(value["args"])) + && (value["env"] === undefined || isStringRecord(value["env"])) + && (value["headers"] === undefined || isStringRecord(value["headers"])) + && (value["auth"] === undefined || value["auth"] === "oauth") + && isOptionalType(value["bearerTokenEnvVar"], "string"); +} + +function hasString(value: unknown, key: string): boolean { + return isPlainObject(value) && typeof value[key] === "string"; +} + +function hasNonEmptyString(value: unknown, key: string): boolean { + return isPlainObject(value) && isNonEmptyString(value[key]); +} + +function hasBoolean(value: unknown, key: string): boolean { + return isPlainObject(value) && typeof value[key] === "boolean"; +} + +function hasStringArray(value: unknown, key: string): boolean { + return isPlainObject(value) && isStringArray(value[key]); +} + +function isStringArray(value: unknown): boolean { + return Array.isArray(value) && value.every((item) => typeof item === "string"); +} + +function isStringRecord(value: unknown): boolean { + return isPlainObject(value) && Object.values(value).every((item) => typeof item === "string"); +} + +function isOptionalType(value: unknown, type: "string" | "boolean"): boolean { + return value === undefined || typeof value === type; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object") return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function invalidMessage(id: string, method: string, error: string): RpcMessageValidation { + return { ok: false, id, method, error }; +} + +function safeMethod(value: unknown): string { + return typeof value === "string" && value.length > 0 ? value : ""; +} diff --git a/apps/vscode/shared/errors.ts b/apps/vscode/shared/errors.ts new file mode 100644 index 0000000000..4640b54ff4 --- /dev/null +++ b/apps/vscode/shared/errors.ts @@ -0,0 +1,106 @@ +import type { ErrorPhase } from "./types"; + +// Keep the released Webview's error contract local. The upper-case values are +// accepted for sessions restored from the legacy extension; the dotted values +// are emitted by the in-process v1 core. +const LEGACY = { + CLI_NOT_FOUND: "CLI_NOT_FOUND", + SPAWN_FAILED: "SPAWN_FAILED", + ALREADY_STARTED: "ALREADY_STARTED", + STDIN_NOT_WRITABLE: "STDIN_NOT_WRITABLE", + HANDSHAKE_TIMEOUT: "HANDSHAKE_TIMEOUT", + PROCESS_CRASHED: "PROCESS_CRASHED", + LLM_NOT_SET: "LLM_NOT_SET", + LLM_NOT_SUPPORTED: "LLM_NOT_SUPPORTED", + INVALID_STATE: "INVALID_STATE", + CHAT_PROVIDER_ERROR: "CHAT_PROVIDER_ERROR", + SESSION_BUSY: "SESSION_BUSY", + SESSION_CLOSED: "SESSION_CLOSED", + TURN_INTERRUPTED: "TURN_INTERRUPTED", + INVALID_JSON: "INVALID_JSON", + INVALID_REQUEST: "INVALID_REQUEST", + INVALID_PARAMS: "INVALID_PARAMS", + INTERNAL_ERROR: "INTERNAL_ERROR", +} as const; + +// Pre-flight: task didn't start at all or was blocked by "gatekeeper" +export const PREFLIGHT_CODES = new Set([ + LEGACY.CLI_NOT_FOUND, + LEGACY.SPAWN_FAILED, + LEGACY.ALREADY_STARTED, + LEGACY.STDIN_NOT_WRITABLE, + LEGACY.PROCESS_CRASHED, + LEGACY.LLM_NOT_SET, + LEGACY.LLM_NOT_SUPPORTED, + LEGACY.INVALID_STATE, + LEGACY.SESSION_BUSY, + "config.invalid", + "model.not_configured", + "auth.login_required", + "session.not_found", + "session.state_not_found", + "session.state_invalid", + "session.init_failed", + "shell.git_bash_not_found", +]); + +// User-friendly error messages +export const ERROR_MESSAGES: Record = { + // Pre-flight + [LEGACY.CLI_NOT_FOUND]: "Kimi Code CLI not found.", + [LEGACY.SPAWN_FAILED]: "Failed to start Kimi Code CLI.", + [LEGACY.ALREADY_STARTED]: "A session is already running.", + [LEGACY.STDIN_NOT_WRITABLE]: "Failed to communicate with Kimi Code CLI.", + [LEGACY.HANDSHAKE_TIMEOUT]: "Connection timed out.", + [LEGACY.PROCESS_CRASHED]: "Process connection lost.", + + // CLI errors + [LEGACY.LLM_NOT_SET]: "Authentication failed. Please sign in.", + [LEGACY.LLM_NOT_SUPPORTED]: "This model is not supported.", + [LEGACY.INVALID_STATE]: "Please wait for the current operation.", + [LEGACY.CHAT_PROVIDER_ERROR]: "Service temporarily unavailable.", + + // Session errors + [LEGACY.SESSION_BUSY]: "A message is being sent. Please wait.", + [LEGACY.SESSION_CLOSED]: "Session was closed.", + [LEGACY.TURN_INTERRUPTED]: "Stopped by user.", + + // Protocol errors + [LEGACY.INVALID_JSON]: "Communication format error.", + [LEGACY.INVALID_REQUEST]: "Invalid request.", + [LEGACY.INVALID_PARAMS]: "Invalid parameters.", + [LEGACY.INTERNAL_ERROR]: "Internal error occurred.", + + "config.invalid": "Kimi Code configuration is invalid.", + "model.not_configured": "No model is configured. Please sign in or configure a provider.", + "auth.login_required": "Authentication failed. Please sign in.", + "session.not_found": "Session was not found.", + "session.state_not_found": "Session data is missing.", + "session.state_invalid": "Session data is invalid.", + "session.init_failed": "Failed to initialize the session.", + "session.closed": "Session was closed.", + "session.fork_active_turn": "Wait for the current response before forking.", + "turn.agent_busy": "A message is being sent. Please wait.", + "provider.api_error": "Service temporarily unavailable.", + "provider.rate_limit": "Too many requests. Please try again later.", + "provider.auth_error": "Authentication failed. Please sign in again.", + "provider.connection_error": "Could not connect to the model provider.", + "request.prompt_input_empty": "Prompt cannot be empty.", + internal: "Internal error occurred.", +}; + +export function classifyError(code: string): ErrorPhase { + return PREFLIGHT_CODES.has(code) ? "preflight" : "runtime"; +} + +export function getUserMessage(code: string, fallback?: string): string { + return ERROR_MESSAGES[code] || fallback || "An unknown error occurred."; +} + +export function isPreflightError(code: string): boolean { + return PREFLIGHT_CODES.has(code); +} + +export function isUserInterrupt(code: string): boolean { + return code === LEGACY.TURN_INTERRUPTED || code === "turn.cancelled"; +} diff --git a/apps/vscode/shared/fork-turn-index.ts b/apps/vscode/shared/fork-turn-index.ts new file mode 100644 index 0000000000..43268b5788 --- /dev/null +++ b/apps/vscode/shared/fork-turn-index.ts @@ -0,0 +1,43 @@ +interface ForkTurnItem { + readonly type: string; +} + +interface ForkTurnMessage { + readonly role: "user" | "assistant"; + readonly forkable?: boolean; + readonly steps?: readonly { + readonly items: readonly ForkTurnItem[]; + }[]; +} + +/** Return the core's zero-based user-visible turn index for an assistant bubble. */ +export function getForkTurnIndex( + messages: readonly ForkTurnMessage[], + messageIndex: number, +): number | undefined { + const target = messages[messageIndex]; + if (target?.role !== "assistant" || target.forkable === false) return undefined; + + let visibleTurns = 0; + for (let index = 0; index <= messageIndex; index += 1) { + const message = messages[index]; + if (message === undefined) continue; + if (message.role === "user" && message.forkable !== false) { + visibleTurns += 1; + continue; + } + if (message.role === "assistant") { + visibleTurns += countSteers(message); + } + } + return visibleTurns - 1; +} + +function countSteers(message: ForkTurnMessage): number { + return ( + message.steps?.reduce( + (count, step) => count + step.items.filter((item) => item.type === "steer").length, + 0, + ) ?? 0 + ); +} diff --git a/apps/vscode/shared/legacy-sdk.ts b/apps/vscode/shared/legacy-sdk.ts new file mode 100644 index 0000000000..03c5bafbda --- /dev/null +++ b/apps/vscode/shared/legacy-sdk.ts @@ -0,0 +1,227 @@ +/** + * UI-facing compatibility types for the released Webview. + * + * The extension host adapts v1 Node SDK events into this shape while the UI is + * migrated without a visual rewrite. This file contains no legacy SDK runtime. + */ + +export type ApprovalResponse = 'approve' | 'approve_for_session' | 'reject'; + +export type ContentPart = + | { type: 'text'; text: string } + | { type: 'think'; think: string; encrypted?: string | null } + | { type: 'image_url'; image_url: { url: string; id?: string | null } } + | { type: 'audio_url'; audio_url: { url: string; id?: string | null } } + | { type: 'video_url'; video_url: { url: string; id?: string | null } }; + +export interface BriefBlock { + type: 'brief'; + text: string; +} + +export interface DiffBlock { + type: 'diff'; + path: string; + old_text: string; + new_text: string; +} + +export interface TodoBlock { + type: 'todo'; + items: Array<{ title: string; status: 'pending' | 'in_progress' | 'done' }>; +} + +export interface ShellBlock { + type: 'shell'; + language: string; + command: string; +} + +export interface UnknownBlock { + type: string; + data?: Record; + [key: string]: unknown; +} + +export type DisplayBlock = BriefBlock | DiffBlock | TodoBlock | ShellBlock | UnknownBlock; + +export interface ToolCall { + type: 'function'; + id: string; + function: { name: string; arguments?: string | null }; + extras?: Record | null; +} + +export interface ToolReturnValue { + is_error: boolean; + output: string | ContentPart[]; + message: string; + display: DisplayBlock[]; + extras?: Record | null; +} + +export interface ToolResult { + tool_call_id: string; + return_value: ToolReturnValue; +} + +export interface TurnBegin { + user_input: string | ContentPart[]; +} + +export interface TokenUsage { + input_other: number; + output: number; + input_cache_read: number; + input_cache_creation: number; +} + +export interface StatusUpdate { + context_usage?: number | null; + token_usage?: TokenUsage | null; + message_id?: string | null; + plan_mode?: boolean | null; + model?: string | null; + thinking_effort?: string | null; + retrying?: { + next_attempt: number; + max_attempts: number; + delay_ms: number; + message: string; + } | null; +} + +export interface ApprovalRequestPayload { + id: string; + tool_call_id: string; + sender: string; + action: string; + description: string; + display?: DisplayBlock[]; +} + +export interface QuestionOption { + label: string; + description?: string; +} + +export interface QuestionItem { + question: string; + header?: string; + options: QuestionOption[]; + multi_select?: boolean; +} + +export interface QuestionRequest { + id: string; + tool_call_id: string; + questions: QuestionItem[]; +} + +export interface QuestionResponse { + request_id: string; + answers: Record; +} + +export interface SubagentEvent { + parent_tool_call_id: string; + event: LegacyWireEvent; +} + +export type LegacyWireEvent = + | { type: 'TurnBegin'; payload: TurnBegin & { forkable?: boolean } } + | { type: 'TurnEnd'; payload: Record } + | { type: 'StepBegin'; payload: { n: number } } + | { type: 'StepInterrupted'; payload: Record } + | { type: 'CompactionBegin'; payload: Record } + | { type: 'CompactionEnd'; payload: Record } + | { type: 'StatusUpdate'; payload: StatusUpdate } + | { type: 'ContentPart'; payload: ContentPart } + | { type: 'ToolCall'; payload: ToolCall } + | { type: 'ToolCallPart'; payload: { tool_call_id?: string; arguments_part?: string | null } } + | { type: 'ToolResult'; payload: ToolResult } + | { type: 'SteerInput'; payload: { user_input: string | ContentPart[] } } + | { type: 'SubagentEvent'; payload: SubagentEvent } + | { type: string; payload: unknown }; + +export type StreamEvent = + | LegacyWireEvent + | { type: 'ApprovalRequest'; payload: ApprovalRequestPayload } + | { type: 'QuestionRequest'; payload: QuestionRequest } + | { type: 'error'; code: string; message: string; raw?: string }; + +export interface RunResult { + status: 'finished' | 'cancelled' | 'max_steps_reached'; + steps?: number; +} + +export interface SlashCommandInfo { + name: string; + description: string; + aliases: string[]; +} + +export interface ModelConfig { + id: string; + name: string; + provider: string; + capabilities: string[]; + adaptive_thinking?: boolean; + support_efforts?: string[]; + default_effort?: string; +} + +export interface KimiConfig { + defaultModel: string | null; + defaultThinking: boolean; + defaultThinkingEffort?: string; + models: ModelConfig[]; +} + +/** Placeholder returned to the Webview instead of a stored MCP credential. */ +export const MCP_SECRET_MASK = '••••••••'; + +export interface MCPServerConfig { + name: string; + transport: 'http' | 'stdio'; + url?: string; + command?: string; + args?: string[]; + env?: Record; + headers?: Record; + auth?: 'oauth'; + bearerTokenEnvVar?: string; +} + +export interface UpdateMCPServerRequest { + originalName: string; + server: MCPServerConfig; +} + +export interface SessionInfo { + id: string; + workDir: string; + updatedAt: number; + brief: string; +} + +export type ThinkingMode = 'none' | 'switch' | 'always' | 'effort'; + +export interface MCPTestResult { + success: boolean; + output: string; +} + +export interface LoginResult { + success: boolean; + error?: string; +} + +export function formatContentOutput(output: string | ContentPart[]): string { + if (typeof output === 'string') return output; + if (!Array.isArray(output)) return JSON.stringify(output); + return output + .map((item) => item.type === 'text' ? item.text : `[${item.type}]`) + .filter(Boolean) + .join('\n'); +} diff --git a/apps/vscode/shared/types.ts b/apps/vscode/shared/types.ts new file mode 100644 index 0000000000..422aa5acc7 --- /dev/null +++ b/apps/vscode/shared/types.ts @@ -0,0 +1,58 @@ +import type { RunResult, StreamEvent } from "./legacy-sdk"; + +export interface SessionConfig { + model: string; + thinking?: boolean; + effort?: string; +} + +export interface ProjectFile { + path: string; + name: string; + isDirectory: boolean; +} + +export interface FileChange { + path: string; + status: "Modified" | "Added" | "Deleted"; + additions: number; + deletions: number; +} + +export interface ExtensionConfig { + yoloMode: boolean; + autosave: boolean; + useCtrlEnterToSend: boolean; + enableNewConversationShortcut: boolean; + showThinkingContent: boolean; + showThinkingExpanded: boolean; + version: string; +} + +export interface WorkspaceStatus { + hasWorkspace: boolean; + path?: string; + workspaceRoot?: string; +} + +export type ErrorPhase = "preflight" | "runtime"; + +export interface StreamError { + type: "error"; + code: string; + message: string; + detail?: string; // 原始服务器错误信息 + phase: ErrorPhase; +} + +export type UIStreamEvent = + | { type: "session_start"; sessionId: string; model?: string; _sessionId?: string } + | { type: "stream_complete"; result: RunResult; _sessionId?: string } + | (StreamError & { _sessionId?: string }) + | (StreamEvent & { _sessionId?: string }); + +export interface LoginStatus { + loggedIn: boolean; +} + +export type { QuestionRequest, QuestionItem, QuestionOption, QuestionResponse } from "./legacy-sdk"; diff --git a/apps/vscode/shared/utils.ts b/apps/vscode/shared/utils.ts new file mode 100644 index 0000000000..625f6c9ea3 --- /dev/null +++ b/apps/vscode/shared/utils.ts @@ -0,0 +1,3 @@ +export function cleanSystemTags(text: string): string { + return text.replace(/.*?<\/system>\s*/gs, "").trim(); +} diff --git a/apps/vscode/src/KimiWebviewProvider.ts b/apps/vscode/src/KimiWebviewProvider.ts new file mode 100644 index 0000000000..2fed3fa09d --- /dev/null +++ b/apps/vscode/src/KimiWebviewProvider.ts @@ -0,0 +1,182 @@ +import * as vscode from "vscode"; +import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk"; +import { Events } from "../shared/bridge"; +import { BridgeHandler } from "./bridge-handler"; + +function getNonce(): string { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let nonce = ""; + for (let i = 0; i < 32; i++) { + nonce += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return nonce; +} + +/** + * Manages webview instances (sidebar and panels). + * Each webview gets a unique viewId for session isolation. + */ +export class KimiWebviewProvider implements vscode.WebviewViewProvider { + private webviews = new Map(); + private bridgeHandler: BridgeHandler; + + constructor( + private readonly extensionUri: vscode.Uri, + context: vscode.ExtensionContext, + showLogs: () => void, + writeLog: (message: string) => void, + ) { + this.bridgeHandler = new BridgeHandler( + this.broadcastInternal.bind(this), + context.workspaceState, + context.globalStorageUri.fsPath, + this.reloadWebview.bind(this), + showLogs, + writeLog, + ); + } + + dispose(): void { + void this.bridgeHandler.dispose(); + } + + shutdown(): Promise { + return this.bridgeHandler.dispose(); + } + + get harness(): KimiHarness { + return this.bridgeHandler.runtime.harness; + } + + resolveWebviewView(webviewView: vscode.WebviewView): void { + const webviewId = `sidebar_${crypto.randomUUID()}`; + this.setupWebview(webviewId, webviewView.webview); + + webviewView.onDidDispose(() => { + void this.bridgeHandler.disposeView(webviewId); + this.webviews.delete(webviewId); + }); + } + + createPanel(): vscode.WebviewPanel { + const webviewId = `panel_${crypto.randomUUID()}`; + + const panel = vscode.window.createWebviewPanel("kimiPanel", "Kimi Code", vscode.ViewColumn.One, { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this.extensionUri], + }); + + this.setupWebview(webviewId, panel.webview); + + panel.onDidDispose(() => { + void this.bridgeHandler.disposeView(webviewId); + this.webviews.delete(webviewId); + }); + + return panel; + } + + broadcast(event: string, data: unknown): void { + this.broadcastInternal(event, data); + } + + async insertEditorMention(documentUri: vscode.Uri, selection: vscode.Selection): Promise { + let inserted = false; + await Promise.all( + [...this.webviews.keys()].map(async (webviewId) => { + const mention = await this.bridgeHandler.getEditorMention(webviewId, documentUri, selection); + if (mention === null) return; + inserted = true; + this.broadcastInternal(Events.InsertMention, { mention }, webviewId); + }), + ); + return inserted; + } + + private setupWebview(webviewId: string, webview: vscode.Webview): void { + webview.options = { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }; + + webview.html = this.getHtml(webviewId, webview); + this.webviews.set(webviewId, webview); + + webview.onDidReceiveMessage(async (msg: unknown) => { + const result = await this.bridgeHandler.handle(msg, webviewId); + webview.postMessage(result); + }); + } + + private broadcastInternal(event: string, data: unknown, targetWebviewId?: string): void { + const msg = { event, data }; + + if (targetWebviewId) { + void this.webviews.get(targetWebviewId)?.postMessage(msg); + } else { + this.webviews.forEach((webview) => { + void webview.postMessage(msg); + }); + } + } + + private reloadWebview(webviewId: string): void { + const webview = this.webviews.get(webviewId); + if (webview) { + webview.html = this.getHtml(webviewId, webview); + } + } + + reloadAllWebviews(): void { + this.webviews.forEach((webview, webviewId) => { + webview.html = this.getHtml(webviewId, webview); + }); + } + + async resetAllWebviews(): Promise { + await Promise.all( + [...this.webviews.keys()].map((webviewId) => this.bridgeHandler.disposeView(webviewId)), + ); + this.reloadAllWebviews(); + } + + getBaselineContent(sessionId: string, filePath: string): Promise { + return this.bridgeHandler.getBaselineContent(sessionId, filePath); + } + + async setYoloModeForActiveSessions(enabled: boolean): Promise { + await this.bridgeHandler.runtime.setYoloModeForActiveSessions(enabled); + } + + private getHtml(webviewId: string, webview: vscode.Webview): string { + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")); + const baseUri = webview.asWebviewUri(this.extensionUri).toString(); + const nonce = getNonce(); + + const csp = [ + `default-src 'none'`, + `style-src ${webview.cspSource} 'unsafe-inline'`, + `img-src ${webview.cspSource} data: blob:`, + `font-src ${webview.cspSource}`, + `media-src ${webview.cspSource} data: blob:`, + `connect-src ${webview.cspSource}`, + `worker-src ${webview.cspSource} blob:`, + `script-src 'nonce-${nonce}' ${webview.cspSource}`, + ].join("; "); + + return ` + + + + + + Kimi Code + + +
+ + +`; + } +} diff --git a/apps/vscode/src/bridge-handler.ts b/apps/vscode/src/bridge-handler.ts new file mode 100644 index 0000000000..d1a5f5a7f7 --- /dev/null +++ b/apps/vscode/src/bridge-handler.ts @@ -0,0 +1,336 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { + validateRpcMessage, + type RpcMethod, + type RpcResult, +} from "../shared/bridge"; +import { VSCodeSettings } from "./config/vscode-settings"; +import { handlers, type BroadcastFn, type HandlerContext, type ReloadWebviewFn, type ShowLogsFn } from "./handlers"; +import { BaselineManager, type BaselineSession } from "./managers/baseline.manager"; +import { FileManager } from "./managers/file.manager"; +import { KimiRuntime } from "./runtime/kimi-runtime"; +import type { SessionRuntime } from "./runtime/session-runtime"; +import { areSameFsPath } from "./utils/fs-path"; +import { + isWorkspacePathContained, + isWorkspacePathContainedSync, + relativeWorkspacePath, + resolveWorkspacePath, + type WorkspacePath, + workDirUriFromPath, +} from "./utils/workspace-path"; + +export class BridgeHandler { + readonly baselineManager: BaselineManager; + readonly runtime: KimiRuntime; + + private readonly customWorkDirs = new Map(); + private readonly fileManager: FileManager; + + constructor( + private readonly broadcast: BroadcastFn, + private readonly workspaceState: vscode.Memento, + globalStoragePath: string, + private readonly reloadWebview: ReloadWebviewFn, + private readonly showLogs: ShowLogsFn, + private readonly writeLog: (message: string) => void, + ) { + this.runtime = new KimiRuntime({ + version: VSCodeSettings.getExtensionConfig().version, + broadcast, + captureBaseline: (session, filePath, webviewIds) => { + this.captureFileBaseline(session, filePath, webviewIds); + }, + log: (message, error) => this.logRuntimeError(message, error), + }); + this.baselineManager = new BaselineManager(globalStoragePath, this.runtime.harness.homeDir); + this.fileManager = new FileManager(this.baselineManager, broadcast); + } + + async handle(value: unknown, webviewId: string): Promise { + const startedAt = Date.now(); + const validation = validateRpcMessage(value); + if (!validation.ok) { + this.trace(validation.id, validation.method, Date.now() - startedAt, false); + this.logRuntimeError(`Bridge request rejected: ${validation.method}`, validation.error); + return { id: validation.id, error: validation.error }; + } + + const msg = validation.message; + try { + const result = await this.dispatch(msg.method, msg.params, webviewId); + this.trace(msg.id, msg.method, Date.now() - startedAt, true); + return { id: msg.id, result }; + } catch (error) { + this.trace(msg.id, msg.method, Date.now() - startedAt, false); + this.logRuntimeError(`Bridge request failed: ${msg.method}`, error); + return { + id: msg.id, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + private get workspaceRoot(): string | null { + return this.workspaceRootUri?.fsPath ?? null; + } + + private get workspaceRootUri(): vscode.Uri | null { + return vscode.workspace.workspaceFolders?.[0]?.uri ?? null; + } + + private getWorkDir(webviewId: string): string | null { + return this.customWorkDirs.get(webviewId) ?? this.workspaceRoot; + } + + private getWorkDirUri(webviewId: string): vscode.Uri | null { + const workspaceRoot = this.workspaceRoot; + const workspaceRootUri = this.workspaceRootUri; + const workDir = this.getWorkDir(webviewId); + if (workspaceRoot === null || workspaceRootUri === null || workDir === null) return null; + return workDirUriFromPath(workspaceRootUri, workspaceRoot, workDir) ?? null; + } + + private async setCustomWorkDir(webviewId: string, workDir: string | null): Promise { + const workspaceRoot = this.workspaceRoot; + const workspaceRootUri = this.workspaceRootUri; + if (workspaceRoot === null || workspaceRootUri === null) throw new Error("No workspace folder open"); + if (workDir !== null) { + const workDirUri = workDirUriFromPath(workspaceRootUri, workspaceRoot, workDir); + if (workDirUri === undefined || !(await isWorkspacePathContained(workspaceRootUri, workDirUri))) { + throw new Error("Working directory must be within the workspace"); + } + } + if (workDir && workDir !== this.workspaceRoot) { + this.customWorkDirs.set(webviewId, workDir); + } else { + this.customWorkDirs.delete(webviewId); + } + await this.runtime.detachView(webviewId); + this.fileManager.clearSession(webviewId); + } + + private requireWorkDir(webviewId: string): string { + const workDir = this.getWorkDir(webviewId); + if (!workDir) throw new Error("No workspace folder open"); + return workDir; + } + + private requireWorkDirUri(webviewId: string): vscode.Uri { + const workDirUri = this.getWorkDirUri(webviewId); + if (!workDirUri) throw new Error("No workspace folder open"); + return workDirUri; + } + + private async dispatch(method: RpcMethod, params: unknown, webviewId: string): Promise { + if (!Object.hasOwn(handlers, method)) throw new Error(`Unknown method: ${method}`); + const handler = handlers[method]; + if (!handler) throw new Error(`Unknown method: ${method}`); + return handler(params, this.createContext(webviewId)); + } + + private createContext(webviewId: string): HandlerContext { + return { + webviewId, + workDir: this.getWorkDir(webviewId), + workDirUri: this.getWorkDirUri(webviewId), + workspaceRoot: this.workspaceRoot, + workspaceRootUri: this.workspaceRootUri, + workspaceState: this.workspaceState, + requireWorkDir: () => this.requireWorkDir(webviewId), + requireWorkDirUri: () => this.requireWorkDirUri(webviewId), + broadcast: this.broadcast, + fileManager: this.fileManager, + baselineManager: this.baselineManager, + runtime: this.runtime, + harness: this.runtime.harness, + reloadWebview: () => this.reloadWebview(webviewId), + showLogs: this.showLogs, + logError: (message, error) => this.logRuntimeError(message, error), + getSession: () => this.runtime.getSessionForView(webviewId), + getSessionId: () => this.fileManager.getSessionId(webviewId), + getOrCreateSession: async (model, effort, sessionId) => { + const runtime = await this.runtime.openSession({ + webviewId, + workDir: this.requireWorkDir(webviewId), + model, + effort, + yoloMode: VSCodeSettings.yoloMode, + ...(sessionId === undefined ? {} : { sessionId }), + }); + this.fileManager.setSession(webviewId, baselineSession(runtime)); + return runtime; + }, + resumeSession: async (sessionId) => { + const current = this.runtime.getSession(sessionId); + const session = + current?.session ?? + (await this.runtime.harness.resumeSession({ id: sessionId, includeSubagents: true })); + if (!areSameFsPath(session.workDir, this.requireWorkDir(webviewId))) { + if (current === undefined) { + await session.close().catch((error: unknown) => { + this.logRuntimeError("Unable to close a rejected session", error); + }); + } + throw new Error("The selected session belongs to a different working directory."); + } + const runtime = await this.runtime.attachResumedSession( + webviewId, + session, + VSCodeSettings.yoloMode, + ); + this.fileManager.setSession(webviewId, baselineSession(runtime)); + return runtime; + }, + closeSession: async () => { + await this.runtime.detachView(webviewId); + this.fileManager.clearSession(webviewId); + }, + saveAllDirty: () => this.saveAllDirty(), + setCustomWorkDir: (workDir) => this.setCustomWorkDir(webviewId, workDir), + }; + } + + private async saveAllDirty(): Promise { + const dirty = vscode.workspace.textDocuments.filter((document) => document.isDirty && !document.isUntitled); + await Promise.all(dirty.map((document) => document.save())); + } + + async disposeView(webviewId: string): Promise { + await this.runtime.detachView(webviewId); + this.customWorkDirs.delete(webviewId); + this.fileManager.disposeView(webviewId); + } + + async getEditorMention( + webviewId: string, + documentUri: vscode.Uri, + selection: vscode.Selection, + ): Promise { + const workDirUri = this.getWorkDirUri(webviewId); + if (workDirUri === null || !(await isWorkspacePathContained(workDirUri, documentUri))) return null; + const relativePath = relativeWorkspacePath(workDirUri, documentUri); + if (relativePath === undefined) return null; + + if (selection.isEmpty) return `@${relativePath}`; + return selection.start.line === selection.end.line + ? `@${relativePath}:${selection.start.line + 1}` + : `@${relativePath}:${selection.start.line + 1}-${selection.end.line + 1}`; + } + + captureFileBaseline( + session: BaselineSession, + filePath: string, + webviewIds: readonly string[], + ): void { + const workspaceRoot = this.workspaceRoot; + const workspaceRootUri = this.workspaceRootUri; + if (workspaceRoot === null || workspaceRootUri === null) return; + + const workDirUri = workDirUriFromPath(workspaceRootUri, workspaceRoot, session.workDir); + if ( + workDirUri === undefined || + !isWorkspacePathContainedSync(workspaceRootUri, workDirUri) + ) { + this.logRuntimeError( + "Unable to capture a file baseline", + new Error("Session working directory is outside the workspace"), + ); + return; + } + + const resolved = resolveSessionFilePath(workDirUri, session.workDir, filePath); + if ( + resolved === undefined || + !isWorkspacePathContainedSync(workDirUri, resolved.uri, { allowMissing: true }) + ) { + this.logRuntimeError( + "Unable to capture a file baseline", + new Error("File is outside the session working directory"), + ); + return; + } + + const capture = this.baselineManager.capture(session, resolved.uri.fsPath); + void capture + .then(async () => { + await Promise.all( + webviewIds.map(async (webviewId) => { + this.fileManager.trackFile(webviewId, resolved.uri.fsPath); + await this.fileManager.refreshChanges(webviewId); + }), + ); + }) + .catch((error) => { + this.logRuntimeError("Unable to capture a file baseline", error); + }); + } + + async dispose(): Promise { + this.fileManager.dispose(); + await this.runtime.dispose(); + } + + async getBaselineContent(sessionId: string, filePath: string): Promise { + const active = this.runtime.getSession(sessionId)?.summary; + const summary = active ?? (await this.runtime.harness.listSessions({ sessionId }))[0]; + if (summary === undefined) throw new Error("Session was not found."); + return this.baselineManager.getContent(baselineSummary(summary), filePath); + } + + private trace(id: string, method: string, durationMs: number, ok: boolean): void { + // Deliberately exclude params, prompt text, file paths, and credentials. + const line = `[bridge] id=${id} method=${method} ok=${String(ok)} durationMs=${durationMs}`; + console.debug(`[kimi-vscode] ${line}`); + this.writeLog(line); + } + + private logRuntimeError(message: string, error?: unknown): void { + const detail = errorDetail(error); + const line = `${message}${detail ? `: ${detail}` : ""}`; + console.error(`[kimi-vscode] ${line}`); + this.writeLog(line); + } +} + +function errorDetail(error: unknown): string { + if (error === undefined) return ""; + if (error instanceof Error) return `${error.name}: ${error.message}`; + if (typeof error === "string") return error; + if (typeof error === "number" || typeof error === "bigint" || typeof error === "boolean") { + return String(error); + } + return "Unknown error"; +} + +function baselineSession(runtime: SessionRuntime): BaselineSession { + return baselineSummary({ + id: runtime.id, + workDir: runtime.session.workDir, + metadata: runtime.summary?.metadata, + }); +} + +function baselineSummary(summary: Pick): BaselineSession { + return { + id: summary.id, + workDir: summary.workDir, + ...(summary.metadata === undefined ? {} : { metadata: summary.metadata }), + }; +} + +function resolveSessionFilePath( + workDirUri: vscode.Uri, + workDir: string, + filePath: string, +): WorkspacePath | undefined { + if (path.isAbsolute(filePath) || path.win32.isAbsolute(filePath)) { + const uri = workDirUriFromPath(workDirUri, workDir, filePath); + if (uri === undefined) return undefined; + const relativePath = relativeWorkspacePath(workDirUri, uri); + return relativePath === undefined ? undefined : { uri, relativePath }; + } + return resolveWorkspacePath(workDirUri, filePath); +} diff --git a/apps/vscode/src/config/vscode-settings.ts b/apps/vscode/src/config/vscode-settings.ts new file mode 100644 index 0000000000..9bca3b1267 --- /dev/null +++ b/apps/vscode/src/config/vscode-settings.ts @@ -0,0 +1,64 @@ +import * as vscode from "vscode"; +import type { ExtensionConfig } from "../../shared/types"; + +declare const __EXTENSION_VERSION__: string; +const EXTENSION_VERSION = typeof __EXTENSION_VERSION__ !== "undefined" ? __EXTENSION_VERSION__ : "0.0.0"; + +function getConfig() { + return vscode.workspace.getConfiguration("kimi"); +} + +export const VSCodeSettings = { + get yoloMode(): boolean { + return getConfig().get("yoloMode", false); + }, + + get autosave(): boolean { + return getConfig().get("autosave", true); + }, + + get enableNewConversationShortcut(): boolean { + return getConfig().get("enableNewConversationShortcut", false); + }, + + get useCtrlEnterToSend(): boolean { + return getConfig().get("useCtrlEnterToSend", false); + }, + + get showThinkingContent(): boolean { + return getConfig().get("showThinkingContent", false); + }, + + get showThinkingExpanded(): boolean { + return getConfig().get("showThinkingExpanded", false); + }, + + get editorContext(): "never" | "onConversationStart" | "onFileChange" { + return getConfig().get<"never" | "onConversationStart" | "onFileChange">("editorContext", "never"); + }, + + getExtensionConfig(): ExtensionConfig { + return { + yoloMode: this.yoloMode, + autosave: this.autosave, + useCtrlEnterToSend: this.useCtrlEnterToSend, + enableNewConversationShortcut: this.enableNewConversationShortcut, + showThinkingContent: this.showThinkingContent, + showThinkingExpanded: this.showThinkingExpanded, + version: EXTENSION_VERSION, + }; + }, +}; + +export function onSettingsChange(callback: (changedKeys: string[]) => void): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration("kimi")) { + return; + } + const keys = ["yoloMode", "autosave", "enableNewConversationShortcut", "useCtrlEnterToSend", "showThinkingContent", "showThinkingExpanded", "editorContext"]; + const changedKeys = keys.filter((key) => e.affectsConfiguration(`kimi.${key}`)); + if (changedKeys.length > 0) { + callback(changedKeys); + } + }); +} diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts new file mode 100644 index 0000000000..4418526fbd --- /dev/null +++ b/apps/vscode/src/extension.ts @@ -0,0 +1,280 @@ +import * as vscode from "vscode"; + +import { Events } from "../shared/bridge"; +import { KimiWebviewProvider } from "./KimiWebviewProvider"; +import { onSettingsChange, VSCodeSettings } from "./config/vscode-settings"; +import { + LegacyMigrationManager, + type LegacyMigrationDiscovery, + type LegacyMigrationRunResult, +} from "./migration"; +import { updateLoginContext } from "./utils/context"; + +let outputChannel: vscode.OutputChannel | undefined; +let provider: KimiWebviewProvider | undefined; + +const LEGACY_REAUTH_NOTICE_KEY = "kimi.legacyMigration.reauthNotice.v1"; +const LEGACY_WARNING_NOTICE_KEY = "kimi.legacyMigration.warningNotice.v1"; + +export async function activate(context: vscode.ExtensionContext): Promise { + outputChannel = vscode.window.createOutputChannel("Kimi Code"); + const remoteInfo = vscode.env.remoteName ? ` (remote: ${vscode.env.remoteName})` : ""; + log(`Kimi Code ${VSCodeSettings.getExtensionConfig().version} activating${remoteInfo}`); + + provider = new KimiWebviewProvider( + context.extensionUri, + context, + () => outputChannel?.show(), + (message) => log(message), + ); + context.subscriptions.push(provider, outputChannel); + + let isLoggedIn = false; + try { + isLoggedIn = await updateLoginContext(provider.harness); + } catch (error) { + logError("Unable to determine login status", error); + } + + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider("kimi-baseline", { + provideTextDocumentContent: async (uri) => { + const sessionId = new URLSearchParams(uri.query).get("sessionId"); + if (!sessionId || !provider) return ""; + const relativePath = decodeURIComponent(uri.path.replace(/^\//, "")); + try { + return await provider.getBaselineContent(sessionId, relativePath); + } catch (error) { + logError("Unable to open baseline content", error); + return ""; + } + }, + }), + ); + + context.subscriptions.push( + onSettingsChange((changedKeys) => { + provider?.broadcast(Events.ExtensionConfigChanged, { + config: VSCodeSettings.getExtensionConfig(), + changedKeys, + }); + if (changedKeys.includes("yoloMode")) { + void provider + ?.setYoloModeForActiveSessions(VSCodeSettings.yoloMode) + .catch((error) => logError("Unable to update session permission", error)); + } + }), + vscode.window.registerWebviewViewProvider("kimi.webview", provider, { + webviewOptions: { retainContextWhenHidden: true }, + }), + ); + + const migrationManager = new LegacyMigrationManager({ + targetHome: provider.harness.homeDir, + workspaceRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath, + legacyEnvironmentVariables: vscode.workspace + .getConfiguration("kimi") + .get("environmentVariables"), + }); + let migrationInFlight: Promise | undefined; + const runMigration = (retry: boolean): Promise => { + if (migrationInFlight !== undefined) return migrationInFlight; + migrationInFlight = performMigration(migrationManager, retry).finally(() => { + migrationInFlight = undefined; + }); + return migrationInFlight; + }; + + const commands: Record void | Promise> = { + "kimi.clearAllState": async () => { + await context.globalState.update("kimi.config", undefined); + await context.globalState.update("kimi.mcpServers", undefined); + await context.workspaceState.update("kimi.mcpEnabled", undefined); + await vscode.window.showInformationMessage("Kimi: Extension UI state cleared."); + }, + "kimi.openInTab": () => { + provider?.createPanel(); + }, + "kimi.openInSideBar": async () => { + await vscode.commands.executeCommand("kimi.webview.focus"); + }, + "kimi.focusInput": async () => { + await vscode.commands.executeCommand("kimi.webview.focus"); + provider?.broadcast(Events.FocusInput, {}); + }, + "kimi.insertMention": async () => { + const editor = vscode.window.activeTextEditor; + if (!editor) { + await vscode.window.showWarningMessage("No active editor"); + return; + } + await vscode.commands.executeCommand("kimi.webview.focus"); + if (!(await provider?.insertEditorMention(editor.document.uri, editor.selection))) { + await vscode.window.showWarningMessage("The active file is outside the selected working directory."); + } + }, + "kimi.newConversation": async () => { + await vscode.commands.executeCommand("kimi.webview.focus"); + provider?.broadcast(Events.NewConversation, {}); + }, + "kimi.showLogs": () => outputChannel?.show(), + "kimi.resetKimi": () => provider?.resetAllWebviews(), + "kimi.logout": async () => { + await vscode.commands.executeCommand("kimi.webview.focus"); + await vscode.window.showInformationMessage("Use the logout button in Kimi settings."); + }, + "kimi.migrateLegacyData": () => runMigration(true), + }; + + for (const [id, handler] of Object.entries(commands)) { + context.subscriptions.push(vscode.commands.registerCommand(id, handler)); + } + + void offerLegacyMigration( + migrationManager, + () => runMigration(false), + context.globalState, + isLoggedIn, + ).catch((error) => { + logError("Unable to check for legacy Kimi data", error); + }); + log("Kimi Code activated"); +} + +export async function deactivate(): Promise { + log("Kimi Code deactivating"); + await provider?.shutdown(); + provider = undefined; +} + +function log(message: string): void { + outputChannel?.appendLine(`[${new Date().toISOString()}] ${message}`); +} + +function logError(message: string, error: unknown): void { + const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + log(`${message}: ${detail}`); +} + +async function offerLegacyMigration( + manager: LegacyMigrationManager, + migrate: () => Promise, + globalState: vscode.Memento, + isLoggedIn: boolean, +): Promise { + const discovery = await manager.discover(); + logMigrationDiscovery(discovery); + const reauthNotice = legacyReauthNotice(discovery, isLoggedIn); + const warningNotice = + discovery.warnings.length === 0 + ? null + : "Some legacy Kimi data could not be inspected. Use “Kimi Code: Migrate Legacy Data” to retry."; + if (discovery.prompt === null) { + if (reauthNotice !== null && !globalState.get(LEGACY_REAUTH_NOTICE_KEY, false)) { + await vscode.window.showWarningMessage(reauthNotice); + await globalState.update(LEGACY_REAUTH_NOTICE_KEY, true); + } + if ( + discovery.warnings.length > 0 && + !globalState.get(LEGACY_WARNING_NOTICE_KEY, false) + ) { + const action = await vscode.window.showWarningMessage( + warningNotice ?? "Some legacy Kimi data could not be inspected.", + "Show Logs", + ); + await globalState.update(LEGACY_WARNING_NOTICE_KEY, true); + if (action === "Show Logs") outputChannel?.show(); + } + return; + } + + const action = await vscode.window.showInformationMessage( + [discovery.prompt.message, reauthNotice, warningNotice] + .filter((message) => message !== null) + .join(" "), + ...discovery.prompt.actions.map(({ label }) => label), + ); + if (reauthNotice !== null) await globalState.update(LEGACY_REAUTH_NOTICE_KEY, true); + if (warningNotice !== null) await globalState.update(LEGACY_WARNING_NOTICE_KEY, true); + if (action === "Migrate Now") await migrate(); +} + +function legacyReauthNotice( + discovery: LegacyMigrationDiscovery, + isLoggedIn: boolean, +): string | null { + const kimiLogins = isLoggedIn ? 0 : discovery.notices.oauthLoginsRequiringRelogin.length; + const mcpLogins = discovery.notices.mcpOauthServersRequiringReauth.length; + if (kimiLogins === 0 && mcpLogins === 0) return null; + if (kimiLogins > 0 && mcpLogins > 0) { + return "Legacy OAuth credentials are not copied. Sign in to Kimi Code and authorize your MCP servers again."; + } + return kimiLogins > 0 + ? "Legacy OAuth credentials are not copied. Sign in to Kimi Code again." + : "Legacy MCP OAuth credentials are not copied. Authorize those MCP servers again."; +} + +async function performMigration( + manager: LegacyMigrationManager, + retry: boolean, +): Promise { + log(`${retry ? "Retrying" : "Starting"} legacy Kimi data migration`); + const result = retry ? await manager.retry() : await manager.migrateNow(); + logMigrationResult(result); + + if (result.status === "completed" || result.status === "partial") { + try { + await provider?.harness.getConfig({ reload: true }); + await provider?.resetAllWebviews(); + } catch (error) { + logError("Migration finished, but the runtime config could not be reloaded", error); + } + } + + const reauthCount = + result.notices.oauthLoginsRequiringRelogin.length + + result.notices.mcpOauthServersRequiringReauth.length; + const reauthNotice = + reauthCount === 0 + ? "" + : ` ${reauthCount} OAuth connection(s) must be signed in again.`; + const message = `${result.message}${reauthNotice}`; + const needsLogs = + result.status === "partial" || + result.status === "failed" || + result.warnings.length > 0 || + result.manualActions.length > 0; + + if (result.status === "failed") { + const action = await vscode.window.showErrorMessage(message, "Show Logs"); + if (action === "Show Logs") outputChannel?.show(); + } else if (needsLogs) { + const action = await vscode.window.showWarningMessage(message, "Show Logs"); + if (action === "Show Logs") outputChannel?.show(); + } else { + await vscode.window.showInformationMessage(message); + } +} + +function logMigrationDiscovery(discovery: LegacyMigrationDiscovery): void { + for (const warning of discovery.warnings) log(`Legacy migration warning: ${warning.message}`); + for (const source of discovery.suppressedSources) { + log(`Legacy migration already completed for ${source.sourceHome}`); + } +} + +function logMigrationResult(result: LegacyMigrationRunResult): void { + const { totals } = result; + log( + `Legacy migration ${result.status}: config=${totals.configFiles} mcp=${totals.mcpServers} history=${totals.userHistoryEntries} skills=${totals.skills} sessions=${totals.sessions} alreadyMigrated=${totals.alreadyMigratedSessions} skipped=${totals.skippedItems} conflicts=${totals.conflicts} failures=${totals.failures}`, + ); + for (const warning of result.warnings) log(`Legacy migration warning: ${warning.message}`); + for (const source of result.sources) { + for (const failure of source.failures) { + log(`Legacy migration failure (${failure.sourceHome}): ${failure.message}`); + } + } + for (const action of result.manualActions) log(`Legacy migration action: ${action}`); +} + +export { log }; diff --git a/apps/vscode/src/handlers/auth.handler.ts b/apps/vscode/src/handlers/auth.handler.ts new file mode 100644 index 0000000000..6ddac4bcf6 --- /dev/null +++ b/apps/vscode/src/handlers/auth.handler.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode"; + +import { Events, Methods } from "../../shared/bridge"; +import type { LoginResult } from "../../shared/legacy-sdk"; +import type { LoginStatus } from "../../shared/types"; +import { updateLoginContext } from "../utils/context"; +import type { Handler } from "./types"; + +export const authHandlers: Record> = { + [Methods.CheckLoginStatus]: async (_, ctx): Promise => { + return { loggedIn: await updateLoginContext(ctx.harness) }; + }, + + [Methods.Login]: async (_, ctx): Promise => { + try { + await ctx.harness.auth.login(undefined, { + onDeviceCode: async (authorization) => { + const url = authorization.verificationUriComplete || authorization.verificationUri; + ctx.broadcast(Events.LoginUrl, { url }, ctx.webviewId); + await vscode.env.openExternal(vscode.Uri.parse(url)); + }, + }); + await updateLoginContext(ctx.harness); + return { success: true }; + } catch (error) { + ctx.logError("Kimi login failed", error); + await updateLoginContext(ctx.harness).catch((statusError: unknown) => { + ctx.logError("Unable to refresh login status after a failed login", statusError); + }); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, + + [Methods.Logout]: async (_, ctx): Promise => { + try { + await ctx.harness.auth.logout(); + await updateLoginContext(ctx.harness); + return { success: true }; + } catch (error) { + ctx.logError("Kimi logout failed", error); + await updateLoginContext(ctx.harness).catch((statusError: unknown) => { + ctx.logError("Unable to refresh login status after a failed logout", statusError); + }); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}; diff --git a/apps/vscode/src/handlers/chat.handler.ts b/apps/vscode/src/handlers/chat.handler.ts new file mode 100644 index 0000000000..21608381e2 --- /dev/null +++ b/apps/vscode/src/handlers/chat.handler.ts @@ -0,0 +1,210 @@ +import * as vscode from "vscode"; +import { isKimiError } from "@moonshot-ai/kimi-code-sdk"; + +import { Events, Methods } from "../../shared/bridge"; +import type { ApprovalResponse, ContentPart } from "../../shared/legacy-sdk"; +import { getUserMessage } from "../../shared/errors"; +import type { ErrorPhase } from "../../shared/types"; +import { VSCodeSettings } from "../config/vscode-settings"; +import type { SessionRuntime } from "../runtime/session-runtime"; +import { isWorkspacePathContained, relativeWorkspacePath } from "../utils/workspace-path"; +import { parseHostSlashCommand, runHostSlashCommand } from "./slash-command"; +import type { Handler } from "./types"; + +interface StreamChatParams { + content: string | ContentPart[]; + model: string; + effort?: string; + thinking?: boolean; + planMode?: boolean; + sessionId?: string; +} + +interface RespondApprovalParams { + requestId: string; + response: ApprovalResponse; +} + +interface RespondQuestionParams { + rpcRequestId: string; + questionRequestId: string; + answers: Record; +} + +const injectedEditorContextSessions = new Map(); + +async function buildSystemContext(sessionId: string, ctx: Parameters[1]): Promise { + const mode = VSCodeSettings.editorContext; + if (mode === "never") return ""; + + const editor = vscode.window.activeTextEditor; + if (!editor || !ctx.workDirUri || !(await isWorkspacePathContained(ctx.workDirUri, editor.document.uri))) { + return ""; + } + + const document = editor.document; + const relativePath = relativeWorkspacePath(ctx.workDirUri, document.uri); + if (relativePath === undefined) return ""; + const lastPath = injectedEditorContextSessions.get(sessionId); + if (mode === "onConversationStart" && lastPath !== undefined) return ""; + if (mode === "onFileChange" && lastPath === relativePath) return ""; + + injectedEditorContextSessions.set(sessionId, relativePath); + const selection = editor.selection; + const selectionInfo = selection.isEmpty + ? "" + : ` (L${selection.start.line + 1}-${selection.end.line + 1} selected)`; + const unsavedInfo = document.isDirty ? ", unsaved" : ""; + return `Editor context (use only if relevant to user's query): ${relativePath}:${selection.active.line + 1}${selectionInfo}${unsavedInfo}.\n`; +} + +function prependSystemContext(content: string | ContentPart[], context: string): string | ContentPart[] { + if (!context) return content; + if (typeof content === "string") return `${content}\n${context}`; + + const index = content.findIndex((part) => part.type === "text"); + if (index < 0) return [{ type: "text", text: context }, ...content]; + const copy = [...content]; + const text = copy[index] as Extract; + copy[index] = { type: "text", text: context + text.text }; + return copy; +} + +const streamChat: Handler = async (params, ctx) => { + if (!ctx.workDir) { + emitPreflightError(ctx, "NO_WORKSPACE", "Please open a folder to start."); + void vscode.window.showWarningMessage("Kimi: Please open a folder first.", "Open Folder").then((action) => { + if (action) void vscode.commands.executeCommand("vscode.openFolder"); + }); + return { done: false }; + } + + if (VSCodeSettings.autosave) { + try { + await ctx.saveAllDirty(); + } catch (error) { + emitCaughtError(ctx, error, "preflight"); + return { done: false }; + } + } + + let runtime: SessionRuntime; + try { + runtime = await ctx.getOrCreateSession( + params.model, + params.effort ?? (params.thinking === true ? "on" : "off"), + params.sessionId, + ); + } catch (error) { + emitCaughtError(ctx, error, "preflight"); + return { done: false }; + } + + try { + const status = await runtime.session.getStatus(); + if (params.planMode !== undefined && status.planMode !== params.planMode) { + await runtime.session.setPlanMode(params.planMode); + } + runtime.announceSessionStart(status.model); + } catch (error) { + emitCaughtError(ctx, error, "preflight", runtime.id); + return { done: false }; + } + + const slash = parseHostSlashCommand(params.content); + if (slash !== undefined) { + try { + return { done: await runHostSlashCommand(runtime, slash, ctx) }; + } catch (error) { + emitCaughtError(ctx, error, "runtime", runtime.id); + return { done: false }; + } + } + + const systemContext = await buildSystemContext(runtime.id, ctx); + try { + const result = await runtime.prompt(prependSystemContext(params.content, systemContext)); + return { done: result.status === "finished" }; + } catch (error) { + emitCaughtError(ctx, error, "runtime", runtime.id); + return { done: false }; + } +}; + +const abortChat: Handler = async (_, ctx) => { + const runtime = ctx.getSession(); + if (runtime !== undefined) await runtime.cancel(); + return { aborted: true }; +}; + +const respondApproval: Handler = async (params, ctx) => { + return { ok: ctx.getSession()?.respondApproval(params.requestId, params.response) ?? false }; +}; + +const respondQuestion: Handler = async (params, ctx) => { + const id = params.questionRequestId || params.rpcRequestId; + return { ok: ctx.getSession()?.respondQuestion(id, params.answers) ?? false }; +}; + +const setPlanMode: Handler<{ enabled: boolean }, { ok: boolean; planMode: boolean }> = async (params, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined) return { ok: false, planMode: false }; + await runtime.session.setPlanMode(params.enabled); + return { ok: true, planMode: params.enabled }; +}; + +const steerChat: Handler<{ content: string | ContentPart[] }, { ok: boolean }> = async (params, ctx) => { + const runtime = ctx.getSession(); + if (runtime === undefined || !runtime.isBusy) return { ok: false }; + await runtime.steer(params.content); + return { ok: true }; +}; + +const resetSession: Handler = async (_, ctx) => { + const runtime = ctx.getSession(); + if (runtime !== undefined) injectedEditorContextSessions.delete(runtime.id); + await ctx.closeSession(); + ctx.fileManager.clearTracked(ctx.webviewId); + return { ok: true }; +}; + +export const chatHandlers: Record> = { + [Methods.StreamChat]: streamChat, + [Methods.AbortChat]: abortChat, + [Methods.RespondApproval]: respondApproval, + [Methods.RespondQuestion]: respondQuestion, + [Methods.SetPlanMode]: setPlanMode, + [Methods.SteerChat]: steerChat, + [Methods.ResetSession]: resetSession, +}; + +function emitCaughtError( + ctx: Parameters[1], + error: unknown, + phase: ErrorPhase, + sessionId?: string, +): void { + const code = isKimiError(error) ? error.code : "internal"; + const detail = error instanceof Error ? error.message : String(error); + ctx.logError(`Chat ${phase} request failed`, error); + ctx.broadcast( + Events.StreamEvent, + { + type: "error", + code, + message: getUserMessage(code, detail), + detail, + phase, + ...(sessionId === undefined ? {} : { _sessionId: sessionId }), + }, + ctx.webviewId, + ); +} + +function emitPreflightError(ctx: Parameters[1], code: string, message: string): void { + ctx.broadcast( + Events.StreamEvent, + { type: "error", code, message, phase: "preflight" }, + ctx.webviewId, + ); +} diff --git a/apps/vscode/src/handlers/config.handler.ts b/apps/vscode/src/handlers/config.handler.ts new file mode 100644 index 0000000000..705edaa205 --- /dev/null +++ b/apps/vscode/src/handlers/config.handler.ts @@ -0,0 +1,149 @@ +import * as vscode from "vscode"; +import { + effectiveModelAlias, + type KimiConfig as SdkKimiConfig, + type ModelAlias, + type ThinkingEffort, +} from "@moonshot-ai/kimi-code-sdk"; + +import { Methods } from "../../shared/bridge"; +import type { + KimiConfig as WebviewKimiConfig, + ModelConfig, + SlashCommandInfo, +} from "../../shared/legacy-sdk"; +import type { ExtensionConfig, SessionConfig } from "../../shared/types"; +import { VSCodeSettings } from "../config/vscode-settings"; +import type { Handler } from "./types"; + +const SLASH_COMMANDS: SlashCommandInfo[] = [ + { name: "init", aliases: [], description: "Analyze the codebase and generate AGENTS.md" }, + { name: "compact", aliases: [], description: "Compact the conversation context" }, + { name: "clear", aliases: ["reset"], description: "Clear the context" }, + { name: "yolo", aliases: [], description: "Toggle YOLO mode (auto-approve all actions)" }, + { + name: "afk", + aliases: [], + description: "Toggle afk mode (auto-dismiss questions and auto-approve tool calls)", + }, + { name: "plan", aliases: [], description: "Toggle plan mode. Usage: /plan [on|off|view|clear]" }, + { + name: "add-dir", + aliases: [], + description: "Add a directory to the workspace. Usage: /add-dir ", + }, + { name: "export", aliases: [], description: "Export current session context to a markdown file" }, + { name: "import", aliases: [], description: "Import context from a file or session ID" }, +]; + +const saveConfig: Handler = async (params, ctx) => { + const effort = sessionConfigEffort(params); + await ctx.harness.setConfig({ + defaultModel: params.model, + thinking: thinkingConfig(effort), + }); + + const runtime = ctx.getSession(); + if (runtime !== undefined) { + const status = await runtime.session.getStatus(); + if (status.model !== params.model) await runtime.session.setModel(params.model); + if (status.thinkingEffort !== effort) await runtime.session.setThinking(effort); + } + return { ok: true }; +}; + +const getExtensionConfig: Handler = async () => { + return VSCodeSettings.getExtensionConfig(); +}; + +const openSettings: Handler = async () => { + await vscode.commands.executeCommand("workbench.action.openSettings", "kimi"); + return { ok: true }; +}; + +const getModels: Handler = async (_, ctx) => { + const config = await ctx.harness.getConfig({ reload: true }); + return toWebviewConfig(config); +}; + +const getSlashCommands: Handler = async (_, ctx) => { + if (!ctx.workDir) return SLASH_COMMANDS; + try { + const skills = await ctx.harness.listWorkspaceSkills(ctx.workDir); + const skillCommands = skills + .filter((skill) => isUserActivatableSkill(skill.type)) + .toSorted((left, right) => left.name.localeCompare(right.name)) + .map((skill) => ({ + name: `skill:${skill.name}`, + aliases: [], + description: skill.description ?? "", + })); + return [...SLASH_COMMANDS, ...skillCommands]; + } catch (error) { + ctx.logError("Unable to list workspace skills", error); + return SLASH_COMMANDS; + } +}; + +const showLogs: Handler = async (_, ctx) => { + ctx.showLogs(); + return { ok: true }; +}; + +const reloadWebview: Handler = async (_, ctx) => { + await ctx.closeSession(); + ctx.fileManager.clearTracked(ctx.webviewId); + ctx.reloadWebview(); + return { ok: true }; +}; + +export const configHandlers = { + [Methods.SaveConfig]: saveConfig, + [Methods.GetExtensionConfig]: getExtensionConfig, + [Methods.OpenSettings]: openSettings, + [Methods.GetModels]: getModels, + [Methods.GetSlashCommands]: getSlashCommands, + [Methods.ShowLogs]: showLogs, + [Methods.ReloadWebview]: reloadWebview, +} as Record>; + +export function toWebviewConfig(config: SdkKimiConfig): WebviewKimiConfig { + const models: ModelConfig[] = Object.entries(config.models ?? {}) + .map(([id, model]) => toWebviewModel(id, model)) + .toSorted((left, right) => left.name.localeCompare(right.name)); + return { + defaultModel: config.defaultModel ?? models[0]?.id ?? null, + defaultThinking: config.thinking?.enabled !== false, + defaultThinkingEffort: config.thinking?.effort, + models, + }; +} + +function toWebviewModel(id: string, model: ModelAlias): ModelConfig { + const effective = effectiveModelAlias(model); + return { + id, + name: effective.displayName ?? effective.model ?? id, + provider: effective.provider, + capabilities: [...(effective.capabilities ?? [])], + adaptive_thinking: effective.adaptiveThinking, + support_efforts: + effective.supportEfforts === undefined ? undefined : [...effective.supportEfforts], + default_effort: effective.defaultEffort, + }; +} + +function sessionConfigEffort(config: SessionConfig): ThinkingEffort { + if (config.effort !== undefined) return config.effort as ThinkingEffort; + return config.thinking === true ? "on" : "off"; +} + +function thinkingConfig(effort: ThinkingEffort): { enabled: boolean; effort?: string } { + if (effort === "off") return { enabled: false }; + if (effort === "on") return { enabled: true }; + return { enabled: true, effort }; +} + +function isUserActivatableSkill(type: string | undefined): boolean { + return type === undefined || type === "prompt" || type === "inline" || type === "flow"; +} diff --git a/apps/vscode/src/handlers/file.handler.ts b/apps/vscode/src/handlers/file.handler.ts new file mode 100644 index 0000000000..50457e8cf8 --- /dev/null +++ b/apps/vscode/src/handlers/file.handler.ts @@ -0,0 +1,226 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { Events, Methods } from "../../shared/bridge"; +import type { FileChange, ProjectFile } from "../../shared/types"; +import type { BaselineSession } from "../managers/baseline.manager"; +import { + isWorkspacePathContained, + resolveWorkspacePath, + type WorkspacePath, +} from "../utils/workspace-path"; +import type { Handler } from "./types"; + +interface GetProjectFilesParams { + query?: string; + directory?: string; +} +interface PickMediaParams { maxCount?: number; includeVideo?: boolean } +interface FilePathParams { filePath: string } +interface OptionalFilePathParams { filePath?: string } +interface PathsParams { paths: string[] } +interface CheckFilesExistParams { paths: string[] } + +const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp"]; +const VIDEO_EXTENSIONS = ["mp4", "webm", "mov"]; +const IMAGE_MIME_TYPES: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".bmp": "image/bmp", + ".ico": "image/x-icon", +}; + +const getProjectFiles: Handler = async (params, ctx) => { + if (!ctx.workDirUri) return []; + return params?.directory !== undefined + ? ctx.fileManager.listDirectory(ctx.workDirUri, params.directory) + : ctx.fileManager.searchFiles(ctx.workDirUri, params?.query); +}; + +const pickMedia: Handler = async (params) => { + const maxCount = params.maxCount ?? 9; + const includeVideo = params.includeVideo ?? true; + const filters: Record = { Images: IMAGE_EXTENSIONS }; + if (includeVideo) { + filters["Videos"] = VIDEO_EXTENSIONS; + filters["All Media"] = [...IMAGE_EXTENSIONS, ...VIDEO_EXTENSIONS]; + } + const uris = await vscode.window.showOpenDialog({ canSelectMany: true, filters, title: "Select Media" }); + if (!uris) return []; + + const results: string[] = []; + for (const uri of uris.slice(0, maxCount)) { + try { + const extension = path.extname(uri.fsPath).toLowerCase().slice(1); + const isVideo = VIDEO_EXTENSIONS.includes(extension); + const stat = await vscode.workspace.fs.stat(uri); + if (stat.size > (isVideo ? 20 : 10) * 1024 * 1024) continue; + const bytes = await vscode.workspace.fs.readFile(uri); + results.push(`data:${mediaMime(extension)};base64,${Buffer.from(bytes).toString("base64")}`); + } catch { + // Skip a file that disappears or cannot be read without failing the whole picker. + } + } + return results; +}; + +const openFile: Handler = async ({ filePath }, ctx) => { + const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath); + if (resolved === undefined) return { ok: false }; + await vscode.commands.executeCommand("vscode.open", resolved.uri); + return { ok: true }; +}; + +const openFileDiff: Handler = async ({ filePath }, ctx) => { + const sessionId = ctx.getSessionId(); + const resolved = await resolveExistingWorkspaceFile(ctx.requireWorkDirUri(), filePath); + if (!sessionId || resolved === undefined) return { ok: false }; + + const baselineUri = vscode.Uri.from({ + scheme: "kimi-baseline", + path: `/${resolved.relativePath}`, + query: new URLSearchParams({ sessionId }).toString(), + }); + await vscode.commands.executeCommand( + "vscode.diff", + baselineUri, + resolved.uri, + `${path.basename(resolved.relativePath)} (changes from Kimi)`, + ); + return { ok: true }; +}; + +const trackFiles: Handler = async ({ paths }, ctx) => { + const session = requireBaselineSession(ctx); + const workDirUri = ctx.requireWorkDirUri(); + for (const filePath of paths) { + const resolved = await resolveWorkspaceFile(workDirUri, filePath, true); + if (resolved !== undefined) ctx.fileManager.trackFile(ctx.webviewId, resolved.uri.fsPath); + } + const changes = await ctx.baselineManager.getChanges(session); + ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId); + return changes; +}; + +const clearTrackedFiles: Handler = async (_, ctx) => { + ctx.fileManager.clearTracked(ctx.webviewId); + ctx.broadcast(Events.FileChangesUpdated, [], ctx.webviewId); + return { ok: true }; +}; + +const revertFiles: Handler = async (params, ctx) => { + const session = requireBaselineSession(ctx); + if (params.filePath) { + const resolved = await resolveWorkspaceFile(ctx.requireWorkDirUri(), params.filePath, true); + if (resolved === undefined) return { ok: false }; + await ctx.baselineManager.undo(session, resolved.relativePath); + } else { + await ctx.baselineManager.undoAll(session); + ctx.fileManager.clearTracked(ctx.webviewId); + } + await ctx.fileManager.refreshChanges(ctx.webviewId); + return { ok: true }; +}; + +const keepChanges: Handler = async (params, ctx) => { + const session = requireBaselineSession(ctx); + if (params.filePath) { + const resolved = await resolveWorkspaceFile(ctx.requireWorkDirUri(), params.filePath, true); + if (resolved === undefined) return { ok: false }; + await ctx.baselineManager.keep(session, resolved.relativePath); + ctx.fileManager.getTracked(ctx.webviewId).delete(resolved.uri.fsPath); + } else { + await ctx.baselineManager.keepAll(session); + ctx.fileManager.clearTracked(ctx.webviewId); + } + await ctx.fileManager.refreshChanges(ctx.webviewId); + return { ok: true }; +}; + +const checkFileExists: Handler = async ({ filePath }, ctx) => { + if (!ctx.workDirUri) return false; + return (await resolveExistingWorkspaceFile(ctx.workDirUri, filePath)) !== undefined; +}; + +const checkFilesExist: Handler> = async ({ paths }, ctx) => { + if (!ctx.workDirUri) return {}; + return Object.fromEntries( + await Promise.all( + paths.map(async (filePath) => [ + filePath, + (await resolveExistingWorkspaceFile(ctx.workDirUri!, filePath)) !== undefined, + ] as const), + ), + ); +}; + +const getImageDataUri: Handler = async ({ filePath }, ctx) => { + if (!ctx.workDirUri) return null; + const resolved = await resolveExistingWorkspaceFile(ctx.workDirUri, decodeURIComponent(filePath)); + if (resolved === undefined) return null; + const mime = IMAGE_MIME_TYPES[path.extname(resolved.relativePath).toLowerCase()]; + if (!mime) return null; + try { + const bytes = await vscode.workspace.fs.readFile(resolved.uri); + return `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`; + } catch { + return null; + } +}; + +export const fileHandlers: Record> = { + [Methods.GetProjectFiles]: getProjectFiles, + [Methods.PickMedia]: pickMedia, + [Methods.OpenFile]: openFile, + [Methods.OpenFileDiff]: openFileDiff, + [Methods.TrackFiles]: trackFiles, + [Methods.ClearTrackedFiles]: clearTrackedFiles, + [Methods.RevertFiles]: revertFiles, + [Methods.KeepChanges]: keepChanges, + [Methods.CheckFileExists]: checkFileExists, + [Methods.CheckFilesExist]: checkFilesExist, + [Methods.GetImageDataUri]: getImageDataUri, +}; + +function requireBaselineSession(ctx: Parameters[1]): BaselineSession { + const session = ctx.fileManager.getSession(ctx.webviewId); + if (session === null) throw new Error("No active session."); + return session; +} + +async function resolveWorkspaceFile( + workDirUri: vscode.Uri, + filePath: string, + allowMissing = false, +): Promise { + const resolved = resolveWorkspacePath(workDirUri, filePath); + if (resolved === undefined || !(await isWorkspacePathContained(workDirUri, resolved.uri, { allowMissing }))) { + return undefined; + } + return resolved; +} + +async function resolveExistingWorkspaceFile( + workDirUri: vscode.Uri, + filePath: string, +): Promise { + const resolved = await resolveWorkspaceFile(workDirUri, filePath); + if (resolved === undefined) return undefined; + try { + await vscode.workspace.fs.stat(resolved.uri); + return resolved; + } catch { + return undefined; + } +} + +function mediaMime(extension: string): string { + return ({ + png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", + mp4: "video/mp4", webm: "video/webm", mov: "video/quicktime", + } as Record)[extension] ?? "application/octet-stream"; +} diff --git a/apps/vscode/src/handlers/index.ts b/apps/vscode/src/handlers/index.ts new file mode 100644 index 0000000000..fa0a72b149 --- /dev/null +++ b/apps/vscode/src/handlers/index.ts @@ -0,0 +1,20 @@ +import { configHandlers } from "./config.handler"; +import { mcpHandlers } from "./mcp.handler"; +import { sessionHandlers } from "./session.handler"; +import { chatHandlers } from "./chat.handler"; +import { fileHandlers } from "./file.handler"; +import { workspaceHandlers } from "./workspace.handler"; +import { authHandlers } from "./auth.handler"; +import type { Handler } from "./types"; + +export type { Handler, HandlerContext, BroadcastFn, ReloadWebviewFn, ShowLogsFn } from "./types"; + +export const handlers: Record> = { + ...workspaceHandlers, + ...configHandlers, + ...mcpHandlers, + ...sessionHandlers, + ...chatHandlers, + ...fileHandlers, + ...authHandlers, +}; diff --git a/apps/vscode/src/handlers/mcp.handler.ts b/apps/vscode/src/handlers/mcp.handler.ts new file mode 100644 index 0000000000..a310f64deb --- /dev/null +++ b/apps/vscode/src/handlers/mcp.handler.ts @@ -0,0 +1,301 @@ +import * as vscode from "vscode"; +import type { McpServerConfig as SdkMcpServerConfig, McpTestResult } from "@moonshot-ai/kimi-code-sdk"; + +import { Events, Methods } from "../../shared/bridge"; +import { + MCP_SECRET_MASK, + type MCPServerConfig, + type MCPTestResult, + type UpdateMCPServerRequest, +} from "../../shared/legacy-sdk"; +import type { Handler } from "./types"; + +const SENSITIVE_MCP_KEY_WORDS = new Set([ + "authorization", + "cookie", + "credential", + "credentials", + "passwd", + "password", + "secret", + "token", +]); + +interface NameParams { name: string } + +export const mcpHandlers: Record> = { + [Methods.GetMCPServers]: async (_, ctx): Promise => { + return toWebviewServers(await ctx.harness.listMcpServers()); + }, + + [Methods.AddMCPServer]: async (params: MCPServerConfig, ctx): Promise => { + const server = restoreMaskedSecrets(undefined, params); + const servers = toWebviewServers(await ctx.harness.addMcpServer(toSdkServer(server))); + ctx.broadcast(Events.MCPServersChanged, servers); + return servers; + }, + + [Methods.UpdateMCPServer]: async ( + params: UpdateMCPServerRequest | MCPServerConfig, + ctx, + ): Promise => { + const request = normalizeUpdateRequest(params); + const current = (await ctx.harness.listMcpServers()).find( + (server) => server.name === request.originalName, + ); + const edited = restoreMaskedSecrets(current, request.server); + const next = mergeEditableServer(current, edited, request.replaceEditableFields); + const servers = toWebviewServers( + await updateOrRenameServer(ctx.harness, request.originalName, current, next), + ); + ctx.broadcast(Events.MCPServersChanged, servers); + return servers; + }, + + [Methods.RemoveMCPServer]: async ({ name }: NameParams, ctx): Promise => { + const servers = toWebviewServers(await ctx.harness.removeMcpServer(name)); + ctx.broadcast(Events.MCPServersChanged, servers); + return servers; + }, + + [Methods.AuthMCP]: async ({ name }: NameParams, ctx) => { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Kimi: Authenticating "${name}"...`, + cancellable: false, + }, + async () => { + try { + await ctx.harness.authenticateMcpServer(name, { + onAuthorizationUrl: async (url) => vscode.env.openExternal(vscode.Uri.parse(url)), + }); + await vscode.window.showInformationMessage(`Kimi: OAuth completed for "${name}"`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await vscode.window.showErrorMessage(`Kimi: OAuth failed for "${name}": ${message}`); + throw error; + } + }, + ); + return { ok: true }; + }, + + [Methods.ResetAuthMCP]: async ({ name }: NameParams, ctx) => { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: `Kimi: Resetting auth for "${name}"...`, + cancellable: false, + }, + async () => { + try { + await ctx.harness.resetMcpServerAuth(name); + await vscode.window.showInformationMessage(`Kimi: Auth reset for "${name}"`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await vscode.window.showErrorMessage(`Kimi: Reset auth failed for "${name}": ${message}`); + throw error; + } + }, + ); + return { ok: true }; + }, + + [Methods.TestMCP]: async ({ name }: NameParams, ctx): Promise => { + void vscode.window.showInformationMessage(`Kimi: Testing MCP server "${name}"...`); + const result = toWebviewTestResult(await ctx.harness.testMcpServer(name, { + cwd: ctx.workDir ?? undefined, + })); + if (!result.success) { + ctx.logError(`MCP server test failed for "${name}"`, new Error(result.output)); + } + return result; + }, +}; + +function toWebviewServers(servers: readonly SdkMcpServerConfig[]): MCPServerConfig[] { + return servers + .filter((server) => server.transport === "stdio" || server.transport === "http") + .map((server) => { + if (server.transport === "stdio") { + return { ...server, env: maskSecretValues(server.env) } as MCPServerConfig; + } + return { ...server, headers: maskSecretValues(server.headers) } as MCPServerConfig; + }); +} + +function maskSecretValues(values: Record | undefined): Record | undefined { + if (values === undefined) return undefined; + return Object.fromEntries(Object.entries(values).map(([key, value]) => [ + key, + isSensitiveMcpKey(key) ? MCP_SECRET_MASK : value, + ])); +} + +function isSensitiveMcpKey(key: string): boolean { + const words = key + .replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean); + const compact = words.join(""); + return words.some((word) => SENSITIVE_MCP_KEY_WORDS.has(word)) + || words.includes("key") + || compact === "proxyauthorization" + || compact === "setcookie" + || compact.endsWith("apikey") + || compact.endsWith("accesskey") + || compact.endsWith("privatekey"); +} + +function restoreMaskedSecrets( + current: SdkMcpServerConfig | undefined, + edited: MCPServerConfig, +): MCPServerConfig { + if (edited.transport === "stdio") { + const currentEnv = current?.transport === "stdio" ? current.env : undefined; + return { + ...edited, + env: restoreMaskedRecord("environment variable", currentEnv, edited.env, false), + }; + } + const currentHeaders = current?.transport === "http" ? current.headers : undefined; + return { + ...edited, + headers: restoreMaskedRecord("header", currentHeaders, edited.headers, true), + }; +} + +function restoreMaskedRecord( + label: string, + current: Record | undefined, + edited: Record | undefined, + caseInsensitiveKeys: boolean, +): Record | undefined { + if (edited === undefined) return undefined; + return Object.fromEntries(Object.entries(edited).map(([key, value]) => { + if (value !== MCP_SECRET_MASK) return [key, value]; + const stored = findStoredSecret(current, key, caseInsensitiveKeys); + if (stored === undefined) { + throw new Error(`Cannot preserve masked MCP ${label} "${key}" because no stored value exists`); + } + return [key, stored]; + })); +} + +function findStoredSecret( + current: Record | undefined, + key: string, + caseInsensitiveKeys: boolean, +): string | undefined { + if (current === undefined) return undefined; + if (Object.hasOwn(current, key)) return current[key]; + if (!caseInsensitiveKeys) return undefined; + const normalized = key.toLowerCase(); + const match = Object.entries(current).find(([storedKey]) => storedKey.toLowerCase() === normalized); + return match?.[1]; +} + +function toSdkServer(server: MCPServerConfig): SdkMcpServerConfig { + const name = server.name.trim(); + if (server.transport === "stdio") { + return { + name, + transport: "stdio", + command: server.command?.trim() ?? "", + args: server.args, + env: server.env, + }; + } + return { + name, + transport: "http", + url: server.url?.trim() ?? "", + headers: server.headers, + auth: server.auth, + bearerTokenEnvVar: server.bearerTokenEnvVar, + }; +} + +function mergeEditableServer( + current: SdkMcpServerConfig | undefined, + edited: MCPServerConfig, + replaceEditableFields: boolean, +): SdkMcpServerConfig { + const next = toSdkServer(edited); + if (current === undefined || current.transport !== next.transport) return next; + if (!replaceEditableFields) { + return mergeReleasedFormUpdate(current, next); + } + return { ...current, ...next } as SdkMcpServerConfig; +} + +function mergeReleasedFormUpdate( + current: SdkMcpServerConfig, + next: SdkMcpServerConfig, +): SdkMcpServerConfig { + const defined = Object.fromEntries( + Object.entries(next).filter(([, value]) => value !== undefined), + ); + return { ...current, ...defined } as SdkMcpServerConfig; +} + +function normalizeUpdateRequest( + params: UpdateMCPServerRequest | MCPServerConfig, +): { + readonly originalName: string; + readonly server: MCPServerConfig; + readonly replaceEditableFields: boolean; +} { + if ("server" in params) { + return { + originalName: params.originalName.trim(), + server: params.server, + replaceEditableFields: true, + }; + } + return { + originalName: params.name.trim(), + server: params, + replaceEditableFields: false, + }; +} + +async function updateOrRenameServer( + harness: Pick< + Parameters[1]["harness"], + "addMcpServer" | "updateMcpServer" | "removeMcpServer" + >, + originalName: string, + current: SdkMcpServerConfig | undefined, + next: SdkMcpServerConfig, +): Promise { + if (next.name === originalName) { + return harness.updateMcpServer(next); + } + if (current === undefined) { + throw new Error(`MCP server "${originalName}" was not found`); + } + + await harness.addMcpServer(next); + try { + return await harness.removeMcpServer(originalName); + } catch (error) { + await harness.removeMcpServer(next.name).catch(() => undefined); + throw error; + } +} + +function toWebviewTestResult(result: McpTestResult): MCPTestResult { + return { success: result.success, output: sanitizeMcpDiagnostic(result.output) }; +} + +function sanitizeMcpDiagnostic(output: string): string { + return output + .replaceAll(/\bBearer\s+[^\s,;]+/gi, "Bearer [redacted]") + .replaceAll( + /(["']?(?:authorization|cookie|credentials?|password|passwd|secret|token|api[-_ ]?key)["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi, + "$1[redacted]", + ); +} diff --git a/apps/vscode/src/handlers/session.handler.ts b/apps/vscode/src/handlers/session.handler.ts new file mode 100644 index 0000000000..817d6960bf --- /dev/null +++ b/apps/vscode/src/handlers/session.handler.ts @@ -0,0 +1,274 @@ +import * as path from "node:path"; +import * as vscode from "vscode"; +import type { SessionSummary } from "@moonshot-ai/kimi-code-sdk"; + +import { Events, Methods } from "../../shared/bridge"; +import type { SessionInfo } from "../../shared/legacy-sdk"; +import type { BaselineSession } from "../managers/baseline.manager"; +import { replaySessionToWebviewEvents } from "../runtime/replay-adapter"; +import { areSameFsPath, isFsPathInsideOrEqual } from "../utils/fs-path"; +import { + isWorkspacePathContained, + workDirUriFromPath, +} from "../utils/workspace-path"; +import type { Handler } from "./types"; + +const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; + +interface LoadHistoryParams { + kimiSessionId: string; +} + +interface DeleteSessionParams { + sessionId: string; +} + +interface ForkSessionParams { + sessionId: string; + turnIndex: number; +} + +export const sessionHandlers: Record> = { + [Methods.GetKimiSessions]: async (_, ctx): Promise => { + if (!ctx.workDir) return []; + return (await ctx.harness.listSessions({ workDir: ctx.workDir })).map(toSessionInfo); + }, + + [Methods.GetAllKimiSessions]: async (_, ctx): Promise => { + if (!ctx.workspaceRoot) return []; + return (await ctx.harness.listSessions()) + .filter((session) => isInsideOrEqual(ctx.workspaceRoot!, session.workDir)) + .map(toSessionInfo); + }, + + [Methods.GetRegisteredWorkDirs]: async (_, ctx): Promise => { + if (!ctx.workspaceRoot) return []; + const sessions = await ctx.harness.listSessions(); + return [ + ...new Set( + sessions + .map((session) => session.workDir) + .filter((workDir) => isInsideOrEqual(ctx.workspaceRoot!, workDir)), + ), + ].toSorted(); + }, + + [Methods.SetWorkDir]: async (params: { workDir: string | null }, ctx) => { + if (!ctx.workspaceRoot || !ctx.workspaceRootUri) return { ok: false }; + const target = params.workDir; + if (target) { + const targetUri = workDirUriFromPath(ctx.workspaceRootUri, ctx.workspaceRoot, target); + if (targetUri === undefined || !(await isWorkspacePathContained(ctx.workspaceRootUri, targetUri))) { + return { ok: false }; + } + } + try { + await ctx.setCustomWorkDir(target); + } catch { + return { ok: false }; + } + return { ok: true, workDir: target ?? ctx.workspaceRoot }; + }, + + [Methods.BrowseWorkDir]: async (_, ctx) => { + if (!ctx.workspaceRoot || !ctx.workspaceRootUri) return { ok: false, workDir: null }; + const workspaceUri = ctx.workspaceRootUri; + let subdirectories: string[] = []; + try { + const entries = await vscode.workspace.fs.readDirectory(workspaceUri); + subdirectories = entries + .filter(([name, type]) => type === vscode.FileType.Directory && !name.startsWith(".")) + .map(([name]) => name) + .toSorted(); + } catch { + // The native picker remains available when directory enumeration fails. + } + + const picked = await vscode.window.showQuickPick( + [ + { label: "$(folder) Browse...", description: "Open folder picker", alwaysShow: true }, + { label: "", kind: vscode.QuickPickItemKind.Separator }, + ...subdirectories.map((name) => ({ + label: `$(folder) ${name}`, + description: path.join(ctx.workspaceRoot!, name), + })), + ], + { placeHolder: "Select a subdirectory or browse...", title: "Working Directory" }, + ); + if (!picked) return { ok: false, workDir: null }; + + let selectedUri: vscode.Uri; + if (picked.label === "$(folder) Browse...") { + const result = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + defaultUri: workspaceUri, + openLabel: "Select Working Directory", + }); + if (!result?.[0]) return { ok: false, workDir: null }; + selectedUri = result[0]; + } else if (picked.description) { + const pickedUri = workDirUriFromPath(workspaceUri, ctx.workspaceRoot, picked.description); + if (pickedUri === undefined) return { ok: false, workDir: null }; + selectedUri = pickedUri; + } else { + return { ok: false, workDir: null }; + } + + if (!(await isWorkspacePathContained(workspaceUri, selectedUri))) { + await vscode.window.showWarningMessage("Selected directory must be within the workspace."); + return { ok: false, workDir: null }; + } + const selected = selectedUri.fsPath; + await ctx.setCustomWorkDir(selected === ctx.workspaceRoot ? null : selected); + return { ok: true, workDir: selected }; + }, + + [Methods.LoadKimiSessionHistory]: async (params: LoadHistoryParams, ctx) => { + if (!ctx.workDir || !isSessionId(params.kimiSessionId)) return []; + const runtime = await ctx.resumeSession(params.kimiSessionId); + if (!areSameFsPath(runtime.session.workDir, ctx.workDir)) { + await ctx.closeSession(); + throw new Error("The selected session belongs to a different working directory."); + } + + let history: ReturnType; + try { + const resumeState = runtime.session.getResumeState(); + if (resumeState?.agents["main"] === undefined) { + throw new Error("Session history is unavailable."); + } + history = replaySessionToWebviewEvents(resumeState, runtime.id); + } catch (error) { + await ctx.closeSession(); + throw error; + } + + ctx.fileManager.clearTracked(ctx.webviewId); + const baseline = baselineSession(runtime.summary ?? { + id: runtime.id, + workDir: runtime.session.workDir, + }); + try { + const changes = await ctx.baselineManager.getChanges(baseline); + for (const change of changes) { + ctx.fileManager.trackFile(ctx.webviewId, path.join(baseline.workDir, change.path)); + } + ctx.broadcast(Events.FileChangesUpdated, changes, ctx.webviewId); + } catch (error) { + ctx.logError("Unable to restore session file changes", error); + ctx.broadcast(Events.FileChangesUpdated, [], ctx.webviewId); + void Promise.resolve( + vscode.window.showWarningMessage( + "Kimi: This conversation opened, but its file change history is unavailable.", + "Show Logs", + ), + ) + .then((action) => { + if (action === "Show Logs") ctx.showLogs(); + }) + .catch((noticeError: unknown) => { + ctx.logError("Unable to show the file change warning", noticeError); + }); + } + return history; + }, + + [Methods.DeleteKimiSession]: async (params: DeleteSessionParams, ctx): Promise<{ ok: boolean }> => { + if (!isSessionId(params.sessionId) || !ctx.workspaceRoot) return { ok: false }; + const summary = (await ctx.harness.listSessions({ sessionId: params.sessionId }))[0]; + if (summary === undefined || !isInsideOrEqual(ctx.workspaceRoot, summary.workDir)) { + return { ok: false }; + } + const affectedViews = ctx.runtime.getSession(params.sessionId)?.subscribers ?? []; + await ctx.runtime.deleteSession(params.sessionId); + await ctx.baselineManager.deleteSession(params.sessionId); + for (const webviewId of affectedViews) { + ctx.fileManager.clearSession(webviewId); + ctx.broadcast(Events.FileChangesUpdated, [], webviewId); + if (webviewId !== ctx.webviewId) { + ctx.broadcast(Events.NewConversation, {}, webviewId); + } + } + return { ok: true }; + }, + + [Methods.ForkKimiSession]: async (params: ForkSessionParams, ctx) => { + if (!ctx.workDir || !isSessionId(params.sessionId) || !Number.isInteger(params.turnIndex) || params.turnIndex < 0) { + return null; + } + const summaries = await ctx.harness.listSessions({ sessionId: params.sessionId }); + const sourceSummary = summaries[0]; + if ( + sourceSummary === undefined || + !ctx.workspaceRoot || + !isInsideOrEqual(ctx.workspaceRoot, sourceSummary.workDir) + ) return null; + + const forkSettledSession = async () => { + const fork = await ctx.harness.forkSession({ id: params.sessionId, turnIndex: params.turnIndex }); + const targetSummary = fork.summary; + if (targetSummary === undefined) { + await fork.close(); + throw new Error("Forked session metadata is unavailable."); + } + + let materializeError: unknown; + try { + await ctx.baselineManager.materializeToFork( + baselineSession(sourceSummary), + baselineSession(targetSummary), + ); + } catch (error) { + materializeError = error; + } + + try { + await fork.close(); + } catch (error) { + ctx.logError("Unable to close a forked session", error); + } + if (materializeError !== undefined) { + await ctx.harness.deleteSession(targetSummary.id).catch((error: unknown) => { + ctx.logError(`Unable to remove failed fork "${targetSummary.id}"`, error); + }); + await ctx.baselineManager.deleteSession(targetSummary.id).catch((error: unknown) => { + ctx.logError(`Unable to remove failed fork baseline "${targetSummary.id}"`, error); + }); + throw materializeError instanceof Error + ? materializeError + : new Error("Unable to materialize the forked session baseline.", { + cause: materializeError, + }); + } + return { sessionId: targetSummary.id }; + }; + + const active = ctx.runtime.getSession(params.sessionId); + return active === undefined + ? forkSettledSession() + : active.runExclusiveAfterCancelling(forkSettledSession); + }, +}; + +function toSessionInfo(summary: SessionSummary): SessionInfo { + return { + id: summary.id, + workDir: summary.workDir, + updatedAt: summary.updatedAt, + brief: summary.title ?? summary.lastPrompt ?? "", + }; +} + +function baselineSession(summary: Pick): BaselineSession { + return { id: summary.id, workDir: summary.workDir, metadata: summary.metadata }; +} + +function isInsideOrEqual(root: string, candidate: string): boolean { + return isFsPathInsideOrEqual(root, candidate); +} + +function isSessionId(value: string): boolean { + return SESSION_ID.test(value); +} diff --git a/apps/vscode/src/handlers/slash-command.ts b/apps/vscode/src/handlers/slash-command.ts new file mode 100644 index 0000000000..e23c9ac1d6 --- /dev/null +++ b/apps/vscode/src/handlers/slash-command.ts @@ -0,0 +1,312 @@ +import type { Stats } from "node:fs"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import * as vscode from "vscode"; + +import type { SessionRuntime } from "../runtime/session-runtime"; +import { + buildExportMarkdown, + isImportableTextFile, + isSensitiveFile, + stringifyContextHistory, +} from "../utils/session-context"; +import type { HandlerContext } from "./types"; + +const HOST_COMMANDS = new Set([ + "init", + "compact", + "clear", + "reset", + "yolo", + "afk", + "plan", + "add-dir", + "export", + "import", +]); +const MAX_IMPORT_BYTES = 10 * 1024 * 1024; + +export interface HostSlashCommand { + readonly name: string; + readonly args: string; + readonly raw: string; +} + +export function parseHostSlashCommand(content: string | readonly unknown[]): HostSlashCommand | undefined { + if (typeof content !== "string") return undefined; + const raw = content.trim(); + const match = /^\/([^\s]+)(?:\s+(.*))?\s*$/s.exec(raw); + if (match === null) return undefined; + const name = match[1]!.toLowerCase(); + if (!HOST_COMMANDS.has(name) && !name.startsWith("skill:")) return undefined; + return { name, args: match[2]?.trim() ?? "", raw }; +} + +export async function runHostSlashCommand( + runtime: SessionRuntime, + command: HostSlashCommand, + ctx: HandlerContext, +): Promise { + if (command.name.startsWith("skill:")) { + const skillName = command.name.slice("skill:".length); + const result = await runtime.runTurnAction(command.raw, () => + runtime.session.activateSkill(skillName, command.args || undefined)); + return result.status === "finished"; + } + + const actionId = runtime.beginHostAction(command.raw, command.name === "import"); + const emit = (text: string): void => runtime.emitHostText(text, actionId); + try { + if (command.name === "import") { + const result = await importContext(runtime, command.args, ctx); + emit(result.message); + if (result.sensitive) { + void vscode.window.showWarningMessage( + "Kimi: The imported file may contain API keys, tokens, or credentials.", + ); + } + } else { + switch (command.name) { + case "init": + await runtime.session.init(); + emit("AGENTS.md has been generated."); + break; + case "compact": + await runtime.compactHostAction(actionId, command.args || undefined); + emit("The context has been compacted."); + break; + case "clear": + case "reset": + await runtime.session.clearContext(); + emit("The context has been cleared."); + break; + case "yolo": + await toggleLegacyPermission(runtime, "yolo", emit); + break; + case "afk": + await toggleLegacyPermission(runtime, "afk", emit); + break; + case "plan": + await runPlanCommand(runtime, command.args, emit); + break; + case "add-dir": + await runAddDirCommand(runtime, command.args, emit); + break; + case "export": + await exportContext(runtime, command.args, emit); + break; + } + } + + if (runtime.wasHostActionCancelled(actionId)) return false; + runtime.completeHostAction("finished", actionId); + return true; + } catch (error) { + if (runtime.wasHostActionCancelled(actionId)) return false; + runtime.failHostAction(actionId); + throw error; + } finally { + runtime.releaseHostAction(actionId); + } +} + +async function toggleLegacyPermission( + runtime: SessionRuntime, + kind: "yolo" | "afk", + emit: (text: string) => void, +): Promise { + const flags = await runtime.toggleLegacyApproval(kind); + + if (kind === "yolo") { + emit(flags.yolo + ? "You only live once! All actions will be auto-approved." + : flags.afk + ? "Yolo disabled, but afk is still on — tool calls remain auto-approved." + : "You only die once! Actions will require approval."); + return; + } + emit(flags.afk + ? "afk mode enabled. Questions will be auto-dismissed and tool calls auto-approved." + : flags.yolo + ? "afk mode disabled. You are back at the keyboard. Yolo is still on." + : "afk mode disabled. You are back at the keyboard."); +} + +async function runPlanCommand( + runtime: SessionRuntime, + args: string, + emit: (text: string) => void, +): Promise { + const subcommand = args.trim().toLowerCase(); + if (subcommand === "view") { + const plan = await runtime.session.getPlan(); + emit(plan?.content.trim() || "No plan file found for this session."); + return; + } + if (subcommand === "clear") { + await runtime.session.clearPlan(); + emit("Plan cleared."); + return; + } + const status = await runtime.session.getStatus(); + const enabled = subcommand === "on" ? true : subcommand === "off" ? false : !status.planMode; + if (subcommand && subcommand !== "on" && subcommand !== "off") { + throw new Error(`Unknown plan subcommand: ${subcommand}`); + } + if (status.planMode !== enabled) await runtime.session.setPlanMode(enabled); + if (!enabled) { + emit("Plan mode OFF. All tools are now available."); + return; + } + const plan = await runtime.session.getPlan().catch(() => null); + emit(plan?.path + ? `Plan mode ON. Plan file: ${plan.path}` + : "Plan mode ON."); +} + +async function runAddDirCommand( + runtime: SessionRuntime, + args: string, + emit: (text: string) => void, +): Promise { + const input = stripMatchingQuotes(args.trim()); + if (!input || input.toLowerCase() === "list") { + const dirs = runtime.session.summary?.additionalDirs ?? []; + emit(dirs.length === 0 + ? "No additional directories. Usage: /add-dir " + : ["Additional directories:", ...dirs.map((path) => ` - ${path}`)].join("\n")); + return; + } + const result = await runtime.session.addAdditionalDir(input, { persist: false }); + emit(`Added directory to workspace: ${result.additionalDirs.at(-1) ?? input}`); +} + +async function exportContext( + runtime: SessionRuntime, + args: string, + emit: (text: string) => void, +): Promise { + const context = await runtime.session.getContext(); + if (context.history.length === 0) { + emit("No messages to export."); + return; + } + const now = new Date(); + const defaultName = defaultExportName(runtime.id, now); + const outputPath = await resolveExportPath(args, runtime.session.workDir, defaultName); + const markdown = buildExportMarkdown({ + sessionId: runtime.id, + workDir: runtime.session.workDir, + history: context.history, + tokenCount: context.tokenCount, + now, + }); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, markdown, "utf8"); + emit( + `Exported ${String(context.history.length)} messages to ${outputPath}\n\n` + + "Note: The exported file may contain sensitive information. Please be cautious when sharing it externally.", + ); + void vscode.window.showInformationMessage("Kimi: Session exported.", "Open File").then((action) => { + if (action !== "Open File") return; + void vscode.window.showTextDocument(vscode.Uri.file(outputPath)); + }); +} + +async function importContext( + runtime: SessionRuntime, + args: string, + ctx: HandlerContext, +): Promise<{ message: string; sensitive: boolean }> { + const target = stripMatchingQuotes(args.trim()); + if (!target) throw new Error("Usage: /import "); + + const candidate = resolveUserPath(target, runtime.session.workDir); + const file = await fileInfo(candidate); + if (file?.isDirectory()) throw new Error("The specified path is a directory; please provide a file to import."); + if (file?.isFile()) { + if (!isImportableTextFile(candidate)) { + throw new Error(`Unsupported file type '${candidate.slice(candidate.lastIndexOf("."))}'. /import only supports text-based files.`); + } + if (file.size > MAX_IMPORT_BYTES) { + throw new Error(`File is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum import size is 10 MB.`); + } + const bytes = await readFile(candidate); + let content: string; + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + throw new Error(`Cannot import '${basename(candidate)}': the file is not valid UTF-8 text.`); + } + if (!content.trim()) throw new Error("The file is empty, nothing to import."); + const source = `file '${basename(candidate)}'`; + await runtime.session.importContext(content, source); + return { + message: `Imported context from ${source} (${String(content.length)} chars).`, + sensitive: isSensitiveFile(basename(candidate)), + }; + } + + if (target === runtime.id) throw new Error("Cannot import the current session into itself."); + const summary = (await ctx.harness.listSessions({ + workDir: runtime.session.workDir, + sessionId: target, + })).find((session) => session.id === target); + if (summary === undefined) throw new Error(`'${target}' is not a valid file path or session ID.`); + + const activeSource = ctx.runtime.getSession(target)?.session; + const sourceSession = activeSource ?? await ctx.harness.resumeSession({ id: target }); + try { + const sourceContext = await sourceSession.getContext(); + if (sourceContext.history.length === 0) throw new Error("The source session has no messages."); + const content = stringifyContextHistory(sourceContext.history); + if (Buffer.byteLength(content, "utf8") > MAX_IMPORT_BYTES) { + throw new Error("Session content is too large. Maximum import size is 10 MB."); + } + const source = `session '${target}'`; + await runtime.session.importContext(content, source); + return { + message: `Imported context from ${source} (${String(content.length)} chars).`, + sensitive: false, + }; + } finally { + if (activeSource === undefined) await sourceSession.close(); + } +} + +async function resolveExportPath(args: string, workDir: string, defaultName: string): Promise { + const raw = stripMatchingQuotes(args.trim()); + if (!raw) return join(workDir, defaultName); + const resolved = resolveUserPath(raw, workDir); + const info = await fileInfo(resolved); + return raw.endsWith("/") || raw.endsWith("\\") || info?.isDirectory() + ? join(resolved, defaultName) + : resolved; +} + +function defaultExportName(sessionId: string, now: Date): string { + const timestamp = now.toISOString().replaceAll(/[-:]/g, "").replace("T", "-").slice(0, 15); + return `kimi-export-${sessionId.slice(0, 8)}-${timestamp}.md`; +} + +function resolveUserPath(value: string, workDir: string): string { + const expanded = value === "~" ? homedir() : value.startsWith("~/") ? join(homedir(), value.slice(2)) : value; + return isAbsolute(expanded) ? expanded : resolve(workDir, expanded); +} + +async function fileInfo(path: string): Promise { + try { + return await stat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +function stripMatchingQuotes(value: string): string { + if (value.length < 2) return value; + const first = value[0]; + const last = value.at(-1); + return (first === last && (first === '"' || first === "'")) ? value.slice(1, -1) : value; +} diff --git a/apps/vscode/src/handlers/types.ts b/apps/vscode/src/handlers/types.ts new file mode 100644 index 0000000000..c50fe482b6 --- /dev/null +++ b/apps/vscode/src/handlers/types.ts @@ -0,0 +1,41 @@ +import type * as vscode from "vscode"; +import type { FileManager } from "../managers/file.manager"; +import type { BaselineManager } from "../managers/baseline.manager"; +import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk"; +import type { KimiRuntime } from "../runtime/kimi-runtime"; +import type { SessionRuntime } from "../runtime/session-runtime"; + +export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void; + +export type ReloadWebviewFn = (webviewId: string) => void; + +export type ShowLogsFn = () => void; + +export interface HandlerContext { + webviewId: string; + workDir: string | null; + workDirUri: vscode.Uri | null; + workspaceRoot: string | null; + workspaceRootUri: vscode.Uri | null; + workspaceState: vscode.Memento; + requireWorkDir: () => string; + requireWorkDirUri: () => vscode.Uri; + broadcast: BroadcastFn; + fileManager: FileManager; + baselineManager: BaselineManager; + runtime: KimiRuntime; + harness: KimiHarness; + reloadWebview: () => void; + showLogs: () => void; + logError: (message: string, error: unknown) => void; + + getSession: () => SessionRuntime | undefined; + getSessionId: () => string | null; + getOrCreateSession: (model: string, effort: string, sessionId?: string) => Promise; + resumeSession: (sessionId: string) => Promise; + closeSession: () => Promise; + saveAllDirty: () => Promise; + setCustomWorkDir: (workDir: string | null) => Promise; +} + +export type Handler = (params: TParams, ctx: HandlerContext) => Promise; diff --git a/apps/vscode/src/handlers/workspace.handler.ts b/apps/vscode/src/handlers/workspace.handler.ts new file mode 100644 index 0000000000..401e5bcdc5 --- /dev/null +++ b/apps/vscode/src/handlers/workspace.handler.ts @@ -0,0 +1,44 @@ +import * as vscode from "vscode"; +import { Methods } from "../../shared/bridge"; +import type { Handler } from "./types"; +import type { WorkspaceStatus } from "shared/types"; + +const INPUT_HISTORY_KEY = "kimi.inputHistory"; +const MAX_HISTORY_SIZE = 100; + +const checkWorkspace: Handler = async (_, ctx) => { + return { + hasWorkspace: ctx.workDir !== null, + path: ctx.workDir ?? undefined, + workspaceRoot: ctx.workspaceRoot ?? undefined, + }; +}; + +const openFolder: Handler = async () => { + await vscode.commands.executeCommand("vscode.openFolder"); + return { ok: true }; +}; + +const getInputHistory: Handler = async (_, ctx) => { + return ctx.workspaceState.get(INPUT_HISTORY_KEY, []); +}; + +const addInputHistory: Handler<{ text: string }, { ok: boolean }> = async ({ text }, ctx) => { + const history = ctx.workspaceState.get(INPUT_HISTORY_KEY, []); + // 避免重复添加相同的最近一条 + if (history[history.length - 1] !== text) { + history.push(text); + if (history.length > MAX_HISTORY_SIZE) { + history.shift(); + } + await ctx.workspaceState.update(INPUT_HISTORY_KEY, history); + } + return { ok: true }; +}; + +export const workspaceHandlers: Record> = { + [Methods.CheckWorkspace]: checkWorkspace, + [Methods.OpenFolder]: openFolder, + [Methods.GetInputHistory]: getInputHistory, + [Methods.AddInputHistory]: addInputHistory, +}; diff --git a/apps/vscode/src/managers/baseline.manager.ts b/apps/vscode/src/managers/baseline.manager.ts new file mode 100644 index 0000000000..46498aafea --- /dev/null +++ b/apps/vscode/src/managers/baseline.manager.ts @@ -0,0 +1,864 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; +import { + lstat, + mkdir, + readFile, + readdir, + realpath, + rename, + rm, + stat, + unlink, + writeFile, +} from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { FileChange } from '../../shared/types'; +import { relativeFsPath } from '../utils/fs-path'; + +const MANIFEST_VERSION = 1; +const SNAPSHOT_HASH = /^[a-f0-9]{64}$/; + +export interface BaselineSession { + readonly id: string; + readonly workDir: string; + readonly metadata?: Readonly>; +} + +interface ManifestEntry { + readonly snapshot: string; + readonly existedBefore: boolean; +} + +interface BaselineManifestV1 { + readonly version: 1; + readonly sessionId: string; + readonly entries: Readonly>; + readonly acceptedLegacyPaths: readonly string[]; +} + +interface MutableManifest { + version: 1; + sessionId: string; + entries: Record; + acceptedLegacyPaths: string[]; +} + +interface ResolvedFile { + readonly absolutePath: string; + readonly relativePath: string; +} + +interface BaselineValue { + readonly content: string; + readonly existedBefore: boolean; +} + +export class BaselineError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'BaselineError'; + } +} + +export class BaselineManager { + private readonly baselinesRoot: string; + private readonly updates = new Map>(); + + constructor(globalStorageRoot: string, homeNamespace = 'default') { + if (globalStorageRoot.length === 0) { + throw new BaselineError('The VSCode global storage path is empty'); + } + if (homeNamespace.length === 0) { + throw new BaselineError('The Kimi home namespace is empty'); + } + this.baselinesRoot = path.join(globalStorageRoot, 'baselines', hash(homeNamespace)); + } + + /** + * Capture the file synchronously before returning control to the caller. + * Persistence is serialized per session and completes through the returned + * promise, but no `await` occurs before the original file has been read. + */ + async capture(session: BaselineSession, filePath: string): Promise { + const resolved = resolveSessionFile(session, filePath); + const captured = captureOriginal(resolved.absolutePath); + + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const localPath = equivalentPath( + session, + Object.keys(manifest.entries), + resolved.relativePath, + ); + if (localPath !== undefined) return; + + const accepted = new Set(manifest.acceptedLegacyPaths); + const acceptedPath = equivalentPath(session, accepted, resolved.relativePath); + if (acceptedPath === undefined) { + const legacyExists = await this.hasLegacyBaseline(session, resolved.relativePath); + if (legacyExists) return; + } + + const snapshot = hash(captured.content); + await this.writeSnapshot(session.id, snapshot, captured.content); + if (acceptedPath !== undefined) accepted.delete(acceptedPath); + + const next = mutableManifest(manifest); + next.entries[resolved.relativePath] = { + snapshot, + existedBefore: captured.existedBefore, + }; + next.acceptedLegacyPaths = uniquePaths(session, accepted); + await this.writeManifest(next); + }); + } + + async getChanges(session: BaselineSession): Promise { + await this.waitForUpdates([session.id]); + const manifest = await this.readManifest(session); + const relativePaths = await this.effectivePaths(session, manifest); + const changes: FileChange[] = []; + + for (const relativePath of relativePaths) { + const baseline = await this.readEffectiveBaseline(session, relativePath, manifest); + if (baseline === undefined) continue; + + const resolved = resolveSessionFile(session, relativePath); + const currentContent = await readCurrentFile(resolved.absolutePath); + if (currentContent === undefined) { + if (baseline.existedBefore) { + changes.push({ + path: relativePath, + status: 'Deleted', + additions: 0, + deletions: countLines(baseline.content), + }); + } + continue; + } + + if (!baseline.existedBefore) { + changes.push({ + path: relativePath, + status: 'Added', + additions: countLines(currentContent), + deletions: 0, + }); + continue; + } + + if (currentContent !== baseline.content) { + const diff = computeLineDiff(baseline.content, currentContent); + changes.push({ + path: relativePath, + status: 'Modified', + additions: diff.additions, + deletions: diff.deletions, + }); + } + } + + return changes; + } + + async getContent(session: BaselineSession, filePath: string): Promise { + await this.waitForUpdates([session.id]); + const resolved = resolveSessionFile(session, filePath); + const manifest = await this.readManifest(session); + const baseline = await this.readEffectiveBaseline(session, resolved.relativePath, manifest); + if (baseline === undefined) { + throw new BaselineError( + `No baseline exists for "${resolved.relativePath}" in session "${session.id}"`, + ); + } + return baseline.content; + } + + async undo(session: BaselineSession, filePath: string): Promise { + const resolved = resolveSessionFile(session, filePath); + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const baseline = await this.readEffectiveBaseline(session, resolved.relativePath, manifest); + if (baseline === undefined) { + throw new BaselineError( + `No baseline exists for "${resolved.relativePath}" in session "${session.id}"`, + ); + } + await restoreFile(session.workDir, resolved.absolutePath, baseline); + }); + } + + async undoAll(session: BaselineSession): Promise { + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const relativePaths = await this.effectivePaths(session, manifest); + for (const relativePath of relativePaths) { + const baseline = await this.readEffectiveBaseline(session, relativePath, manifest); + if (baseline === undefined) continue; + await restoreFile( + session.workDir, + resolveSessionFile(session, relativePath).absolutePath, + baseline, + ); + } + }); + } + + async keep(session: BaselineSession, filePath: string): Promise { + const resolved = resolveSessionFile(session, filePath); + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const localPath = equivalentPath( + session, + Object.keys(manifest.entries), + resolved.relativePath, + ); + const hadLocal = localPath !== undefined; + const hasLegacy = await this.hasLegacyBaseline(session, resolved.relativePath); + if (!hadLocal && !hasLegacy) return; + + const next = mutableManifest(manifest); + if (localPath !== undefined) delete next.entries[localPath]; + const accepted = new Set(next.acceptedLegacyPaths); + const acceptedPath = equivalentPath(session, accepted, resolved.relativePath); + if (acceptedPath !== undefined) accepted.delete(acceptedPath); + if (hasLegacy) accepted.add(resolved.relativePath); + next.acceptedLegacyPaths = uniquePaths(session, accepted); + + await this.writeManifest(next); + await this.removeUnreferencedSnapshots(session.id, next); + }); + } + + async keepAll(session: BaselineSession): Promise { + await this.serialize([session.id], async () => { + const manifest = await this.readManifest(session); + const legacyPaths = await this.listLegacyPaths(session); + const next = mutableManifest(manifest); + next.entries = {}; + next.acceptedLegacyPaths = uniquePaths(session, [ + ...next.acceptedLegacyPaths, + ...legacyPaths, + ]); + + await this.writeManifest(next); + await this.removeUnreferencedSnapshots(session.id, next); + }); + } + + async materializeToFork(source: BaselineSession, target: BaselineSession): Promise { + if (source.id === target.id) { + throw new BaselineError('Cannot materialize a baseline fork onto the source session'); + } + + await this.serialize([source.id, target.id], async () => { + const sourceManifest = await this.readManifest(source); + const sourcePaths = await this.effectivePaths(source, sourceManifest); + const values = new Map(); + for (const relativePath of sourcePaths) { + const baseline = await this.readEffectiveBaseline(source, relativePath, sourceManifest); + if (baseline !== undefined) values.set(relativePath, baseline); + } + + const targetManifest = await this.readManifest(target); + const next = mutableManifest(targetManifest); + const accepted = uniquePaths(target, [ + ...next.acceptedLegacyPaths, + ...sourceManifest.acceptedLegacyPaths, + ]); + + for (const [relativePath, baseline] of values) { + const existingPath = equivalentPath(target, Object.keys(next.entries), relativePath); + if (existingPath !== undefined) continue; + const snapshot = hash(baseline.content); + await this.writeSnapshot(target.id, snapshot, baseline.content); + next.entries[relativePath] = { + snapshot, + existedBefore: baseline.existedBefore, + }; + } + + next.acceptedLegacyPaths = accepted; + await this.writeManifest(next); + }); + } + + async deleteSession(sessionId: string): Promise { + requireSessionId(sessionId); + await this.serialize([sessionId], async () => { + await rm(this.sessionRoot(sessionId), { recursive: true, force: true }); + }); + } + + private async effectivePaths( + session: BaselineSession, + manifest: BaselineManifestV1, + ): Promise { + const paths = new Map(); + for (const relativePath of Object.keys(manifest.entries)) { + paths.set(pathComparisonKey(session, relativePath), relativePath); + } + const accepted = new Set( + manifest.acceptedLegacyPaths.map((relativePath) => + pathComparisonKey(session, relativePath), + ), + ); + for (const relativePath of await this.listLegacyPaths(session)) { + const key = pathComparisonKey(session, relativePath); + if (!accepted.has(key) && !paths.has(key)) paths.set(key, relativePath); + } + return [...paths.values()].toSorted(); + } + + private async readEffectiveBaseline( + session: BaselineSession, + relativePath: string, + manifest: BaselineManifestV1, + ): Promise { + const localPath = equivalentPath(session, Object.keys(manifest.entries), relativePath); + const local = localPath === undefined ? undefined : manifest.entries[localPath]; + if (localPath !== undefined && local !== undefined) { + const content = await this.readSnapshot(session.id, local.snapshot, localPath); + return { content, existedBefore: local.existedBefore }; + } + + if (equivalentPath(session, manifest.acceptedLegacyPaths, relativePath) !== undefined) { + return undefined; + } + return this.readLegacyBaseline(session, relativePath); + } + + private async readManifest(session: BaselineSession): Promise { + requireSession(session); + let text: string; + try { + text = await readFile(this.manifestPath(session.id), 'utf-8'); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return emptyManifest(session.id); + throw new BaselineError(`Unable to read baseline manifest for session "${session.id}"`, { + cause: error, + }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(text) as unknown; + } catch (error) { + throw new BaselineError(`Baseline manifest for session "${session.id}" is invalid JSON`, { + cause: error, + }); + } + return parseManifest(parsed, session); + } + + private async writeManifest(manifest: BaselineManifestV1): Promise { + if ( + Object.keys(manifest.entries).length === 0 && + manifest.acceptedLegacyPaths.length === 0 + ) { + await rm(this.sessionRoot(manifest.sessionId), { recursive: true, force: true }); + return; + } + + const text = `${JSON.stringify(manifest, null, 2)}\n`; + await atomicWrite(this.manifestPath(manifest.sessionId), text); + } + + private async writeSnapshot(sessionId: string, snapshot: string, content: string): Promise { + const snapshotPath = this.snapshotPath(sessionId, snapshot); + try { + const existing = await readFile(snapshotPath, 'utf-8'); + if (hash(existing) !== snapshot) { + throw new BaselineError( + `Baseline snapshot "${snapshot}" for session "${sessionId}" is corrupt`, + ); + } + return; + } catch (error) { + if (!isErrorCode(error, 'ENOENT')) { + if (error instanceof BaselineError) throw error; + throw new BaselineError( + `Unable to inspect baseline snapshot "${snapshot}" for session "${sessionId}"`, + { cause: error }, + ); + } + } + await atomicWrite(snapshotPath, content); + } + + private async readSnapshot( + sessionId: string, + snapshot: string, + relativePath: string, + ): Promise { + let content: string; + try { + content = await readFile(this.snapshotPath(sessionId, snapshot), 'utf-8'); + } catch (error) { + throw new BaselineError( + `Unable to read baseline snapshot for "${relativePath}" in session "${sessionId}"`, + { cause: error }, + ); + } + if (hash(content) !== snapshot) { + throw new BaselineError( + `Baseline snapshot for "${relativePath}" in session "${sessionId}" is corrupt`, + ); + } + return content; + } + + private async removeUnreferencedSnapshots( + sessionId: string, + manifest: BaselineManifestV1, + ): Promise { + const snapshotsDir = this.snapshotsRoot(sessionId); + let names: string[]; + try { + names = await readdir(snapshotsDir); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return; + throw new BaselineError(`Unable to clean baseline snapshots for session "${sessionId}"`, { + cause: error, + }); + } + + const referenced = new Set(Object.values(manifest.entries).map((entry) => entry.snapshot)); + await Promise.all( + names.map(async (name) => { + if (referenced.has(name)) return; + await rm(path.join(snapshotsDir, name), { force: true }); + }), + ); + } + + private async listLegacyPaths(session: BaselineSession): Promise { + const root = legacyBaselineRoot(session); + if (root === undefined) return []; + + const result: string[] = []; + await walkLegacyBaselines(root, '', result); + return result.toSorted(); + } + + private async hasLegacyBaseline( + session: BaselineSession, + relativePath: string, + ): Promise { + const legacyPath = legacyBaselinePath(session, relativePath); + if (legacyPath === undefined) return false; + try { + const info = await stat(legacyPath); + if (!info.isFile()) { + throw new BaselineError(`Legacy baseline "${relativePath}" is not a regular file`); + } + return true; + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return false; + if (error instanceof BaselineError) throw error; + throw new BaselineError(`Unable to inspect legacy baseline "${relativePath}"`, { + cause: error, + }); + } + } + + private async readLegacyBaseline( + session: BaselineSession, + relativePath: string, + ): Promise { + const legacyPath = legacyBaselinePath(session, relativePath); + if (legacyPath === undefined) return undefined; + + let info; + try { + info = await stat(legacyPath); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return undefined; + throw new BaselineError(`Unable to inspect legacy baseline "${relativePath}"`, { + cause: error, + }); + } + if (!info.isFile()) { + throw new BaselineError(`Legacy baseline "${relativePath}" is not a regular file`); + } + + try { + const content = await readFile(legacyPath, 'utf-8'); + return { content, existedBefore: content.length > 0 }; + } catch (error) { + throw new BaselineError(`Unable to read legacy baseline "${relativePath}"`, { + cause: error, + }); + } + } + + private async serialize(sessionIds: readonly string[], operation: () => Promise): Promise { + const ids = [...new Set(sessionIds)].toSorted(); + for (const id of ids) requireSessionId(id); + + const previous = ids.map((id) => this.updates.get(id) ?? Promise.resolve()); + const run = Promise.all(previous).then(operation); + const settled = run.then( + () => undefined, + () => undefined, + ); + for (const id of ids) this.updates.set(id, settled); + void settled.then(() => { + for (const id of ids) { + if (this.updates.get(id) === settled) this.updates.delete(id); + } + }); + return run; + } + + private async waitForUpdates(sessionIds: readonly string[]): Promise { + await Promise.all(sessionIds.map((id) => this.updates.get(id) ?? Promise.resolve())); + } + + private sessionRoot(sessionId: string): string { + return path.join(this.baselinesRoot, hash(sessionId)); + } + + private manifestPath(sessionId: string): string { + return path.join(this.sessionRoot(sessionId), 'manifest.json'); + } + + private snapshotsRoot(sessionId: string): string { + return path.join(this.sessionRoot(sessionId), 'snapshots'); + } + + private snapshotPath(sessionId: string, snapshot: string): string { + if (!SNAPSHOT_HASH.test(snapshot)) { + throw new BaselineError(`Invalid baseline snapshot hash "${snapshot}"`); + } + return path.join(this.snapshotsRoot(sessionId), snapshot); + } +} + +function emptyManifest(sessionId: string): BaselineManifestV1 { + return { version: MANIFEST_VERSION, sessionId, entries: {}, acceptedLegacyPaths: [] }; +} + +function mutableManifest(manifest: BaselineManifestV1): MutableManifest { + return { + version: MANIFEST_VERSION, + sessionId: manifest.sessionId, + entries: { ...manifest.entries }, + acceptedLegacyPaths: [...manifest.acceptedLegacyPaths], + }; +} + +function parseManifest(value: unknown, session: BaselineSession): BaselineManifestV1 { + if (!isRecord(value) || value['version'] !== MANIFEST_VERSION) { + throw new BaselineError(`Unsupported baseline manifest for session "${session.id}"`); + } + if (value['sessionId'] !== session.id) { + throw new BaselineError(`Baseline manifest does not belong to session "${session.id}"`); + } + + const rawEntries = value['entries']; + const rawAccepted = value['acceptedLegacyPaths']; + if (!isRecord(rawEntries) || !Array.isArray(rawAccepted)) { + throw new BaselineError(`Invalid baseline manifest for session "${session.id}"`); + } + + const entries: Record = {}; + const entryKeys = new Set(); + for (const [rawPath, rawEntry] of Object.entries(rawEntries)) { + if ( + !isRecord(rawEntry) || + typeof rawEntry['snapshot'] !== 'string' || + !SNAPSHOT_HASH.test(rawEntry['snapshot']) || + typeof rawEntry['existedBefore'] !== 'boolean' + ) { + throw new BaselineError(`Invalid baseline entry "${rawPath}" in session "${session.id}"`); + } + const relativePath = resolveSessionFile(session, rawPath).relativePath; + const comparisonKey = pathComparisonKey(session, relativePath); + if (relativePath !== rawPath || entryKeys.has(comparisonKey)) { + throw new BaselineError(`Unsafe baseline path "${rawPath}" in session "${session.id}"`); + } + entryKeys.add(comparisonKey); + entries[relativePath] = { + snapshot: rawEntry['snapshot'], + existedBefore: rawEntry['existedBefore'], + }; + } + + const acceptedLegacyPaths: string[] = []; + for (const rawPath of rawAccepted) { + if (typeof rawPath !== 'string') { + throw new BaselineError(`Invalid accepted legacy path in session "${session.id}"`); + } + const relativePath = resolveSessionFile(session, rawPath).relativePath; + if (relativePath !== rawPath) { + throw new BaselineError(`Unsafe accepted legacy path "${rawPath}" in session "${session.id}"`); + } + if (equivalentPath(session, acceptedLegacyPaths, relativePath) === undefined) { + acceptedLegacyPaths.push(relativePath); + } + } + + return { + version: MANIFEST_VERSION, + sessionId: session.id, + entries, + acceptedLegacyPaths: uniquePaths(session, acceptedLegacyPaths), + }; +} + +function equivalentPath( + session: BaselineSession, + paths: Iterable, + candidate: string, +): string | undefined { + const candidateKey = pathComparisonKey(session, candidate); + for (const existing of paths) { + if (pathComparisonKey(session, existing) === candidateKey) return existing; + } + return undefined; +} + +function uniquePaths(session: BaselineSession, paths: Iterable): string[] { + const unique = new Map(); + for (const relativePath of paths) { + const key = pathComparisonKey(session, relativePath); + if (!unique.has(key)) unique.set(key, relativePath); + } + return [...unique.values()].toSorted(); +} + +function pathComparisonKey(session: BaselineSession, relativePath: string): string { + return isWindowsAbsolute(session.workDir) ? relativePath.toLowerCase() : relativePath; +} + +function resolveSessionFile(session: BaselineSession, filePath: string): ResolvedFile { + requireSession(session); + if (filePath.length === 0) throw new BaselineError('The baseline file path is empty'); + + const windows = isWindowsAbsolute(session.workDir); + if (!windows && isWindowsAbsolute(filePath)) { + throw new BaselineError(`File "${filePath}" is outside workspace "${session.workDir}"`); + } + + const paths = windows ? path.win32 : path; + const root = paths.resolve(session.workDir); + const absolutePath = paths.resolve(root, filePath); + const relativePath = paths.relative(root, absolutePath); + const parentPrefix = `..${paths.sep}`; + if ( + relativePath.length === 0 || + relativePath === '..' || + relativePath.startsWith(parentPrefix) || + paths.isAbsolute(relativePath) + ) { + throw new BaselineError(`File "${filePath}" is outside workspace "${session.workDir}"`); + } + + return { + absolutePath, + relativePath: windows ? relativePath.replaceAll('\\', '/') : relativePath, + }; +} + +function isWindowsAbsolute(value: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); +} + +function legacyBaselineRoot(session: BaselineSession): string | undefined { + const source = session.metadata?.['kimi_cli_source_path']; + if (typeof source !== 'string' || source.length === 0) return undefined; + + const sourceIsWindows = isWindowsAbsolute(source); + if (sourceIsWindows !== (process.platform === 'win32')) return undefined; + if (!path.isAbsolute(source)) return undefined; + return path.join(source, 'baseline'); +} + +function legacyBaselinePath( + session: BaselineSession, + relativePath: string, +): string | undefined { + const root = legacyBaselineRoot(session); + if (root === undefined) return undefined; + const resolved = resolveSessionFile(session, relativePath); + return path.join(root, ...resolved.relativePath.split('/')); +} + +async function walkLegacyBaselines( + directory: string, + relativeDirectory: string, + result: string[], +): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (isErrorCode(error, 'ENOENT') && relativeDirectory.length === 0) return; + throw new BaselineError(`Unable to list legacy baseline directory "${directory}"`, { + cause: error, + }); + } + + for (const entry of entries) { + const relativePath = relativeDirectory + ? `${relativeDirectory}/${entry.name}` + : entry.name; + if (entry.isDirectory()) { + await walkLegacyBaselines(path.join(directory, entry.name), relativePath, result); + } else if (entry.isFile()) { + result.push(relativePath); + } + } +} + +function captureOriginal(absolutePath: string): BaselineValue { + let info; + try { + info = statSync(absolutePath); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return { content: '', existedBefore: false }; + throw new BaselineError(`Unable to inspect original file "${absolutePath}"`, { + cause: error, + }); + } + if (!info.isFile()) { + throw new BaselineError(`Original path "${absolutePath}" is not a regular file`); + } + + try { + return { content: readFileSync(absolutePath, 'utf-8'), existedBefore: true }; + } catch (error) { + throw new BaselineError(`Unable to capture original file "${absolutePath}"`, { + cause: error, + }); + } +} + +async function readCurrentFile(absolutePath: string): Promise { + try { + return await readFile(absolutePath, 'utf-8'); + } catch (error) { + if (isErrorCode(error, 'ENOENT')) return undefined; + throw new BaselineError(`Unable to read current file "${absolutePath}"`, { cause: error }); + } +} + +async function restoreFile( + workDir: string, + absolutePath: string, + baseline: BaselineValue, +): Promise { + await requireContainedRestorePath(workDir, absolutePath); + if (!baseline.existedBefore) { + try { + await unlink(absolutePath); + } catch (error) { + if (!isErrorCode(error, 'ENOENT')) { + throw new BaselineError(`Unable to remove newly created file "${absolutePath}"`, { + cause: error, + }); + } + } + return; + } + + try { + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, baseline.content, 'utf-8'); + } catch (error) { + throw new BaselineError(`Unable to restore file "${absolutePath}"`, { cause: error }); + } +} + +async function requireContainedRestorePath(workDir: string, absolutePath: string): Promise { + try { + const [realWorkDir, realTarget] = await Promise.all([ + realpath(workDir), + realExistingPath(absolutePath), + ]); + if (relativeFsPath(realWorkDir, realTarget) === undefined) { + throw new BaselineError(`Refusing to restore path outside the session workspace: "${absolutePath}"`); + } + } catch (error) { + if (error instanceof BaselineError) throw error; + throw new BaselineError(`Unable to validate restore path "${absolutePath}"`, { cause: error }); + } +} + +async function realExistingPath(candidate: string): Promise { + let current = candidate; + while (true) { + try { + return await realpath(current); + } catch (error) { + if (!isErrorCode(error, 'ENOENT')) throw error; + let isDanglingSymlink = false; + try { + isDanglingSymlink = (await lstat(current)).isSymbolicLink(); + } catch (lstatError) { + if (!isErrorCode(lstatError, 'ENOENT')) throw lstatError; + } + if (isDanglingSymlink) throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } +} + +async function atomicWrite(targetPath: string, content: string): Promise { + await mkdir(path.dirname(targetPath), { recursive: true, mode: 0o700 }); + const temporaryPath = `${targetPath}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(temporaryPath, content, { encoding: 'utf-8', mode: 0o600 }); + await rename(temporaryPath, targetPath); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw new BaselineError(`Unable to atomically write "${targetPath}"`, { cause: error }); + } +} + +function requireSession(session: BaselineSession): void { + requireSessionId(session.id); + if (session.workDir.length === 0) throw new BaselineError('The session workspace path is empty'); +} + +function requireSessionId(sessionId: string): void { + if (sessionId.length === 0) throw new BaselineError('The baseline session id is empty'); +} + +function hash(value: string): string { + return createHash('sha256').update(value, 'utf-8').digest('hex'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isErrorCode(error: unknown, code: string): boolean { + return isRecord(error) && error['code'] === code; +} + +function countLines(content: string): number { + if (content.length === 0) return 0; + return content.replaceAll('\r\n', '\n').split('\n').length; +} + +function computeLineDiff( + oldContent: string, + newContent: string, +): { additions: number; deletions: number } { + const lines = (content: string): string[] => + content.length === 0 ? [] : content.replaceAll('\r\n', '\n').split('\n'); + const oldLines = lines(oldContent); + const newLines = lines(newContent); + const oldSet = new Set(oldLines); + const newSet = new Set(newLines); + return { + additions: newLines.filter((line) => !oldSet.has(line)).length, + deletions: oldLines.filter((line) => !newSet.has(line)).length, + }; +} diff --git a/apps/vscode/src/managers/file.manager.ts b/apps/vscode/src/managers/file.manager.ts new file mode 100644 index 0000000000..2e1b1f6c17 --- /dev/null +++ b/apps/vscode/src/managers/file.manager.ts @@ -0,0 +1,208 @@ +import * as vscode from "vscode"; +import * as path from "node:path"; +import { BaselineManager, type BaselineSession } from "./baseline.manager"; +import { Events } from "../../shared/bridge"; +import type { ProjectFile } from "../../shared/types"; +import { buildCaseInsensitiveGlobLiteral } from "../utils/string"; +import { + isWorkspacePathContained, + relativeWorkspacePath, + resolveWorkspacePath, +} from "../utils/workspace-path"; + +export type BroadcastFn = (event: string, data: unknown, webviewId?: string) => void; + +const IGNORE_DIRS = new Set([ + "node_modules", + ".git", + ".svn", + ".hg", + "dist", + "build", + "out", + ".next", + ".nuxt", + "__pycache__", + ".cache", + ".venv", + "venv", + ".gradle", + ".idea", + ".DS_Store", + "Thumbs.db", + "coverage", + ".nyc_output", + ".pytest_cache", + ".mypy_cache", + ".tox", + ".eggs", + ".sass-cache", + ".parcel-cache", + "bower_components", + "jspm_packages", + ".turbo", +]); + +const IGNORE_EXT = new Set([".lock", ".log", ".map", ".min.js", ".min.css", ".chunk.js", ".chunk.css"]); + +function shouldIgnore(name: string): boolean { + if (IGNORE_DIRS.has(name)) { + return true; + } + const ext = path.extname(name).toLowerCase(); + return IGNORE_EXT.has(ext); +} + +const SEARCH_EXCLUDE = `{${[...IGNORE_DIRS].map((d) => `**/${d}`).join(",")}}`; + +interface ViewState { + session: BaselineSession | null; + trackedFiles: Set; +} + +export class FileManager { + private viewStates = new Map(); + private disposables: vscode.Disposable[] = []; + + constructor( + private readonly baselineManager: BaselineManager, + private broadcast: BroadcastFn, + ) { + // Watch for file changes + const watcher = vscode.workspace.createFileSystemWatcher("**/*"); + + const refresh = (uri: vscode.Uri) => { + void this.onFileChange(uri).catch((error) => { + console.error("[kimi-vscode] Unable to refresh file changes", error); + }); + }; + watcher.onDidChange(refresh); + watcher.onDidCreate(refresh); + watcher.onDidDelete(refresh); + + this.disposables.push(watcher); + } + + private getViewState(webviewId: string): ViewState { + let state = this.viewStates.get(webviewId); + if (!state) { + state = { session: null, trackedFiles: new Set() }; + this.viewStates.set(webviewId, state); + } + return state; + } + + setSession(webviewId: string, session: BaselineSession): void { + this.getViewState(webviewId).session = session; + } + + clearSession(webviewId: string): void { + const state = this.getViewState(webviewId); + state.session = null; + state.trackedFiles.clear(); + } + + getSessionId(webviewId: string): string | null { + return this.getViewState(webviewId).session?.id ?? null; + } + + getSession(webviewId: string): BaselineSession | null { + return this.getViewState(webviewId).session; + } + + trackFile(webviewId: string, absolutePath: string): void { + this.getViewState(webviewId).trackedFiles.add(absolutePath); + } + + getTracked(webviewId: string): Set { + return this.getViewState(webviewId).trackedFiles; + } + + clearTracked(webviewId: string): void { + this.getViewState(webviewId).trackedFiles.clear(); + } + + disposeView(webviewId: string): void { + this.viewStates.delete(webviewId); + } + + private async onFileChange(uri: vscode.Uri): Promise { + const absolutePath = uri.fsPath; + + for (const [webviewId, state] of this.viewStates) { + if (!state.session || !state.trackedFiles.has(absolutePath)) { + continue; + } + + await this.refreshChanges(webviewId); + } + } + + async refreshChanges(webviewId: string): Promise { + const state = this.getViewState(webviewId); + if (state.session === null) { + this.broadcast(Events.FileChangesUpdated, [], webviewId); + return; + } + const changes = await this.baselineManager.getChanges(state.session); + this.broadcast(Events.FileChangesUpdated, changes, webviewId); + } + + async searchFiles(workDirUri: vscode.Uri, query?: string): Promise { + query = query ? buildCaseInsensitiveGlobLiteral(query) : ""; + const pattern = query ? `**/*${query}*` : "**/*"; + const files = await vscode.workspace.findFiles( + new vscode.RelativePattern(workDirUri, pattern), + new vscode.RelativePattern(workDirUri, SEARCH_EXCLUDE), + 200, + ); + const results = await Promise.all( + files.map(async (uri): Promise => { + const relativePath = relativeWorkspacePath(workDirUri, uri); + if (relativePath === undefined || !(await isWorkspacePathContained(workDirUri, uri))) return undefined; + return { + path: relativePath, + name: path.posix.basename(relativePath), + isDirectory: false, + }; + }), + ); + return results.filter((result): result is ProjectFile => result !== undefined); + } + + async listDirectory(workDirUri: vscode.Uri, directory: string): Promise { + const requested = resolveWorkspacePath(workDirUri, directory, { allowRoot: true }); + if (requested === undefined || !(await isWorkspacePathContained(workDirUri, requested.uri))) return []; + try { + const entries = await vscode.workspace.fs.readDirectory(requested.uri); + const resolvedEntries = await Promise.all( + entries.map(async ([name, type]): Promise => { + if (shouldIgnore(name)) return undefined; + const relativePath = requested.relativePath ? `${requested.relativePath}/${name}` : name; + const entry = resolveWorkspacePath(workDirUri, relativePath); + if (entry === undefined || !(await isWorkspacePathContained(workDirUri, entry.uri))) return undefined; + return { + path: entry.relativePath, + name, + isDirectory: (type & vscode.FileType.Directory) !== 0, + }; + }), + ); + return resolvedEntries + .filter((entry): entry is ProjectFile => entry !== undefined) + .toSorted((a, b) => + a.isDirectory === b.isDirectory ? a.name.localeCompare(b.name) : a.isDirectory ? -1 : 1, + ); + } catch { + return []; + } + } + + dispose(): void { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + this.viewStates.clear(); + } +} diff --git a/apps/vscode/src/migration/index.ts b/apps/vscode/src/migration/index.ts new file mode 100644 index 0000000000..6e22499bd6 --- /dev/null +++ b/apps/vscode/src/migration/index.ts @@ -0,0 +1 @@ +export * from "./legacy-migration.manager"; diff --git a/apps/vscode/src/migration/legacy-migration.manager.ts b/apps/vscode/src/migration/legacy-migration.manager.ts new file mode 100644 index 0000000000..70d2e8c692 --- /dev/null +++ b/apps/vscode/src/migration/legacy-migration.manager.ts @@ -0,0 +1,705 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve, win32 } from "node:path"; + +import { + detectMigration, + runMigration, + shouldSuppressMigration, + type MigrationPlan, + type MigrationReport, + type MigrationScope, +} from "@moonshot-ai/migration-legacy"; + +const FULL_MIGRATION_SCOPE = { + config: true, + mcp: true, + userHistory: true, + skills: true, + sessions: true, +} satisfies MigrationScope; + +export type LegacyMigrationSourceOrigin = "default" | "legacy-vscode-setting"; + +export type LegacyMigrationWarningCode = + | "invalid-share-dir" + | "relative-share-dir" + | "source-equals-target" + | "source-not-directory" + | "source-unreadable" + | "legacy-session-unreadable" + | "detection-failed"; + +export interface LegacyMigrationWarning { + readonly code: LegacyMigrationWarningCode; + readonly message: string; + readonly sourceHome?: string; +} + +export interface LegacyMigrationReauthItem { + readonly sourceHome: string; + readonly name: string; +} + +export interface LegacyMigrationNotices { + readonly oauthLoginsRequiringRelogin: readonly LegacyMigrationReauthItem[]; + readonly mcpOauthServersRequiringReauth: readonly LegacyMigrationReauthItem[]; +} + +export interface LegacyMigrationSourcePreview { + readonly sourceHome: string; + readonly origin: LegacyMigrationSourceOrigin; + readonly hasConfig: boolean; + readonly hasMcp: boolean; + readonly hasUserHistory: boolean; + readonly hasSkills: boolean; + readonly totalSessions: number; + readonly sessionIssues: number; +} + +export interface LegacyMigrationPromptModel { + readonly kind: "legacy-migration"; + readonly message: string; + readonly actions: readonly [ + { readonly id: "now"; readonly label: "Migrate Now" }, + { readonly id: "later"; readonly label: "Later" }, + ]; + readonly sources: readonly LegacyMigrationSourcePreview[]; + readonly notices: LegacyMigrationNotices; +} + +export interface LegacyMigrationDiscovery { + readonly prompt: LegacyMigrationPromptModel | null; + readonly suppressedSources: readonly LegacyMigrationSourcePreview[]; + readonly warnings: readonly LegacyMigrationWarning[]; + readonly notices: LegacyMigrationNotices; +} + +export interface LegacyMigrationManagerOptions { + /** Harness-resolved homeDir. */ + readonly targetHome: string; + /** Defaults to the legacy kimi-cli home (`~/.kimi`). Injectable for isolated tests. */ + readonly defaultSourceHome?: string; + /** First workspace root. Used only to resolve a relative legacy KIMI_SHARE_DIR. */ + readonly workspaceRoot?: string | null; + /** The removed `kimi.environmentVariables` VS Code setting, read once for migration. */ + readonly legacyEnvironmentVariables?: unknown; +} + +export type LegacyMigrationFailureCode = + | "run-failed" + | "legacy-config-unreadable" + | "legacy-mcp-unreadable" + | "session-failed"; + +export interface LegacyMigrationFailure { + readonly code: LegacyMigrationFailureCode; + readonly sourceHome: string; + readonly item?: string; + readonly message: string; +} + +export interface LegacyMigrationTotals { + readonly configFiles: number; + readonly mcpServers: number; + readonly userHistoryEntries: number; + readonly skills: number; + readonly sessions: number; + readonly alreadyMigratedSessions: number; + readonly skippedItems: number; + readonly conflicts: number; + readonly failures: number; +} + +export interface LegacyMigrationSourceResult { + readonly source: LegacyMigrationSourcePreview; + readonly status: "completed" | "partial" | "failed"; + readonly report?: MigrationReport; + readonly failures: readonly LegacyMigrationFailure[]; + readonly error?: unknown; +} + +export interface LegacyMigrationRunResult { + readonly status: "completed" | "partial" | "failed" | "nothing-to-migrate"; + readonly message: string; + readonly sources: readonly LegacyMigrationSourceResult[]; + readonly suppressedSources: readonly LegacyMigrationSourcePreview[]; + readonly totals: LegacyMigrationTotals; + readonly warnings: readonly LegacyMigrationWarning[]; + readonly notices: LegacyMigrationNotices; + readonly manualActions: readonly string[]; +} + +interface InspectedSource { + readonly preview: LegacyMigrationSourcePreview; + readonly plan: MigrationPlan; + readonly legacyMcpJsonValid: boolean; +} + +interface InspectionResult { + readonly pending: readonly InspectedSource[]; + readonly suppressed: readonly LegacyMigrationSourcePreview[]; + readonly warnings: readonly LegacyMigrationWarning[]; + readonly notices: LegacyMigrationNotices; +} + +interface SourceCandidate { + readonly sourceHome: string; + readonly origin: LegacyMigrationSourceOrigin; +} + +/** + * Coordinates legacy kimi-cli migration for the VS Code host while keeping all + * data translation inside the shared migration package. + */ +export class LegacyMigrationManager { + readonly targetHome: string; + + private readonly defaultSourceHome: string; + private readonly workspaceRoot: string | null; + private readonly legacyEnvironmentVariables: unknown; + + constructor(options: LegacyMigrationManagerOptions) { + if (options.targetHome.trim().length === 0) { + throw new Error("LegacyMigrationManager requires a non-empty targetHome."); + } + this.targetHome = resolve(options.targetHome); + this.defaultSourceHome = resolve(options.defaultSourceHome ?? join(homedir(), ".kimi")); + this.workspaceRoot = + options.workspaceRoot === undefined || options.workspaceRoot === null + ? null + : resolve(options.workspaceRoot); + this.legacyEnvironmentVariables = options.legacyEnvironmentVariables; + } + + /** Detect first-launch work without changing the source or target. */ + async discover(): Promise { + const inspection = await this.inspect(false); + const sources = inspection.pending.map((source) => source.preview); + return { + prompt: + sources.length === 0 + ? null + : { + kind: "legacy-migration", + message: + "Legacy Kimi data was found. Migrate config, MCP servers, history, skills, and sessions into Kimi Code? Your old data will be kept.", + actions: [ + { id: "now", label: "Migrate Now" }, + { id: "later", label: "Later" }, + ], + sources, + notices: inspection.notices, + }, + suppressedSources: inspection.suppressed, + warnings: inspection.warnings, + notices: inspection.notices, + }; + } + + /** Run the migration selected from the first-launch prompt. */ + async migrateNow(): Promise { + return this.execute(false); + } + + /** + * Explicit command-palette retry. Marker suppression is intentionally + * bypassed because a completed marker may also describe a partially failed + * run, while the shared migrator itself remains idempotent. + */ + async retry(): Promise { + return this.execute(true); + } + + private async execute(ignoreMarker: boolean): Promise { + const inspection = await this.inspect(ignoreMarker); + const sourceResults: LegacyMigrationSourceResult[] = []; + + for (const source of inspection.pending) { + try { + const report = await runMigration({ + // The shared package owns its migration/schema version. It must not + // be coupled to the VS Code extension's release version. + plan: source.plan, + scope: FULL_MIGRATION_SCOPE, + source: source.preview.sourceHome, + target: this.targetHome, + }); + const failures = failuresFromReport(source, report); + sourceResults.push({ + source: source.preview, + status: failures.length === 0 ? "completed" : "partial", + report, + failures, + }); + } catch (error) { + sourceResults.push({ + source: source.preview, + status: "failed", + failures: [ + { + code: "run-failed", + sourceHome: source.preview.sourceHome, + message: formatError(error), + }, + ], + error, + }); + } + } + + const status = runStatus(sourceResults); + const totals = aggregateTotals(sourceResults); + const manualActions = aggregateManualActions(sourceResults); + return { + status, + message: runMessage(status, totals), + sources: sourceResults, + suppressedSources: inspection.suppressed, + totals, + warnings: inspection.warnings, + notices: mergeRunNotices(inspection.notices, sourceResults), + manualActions, + }; + } + + private async inspect(ignoreMarker: boolean): Promise { + const { candidates, warnings } = this.sourceCandidates(); + const pending: InspectedSource[] = []; + const suppressed: LegacyMigrationSourcePreview[] = []; + const oauthLoginsRequiringRelogin: LegacyMigrationReauthItem[] = []; + const mcpOauthServersRequiringReauth: LegacyMigrationReauthItem[] = []; + + for (const candidate of candidates) { + const sourceCheck = await checkSourceDirectory(candidate.sourceHome); + if (sourceCheck === "missing") continue; + if (sourceCheck === "not-directory") { + warnings.push({ + code: "source-not-directory", + sourceHome: candidate.sourceHome, + message: `Legacy migration source is not a directory: ${candidate.sourceHome}`, + }); + continue; + } + if (sourceCheck === "unreadable") { + warnings.push({ + code: "source-unreadable", + sourceHome: candidate.sourceHome, + message: `Legacy migration source cannot be read: ${candidate.sourceHome}`, + }); + continue; + } + + let plan: MigrationPlan; + try { + plan = await detectMigration({ sourcePath: candidate.sourceHome }); + } catch (error) { + warnings.push({ + code: "detection-failed", + sourceHome: candidate.sourceHome, + message: `Unable to inspect legacy data at ${candidate.sourceHome}: ${formatError(error)}`, + }); + continue; + } + + oauthLoginsRequiringRelogin.push( + ...plan.oauthCredentials.map((name) => ({ sourceHome: candidate.sourceHome, name })), + ); + mcpOauthServersRequiringReauth.push( + ...plan.detectedMcpOauthServers.map((name) => ({ + sourceHome: candidate.sourceHome, + name, + })), + ); + + const hasSkills = await directoryHasEntries(join(candidate.sourceHome, "skills")); + const sessionScanFailures = plan.sessionScanFailures ?? []; + warnings.push( + ...sessionScanFailures.map((failure) => ({ + code: "legacy-session-unreadable" as const, + sourceHome: candidate.sourceHome, + message: `${failure.reason} Source: ${failure.sourcePath}`, + })), + ); + const preview: LegacyMigrationSourcePreview = { + sourceHome: candidate.sourceHome, + origin: candidate.origin, + hasConfig: plan.hasConfig, + hasMcp: plan.hasMcp, + hasUserHistory: plan.hasUserHistory, + hasSkills, + totalSessions: plan.totalSessions, + sessionIssues: sessionScanFailures.length, + }; + if (!hasMigratableData(preview)) continue; + + if ( + !ignoreMarker && + shouldSuppressMigration({ + sourceHome: candidate.sourceHome, + targetHome: this.targetHome, + }) + ) { + suppressed.push(preview); + continue; + } + + pending.push({ + preview, + plan, + legacyMcpJsonValid: await isLegacyMcpJsonValid(plan, candidate.sourceHome), + }); + } + + return { + pending, + suppressed, + warnings, + notices: { + oauthLoginsRequiringRelogin: dedupeReauthItems(oauthLoginsRequiringRelogin), + mcpOauthServersRequiringReauth: dedupeReauthItems( + mcpOauthServersRequiringReauth, + ), + }, + }; + } + + private sourceCandidates(): { + candidates: SourceCandidate[]; + warnings: LegacyMigrationWarning[]; + } { + const warnings: LegacyMigrationWarning[] = []; + const candidates: SourceCandidate[] = [ + { sourceHome: this.defaultSourceHome, origin: "default" }, + ]; + const shareDir = readLegacyShareDir(this.legacyEnvironmentVariables); + + if (shareDir.kind === "invalid") { + warnings.push({ + code: "invalid-share-dir", + message: shareDir.message, + }); + } else if (shareDir.kind === "value") { + let sourceHome: string | undefined; + if (isAbsolute(shareDir.value)) { + sourceHome = resolve(shareDir.value); + } else if (this.workspaceRoot === null) { + warnings.push({ + code: "invalid-share-dir", + message: + "The legacy KIMI_SHARE_DIR is relative, but no workspace is open; this migration source was ignored.", + }); + } else { + sourceHome = resolve(this.workspaceRoot, shareDir.value); + warnings.push({ + code: "relative-share-dir", + sourceHome, + message: `The legacy relative KIMI_SHARE_DIR was resolved against the workspace: ${sourceHome}`, + }); + } + + if (sourceHome !== undefined && samePath(sourceHome, this.targetHome)) { + warnings.push({ + code: "source-equals-target", + sourceHome, + message: "The legacy KIMI_SHARE_DIR resolves to the Kimi Code home and was ignored.", + }); + } else if ( + sourceHome !== undefined && + !candidates.some((candidate) => samePath(candidate.sourceHome, sourceHome)) + ) { + candidates.push({ sourceHome, origin: "legacy-vscode-setting" }); + } + } + + return { candidates, warnings }; + } +} + +function readLegacyShareDir( + environmentVariables: unknown, +): { readonly kind: "missing" } | { readonly kind: "value"; readonly value: string } | { + readonly kind: "invalid"; + readonly message: string; +} { + if (environmentVariables === undefined) return { kind: "missing" }; + if ( + typeof environmentVariables !== "object" || + environmentVariables === null || + Array.isArray(environmentVariables) + ) { + return { + kind: "invalid", + message: "The legacy kimi.environmentVariables setting is invalid and was ignored.", + }; + } + + const value = (environmentVariables as Record)["KIMI_SHARE_DIR"]; + if (value === undefined) return { kind: "missing" }; + if (typeof value !== "string" || value.trim().length === 0) { + return { + kind: "invalid", + message: "The legacy KIMI_SHARE_DIR must be a non-empty string and was ignored.", + }; + } + return { kind: "value", value }; +} + +async function checkSourceDirectory( + sourceHome: string, +): Promise<"ok" | "missing" | "not-directory" | "unreadable"> { + try { + const sourceStat = await stat(sourceHome); + if (!sourceStat.isDirectory()) return "not-directory"; + } catch (error) { + return isMissingError(error) ? "missing" : "unreadable"; + } + + try { + await readdir(sourceHome); + return "ok"; + } catch { + return "unreadable"; + } +} + +function isMissingError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { readonly code?: unknown }).code === "ENOENT" + ); +} + +async function directoryHasEntries(path: string): Promise { + try { + return (await readdir(path)).length > 0; + } catch { + return false; + } +} + +async function isLegacyMcpJsonValid( + plan: MigrationPlan, + sourceHome: string, +): Promise { + if (!plan.hasMcp) return true; + try { + JSON.parse(await readFile(join(sourceHome, "mcp.json"), "utf-8")); + return true; + } catch { + return false; + } +} + +function hasMigratableData(source: LegacyMigrationSourcePreview): boolean { + return ( + source.hasConfig || + source.hasMcp || + source.hasUserHistory || + source.hasSkills || + source.totalSessions > 0 || + source.sessionIssues > 0 + ); +} + +function failuresFromReport( + source: InspectedSource, + report: MigrationReport, +): LegacyMigrationFailure[] { + const failures: LegacyMigrationFailure[] = []; + if (source.plan.hasConfig && !report.summary.config.migrated) { + failures.push({ + code: "legacy-config-unreadable", + sourceHome: source.preview.sourceHome, + item: "config.toml", + message: "The legacy config.toml could not be read or parsed; review it manually.", + }); + } + if (source.plan.hasMcp && !source.legacyMcpJsonValid) { + failures.push({ + code: "legacy-mcp-unreadable", + sourceHome: source.preview.sourceHome, + item: "mcp.json", + message: "The legacy mcp.json could not be parsed; review it manually.", + }); + } + failures.push( + ...report.summary.sessions.sessionsFailed.map((failure) => ({ + code: "session-failed" as const, + sourceHome: source.preview.sourceHome, + item: failure.sourcePath, + message: failure.reason, + })), + ); + return failures; +} + +function runStatus( + sources: readonly LegacyMigrationSourceResult[], +): LegacyMigrationRunResult["status"] { + if (sources.length === 0) return "nothing-to-migrate"; + if (sources.every((source) => source.status === "failed")) return "failed"; + if (sources.some((source) => source.status !== "completed")) return "partial"; + return "completed"; +} + +function aggregateTotals(sources: readonly LegacyMigrationSourceResult[]): LegacyMigrationTotals { + let configFiles = 0; + let mcpServers = 0; + let userHistoryEntries = 0; + let skills = 0; + let sessions = 0; + let alreadyMigratedSessions = 0; + let skippedItems = 0; + let conflicts = 0; + let failures = 0; + + for (const source of sources) { + failures += source.failures.length; + const summary = source.report?.summary; + if (summary === undefined) continue; + configFiles += summary.config.migrated ? 1 : 0; + mcpServers += summary.mcp.mergedServers.length; + userHistoryEntries += summary.userHistory.copied; + skills += summary.skills.copied; + sessions += summary.sessions.sessionsMigrated; + alreadyMigratedSessions += summary.sessions.sessionsAlreadyMigrated; + skippedItems += + summary.userHistory.skippedExisting + + summary.skills.skippedExisting + + summary.sessions.sessionsSkippedPlaceholder + + summary.sessions.sessionsSkippedEmpty + + summary.sessions.sessionsSkippedMalformed; + conflicts += + summary.config.configConflicts.length + + summary.mcp.keptNewForConflicts.length + + summary.sessions.sessionsConflicts.length; + } + + return { + configFiles, + mcpServers, + userHistoryEntries, + skills, + sessions, + alreadyMigratedSessions, + skippedItems, + conflicts, + failures, + }; +} + +function aggregateManualActions( + sources: readonly LegacyMigrationSourceResult[], +): readonly string[] { + const actions: string[] = []; + for (const source of sources) { + for (const failure of source.failures) { + if (failure.code === "run-failed") { + actions.push( + `Fix access to ${source.source.sourceHome} or the Kimi Code home, then run “Kimi Code: Migrate Legacy Data” again.`, + ); + } else { + actions.push( + `${failure.message} Source: ${failure.item ?? source.source.sourceHome}`, + ); + } + } + + const summary = source.report?.summary; + if (summary === undefined) continue; + if (summary.config.wroteSiblingDueToConflict) { + actions.push("Review and merge config.migrated-from-kimi-cli.toml."); + } + if (summary.config.wroteTuiSibling) { + actions.push("Review and merge tui.migrated-from-kimi-cli.toml."); + } + if (summary.mcp.wroteSiblingDueToConflict) { + actions.push("Review and merge mcp.migrated-from-kimi-cli.json."); + } + if (summary.sessions.sessionsConflicts.length > 0) { + actions.push( + `${summary.sessions.sessionsConflicts.length} legacy session(s) conflicted with existing target sessions and were kept unchanged.`, + ); + } + } + return [...new Set(actions)]; +} + +function mergeRunNotices( + detectionNotices: LegacyMigrationNotices, + sources: readonly LegacyMigrationSourceResult[], +): LegacyMigrationNotices { + const oauth = [...detectionNotices.oauthLoginsRequiringRelogin]; + const mcpOauth = [...detectionNotices.mcpOauthServersRequiringReauth]; + for (const source of sources) { + const notices = source.report?.notices; + if (notices === undefined) continue; + oauth.push( + ...notices.oauthLoginsRequiringRelogin.map((name) => ({ + sourceHome: source.source.sourceHome, + name, + })), + ); + mcpOauth.push( + ...notices.mcpOauthServersRequiringReauth.map((name) => ({ + sourceHome: source.source.sourceHome, + name, + })), + ); + } + return { + oauthLoginsRequiringRelogin: dedupeReauthItems(oauth), + mcpOauthServersRequiringReauth: dedupeReauthItems(mcpOauth), + }; +} + +function dedupeReauthItems( + items: readonly LegacyMigrationReauthItem[], +): readonly LegacyMigrationReauthItem[] { + const seen = new Set(); + return items.filter((item) => { + const key = `${pathKey(item.sourceHome)}\0${item.name}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function runMessage( + status: LegacyMigrationRunResult["status"], + totals: LegacyMigrationTotals, +): string { + if (status === "nothing-to-migrate") return "No legacy Kimi data needs migration."; + if (status === "failed") { + return "Legacy migration failed. Fix the reported path or data error, then retry from the command palette."; + } + const migrated = `${totals.configFiles} config, ${totals.mcpServers} MCP server(s), ${totals.userHistoryEntries} history item(s), ${totals.skills} skill(s), and ${totals.sessions} session(s)`; + if (status === "partial") { + return `Legacy migration completed with ${totals.failures} failure(s): ${migrated}. Review the details and retry from the command palette.`; + } + return `Legacy migration complete: ${migrated}. Old data was kept.`; +} + +function formatError(error: unknown): string { + if (!(error instanceof Error)) return String(error); + const messages: string[] = []; + let current: unknown = error; + while (current instanceof Error) { + messages.push(current.message || current.name); + current = current.cause; + } + return messages.join(": "); +} + +function samePath(left: string, right: string): boolean { + return pathKey(left) === pathKey(right); +} + +function pathKey(path: string): string { + if (process.platform === "win32") return win32.resolve(path).toLowerCase(); + const windowsAbsolute = win32.isAbsolute(path); + return windowsAbsolute ? win32.resolve(path).toLowerCase() : resolve(path); +} diff --git a/apps/vscode/src/raw-modules.d.ts b/apps/vscode/src/raw-modules.d.ts new file mode 100644 index 0000000000..c65c77acd5 --- /dev/null +++ b/apps/vscode/src/raw-modules.d.ts @@ -0,0 +1,4 @@ +declare module "*?raw" { + const text: string; + export default text; +} diff --git a/apps/vscode/src/runtime/event-adapter.ts b/apps/vscode/src/runtime/event-adapter.ts new file mode 100644 index 0000000000..55c2c44524 --- /dev/null +++ b/apps/vscode/src/runtime/event-adapter.ts @@ -0,0 +1,446 @@ +import type { Event } from '@moonshot-ai/kimi-code-sdk'; + +import type { + DisplayBlock, + LegacyWireEvent, + StatusUpdate, + TokenUsage, + TurnBegin, +} from '../../shared/legacy-sdk'; +import type { ErrorPhase, UIStreamEvent } from '../../shared/types'; +import { toLegacyDisplay } from './tool-display'; + +const DEFAULT_MAIN_AGENT_ID = 'main'; + +export interface AdapterTokenUsage { + readonly inputOther: number; + readonly output: number; + readonly inputCacheRead: number; + readonly inputCacheCreation: number; +} + +export interface SubagentParent { + readonly parentAgentId: string; + readonly parentToolCallId: string; +} + +export interface EventAdapterState { + readonly subagentParents: Readonly>; + readonly turnUsageByAgent: Readonly>; + readonly toolDisplays: Readonly>; +} + +export interface AdaptedToolCallPartEvent { + readonly type: 'ToolCallPart'; + readonly payload: { + /** Lets the Webview update the right call when tool arguments interleave. */ + readonly tool_call_id: string; + readonly arguments_part?: string | null; + }; + readonly _sessionId?: string; +} + +export type AdaptedUIStreamEvent = UIStreamEvent | AdaptedToolCallPartEvent; + +type SdkTurnEndedEvent = Extract; + +export interface TurnTerminalMetadata { + /** Stable within one adapter stream and suitable for terminal-event de-duplication. */ + readonly key: string; + readonly sessionId: string; + readonly agentId: string; + readonly turnId: number; + readonly reason: SdkTurnEndedEvent['reason']; + readonly error?: NonNullable; +} + +export interface EventAdapterResult { + readonly state: EventAdapterState; + readonly event?: AdaptedUIStreamEvent; + /** SessionRuntime owns conversion of this metadata to exactly one complete/error event. */ + readonly terminal?: TurnTerminalMetadata; +} + +export interface AdaptSdkEventOptions { + /** The SDK turn-start event intentionally does not repeat prompt content. */ + readonly pendingInput?: TurnBegin['user_input']; + readonly mainAgentId?: string; + readonly errorPhase?: ErrorPhase; +} + +export function createEventAdapterState(): EventAdapterState { + return { + subagentParents: {}, + turnUsageByAgent: {}, + toolDisplays: {}, + }; +} + +/** + * Purely projects one public Node SDK event into the released Webview protocol. + * The returned state must be passed into the next call; the input state is never mutated. + */ +export function adaptSdkEvent( + state: EventAdapterState, + sdkEvent: Event, + options: AdaptSdkEventOptions = {}, +): EventAdapterResult { + const mainAgentId = options.mainAgentId ?? DEFAULT_MAIN_AGENT_ID; + + if (sdkEvent.type === 'subagent.spawned') { + const parentAgentId = sdkEvent.parentAgentId ?? sdkEvent.callerAgentId ?? sdkEvent.agentId; + return { + state: { + ...state, + subagentParents: { + ...state.subagentParents, + [sdkEvent.subagentId]: { + parentAgentId, + parentToolCallId: scopedToolCallId( + parentAgentId, + sdkEvent.parentToolCallId, + mainAgentId, + ), + }, + }, + }, + }; + } + + if (sdkEvent.type === 'turn.started') { + const nextState = resetTurnUsage(state, sdkEvent.agentId); + if (sdkEvent.agentId !== mainAgentId || options.pendingInput === undefined) { + return { state: nextState }; + } + return { + state: nextState, + event: withSessionId( + { + type: 'TurnBegin', + payload: { user_input: options.pendingInput }, + }, + sdkEvent.sessionId, + ), + }; + } + + if (sdkEvent.type === 'turn.ended') { + if (sdkEvent.agentId !== mainAgentId) return { state }; + return { + state, + terminal: { + key: `${sdkEvent.sessionId}:${sdkEvent.agentId}:${sdkEvent.turnId}`, + sessionId: sdkEvent.sessionId, + agentId: sdkEvent.agentId, + turnId: sdkEvent.turnId, + reason: sdkEvent.reason, + error: sdkEvent.error, + }, + }; + } + + if (sdkEvent.type === 'error') { + if (sdkEvent.agentId !== mainAgentId) return { state }; + return { + state, + event: { + type: 'error', + code: sdkEvent.code, + message: sdkEvent.message, + detail: serializeDetails(sdkEvent.details), + phase: options.errorPhase ?? 'runtime', + _sessionId: sdkEvent.sessionId, + }, + }; + } + + const mapped = mapLegacyWireEvent(state, sdkEvent, mainAgentId); + if (mapped.event === undefined) return { state: mapped.state }; + + const routed = routeSubagentEvent( + mapped.state, + sdkEvent.agentId, + mapped.event, + mainAgentId, + ); + if (routed === undefined) return { state: mapped.state }; + + return { + state: mapped.state, + event: withSessionId(routed, sdkEvent.sessionId), + }; +} + +export function toLegacyToolName(name: string): string { + switch (name) { + case 'Bash': + return 'Shell'; + case 'Read': + return 'ReadFile'; + case 'Write': + return 'WriteFile'; + case 'Edit': + return 'StrReplaceFile'; + case 'TodoList': + return 'SetTodoList'; + default: + return name; + } +} + +interface MappedLegacyWireEvent { + readonly state: EventAdapterState; + readonly event?: LegacyWireEvent; +} + +function mapLegacyWireEvent( + state: EventAdapterState, + sdkEvent: Event, + mainAgentId: string, +): MappedLegacyWireEvent { + switch (sdkEvent.type) { + case 'turn.step.started': + return { + state, + event: { type: 'StepBegin', payload: { n: sdkEvent.step } }, + }; + case 'turn.step.retrying': + return { + state, + event: { + type: 'StatusUpdate', + payload: { + retrying: { + next_attempt: sdkEvent.nextAttempt, + max_attempts: sdkEvent.maxAttempts, + delay_ms: sdkEvent.delayMs, + message: sdkEvent.errorMessage, + }, + }, + }, + }; + case 'turn.step.interrupted': + return { + state, + event: { type: 'StepInterrupted', payload: {} }, + }; + case 'assistant.delta': + return { + state, + event: { type: 'ContentPart', payload: { type: 'text', text: sdkEvent.delta } }, + }; + case 'hook.result': + return { + state, + event: { type: 'ContentPart', payload: { type: 'text', text: sdkEvent.content } }, + }; + case 'thinking.delta': + return { + state, + event: { type: 'ContentPart', payload: { type: 'think', think: sdkEvent.delta } }, + }; + case 'tool.call.started': { + const toolCallId = scopedToolCallId( + sdkEvent.agentId, + sdkEvent.toolCallId, + mainAgentId, + ); + const display = sdkEvent.display === undefined ? undefined : toLegacyDisplay(sdkEvent.display); + return { + state: display === undefined + ? state + : { + ...state, + toolDisplays: { ...state.toolDisplays, [toolCallId]: display }, + }, + event: { + type: 'ToolCall', + payload: { + type: 'function', + id: toolCallId, + function: { + name: toLegacyToolName(sdkEvent.name), + arguments: serializeArguments(sdkEvent.args), + }, + }, + }, + }; + } + case 'tool.call.delta': { + const event: AdaptedToolCallPartEvent = { + type: 'ToolCallPart', + payload: { + tool_call_id: scopedToolCallId( + sdkEvent.agentId, + sdkEvent.toolCallId, + mainAgentId, + ), + arguments_part: sdkEvent.argumentsPart, + }, + }; + return { state, event: event as LegacyWireEvent }; + } + case 'tool.result': { + const toolCallId = scopedToolCallId( + sdkEvent.agentId, + sdkEvent.toolCallId, + mainAgentId, + ); + const display = state.toolDisplays[toolCallId] ?? []; + const toolDisplays = { ...state.toolDisplays }; + delete toolDisplays[toolCallId]; + const output = serializeToolOutput(sdkEvent.output); + return { + state: { ...state, toolDisplays }, + event: { + type: 'ToolResult', + payload: { + tool_call_id: toolCallId, + return_value: { + is_error: sdkEvent.isError === true, + output, + message: '', + display: [...display], + }, + }, + }, + }; + } + case 'agent.status.updated': + return mapStatusUpdate(state, sdkEvent); + case 'compaction.started': + return { + state, + event: { type: 'CompactionBegin', payload: {} }, + }; + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + return { + state, + event: { type: 'CompactionEnd', payload: {} }, + }; + default: + return { state }; + } +} + +function mapStatusUpdate( + state: EventAdapterState, + sdkEvent: Extract, +): MappedLegacyWireEvent { + const payload: StatusUpdate = {}; + if (sdkEvent.contextUsage !== undefined) payload.context_usage = sdkEvent.contextUsage; + if (sdkEvent.planMode !== undefined) payload.plan_mode = sdkEvent.planMode; + if (sdkEvent.model !== undefined) payload.model = sdkEvent.model; + if (sdkEvent.thinkingEffort !== undefined) payload.thinking_effort = sdkEvent.thinkingEffort; + + const currentTurn = sdkEvent.usage?.currentTurn; + if (currentTurn === undefined) { + return Object.keys(payload).length === 0 + ? { state } + : { state, event: { type: 'StatusUpdate', payload } }; + } + + const previous = state.turnUsageByAgent[sdkEvent.agentId]; + payload.token_usage = usageDelta(currentTurn, previous); + return { + state: { + ...state, + turnUsageByAgent: { + ...state.turnUsageByAgent, + [sdkEvent.agentId]: currentTurn, + }, + }, + event: { type: 'StatusUpdate', payload }, + }; +} + +function usageDelta(current: AdapterTokenUsage, previous: AdapterTokenUsage | undefined): TokenUsage { + return { + input_other: delta(current.inputOther, previous?.inputOther), + output: delta(current.output, previous?.output), + input_cache_read: delta(current.inputCacheRead, previous?.inputCacheRead), + input_cache_creation: delta( + current.inputCacheCreation, + previous?.inputCacheCreation, + ), + }; +} + +function delta(current: number, previous: number | undefined): number { + if (previous === undefined || current < previous) return current; + return current - previous; +} + +function resetTurnUsage(state: EventAdapterState, agentId: string): EventAdapterState { + if (state.turnUsageByAgent[agentId] === undefined) return state; + const nextUsage = { ...state.turnUsageByAgent }; + delete nextUsage[agentId]; + return { ...state, turnUsageByAgent: nextUsage }; +} + +function routeSubagentEvent( + state: EventAdapterState, + agentId: string, + event: LegacyWireEvent, + mainAgentId: string, +): LegacyWireEvent | undefined { + if (agentId === mainAgentId) return event; + + let currentAgentId = agentId; + let routed = event; + const visited = new Set(); + + while (currentAgentId !== mainAgentId) { + if (visited.has(currentAgentId)) return undefined; + visited.add(currentAgentId); + + const parent = state.subagentParents[currentAgentId]; + if (parent === undefined) return undefined; + routed = { + type: 'SubagentEvent', + payload: { + parent_tool_call_id: parent.parentToolCallId, + event: routed, + }, + }; + currentAgentId = parent.parentAgentId; + } + + return routed; +} + +function scopedToolCallId(agentId: string, toolCallId: string, mainAgentId: string): string { + return agentId === mainAgentId ? toolCallId : `${agentId}:${toolCallId}`; +} + +function withSessionId(event: LegacyWireEvent, sessionId: string): AdaptedUIStreamEvent { + return { ...event, _sessionId: sessionId } as AdaptedUIStreamEvent; +} + +function serializeArguments(args: unknown): string { + try { + return JSON.stringify(args) ?? '{}'; + } catch { + return '{}'; + } +} + +function serializeToolOutput(output: unknown): string { + if (typeof output === 'string') return output; + try { + return JSON.stringify(output, null, 2) ?? ''; + } catch { + return String(output); + } +} + +function serializeDetails(details: Record | undefined): string | undefined { + if (details === undefined) return undefined; + try { + return JSON.stringify(details, null, 2); + } catch { + return "[Unable to serialize error details]"; + } +} diff --git a/apps/vscode/src/runtime/kimi-runtime.ts b/apps/vscode/src/runtime/kimi-runtime.ts new file mode 100644 index 0000000000..9da8e97543 --- /dev/null +++ b/apps/vscode/src/runtime/kimi-runtime.ts @@ -0,0 +1,285 @@ +import { + createKimiHarness, + type KimiHarness, + type Session, + type SessionSummary, + type ThinkingEffort, +} from "@moonshot-ai/kimi-code-sdk"; + +import type { RuntimeBroadcast } from "./session-runtime"; +import { + corePermissionForLegacyApproval, + legacyApprovalMetadata, + readLegacyApprovalFlags, + readMigratedLegacyApprovalFlags, + withGlobalYoloMode, + type LegacyApprovalFlags, +} from "./legacy-approval"; +import { SessionRuntime } from "./session-runtime"; +import { areSameFsPath } from "../utils/fs-path"; + +export interface KimiRuntimeOptions { + readonly version: string; + readonly broadcast: RuntimeBroadcast; + readonly captureBaseline: ( + session: Pick, + filePath: string, + webviewIds: readonly string[], + ) => void; + readonly log: (message: string, error?: unknown) => void; + readonly homeDir?: string; + readonly harness?: KimiHarness; +} + +export interface OpenSessionOptions { + readonly webviewId: string; + readonly workDir: string; + readonly sessionId?: string; + readonly model: string; + readonly effort: string; + readonly yoloMode: boolean; +} + +/** Extension-host owner for one in-process Node SDK harness. */ +export class KimiRuntime { + readonly harness: KimiHarness; + + private readonly broadcast: RuntimeBroadcast; + private readonly captureBaseline: KimiRuntimeOptions["captureBaseline"]; + private readonly log: KimiRuntimeOptions["log"]; + private readonly sessions = new Map(); + private readonly sessionByView = new Map(); + private closed = false; + + constructor(options: KimiRuntimeOptions) { + this.broadcast = options.broadcast; + this.captureBaseline = options.captureBaseline; + this.log = options.log; + this.harness = + options.harness ?? + createKimiHarness({ + ...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }), + identity: { + userAgentProduct: "kimi-code-vscode", + version: options.version, + }, + uiMode: "vscode", + }); + } + + getSessionForView(webviewId: string): SessionRuntime | undefined { + const id = this.sessionByView.get(webviewId); + return id === undefined ? undefined : this.sessions.get(id); + } + + getSession(id: string): SessionRuntime | undefined { + return this.sessions.get(id); + } + + async openSession(options: OpenSessionOptions): Promise { + this.ensureOpen(); + const current = this.getSessionForView(options.webviewId); + const requestedId = options.sessionId ?? current?.id; + + if ( + current !== undefined && + requestedId === current.id && + areSameFsPath(current.session.workDir, options.workDir) + ) { + await applySessionSettings(current.session, options, current.legacyApprovalFlags); + await current.announceStatus(options.webviewId); + return current; + } + + let runtime = requestedId === undefined ? undefined : this.sessions.get(requestedId); + if (runtime !== undefined) { + assertSessionWorkDir(runtime.session, options.workDir); + await applySessionSettings(runtime.session, options, runtime.legacyApprovalFlags); + await this.detachView(options.webviewId); + } else { + const defaultApproval: LegacyApprovalFlags = { yolo: options.yoloMode, afk: false }; + const session = + requestedId === undefined + ? await this.harness.createSession({ + workDir: options.workDir, + model: options.model || undefined, + thinking: normalizeEffort(options.effort), + permission: corePermissionForLegacyApproval(defaultApproval), + metadata: legacyApprovalMetadata(defaultApproval), + }) + : await this.harness.resumeSession({ id: requestedId, includeSubagents: true }); + try { + assertSessionWorkDir(session, options.workDir); + const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); + const restoredApproval = + storedApproval ?? (await this.readMigratedLegacyApproval(session)) ?? defaultApproval; + const approval = withGlobalYoloMode(restoredApproval, options.yoloMode); + if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) { + await session.updateMetadata(legacyApprovalMetadata(approval)); + } + await applySessionSettings(session, options, approval); + await this.detachView(options.webviewId); + runtime = this.wrapSession(session, approval); + } catch (error) { + await session.close().catch((closeError: unknown) => { + this.log("Failed to close a rejected session", closeError); + }); + throw error; + } + } + + runtime.subscribe(options.webviewId); + this.sessionByView.set(options.webviewId, runtime.id); + await runtime.announceStatus(options.webviewId); + return runtime; + } + + async attachResumedSession( + webviewId: string, + session: Session, + defaultYoloMode = false, + ): Promise { + const existing = this.sessions.get(session.id); + if (existing !== undefined && this.sessionByView.get(webviewId) === session.id) { + existing.subscribe(webviewId); + await existing.announceStatus(webviewId); + return existing; + } + await this.detachView(webviewId); + let runtime = existing ?? this.sessions.get(session.id); + if (runtime === undefined) { + try { + const storedApproval = readLegacyApprovalFlags(session.summary?.metadata); + const restoredApproval = + storedApproval ?? + (await this.readMigratedLegacyApproval(session)) ?? + { yolo: defaultYoloMode, afk: false }; + const approval = withGlobalYoloMode(restoredApproval, defaultYoloMode); + if (storedApproval === undefined || flagsDiffer(storedApproval, approval)) { + await session.updateMetadata(legacyApprovalMetadata(approval)); + } + const status = await session.getStatus(); + const permission = corePermissionForLegacyApproval(approval); + if (status.permission !== permission) await session.setPermission(permission); + runtime = this.wrapSession(session, approval); + } catch (error) { + await session.close().catch((closeError: unknown) => { + this.log("Failed to close a rejected session", closeError); + }); + throw error; + } + } + runtime.subscribe(webviewId); + this.sessionByView.set(webviewId, runtime.id); + await runtime.announceStatus(webviewId); + return runtime; + } + + async detachView(webviewId: string): Promise { + const id = this.sessionByView.get(webviewId); + if (id === undefined) return; + this.sessionByView.delete(webviewId); + const runtime = this.sessions.get(id); + if (runtime === undefined) return; + runtime.unsubscribeView(webviewId); + if (runtime.subscribers.length === 0) { + this.sessions.delete(id); + await runtime.close(); + } + } + + async closeSession(id: string): Promise { + const runtime = this.sessions.get(id); + if (runtime === undefined) { + await this.harness.closeSession(id); + return; + } + this.sessions.delete(id); + for (const webviewId of runtime.subscribers) { + this.sessionByView.delete(webviewId); + } + await runtime.close(); + } + + async deleteSession(id: string): Promise { + await this.closeSession(id); + await this.harness.deleteSession(id); + } + + async setYoloModeForActiveSessions(enabled: boolean): Promise { + await Promise.all( + [...this.sessions.values()].map((session) => session.setLegacyYoloMode(enabled)), + ); + } + + async dispose(): Promise { + if (this.closed) return; + this.closed = true; + await Promise.all([...this.sessions.values()].map((session) => session.close())); + this.sessions.clear(); + this.sessionByView.clear(); + await this.harness.close(); + } + + private wrapSession(session: Session, legacyApproval: LegacyApprovalFlags): SessionRuntime { + const runtime = new SessionRuntime({ + session, + legacyApproval, + broadcast: this.broadcast, + captureBaseline: this.captureBaseline, + log: this.log, + }); + this.sessions.set(session.id, runtime); + return runtime; + } + + private async readMigratedLegacyApproval( + session: Session, + ): Promise { + const metadata = session.summary?.metadata; + try { + return await readMigratedLegacyApprovalFlags(metadata); + } catch (error) { + this.log("Unable to restore legacy session approval settings", error); + return undefined; + } + } + + private ensureOpen(): void { + if (this.closed) throw new Error("Kimi runtime is closed."); + } +} + +async function applySessionSettings( + session: Session, + options: OpenSessionOptions, + legacyApproval: LegacyApprovalFlags, +): Promise { + const status = await session.getStatus(); + if (options.model && status.model !== options.model) { + await session.setModel(options.model); + } + // Thinking effort is applied only when the session is created (see + // openSession). An existing session keeps its own effort — the global + // config value is a default for new sessions, matching CLI/TUI resume + // semantics. Effort changes made in the picker reach the active session + // through the SaveConfig handler instead. + const permission = corePermissionForLegacyApproval(legacyApproval); + if (status.permission !== permission) { + await session.setPermission(permission); + } +} + +function normalizeEffort(effort: string): ThinkingEffort { + return (effort.trim() || "off") as ThinkingEffort; +} + +function flagsDiffer(a: LegacyApprovalFlags, b: LegacyApprovalFlags): boolean { + return a.yolo !== b.yolo || a.afk !== b.afk; +} + +function assertSessionWorkDir(session: Pick, expectedWorkDir: string): void { + if (!areSameFsPath(session.workDir, expectedWorkDir)) { + throw new Error("The selected session belongs to a different working directory."); + } +} diff --git a/apps/vscode/src/runtime/legacy-approval.ts b/apps/vscode/src/runtime/legacy-approval.ts new file mode 100644 index 0000000000..82bf8f178f --- /dev/null +++ b/apps/vscode/src/runtime/legacy-approval.ts @@ -0,0 +1,71 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import type { JsonObject, PermissionMode } from "@moonshot-ai/kimi-code-sdk"; + +export const LEGACY_APPROVAL_METADATA_KEY = "vscode_legacy_approval"; + +export interface LegacyApprovalFlags { + readonly yolo: boolean; + readonly afk: boolean; +} + +export function readLegacyApprovalFlags( + metadata: Readonly> | undefined, +): LegacyApprovalFlags | undefined { + const value = metadata?.[LEGACY_APPROVAL_METADATA_KEY]; + return parseLegacyApprovalFlags(value); +} + +export async function readMigratedLegacyApprovalFlags( + metadata: Readonly> | undefined, +): Promise { + const sourcePath = metadata?.["kimi_cli_source_path"]; + if (typeof sourcePath !== "string" || sourcePath.length === 0) return undefined; + let text: string; + try { + text = await readFile(join(sourcePath, "state.json"), "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + const state = JSON.parse(text) as { readonly approval?: unknown }; + return parseLegacyApprovalFlags(state.approval); +} + +function parseLegacyApprovalFlags(value: unknown): LegacyApprovalFlags | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; + const yolo = Reflect.get(value, "yolo"); + const afk = Reflect.get(value, "afk"); + if (typeof yolo !== "boolean" && typeof afk !== "boolean") return undefined; + return { + yolo: typeof yolo === "boolean" ? yolo : false, + afk: typeof afk === "boolean" ? afk : false, + }; +} + +export function legacyApprovalMetadata(flags: LegacyApprovalFlags): JsonObject { + return { + [LEGACY_APPROVAL_METADATA_KEY]: { + yolo: flags.yolo, + afk: flags.afk, + }, + }; +} + +export function corePermissionForLegacyApproval(flags: LegacyApprovalFlags): PermissionMode { + if (flags.afk) return "auto"; + return flags.yolo ? "yolo" : "manual"; +} + +/** + * The global `kimi.yoloMode` setting is authoritative whenever a session + * attaches to the runtime; afk stays per-session because it has no global + * setting counterpart. + */ +export function withGlobalYoloMode( + flags: LegacyApprovalFlags, + yoloMode: boolean, +): LegacyApprovalFlags { + return flags.yolo === yoloMode ? flags : { yolo: yoloMode, afk: flags.afk }; +} diff --git a/apps/vscode/src/runtime/replay-adapter.ts b/apps/vscode/src/runtime/replay-adapter.ts new file mode 100644 index 0000000000..facd978ac6 --- /dev/null +++ b/apps/vscode/src/runtime/replay-adapter.ts @@ -0,0 +1,594 @@ +import type { + AgentReplayRecord, + ContentPart, + PromptOrigin, + ResumedAgentState, + ResumedSessionState, +} from "@moonshot-ai/kimi-code-sdk"; + +import type { + ContentPart as LegacyContentPart, + DisplayBlock, + LegacyWireEvent, + RunResult, + TokenUsage, + ToolCall, +} from "../../shared/legacy-sdk"; +import type { UIStreamEvent } from "../../shared/types"; +import { toLegacyToolName } from "./event-adapter"; +import { toLegacyDisplay } from "./tool-display"; + +interface SubagentReplayInvocation { + readonly parentAgentId: string; + readonly parentToolCallId: string; + readonly childAgentId: string; + readonly startedAt: number; + readonly order: number; + records: readonly AgentReplayRecord[]; +} + +interface SubagentReplayIndex { + readonly byParentCall: ReadonlyMap; +} + +/** Projects a complete resumed SDK session, including persisted subagent steps. */ +export function replaySessionToWebviewEvents( + state: ResumedSessionState, + sessionId: string, +): UIStreamEvent[] { + const main = state.agents["main"]; + if (main === undefined) throw new Error("Session history is unavailable."); + return replayAgentToWebviewEvents(main, sessionId, buildSubagentReplayIndex(state)); +} + +/** Projects the public SDK resume replay into the released Webview protocol. */ +export function replayToWebviewEvents( + agent: ResumedAgentState, + sessionId: string, +): UIStreamEvent[] { + return replayAgentToWebviewEvents(agent, sessionId); +} + +function replayAgentToWebviewEvents( + agent: ResumedAgentState, + sessionId: string, + subagents?: SubagentReplayIndex, +): UIStreamEvent[] { + const events: UIStreamEvent[] = []; + let turnOpen = false; + let step = 0; + const toolDisplays = new Map(); + + events.push( + withSession( + { + type: "StatusUpdate", + payload: { + ...(agent.config.modelAlias === undefined ? {} : { model: agent.config.modelAlias }), + thinking_effort: agent.config.thinkingEffort, + plan_mode: agent.plan !== null, + }, + }, + sessionId, + ), + ); + + const completeTurn = () => { + if (!turnOpen) return; + const result: RunResult = { status: "finished" }; + events.push({ type: "stream_complete", result, _sessionId: sessionId }); + turnOpen = false; + }; + + const ensureStep = () => { + if (!turnOpen) return; + if (step === 0) { + step = 1; + events.push(withSession({ type: "StepBegin", payload: { n: step } }, sessionId)); + } + }; + + for (const record of agent.replay) { + switch (record.type) { + case "message": { + const message = record.message; + if (message.role === "user") { + if (!isVisibleUserMessage(message.origin)) break; + const imported = importedContextReplay(message.content); + completeTurn(); + step = 0; + turnOpen = true; + events.push( + withSession( + { + type: "TurnBegin", + payload: { + user_input: imported?.input ?? replayUserInput(message.content, message.origin), + }, + }, + sessionId, + ), + ); + if (imported !== undefined) { + ensureStep(); + events.push( + withSession( + { type: "ContentPart", payload: { type: "text", text: imported.confirmation } }, + sessionId, + ), + ); + } + break; + } + + if (message.role === "assistant") { + if (!turnOpen) break; + ensureStep(); + for (const part of toLegacyContent(message.content)) { + events.push(withSession({ type: "ContentPart", payload: part }, sessionId)); + } + for (const call of message.toolCalls) { + const display = message.toolCallDisplays?.[call.id]; + if (display !== undefined) { + toolDisplays.set(call.id, toLegacyDisplay(display)); + } + const toolCall: ToolCall = { + type: "function", + id: call.id, + function: { + name: toLegacyToolName(call.name), + arguments: call.arguments, + }, + }; + events.push(withSession({ type: "ToolCall", payload: toolCall }, sessionId)); + if (subagents !== undefined) { + for (const nested of renderSubagentInvocations( + subagents, + "main", + call.id, + [], + new Set(), + )) { + events.push(withSession(nested, sessionId)); + } + } + } + break; + } + + if (message.role === "tool" && turnOpen && message.toolCallId !== undefined) { + ensureStep(); + const display = toolDisplays.get(message.toolCallId) ?? []; + toolDisplays.delete(message.toolCallId); + events.push( + withSession( + { + type: "ToolResult", + payload: { + tool_call_id: message.toolCallId, + return_value: { + is_error: message.isError === true, + output: toLegacyContent(message.content), + message: "", + display: [...display], + }, + }, + }, + sessionId, + ), + ); + } + break; + } + case "compaction": + if (!turnOpen) break; + ensureStep(); + events.push(withSession({ type: "CompactionBegin", payload: {} }, sessionId)); + if (record.result !== undefined) { + events.push(withSession({ type: "CompactionEnd", payload: {} }, sessionId)); + } + break; + case "plan_updated": + if (turnOpen) { + ensureStep(); + events.push( + withSession( + { type: "StatusUpdate", payload: { plan_mode: record.enabled } }, + sessionId, + ), + ); + } + break; + case "config_updated": + case "permission_updated": + case "approval_result": + case "goal_updated": + break; + } + } + + completeTurn(); + const resumedStatus = resumedStatusPayload(agent); + if (Object.keys(resumedStatus).length > 0) { + events.push(withSession({ type: "StatusUpdate", payload: resumedStatus }, sessionId)); + } + return events; +} + +function buildSubagentReplayIndex(state: ResumedSessionState): SubagentReplayIndex { + const invocations: SubagentReplayInvocation[] = []; + let order = 0; + + for (const [parentAgentId, parent] of Object.entries(state.agents)) { + const calls = new Map< + string, + { readonly name: string; readonly startedAt: number; readonly order: number } + >(); + for (const record of parent.replay) { + if (record.type !== "message") continue; + const { message } = record; + if (message.role === "assistant") { + for (const call of message.toolCalls) { + calls.set(call.id, { name: call.name, startedAt: record.time, order: order++ }); + } + continue; + } + if (message.role !== "tool" || message.toolCallId === undefined) continue; + const call = calls.get(message.toolCallId); + if (call === undefined || (call.name !== "Agent" && call.name !== "AgentSwarm")) continue; + for (const childAgentId of subagentIdsFromResult(call.name, message.content)) { + const metadata = state.sessionMetadata.agents[childAgentId]; + if (metadata?.parentAgentId !== parentAgentId || state.agents[childAgentId] === undefined) { + continue; + } + invocations.push({ + parentAgentId, + parentToolCallId: message.toolCallId, + childAgentId, + startedAt: call.startedAt, + order: call.order, + records: [], + }); + } + } + } + + const byChild = new Map(); + for (const invocation of invocations) { + const entries = byChild.get(invocation.childAgentId) ?? []; + entries.push(invocation); + byChild.set(invocation.childAgentId, entries); + } + for (const [childAgentId, entries] of byChild) { + entries.sort(compareInvocation); + const replay = state.agents[childAgentId]?.replay ?? []; + for (const [index, invocation] of entries.entries()) { + const next = entries[index + 1]; + invocation.records = replay.filter( + (record) => + record.time >= invocation.startedAt && + (next === undefined || record.time < next.startedAt), + ); + } + } + + const byParentCall = new Map(); + for (const invocation of invocations) { + const key = parentCallKey(invocation.parentAgentId, invocation.parentToolCallId); + const entries = byParentCall.get(key) ?? []; + entries.push(invocation); + byParentCall.set(key, entries); + } + for (const entries of byParentCall.values()) entries.sort(compareInvocation); + return { byParentCall }; +} + +function compareInvocation(a: SubagentReplayInvocation, b: SubagentReplayInvocation): number { + return a.startedAt - b.startedAt || a.order - b.order; +} + +function subagentIdsFromResult( + toolName: "Agent" | "AgentSwarm", + content: readonly ContentPart[], +): readonly string[] { + const text = content + .filter((part): part is Extract => part.type === "text") + .map((part) => part.text) + .join(""); + if (toolName === "Agent") { + const header = text.split("\n\n", 1)[0] ?? text; + const match = /(?:^|\n)agent_id:\s*([^\s]+)\s*(?=\n|$)/.exec(header); + return match === null ? [] : [match[1]!]; + } + const pattern = /]*\bagent_id="([^"]+)"[^>]*\boutcome="[^"]+">/g; + return [...text.matchAll(pattern)].map((match) => match[1]!).filter(uniqueString); +} + +function uniqueString(value: string, index: number, values: readonly string[]): boolean { + return values.indexOf(value) === index; +} + +function renderSubagentInvocations( + index: SubagentReplayIndex, + parentAgentId: string, + parentToolCallId: string, + parentChain: readonly SubagentReplayInvocation[], + visited: ReadonlySet, +): LegacyWireEvent[] { + const result: LegacyWireEvent[] = []; + const invocations = index.byParentCall.get(parentCallKey(parentAgentId, parentToolCallId)) ?? []; + for (const invocation of invocations) { + const invocationKey = `${invocation.parentAgentId}\u0000${invocation.parentToolCallId}\u0000${invocation.childAgentId}\u0000${String(invocation.startedAt)}`; + if (visited.has(invocationKey)) continue; + const nextVisited = new Set([...visited, invocationKey]); + result.push( + ...renderSubagentInvocation(index, invocation, [invocation, ...parentChain], nextVisited), + ); + } + return result; +} + +function renderSubagentInvocation( + index: SubagentReplayIndex, + invocation: SubagentReplayInvocation, + chain: readonly SubagentReplayInvocation[], + visited: ReadonlySet, +): LegacyWireEvent[] { + const events: LegacyWireEvent[] = []; + const toolDisplays = new Map(); + let step = 0; + + const emit = (event: LegacyWireEvent) => { + events.push(wrapSubagentEvent(event, chain)); + }; + + for (const record of invocation.records) { + switch (record.type) { + case "message": { + const { message } = record; + if (message.role === "user") { + step = 0; + break; + } + if (message.role === "assistant") { + step += 1; + emit({ type: "StepBegin", payload: { n: step } }); + for (const part of toLegacyContent(message.content)) { + emit({ type: "ContentPart", payload: part }); + } + for (const call of message.toolCalls) { + const toolCallId = scopedReplayToolCallId(invocation.childAgentId, call.id); + const display = message.toolCallDisplays?.[call.id]; + if (display !== undefined) toolDisplays.set(toolCallId, toLegacyDisplay(display)); + emit({ + type: "ToolCall", + payload: { + type: "function", + id: toolCallId, + function: { + name: toLegacyToolName(call.name), + arguments: call.arguments, + }, + }, + }); + events.push( + ...renderSubagentInvocations( + index, + invocation.childAgentId, + call.id, + chain, + visited, + ), + ); + } + break; + } + if (message.role === "tool" && message.toolCallId !== undefined) { + const toolCallId = scopedReplayToolCallId( + invocation.childAgentId, + message.toolCallId, + ); + const display = toolDisplays.get(toolCallId) ?? []; + toolDisplays.delete(toolCallId); + emit({ + type: "ToolResult", + payload: { + tool_call_id: toolCallId, + return_value: { + is_error: message.isError === true, + output: toLegacyContent(message.content), + message: "", + display: [...display], + }, + }, + }); + } + break; + } + case "compaction": + emit({ type: "CompactionBegin", payload: {} }); + if (record.result !== undefined) emit({ type: "CompactionEnd", payload: {} }); + break; + case "plan_updated": + emit({ type: "StatusUpdate", payload: { plan_mode: record.enabled } }); + break; + case "config_updated": + case "permission_updated": + case "approval_result": + case "goal_updated": + break; + } + } + return events; +} + +function wrapSubagentEvent( + event: LegacyWireEvent, + chain: readonly SubagentReplayInvocation[], +): LegacyWireEvent { + let routed = event; + for (const invocation of chain) { + routed = { + type: "SubagentEvent", + payload: { + parent_tool_call_id: scopedReplayToolCallId( + invocation.parentAgentId, + invocation.parentToolCallId, + ), + event: routed, + }, + }; + } + return routed; +} + +function scopedReplayToolCallId(agentId: string, toolCallId: string): string { + return agentId === "main" ? toolCallId : `${agentId}:${toolCallId}`; +} + +function parentCallKey(agentId: string, toolCallId: string): string { + return `${agentId}\u0000${toolCallId}`; +} + +function resumedStatusPayload(agent: ResumedAgentState): { + context_usage?: number; + token_usage?: TokenUsage; +} { + const maxContextTokens = agent.config.modelCapabilities.max_context_tokens; + const totalUsage = agent.usage.total ?? sumUsage(agent.usage.byModel) ?? agent.usage.currentTurn; + return { + ...(maxContextTokens > 0 + ? { context_usage: agent.context.tokenCount / maxContextTokens } + : {}), + ...(totalUsage === undefined + ? {} + : { + token_usage: { + input_other: totalUsage.inputOther, + output: totalUsage.output, + input_cache_read: totalUsage.inputCacheRead, + input_cache_creation: totalUsage.inputCacheCreation, + }, + }), + }; +} + +function sumUsage( + byModel: ResumedAgentState["usage"]["byModel"], +): ResumedAgentState["usage"]["total"] { + if (byModel === undefined || Object.keys(byModel).length === 0) return undefined; + return Object.values(byModel).reduce( + (total, usage) => ({ + inputOther: total.inputOther + usage.inputOther, + output: total.output + usage.output, + inputCacheRead: total.inputCacheRead + usage.inputCacheRead, + inputCacheCreation: total.inputCacheCreation + usage.inputCacheCreation, + }), + { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }, + ); +} + +function toLegacyContent(content: readonly ContentPart[]): LegacyContentPart[] { + const result: LegacyContentPart[] = []; + for (const part of content) { + switch (part.type) { + case "text": + result.push({ type: "text", text: part.text }); + break; + case "think": + result.push({ + type: "think", + think: part.think, + ...(part.encrypted === undefined ? {} : { encrypted: part.encrypted }), + }); + break; + case "image_url": + result.push({ type: "image_url", image_url: { ...part.imageUrl } }); + break; + case "audio_url": + result.push({ type: "audio_url", audio_url: { ...part.audioUrl } }); + break; + case "video_url": + result.push({ type: "video_url", video_url: { ...part.videoUrl } }); + break; + } + } + return result; +} + +function isVisibleUserMessage(origin: PromptOrigin | undefined): boolean { + if (origin === undefined || origin.kind === "user") return true; + if (origin.kind === "skill_activation" || origin.kind === "plugin_command") { + return origin.trigger === "user-slash"; + } + return origin.kind === "shell_command" && origin.phase === "input"; +} + +function replayUserInput( + content: readonly ContentPart[], + origin: PromptOrigin | undefined, +): LegacyContentPart[] { + if (origin?.kind === "skill_activation" && origin.trigger === "user-slash") { + const args = origin.skillArgs?.trim(); + return [{ + type: "text", + text: `/skill:${origin.skillName}${args ? ` ${args}` : ""}`, + }]; + } + if (origin?.kind === "plugin_command") { + const args = origin.commandArgs?.trim(); + return [{ + type: "text", + text: `/${origin.pluginId}:${origin.commandName}${args ? ` ${args}` : ""}`, + }]; + } + return toLegacyContent(content); +} + +function importedContextReplay( + content: readonly ContentPart[], +): { input: LegacyContentPart[]; confirmation: string } | undefined { + const importedPart = content.find( + (part): part is Extract => + part.type === "text" && part.text.startsWith("\n([\s\S]*)\n<\/imported_context>$/.exec( + importedPart.text, + ); + if (match === null) return undefined; + const source = decodeXml(match[1]!); + const importedText = match[2]!; + const target = importTarget(source); + return { + input: [{ type: "text", text: `/import ${target}` }], + confirmation: `Imported context from ${source} (${String(importedText.length)} chars).`, + }; +} + +function importTarget(source: string): string { + const quoted = /^(?:file|session) '([\s\S]*)'$/.exec(source); + return quoted?.[1] ?? source; +} + +function decodeXml(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function withSession(event: T, sessionId: string): T & { _sessionId: string } { + return { ...event, _sessionId: sessionId }; +} + +export function replayRecordTurnCount(records: readonly AgentReplayRecord[]): number { + return records.filter( + (record) => + record.type === "message" && + record.message.role === "user" && + isVisibleUserMessage(record.message.origin), + ).length; +} diff --git a/apps/vscode/src/runtime/reverse-rpc.ts b/apps/vscode/src/runtime/reverse-rpc.ts new file mode 100644 index 0000000000..13a66f049c --- /dev/null +++ b/apps/vscode/src/runtime/reverse-rpc.ts @@ -0,0 +1,97 @@ +import { randomUUID } from "node:crypto"; + +import type { + ApprovalRequest, + ApprovalResponse as CoreApprovalResponse, + QuestionRequest, + QuestionResult, +} from "@moonshot-ai/kimi-code-sdk"; + +import type { ApprovalResponse, QuestionRequest as LegacyQuestionRequest } from "../../shared/legacy-sdk"; +import { describeToolDisplay, toLegacyDisplay } from "./tool-display"; + +export type ReverseRpcEvent = + | { type: "ApprovalRequest"; payload: ReturnType } + | { type: "QuestionRequest"; payload: LegacyQuestionRequest }; + +export class ReverseRpcController { + private readonly approvals = new Map void>(); + private readonly questions = new Map void>(); + + constructor(private readonly emit: (event: ReverseRpcEvent) => void) {} + + requestApproval(request: ApprovalRequest): Promise { + const id = randomUUID(); + return new Promise((resolve) => { + this.approvals.set(id, resolve); + this.emit({ type: "ApprovalRequest", payload: approvalPayload(id, request) }); + }); + } + + requestQuestion(request: QuestionRequest): Promise { + const id = randomUUID(); + return new Promise((resolve) => { + this.questions.set(id, resolve); + this.emit({ + type: "QuestionRequest", + payload: { + id, + tool_call_id: request.toolCallId ?? "", + questions: request.questions.map((question) => ({ + question: question.question, + header: question.header, + options: question.options.map((option) => ({ + label: option.label, + description: option.description, + })), + multi_select: question.multiSelect, + })), + }, + }); + }); + } + + respondApproval(id: string, response: ApprovalResponse): boolean { + const resolve = this.approvals.get(id); + if (!resolve) return false; + this.approvals.delete(id); + if (response === "approve_for_session") { + resolve({ decision: "approved", scope: "session" }); + } else if (response === "approve") { + resolve({ decision: "approved" }); + } else { + resolve({ decision: "rejected" }); + } + return true; + } + + respondQuestion(id: string, answers: Record): boolean { + const resolve = this.questions.get(id); + if (!resolve) return false; + this.questions.delete(id); + resolve({ answers }); + return true; + } + + cancelAll(reason: string): void { + for (const resolve of this.approvals.values()) { + resolve({ decision: "cancelled", feedback: reason }); + } + for (const resolve of this.questions.values()) { + resolve(null); + } + this.approvals.clear(); + this.questions.clear(); + } +} + +function approvalPayload(id: string, request: ApprovalRequest) { + return { + id, + tool_call_id: request.toolCallId, + sender: request.toolName, + action: request.action, + description: describeToolDisplay(request.display), + display: toLegacyDisplay(request.display), + }; +} diff --git a/apps/vscode/src/runtime/session-runtime.ts b/apps/vscode/src/runtime/session-runtime.ts new file mode 100644 index 0000000000..c1009930ff --- /dev/null +++ b/apps/vscode/src/runtime/session-runtime.ts @@ -0,0 +1,628 @@ +import { + isKimiError, + type ContentPart as SdkContentPart, + type Event, + type PromptInput, + type Session, + type SessionSummary, +} from "@moonshot-ai/kimi-code-sdk"; + +import type { ContentPart as LegacyContentPart, ApprovalResponse } from "../../shared/legacy-sdk"; +import { Events } from "../../shared/bridge"; +import { getUserMessage } from "../../shared/errors"; +import type { ErrorPhase, UIStreamEvent } from "../../shared/types"; +import { + adaptSdkEvent, + createEventAdapterState, + type EventAdapterState, + type TurnTerminalMetadata, +} from "./event-adapter"; +import { + corePermissionForLegacyApproval, + legacyApprovalMetadata, + type LegacyApprovalFlags, +} from "./legacy-approval"; +import { ReverseRpcController } from "./reverse-rpc"; + +export type RuntimeBroadcast = (event: string, data: unknown, webviewId?: string) => void; + +export interface SessionRuntimeOptions { + readonly session: Session; + readonly legacyApproval: LegacyApprovalFlags; + readonly broadcast: RuntimeBroadcast; + readonly captureBaseline: ( + session: Pick, + filePath: string, + webviewIds: readonly string[], + ) => void; + readonly log: (message: string, error?: unknown) => void; +} + +interface ActivePrompt { + readonly input: LegacyContentPart[] | string; + started: boolean; + settled: boolean; + resolve: (result: PromptResult) => void; +} + +export interface PromptResult { + readonly status: "finished" | "cancelled" | "failed"; +} + +interface SuppressedError { + readonly code: string; + readonly message: string; +} + +interface PendingHostCompaction { + readonly actionId: number; + readonly resolve: (result: "completed" | "cancelled") => void; + readonly reject: (error: unknown) => void; +} + +/** + * Owns the one SDK event subscription and reverse-RPC handlers for a session. + * Any number of Webviews may subscribe without replacing each other's approval + * handler or duplicating streamed events. + */ +export class SessionRuntime { + readonly session: Session; + + private readonly broadcast: RuntimeBroadcast; + private readonly captureBaseline: SessionRuntimeOptions["captureBaseline"]; + private readonly log: SessionRuntimeOptions["log"]; + private readonly webviewIds = new Set(); + private readonly reverseRpc: ReverseRpcController; + private readonly unsubscribe: () => void; + private adapterState: EventAdapterState = createEventAdapterState(); + private activePrompt: ActivePrompt | undefined; + private hostActionActive = false; + private hostActionSequence = 0; + private activeHostActionId: number | undefined; + private readonly cancelledHostActions = new Set(); + private pendingHostCompaction: PendingHostCompaction | undefined; + private readonly activeWorkSettledWaiters = new Set<() => void>(); + private exclusiveActionActive = false; + private readonly terminalKeys = new Set(); + private suppressedError: SuppressedError | undefined; + private legacyApproval: LegacyApprovalFlags; + private closed = false; + + constructor(options: SessionRuntimeOptions) { + this.session = options.session; + this.broadcast = options.broadcast; + this.captureBaseline = options.captureBaseline; + this.log = options.log; + this.legacyApproval = options.legacyApproval; + this.reverseRpc = new ReverseRpcController((event) => this.emitStreamEvent(event)); + + this.session.setApprovalHandler((request) => + this.legacyApproval.yolo || this.legacyApproval.afk + ? Promise.resolve({ decision: "approved" }) + : this.reverseRpc.requestApproval(request), + ); + this.session.setQuestionHandler((request) => this.reverseRpc.requestQuestion(request)); + this.unsubscribe = this.session.onEvent((event) => this.onSdkEvent(event)); + } + + get id(): string { + return this.session.id; + } + + get summary(): SessionSummary | undefined { + return this.session.summary; + } + + get subscribers(): readonly string[] { + return [...this.webviewIds]; + } + + get isBusy(): boolean { + return this.hasActiveWork || this.exclusiveActionActive; + } + + get legacyApprovalFlags(): LegacyApprovalFlags { + return this.legacyApproval; + } + + async toggleLegacyApproval(kind: keyof LegacyApprovalFlags): Promise { + const next = { ...this.legacyApproval, [kind]: !this.legacyApproval[kind] }; + await this.applyLegacyApproval(next); + return next; + } + + async setLegacyYoloMode(enabled: boolean): Promise { + if (this.legacyApproval.yolo === enabled) return; + await this.applyLegacyApproval({ ...this.legacyApproval, yolo: enabled }); + } + + subscribe(webviewId: string): void { + this.ensureOpen(); + this.webviewIds.add(webviewId); + } + + /** + * Push the session's current status to a view. Called whenever a view + * opens or re-enters a session so the display (model, thinking effort, + * plan mode) matches engine truth instead of the global defaults. + */ + async announceStatus(webviewId: string): Promise { + this.ensureOpen(); + const status = await this.session.getStatus(); + if (this.closed || !this.webviewIds.has(webviewId)) return; + this.broadcast( + Events.StreamEvent, + { + type: "StatusUpdate", + payload: { + model: status.model, + thinking_effort: status.thinkingEffort, + plan_mode: status.planMode, + }, + _sessionId: this.id, + }, + webviewId, + ); + } + + unsubscribeView(webviewId: string): void { + this.webviewIds.delete(webviewId); + } + + async prompt(input: string | LegacyContentPart[]): Promise { + return this.runTurnAction(input, () => this.session.prompt(toSdkPromptInput(input))); + } + + async runTurnAction( + input: string | LegacyContentPart[], + action: () => Promise, + ): Promise { + this.ensureOpen(); + if (this.isBusy) { + throw new Error("A response is already being generated for this session."); + } + + let resolveCompletion!: (result: PromptResult) => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + }); + this.activePrompt = { + input, + started: false, + settled: false, + resolve: resolveCompletion, + }; + + try { + await action(); + } catch (error) { + if (this.activePrompt !== undefined && !this.activePrompt.started) { + this.emitError(error, "preflight"); + this.settlePrompt({ status: "failed" }); + } else { + this.emitError(error, "runtime"); + this.settlePrompt({ status: "failed" }); + } + } + + return completion; + } + + beginHostAction(input: string | LegacyContentPart[], forkable = false): number { + this.ensureOpen(); + if (this.isBusy) { + throw new Error("A response is already being generated for this session."); + } + const actionId = ++this.hostActionSequence; + this.hostActionActive = true; + this.activeHostActionId = actionId; + this.emitStreamEvent({ + type: "TurnBegin", + payload: { user_input: input, forkable }, + _sessionId: this.id, + }); + this.emitStreamEvent({ + type: "StepBegin", + payload: { n: 1 }, + _sessionId: this.id, + }); + return actionId; + } + + emitHostText(text: string, actionId = this.activeHostActionId): void { + if (!this.hostActionActive || actionId !== this.activeHostActionId || text.length === 0) return; + this.emitStreamEvent({ + type: "ContentPart", + payload: { type: "text", text }, + _sessionId: this.id, + }); + } + + announceSessionStart(model?: string): void { + this.emitStreamEvent({ + type: "session_start", + sessionId: this.id, + ...(model === undefined ? {} : { model }), + _sessionId: this.id, + }); + } + + completeHostAction( + status: "finished" | "cancelled" = "finished", + actionId = this.activeHostActionId, + ): void { + if (!this.hostActionActive || actionId !== this.activeHostActionId) return; + this.hostActionActive = false; + this.activeHostActionId = undefined; + this.emitStreamEvent({ + type: "stream_complete", + result: { status }, + _sessionId: this.id, + }); + this.notifyActiveWorkSettled(); + } + + failHostAction(actionId: number): void { + if (!this.hostActionActive || actionId !== this.activeHostActionId) return; + this.hostActionActive = false; + this.activeHostActionId = undefined; + this.notifyActiveWorkSettled(); + } + + wasHostActionCancelled(actionId: number): boolean { + return this.cancelledHostActions.has(actionId); + } + + releaseHostAction(actionId: number): void { + this.cancelledHostActions.delete(actionId); + } + + async compactHostAction(actionId: number, instruction?: string): Promise { + if (!this.hostActionActive || actionId !== this.activeHostActionId) { + throw new Error("The host action is no longer active."); + } + if (this.pendingHostCompaction !== undefined) { + throw new Error("A context compaction is already running."); + } + + let resolveCompletion!: (result: "completed" | "cancelled") => void; + let rejectCompletion!: (error: unknown) => void; + const completion = new Promise<"completed" | "cancelled">((resolve, reject) => { + resolveCompletion = resolve; + rejectCompletion = reject; + }); + this.pendingHostCompaction = { + actionId, + resolve: resolveCompletion, + reject: rejectCompletion, + }; + + try { + await this.session.compact(instruction === undefined ? {} : { instruction }); + } catch (error) { + if (this.pendingHostCompaction?.actionId === actionId) { + this.pendingHostCompaction = undefined; + rejectCompletion(error); + } + } + + const result = await completion; + if (result === "cancelled") { + throw new Error("Context compaction was cancelled."); + } + } + + async cancel(): Promise { + if (this.closed || !this.hasActiveWork) return; + this.reverseRpc.cancelAll("Turn cancelled"); + const cancellingHostAction = this.hostActionActive; + const hostActionId = this.activeHostActionId; + if (cancellingHostAction && hostActionId !== undefined) { + this.cancelledHostActions.add(hostActionId); + this.completeHostAction("cancelled", hostActionId); + } + // A manual compaction is not a model turn, so Session.cancel() alone does + // not stop it. Calling both public cancellation surfaces is harmless when + // the other operation is idle and keeps the Stop button correct for both + // normal turns and host-side slash commands such as /compact and /init. + const results = await Promise.allSettled([ + this.session.cancel(), + ...(cancellingHostAction ? [this.session.cancelCompaction()] : []), + ]); + const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failure !== undefined) throw failure.reason; + } + + /** + * Stop any in-flight work, wait for its terminal event, and keep new turns + * out until the supplied operation finishes. Forking uses this so it reads a + * fully settled session instead of racing the asynchronous cancel event. + */ + async runExclusiveAfterCancelling(action: () => Promise): Promise { + this.ensureOpen(); + if (this.exclusiveActionActive) { + throw new Error("Another session operation is already in progress."); + } + + this.exclusiveActionActive = true; + try { + const settled = this.waitForActiveWorkToSettle(); + await this.cancel(); + await settled; + this.ensureOpen(); + return await action(); + } finally { + this.exclusiveActionActive = false; + } + } + + async steer(input: string | LegacyContentPart[]): Promise { + this.ensureOpen(); + await this.session.steer(toSdkPromptInput(input)); + this.emitStreamEvent({ + type: "SteerInput", + payload: { user_input: input }, + _sessionId: this.id, + }); + } + + respondApproval(id: string, response: ApprovalResponse): boolean { + return this.reverseRpc.respondApproval(id, response); + } + + respondQuestion(id: string, answers: Record): boolean { + return this.reverseRpc.respondQuestion(id, answers); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.pendingHostCompaction?.reject(new Error("Session closed during context compaction.")); + this.pendingHostCompaction = undefined; + this.reverseRpc.cancelAll("Session closed"); + this.unsubscribe(); + this.session.setApprovalHandler(undefined); + this.session.setQuestionHandler(undefined); + if (this.activePrompt !== undefined || this.hostActionActive) { + try { + await this.session.cancel(); + } catch (error) { + this.log("Failed to cancel the active turn while closing a session", error); + } + if (this.activePrompt !== undefined) this.settlePrompt({ status: "cancelled" }); + this.hostActionActive = false; + this.activeHostActionId = undefined; + this.notifyActiveWorkSettled(); + } + this.cancelledHostActions.clear(); + await this.session.close(); + this.webviewIds.clear(); + } + + private async applyLegacyApproval(flags: LegacyApprovalFlags): Promise { + this.ensureOpen(); + const permission = corePermissionForLegacyApproval(flags); + const status = await this.session.getStatus(); + const permissionChanged = status.permission !== permission; + if (permissionChanged) await this.session.setPermission(permission); + try { + await this.session.updateMetadata(legacyApprovalMetadata(flags)); + } catch (error) { + if (permissionChanged) { + await this.session.setPermission(status.permission).catch((rollbackError: unknown) => { + this.log("Failed to restore session permission after a metadata error", rollbackError); + }); + } + throw error; + } + this.legacyApproval = flags; + } + + private onSdkEvent(event: Event): void { + if (this.closed) return; + + if (event.type === "compaction.completed" || event.type === "compaction.cancelled") { + const pending = this.pendingHostCompaction; + if (pending !== undefined) { + this.pendingHostCompaction = undefined; + pending.resolve(event.type === "compaction.completed" ? "completed" : "cancelled"); + } + } + + if (event.type === "turn.started" && event.agentId === "main" && this.activePrompt !== undefined) { + this.activePrompt.started = true; + } + + if (event.type === "tool.call.started") { + this.captureFileBaseline(event); + } + + if (event.type === "turn.step.retrying") { + this.log( + `Provider retry ${event.nextAttempt}/${event.maxAttempts} in ${event.delayMs}ms`, + new Error(event.errorMessage), + ); + } + + if (event.type === "error" && this.consumeSuppressedError(event.code, event.message)) { + return; + } + + const pendingInput = this.activePrompt?.input; + const adapted = adaptSdkEvent(this.adapterState, event, { + pendingInput, + errorPhase: this.activePrompt?.started === false ? "preflight" : "runtime", + }); + this.adapterState = adapted.state; + + if (adapted.terminal !== undefined) { + this.emitTerminal(adapted.terminal); + return; + } + + if (adapted.event !== undefined) { + this.emitStreamEvent(adapted.event); + if (adapted.event.type === "error" && this.activePrompt !== undefined && !this.activePrompt.started) { + this.settlePrompt({ status: "failed" }); + } + } + } + + private captureFileBaseline(event: Extract): void { + if (event.name !== "Write" && event.name !== "Edit") return; + if (!isRecord(event.args)) return; + const filePath = event.args["path"]; + if (typeof filePath !== "string" || filePath.length === 0) return; + + const summary = this.session.summary; + this.captureBaseline( + { + id: this.session.id, + workDir: this.session.workDir, + metadata: summary?.metadata, + }, + filePath, + this.subscribers, + ); + } + + private emitTerminal(terminal: TurnTerminalMetadata): void { + if (this.terminalKeys.has(terminal.key)) return; + this.terminalKeys.add(terminal.key); + + if (terminal.reason === "completed") { + this.emitStreamEvent({ + type: "stream_complete", + result: { status: "finished" }, + _sessionId: terminal.sessionId, + }); + this.settlePrompt({ status: "finished" }); + return; + } + + if (terminal.reason === "cancelled") { + this.reverseRpc.cancelAll("Turn cancelled"); + this.emitStreamEvent({ + type: "stream_complete", + result: { status: "cancelled" }, + _sessionId: terminal.sessionId, + }); + this.settlePrompt({ status: "cancelled" }); + return; + } + + const code = terminal.error?.code ?? `turn.${terminal.reason}`; + this.reverseRpc.cancelAll("Turn ended"); + const detail = terminal.error?.message ?? `Turn ended with reason: ${terminal.reason}`; + const message = getUserMessage(code, detail); + this.log("Session turn failed", new Error(`${code}: ${detail}`)); + this.emitStreamEvent({ + type: "error", + code, + message, + detail, + phase: "runtime", + _sessionId: terminal.sessionId, + }); + if (terminal.error !== undefined) { + this.suppressedError = { code: terminal.error.code, message: terminal.error.message }; + } + this.settlePrompt({ status: "failed" }); + } + + private consumeSuppressedError(code: string, message: string): boolean { + const suppressed = this.suppressedError; + if (suppressed === undefined) return false; + this.suppressedError = undefined; + return suppressed.code === code && suppressed.message === message; + } + + private emitError(error: unknown, phase: ErrorPhase): void { + const code = isKimiError(error) ? error.code : "internal"; + const detail = error instanceof Error ? error.message : String(error); + this.log(`Session ${phase} error`, error); + this.emitStreamEvent({ + type: "error", + code, + message: getUserMessage(code, detail), + detail, + phase, + _sessionId: this.session.id, + }); + } + + private emitStreamEvent(event: UIStreamEvent | { type: string; payload: unknown }): void { + for (const webviewId of this.webviewIds) { + this.broadcast(Events.StreamEvent, event, webviewId); + } + } + + private settlePrompt(result: PromptResult): void { + const active = this.activePrompt; + if (active === undefined || active.settled) return; + active.settled = true; + this.activePrompt = undefined; + active.resolve(result); + this.notifyActiveWorkSettled(); + } + + private get hasActiveWork(): boolean { + return this.activePrompt !== undefined || this.hostActionActive; + } + + private waitForActiveWorkToSettle(): Promise { + if (!this.hasActiveWork) return Promise.resolve(); + return new Promise((resolve) => { + this.activeWorkSettledWaiters.add(resolve); + }); + } + + private notifyActiveWorkSettled(): void { + if (this.hasActiveWork) return; + for (const resolve of this.activeWorkSettledWaiters) resolve(); + this.activeWorkSettledWaiters.clear(); + } + + private ensureOpen(): void { + if (this.closed) throw new Error("Session is closed."); + } +} + +export function toSdkPromptInput(input: string | LegacyContentPart[]): string | PromptInput { + if (typeof input === "string") return input; + const parts: SdkContentPart[] = []; + for (const part of input) { + switch (part.type) { + case "text": + parts.push({ type: "text", text: part.text }); + break; + case "image_url": + parts.push({ + type: "image_url", + imageUrl: { + url: part.image_url.url, + ...(part.image_url.id === null || part.image_url.id === undefined ? {} : { id: part.image_url.id }), + }, + }); + break; + case "video_url": + parts.push({ + type: "video_url", + videoUrl: { + url: part.video_url.url, + ...(part.video_url.id === null || part.video_url.id === undefined ? {} : { id: part.video_url.id }), + }, + }); + break; + case "audio_url": + case "think": + // PromptInput intentionally accepts user text/images/videos only. + break; + } + } + return parts as PromptInput; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/vscode/src/runtime/tool-display.ts b/apps/vscode/src/runtime/tool-display.ts new file mode 100644 index 0000000000..fe77509774 --- /dev/null +++ b/apps/vscode/src/runtime/tool-display.ts @@ -0,0 +1,75 @@ +import type { ToolInputDisplay } from "@moonshot-ai/kimi-code-sdk"; + +import type { DisplayBlock } from "../../shared/legacy-sdk"; + +export function describeToolDisplay(display: ToolInputDisplay): string { + switch (display.kind) { + case "command": + return display.command; + case "file_io": + return `${display.operation} ${display.path}`; + case "diff": + return `Edit ${display.path}`; + case "search": + return `Search for ${display.query}`; + case "url_fetch": + return display.url; + case "agent_call": + return display.prompt; + case "skill_call": + return display.args ? `${display.skill_name} ${display.args}` : display.skill_name; + case "todo_list": + return "Update the task list"; + case "task": + return display.description; + case "task_stop": + return display.task_description; + case "plan_review": + return display.plan; + case "goal_start": + return display.objective; + case "generic": + return display.summary; + } +} + +export function toLegacyDisplay(display: ToolInputDisplay): DisplayBlock[] { + switch (display.kind) { + case "command": + return [{ type: "shell", language: display.language ?? "bash", command: display.command }]; + case "diff": + return [{ type: "diff", path: display.path, old_text: display.before, new_text: display.after }]; + case "file_io": + if ( + display.before !== undefined || + display.after !== undefined || + display.content !== undefined + ) { + return [{ + type: "diff", + path: display.path, + old_text: display.before ?? "", + new_text: display.after ?? display.content ?? "", + }]; + } + return [{ type: "brief", text: describeToolDisplay(display) }]; + case "todo_list": + return [{ + type: "todo", + items: display.items.map((item) => ({ + title: item.title, + status: item.status === "done" || item.status === "in_progress" ? item.status : "pending", + })), + }]; + case "search": + case "url_fetch": + case "agent_call": + case "skill_call": + case "task": + case "task_stop": + case "plan_review": + case "goal_start": + case "generic": + return [{ type: "brief", text: describeToolDisplay(display) }]; + } +} diff --git a/apps/vscode/src/utils/context.ts b/apps/vscode/src/utils/context.ts new file mode 100644 index 0000000000..6f10587073 --- /dev/null +++ b/apps/vscode/src/utils/context.ts @@ -0,0 +1,9 @@ +import * as vscode from "vscode"; +import type { KimiHarness } from "@moonshot-ai/kimi-code-sdk"; + +export async function updateLoginContext(harness: KimiHarness): Promise { + const status = await harness.auth.status(); + const loggedIn = status.providers.some((provider) => provider.hasToken); + await vscode.commands.executeCommand("setContext", "kimi.isLoggedIn", loggedIn); + return loggedIn; +} diff --git a/apps/vscode/src/utils/fs-path.ts b/apps/vscode/src/utils/fs-path.ts new file mode 100644 index 0000000000..d82a2dbb89 --- /dev/null +++ b/apps/vscode/src/utils/fs-path.ts @@ -0,0 +1,28 @@ +import * as path from "node:path"; + +/** Return a forward-slash relative path when `candidate` is inside `root`. */ +export function relativeFsPath(root: string, candidate: string): string | undefined { + const paths = isWindowsPath(root) || isWindowsPath(candidate) ? path.win32 : path; + const relativePath = paths.relative(paths.resolve(root), paths.resolve(candidate)); + if ( + relativePath === "" || + (!relativePath.startsWith(`..${paths.sep}`) && relativePath !== ".." && !paths.isAbsolute(relativePath)) + ) { + return relativePath.split(paths.sep).join("/"); + } + return undefined; +} + +/** Whether two native filesystem paths identify the same location. */ +export function areSameFsPath(left: string, right: string): boolean { + return relativeFsPath(left, right) === ""; +} + +/** Whether `candidate` is `root` or a descendant using native path rules. */ +export function isFsPathInsideOrEqual(root: string, candidate: string): boolean { + return relativeFsPath(root, candidate) !== undefined; +} + +function isWindowsPath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || /^[\\/]{2}[^\\/]+[\\/][^\\/]+/.test(value); +} diff --git a/apps/vscode/src/utils/session-context.ts b/apps/vscode/src/utils/session-context.ts new file mode 100644 index 0000000000..e4ff4ce5b6 --- /dev/null +++ b/apps/vscode/src/utils/session-context.ts @@ -0,0 +1,232 @@ +import type { + ContentPart, + ContextMessage, + PromptOrigin, + ToolCall, +} from "@moonshot-ai/kimi-code-sdk"; + +const INTERNAL_ORIGINS = new Set([ + "injection", + "system_trigger", + "compaction_summary", + "hook_result", + "cron_job", + "cron_missed", +]); + +const TOOL_HINT_KEYS = ["path", "file_path", "command", "query", "url", "name", "pattern"]; + +export function buildExportMarkdown(input: { + readonly sessionId: string; + readonly workDir: string; + readonly history: readonly ContextMessage[]; + readonly tokenCount: number; + readonly now: Date; +}): string { + const turns = groupIntoTurns(input.history); + const firstUser = input.history.find( + (message) => message.role === "user" && !isInternalMessage(message), + ); + const topic = firstUser === undefined ? "" : shorten(stringifyParts(firstUser.content), 80); + const toolCalls = input.history.reduce((count, message) => count + message.toolCalls.length, 0); + const lines = [ + "---", + `session_id: ${input.sessionId}`, + `exported_at: ${input.now.toISOString()}`, + `work_dir: ${input.workDir}`, + `message_count: ${String(input.history.length)}`, + `token_count: ${String(input.tokenCount)}`, + "---", + "", + "# Kimi Session Export", + "", + "## Overview", + "", + topic ? `- **Topic**: ${topic}` : "- **Topic**: (empty)", + `- **Conversation**: ${String(turns.length)} turns | ${String(toolCalls)} tool calls`, + "", + "---", + "", + ]; + + for (let index = 0; index < turns.length; index += 1) { + lines.push(formatTurn(turns[index]!, index + 1)); + } + return lines.join("\n"); +} + +export function stringifyContextHistory(history: readonly ContextMessage[]): string { + const messages: string[] = []; + for (const message of history) { + if (isInternalMessage(message)) continue; + const sections: string[] = []; + const content = stringifyParts(message.content); + if (content.trim()) sections.push(content); + if (message.toolCalls.length > 0) { + sections.push(message.toolCalls.map(stringifyToolCall).join("\n")); + } + if (sections.length === 0) continue; + const callId = message.role === "tool" && message.toolCallId + ? ` (call_id: ${message.toolCallId})` + : ""; + messages.push(`[${message.role.toUpperCase()}]${callId}\n${sections.join("\n")}`); + } + return messages.join("\n\n"); +} + +export function isImportableTextFile(fileName: string): boolean { + const dot = fileName.lastIndexOf("."); + if (dot <= 0) return true; + return IMPORTABLE_EXTENSIONS.has(fileName.slice(dot).toLowerCase()); +} + +export function isSensitiveFile(fileName: string): boolean { + const normalized = fileName.toLowerCase(); + return [".env", "credentials", "secrets", ".pem", ".key", ".p12", ".pfx", ".keystore"] + .some((pattern) => normalized.includes(pattern)); +} + +function isInternalMessage(message: ContextMessage): boolean { + return message.origin !== undefined && INTERNAL_ORIGINS.has(message.origin.kind); +} + +function groupIntoTurns(history: readonly ContextMessage[]): ContextMessage[][] { + const turns: ContextMessage[][] = []; + let current: ContextMessage[] = []; + for (const message of history) { + if (isInternalMessage(message)) continue; + if (message.role === "user" && current.length > 0) { + turns.push(current); + current = []; + } + current.push(message); + } + if (current.length > 0) turns.push(current); + return turns; +} + +function formatTurn(messages: readonly ContextMessage[], turnNumber: number): string { + const lines = [`## Turn ${String(turnNumber)}`, ""]; + const toolInfo = new Map(); + let assistantHeading = false; + for (const message of messages) { + if (message.role === "user") { + lines.push("### User", "", stringifyParts(message.content), ""); + continue; + } + if (message.role === "assistant") { + if (!assistantHeading) { + lines.push("### Assistant", ""); + assistantHeading = true; + } + const content = formatPartsMarkdown(message.content); + if (content) lines.push(content, ""); + for (const call of message.toolCalls) { + const hint = toolCallHint(call); + toolInfo.set(call.id, { name: call.name, hint }); + lines.push(formatToolCallMarkdown(call, hint), ""); + } + continue; + } + if (message.role === "tool") { + const info = toolInfo.get(message.toolCallId ?? "") ?? { name: "unknown", hint: "" }; + const hint = info.hint ? ` (\`${info.hint}\`)` : ""; + lines.push( + `
Tool Result: ${info.name}${hint}`, + "", + ``, + formatPartsMarkdown(message.content), + "", + "
", + "", + ); + continue; + } + lines.push(`### ${capitalize(message.role)}`, "", formatPartsMarkdown(message.content), ""); + } + return lines.join("\n"); +} + +function formatToolCallMarkdown(call: ToolCall, hint: string): string { + let args = call.arguments ?? "{}"; + try { + args = JSON.stringify(JSON.parse(args), null, 2); + } catch { + // Preserve malformed arguments exactly as recorded. + } + const suffix = hint ? ` (\`${hint}\`)` : ""; + return `#### Tool Call: ${call.name}${suffix}\n\n\`\`\`json\n${args}\n\`\`\``; +} + +function toolCallHint(call: ToolCall): string { + let args: unknown; + try { + args = JSON.parse(call.arguments ?? "{}"); + } catch { + return ""; + } + if (!isRecord(args)) return ""; + for (const key of TOOL_HINT_KEYS) { + const value = args[key]; + if (typeof value === "string" && value.trim()) return shorten(value, 60); + } + return ""; +} + +function stringifyToolCall(call: ToolCall): string { + let args = call.arguments ?? "{}"; + try { + args = JSON.stringify(JSON.parse(args)); + } catch { + // Preserve malformed arguments exactly as recorded. + } + return `Tool Call: ${call.name}(${args})`; +} + +function formatPartsMarkdown(parts: readonly ContentPart[]): string { + return parts.map(formatPartMarkdown).filter(Boolean).join("\n"); +} + +function formatPartMarkdown(part: ContentPart): string { + switch (part.type) { + case "text": return part.text; + case "think": return part.think.trim() + ? `
Thinking\n\n${part.think}\n\n
` + : ""; + case "image_url": return "[image]"; + case "audio_url": return "[audio]"; + case "video_url": return "[video]"; + } +} + +function stringifyParts(parts: readonly ContentPart[]): string { + return parts.map((part) => { + if (part.type === "text") return part.text; + if (part.type === "think") return part.think.trim() ? `\n${part.think}\n` : ""; + if (part.type === "image_url") return "[image]"; + if (part.type === "audio_url") return "[audio]"; + return "[video]"; + }).filter(Boolean).join("\n"); +} + +function capitalize(value: string): string { + return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1); +} + +function shorten(value: string, width: number): string { + return value.length <= width ? value : `${value.slice(0, width)}…`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const IMPORTABLE_EXTENSIONS = new Set([ + ".md", ".markdown", ".txt", ".text", ".rst", ".json", ".jsonl", ".yaml", ".yml", + ".toml", ".ini", ".cfg", ".conf", ".csv", ".tsv", ".xml", ".env", ".properties", + ".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".kt", ".go", ".rs", ".c", ".cpp", + ".h", ".hpp", ".cs", ".rb", ".php", ".swift", ".scala", ".sh", ".bash", ".zsh", + ".fish", ".ps1", ".bat", ".cmd", ".r", ".lua", ".pl", ".pm", ".ex", ".exs", + ".erl", ".hs", ".ml", ".sql", ".graphql", ".proto", ".html", ".htm", ".css", + ".scss", ".sass", ".less", ".svg", ".log", ".tex", ".bib", ".org", ".adoc", ".wiki", +]); diff --git a/apps/vscode/src/utils/string.ts b/apps/vscode/src/utils/string.ts new file mode 100644 index 0000000000..801d46ede4 --- /dev/null +++ b/apps/vscode/src/utils/string.ts @@ -0,0 +1,17 @@ +export function buildCaseInsensitiveGlobLiteral(input: string) { + let out = ""; + for (const ch of input) { + if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")) { + const l = ch.toLowerCase(); + const u = ch.toUpperCase(); + out += `[${l}${u}]`; + } else { + if ("*?[]{}\\".includes(ch)) { + out += "\\" + ch; + } else { + out += ch; + } + } + } + return out; +} diff --git a/apps/vscode/src/utils/workspace-path.ts b/apps/vscode/src/utils/workspace-path.ts new file mode 100644 index 0000000000..422ed0add4 --- /dev/null +++ b/apps/vscode/src/utils/workspace-path.ts @@ -0,0 +1,171 @@ +import { lstatSync, realpathSync } from "node:fs"; +import { lstat, realpath } from "node:fs/promises"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { relativeFsPath } from "./fs-path"; + +export interface WorkspacePath { + readonly uri: vscode.Uri; + readonly relativePath: string; +} + +export function workDirUriFromPath( + workspaceRootUri: vscode.Uri, + workspaceRoot: string, + workDir: string, +): vscode.Uri | undefined { + const relativePath = relativeFsPath(workspaceRoot, workDir); + if (relativePath === undefined) return undefined; + return relativePath === "" + ? workspaceRootUri + : vscode.Uri.joinPath(workspaceRootUri, ...toPathSegments(relativePath)); +} + +export function resolveWorkspacePath( + workDirUri: vscode.Uri, + input: string, + options: { allowRoot?: boolean } = {}, +): WorkspacePath | undefined { + const normalized = input.replaceAll("\\", "/"); + if ( + path.posix.isAbsolute(normalized) || + path.win32.isAbsolute(input) || + /^[A-Za-z]:/.test(input) + ) return undefined; + + const segments = normalized.split("/").filter((segment) => segment !== "" && segment !== "."); + if (segments.includes("..")) return undefined; + if (segments.length === 0) { + return options.allowRoot === true ? { uri: workDirUri, relativePath: "" } : undefined; + } + + return { + uri: vscode.Uri.joinPath(workDirUri, ...segments), + relativePath: segments.join("/"), + }; +} + +export function relativeWorkspacePath(rootUri: vscode.Uri, candidateUri: vscode.Uri): string | undefined { + if (rootUri.scheme !== candidateUri.scheme) return undefined; + + // VS Code exposes native Windows file paths through `fsPath`, while URI + // paths always use `/`. Comparing the URI strings makes the same drive or + // UNC path look different when only separator or drive/share casing differs. + // Native filesystem semantics are the right contract for `file:` URIs. + if (rootUri.scheme === "file") { + const relativePath = relativeFsPath(rootUri.fsPath, candidateUri.fsPath); + return relativePath === "" ? undefined : relativePath; + } + + if (rootUri.authority !== candidateUri.authority) return undefined; + + const relativePath = path.posix.relative(normalizeUriPath(rootUri.path), normalizeUriPath(candidateUri.path)); + if ( + relativePath === "" || + relativePath === ".." || + relativePath.startsWith("../") || + path.posix.isAbsolute(relativePath) + ) { + return undefined; + } + return relativePath; +} + +export async function isWorkspacePathContained( + rootUri: vscode.Uri, + candidateUri: vscode.Uri, + options: { allowMissing?: boolean } = {}, +): Promise { + if (rootUri.toString() !== candidateUri.toString() && relativeWorkspacePath(rootUri, candidateUri) === undefined) { + return false; + } + if (rootUri.scheme !== "file" || candidateUri.scheme !== "file") return true; + + try { + const [realRoot, realCandidate] = await Promise.all([ + realpath(rootUri.fsPath), + options.allowMissing === true ? realExistingPath(candidateUri.fsPath) : realpath(candidateUri.fsPath), + ]); + return relativeFsPath(realRoot, realCandidate) !== undefined; + } catch { + return false; + } +} + +export function isWorkspacePathContainedSync( + rootUri: vscode.Uri, + candidateUri: vscode.Uri, + options: { allowMissing?: boolean } = {}, +): boolean { + if (rootUri.toString() !== candidateUri.toString() && relativeWorkspacePath(rootUri, candidateUri) === undefined) { + return false; + } + if (rootUri.scheme !== "file" || candidateUri.scheme !== "file") return true; + + try { + const realRoot = realpathSync(rootUri.fsPath); + const realCandidate = + options.allowMissing === true + ? realExistingPathSync(candidateUri.fsPath) + : realpathSync(candidateUri.fsPath); + return relativeFsPath(realRoot, realCandidate) !== undefined; + } catch { + return false; + } +} + +async function realExistingPath(candidate: string): Promise { + let current = candidate; + while (true) { + try { + return await realpath(current); + } catch (error) { + if (!isMissingPathError(error)) throw error; + let isDanglingSymlink = false; + try { + isDanglingSymlink = (await lstat(current)).isSymbolicLink(); + } catch (lstatError) { + if (!isMissingPathError(lstatError)) throw lstatError; + } + if (isDanglingSymlink) throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } +} + +function realExistingPathSync(candidate: string): string { + let current = candidate; + while (true) { + try { + return realpathSync(current); + } catch (error) { + if (!isMissingPathError(error)) throw error; + let isDanglingSymlink = false; + try { + isDanglingSymlink = lstatSync(current).isSymbolicLink(); + } catch (lstatError) { + if (!isMissingPathError(lstatError)) throw lstatError; + } + if (isDanglingSymlink) throw error; + const parent = path.dirname(current); + if (parent === current) throw error; + current = parent; + } + } +} + +function isMissingPathError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT"; +} + +function toPathSegments(value: string): string[] { + return value.replaceAll("\\", "/").split("/").filter(Boolean); +} + +function normalizeUriPath(value: string): string { + const normalized = path.posix.normalize(value); + return normalized.length > 1 ? normalized.replace(/\/$/, "") : normalized; +} diff --git a/apps/vscode/test/baseline.manager.test.ts b/apps/vscode/test/baseline.manager.test.ts new file mode 100644 index 0000000000..2554f288c5 --- /dev/null +++ b/apps/vscode/test/baseline.manager.test.ts @@ -0,0 +1,412 @@ +/** + * Scenario: VSCode-owned file baselines for new, migrated, and forked sessions. + * Responsibilities: capture originals, show changes, keep/undo, persist legacy tombstones, and reject unsafe paths. + * Wiring: real temporary workspace/global-storage/legacy files; no stubbed collaborators. + * Run: pnpm --filter kimi-code test -- baseline.manager.test.ts + */ +import { existsSync, writeFileSync } from 'node:fs'; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + BaselineManager, + type BaselineSession, +} from '../src/managers/baseline.manager'; + +let root: string; +let workDir: string; +let storageRoot: string; +let manager: BaselineManager; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'vscode-baseline-')); + workDir = join(root, 'workspace'); + storageRoot = join(root, 'global-storage'); + await mkdir(workDir, { recursive: true }); + manager = new BaselineManager(storageRoot); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +describe('file baselines (capture, compare, keep, and undo)', () => { + it('keeps the first original when overlapping captures race', async () => { + const session = createSession(); + const filePath = join(workDir, 'src', 'app.ts'); + await mkdir(join(workDir, 'src'), { recursive: true }); + writeFileSync(filePath, 'original\n', 'utf-8'); + + const firstCapture = manager.capture(session, filePath); + writeFileSync(filePath, 'after first edit\n', 'utf-8'); + const secondCapture = manager.capture(session, filePath); + await Promise.all([firstCapture, secondCapture]); + + expect(await manager.getContent(session, filePath)).toBe('original\n'); + }); + + it('returns the captured original after the manager is recreated', async () => { + const session = createSession(); + const filePath = join(workDir, 'app.ts'); + await writeFile(filePath, 'persisted original\n', 'utf-8'); + await manager.capture(session, filePath); + + const reloadedManager = new BaselineManager(storageRoot); + + await expect(reloadedManager.getContent(session, filePath)).resolves.toBe( + 'persisted original\n', + ); + }); + + it('isolates the same session id between different Kimi homes', async () => { + const session = createSession(); + const filePath = join(workDir, 'app.ts'); + const firstHome = new BaselineManager(storageRoot, join(root, 'home-a')); + const secondHome = new BaselineManager(storageRoot, join(root, 'home-b')); + await writeFile(filePath, 'home A original\n', 'utf-8'); + await firstHome.capture(session, filePath); + await writeFile(filePath, 'home B original\n', 'utf-8'); + await secondHome.capture(session, filePath); + + await expect(firstHome.getContent(session, filePath)).resolves.toBe('home A original\n'); + await expect(secondHome.getContent(session, filePath)).resolves.toBe('home B original\n'); + }); + + it('reports a modified file when current content differs from its original', async () => { + const session = createSession(); + const filePath = join(workDir, 'app.ts'); + await writeFile(filePath, 'before\n', 'utf-8'); + await manager.capture(session, filePath); + await writeFile(filePath, 'after\n', 'utf-8'); + + await expect(manager.getChanges(session)).resolves.toEqual([ + { path: 'app.ts', status: 'Modified', additions: 1, deletions: 1 }, + ]); + }); + + it('reports an added file when it did not exist at capture time', async () => { + const session = createSession(); + const filePath = join(workDir, 'new.ts'); + await manager.capture(session, filePath); + await writeFile(filePath, 'new content\n', 'utf-8'); + + await expect(manager.getChanges(session)).resolves.toEqual([ + { path: 'new.ts', status: 'Added', additions: 2, deletions: 0 }, + ]); + }); + + it('reports a deleted file when an existing original is removed', async () => { + const session = createSession(); + const filePath = join(workDir, 'old.ts'); + await writeFile(filePath, 'line one\nline two', 'utf-8'); + await manager.capture(session, filePath); + await unlink(filePath); + + await expect(manager.getChanges(session)).resolves.toEqual([ + { path: 'old.ts', status: 'Deleted', additions: 0, deletions: 2 }, + ]); + }); + + it('removes a newly created file when undo restores its missing original', async () => { + const session = createSession(); + const filePath = join(workDir, 'new.ts'); + await manager.capture(session, filePath); + await writeFile(filePath, 'created\n', 'utf-8'); + + await manager.undo(session, filePath); + + expect(existsSync(filePath)).toBe(false); + }); + + it('restores an existing empty file without deleting it when undo runs', async () => { + const session = createSession(); + const filePath = join(workDir, 'empty.ts'); + await writeFile(filePath, '', 'utf-8'); + await manager.capture(session, filePath); + await writeFile(filePath, 'changed', 'utf-8'); + + await manager.undo(session, filePath); + + expect(existsSync(filePath)).toBe(true); + await expect(readFile(filePath, 'utf-8')).resolves.toBe(''); + }); + + it('preserves CRLF bytes when undo restores the original file', async () => { + const session = createSession(); + const filePath = join(workDir, 'windows.txt'); + await writeFile(filePath, 'first\r\nsecond\r\n', 'utf-8'); + await manager.capture(session, filePath); + await writeFile(filePath, 'changed\n', 'utf-8'); + + await manager.undo(session, filePath); + + await expect(readFile(filePath, 'utf-8')).resolves.toBe('first\r\nsecond\r\n'); + }); + + it('captures a new original after keep accepts the previous changes', async () => { + const session = createSession(); + const filePath = join(workDir, 'app.ts'); + await writeFile(filePath, 'first\n', 'utf-8'); + await manager.capture(session, filePath); + await writeFile(filePath, 'accepted\n', 'utf-8'); + await manager.keep(session, filePath); + + await manager.capture(session, filePath); + await writeFile(filePath, 'next edit\n', 'utf-8'); + + expect(await manager.getContent(session, filePath)).toBe('accepted\n'); + }); + + it('restores every tracked original when undo all runs', async () => { + const session = createSession(); + await writeFile(join(workDir, 'one.ts'), 'one before', 'utf-8'); + await writeFile(join(workDir, 'two.ts'), 'two before', 'utf-8'); + await manager.capture(session, 'one.ts'); + await manager.capture(session, 'two.ts'); + await writeFile(join(workDir, 'one.ts'), 'one after', 'utf-8'); + await writeFile(join(workDir, 'two.ts'), 'two after', 'utf-8'); + + await manager.undoAll(session); + + await expect(readFile(join(workDir, 'one.ts'), 'utf-8')).resolves.toBe('one before'); + await expect(readFile(join(workDir, 'two.ts'), 'utf-8')).resolves.toBe('two before'); + }); + + it('refuses undo all when a tracked directory now links outside the workspace', async () => { + const session = createSession(); + const trackedDir = join(workDir, 'src'); + const outsideDir = join(root, 'outside'); + await mkdir(trackedDir); + await mkdir(outsideDir); + await writeFile(join(trackedDir, 'app.ts'), 'original'); + await manager.capture(session, 'src/app.ts'); + await rm(trackedDir, { recursive: true }); + await writeFile(join(outsideDir, 'app.ts'), 'outside'); + await symlink(outsideDir, trackedDir, process.platform === 'win32' ? 'junction' : 'dir'); + + await expect(manager.undoAll(session)).rejects.toThrow('outside the session workspace'); + await expect(readFile(join(outsideDir, 'app.ts'), 'utf-8')).resolves.toBe('outside'); + }); + + it('removes every visible change when keep all runs', async () => { + const session = createSession(); + await writeFile(join(workDir, 'one.ts'), 'one before', 'utf-8'); + await writeFile(join(workDir, 'two.ts'), 'two before', 'utf-8'); + await manager.capture(session, 'one.ts'); + await manager.capture(session, 'two.ts'); + await writeFile(join(workDir, 'one.ts'), 'one after', 'utf-8'); + await writeFile(join(workDir, 'two.ts'), 'two after', 'utf-8'); + + await manager.keepAll(session); + + await expect(manager.getChanges(session)).resolves.toEqual([]); + }); +}); + +describe('legacy baselines (fallback, tombstones, and fork isolation)', () => { + it('returns the legacy original when no local baseline exists', async () => { + const { session } = await createLegacySession('legacy original\n'); + await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8'); + + await expect(manager.getContent(session, 'app.ts')).resolves.toBe('legacy original\n'); + }); + + it('reports a legacy file when current content differs from its original', async () => { + const { session } = await createLegacySession('legacy original\n'); + await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8'); + + await expect(manager.getChanges(session)).resolves.toEqual([ + { path: 'app.ts', status: 'Modified', additions: 1, deletions: 1 }, + ]); + }); + + it('restores a legacy original without mutating its snapshot when undo runs', async () => { + const { session, baselinePath } = await createLegacySession('legacy original\n'); + await writeFile(join(workDir, 'app.ts'), 'current content\n', 'utf-8'); + + await manager.undo(session, 'app.ts'); + + await expect(readFile(join(workDir, 'app.ts'), 'utf-8')).resolves.toBe('legacy original\n'); + await expect(readFile(baselinePath, 'utf-8')).resolves.toBe('legacy original\n'); + }); + + it('persists a keep tombstone for legacy content without deleting the old snapshot', async () => { + const { session, baselinePath } = await createLegacySession('legacy original\n'); + await writeFile(join(workDir, 'app.ts'), 'accepted content\n', 'utf-8'); + await manager.keep(session, 'app.ts'); + + const reloadedManager = new BaselineManager(storageRoot); + + await expect(reloadedManager.getChanges(session)).resolves.toEqual([]); + await expect(readFile(baselinePath, 'utf-8')).resolves.toBe('legacy original\n'); + }); + + it('uses a materialized local original before the fork target legacy fallback', async () => { + const source = createSession('ses-source'); + const filePath = join(workDir, 'app.ts'); + await writeFile(filePath, 'local original\n', 'utf-8'); + await manager.capture(source, filePath); + const { session: target } = await createLegacySession('legacy original\n', 'ses-target'); + + await manager.materializeToFork(source, target); + + await expect(manager.getContent(target, 'app.ts')).resolves.toBe('local original\n'); + }); + + it('keeps a fork baseline readable when the legacy source is later deleted', async () => { + const { session: source, legacySessionDir } = await createLegacySession( + 'legacy original\n', + 'ses-source', + ); + const target = createSession('ses-target', source.metadata); + await manager.materializeToFork(source, target); + await rm(legacySessionDir, { recursive: true, force: true }); + + await expect(manager.getContent(target, 'app.ts')).resolves.toBe('legacy original\n'); + }); + + it('keeps the source baseline visible when the fork accepts its own copy', async () => { + const { session: source } = await createLegacySession('legacy original\n', 'ses-source'); + const target = createSession('ses-target', source.metadata); + await manager.materializeToFork(source, target); + + await manager.keep(target, 'app.ts'); + + await expect(manager.getContent(source, 'app.ts')).resolves.toBe('legacy original\n'); + }); + + it('preserves accepted legacy paths when a fork is materialized', async () => { + const { session: source } = await createLegacySession('legacy original\n', 'ses-source'); + const target = createSession('ses-target', source.metadata); + await manager.keep(source, 'app.ts'); + + await manager.materializeToFork(source, target); + + await expect(manager.getContent(target, 'app.ts')).rejects.toThrow('No baseline exists'); + }); +}); + +describe('baseline boundaries (errors, cleanup, and platform paths)', () => { + it.skipIf(process.platform === 'win32')( + 'rejects an unreadable original without recording an empty baseline', + async () => { + const session = createSession(); + const filePath = join(workDir, 'unreadable.ts'); + await writeFile(filePath, 'original', 'utf-8'); + await chmod(filePath, 0o000); + + try { + await expect(manager.capture(session, filePath)).rejects.toThrow( + 'Unable to capture original file', + ); + await expect(manager.getContent(session, filePath)).rejects.toThrow( + 'No baseline exists', + ); + } finally { + await chmod(filePath, 0o600); + } + }, + ); + + it('rejects a directory path without recording an empty baseline', async () => { + const session = createSession(); + const directoryPath = join(workDir, 'not-a-file'); + await mkdir(directoryPath); + + await expect(manager.capture(session, directoryPath)).rejects.toThrow( + 'is not a regular file', + ); + await expect(manager.getContent(session, directoryPath)).rejects.toThrow( + 'No baseline exists', + ); + }); + + it('throws a clear error when requested baseline content does not exist', async () => { + await expect(manager.getContent(createSession(), 'missing.ts')).rejects.toThrow( + 'No baseline exists for "missing.ts"', + ); + }); + + it('rejects a relative path that escapes the workspace', async () => { + await expect(manager.capture(createSession(), '../outside.ts')).rejects.toThrow( + 'is outside workspace', + ); + }); + + it('normalizes Windows drive case for an in-workspace baseline', async () => { + const session: BaselineSession = { id: 'ses-windows', workDir: 'C:\\Workspace' }; + await manager.capture(session, 'C:\\Workspace\\src\\new.ts'); + + await expect( + manager.getContent(session, 'c:\\WORKSPACE\\SRC\\NEW.ts'), + ).resolves.toBe(''); + }); + + it('rejects a Windows path on another drive', async () => { + const session: BaselineSession = { id: 'ses-windows', workDir: 'C:\\Workspace' }; + + await expect(manager.capture(session, 'D:\\Workspace\\file.ts')).rejects.toThrow( + 'is outside workspace', + ); + }); + + it('normalizes case differences in an in-workspace UNC path', async () => { + const session: BaselineSession = { + id: 'ses-unc', + workDir: '\\\\Server\\Share\\Workspace', + }; + await manager.capture(session, '\\\\server\\share\\workspace\\src\\new.ts'); + + await expect(manager.getContent(session, 'src\\new.ts')).resolves.toBe(''); + }); + + it('rejects a UNC path from another share', async () => { + const session: BaselineSession = { + id: 'ses-unc', + workDir: '\\\\Server\\Share\\Workspace', + }; + + await expect( + manager.capture(session, '\\\\Server\\OtherShare\\Workspace\\file.ts'), + ).rejects.toThrow('is outside workspace'); + }); + + it('removes persisted originals when the session baseline is deleted', async () => { + const session = createSession(); + const filePath = join(workDir, 'app.ts'); + await writeFile(filePath, 'original', 'utf-8'); + await manager.capture(session, filePath); + + await manager.deleteSession(session.id); + + await expect(manager.getContent(session, filePath)).rejects.toThrow('No baseline exists'); + }); +}); + +function createSession( + id = 'ses-local', + metadata?: Readonly>, +): BaselineSession { + return { id, workDir, metadata }; +} + +async function createLegacySession( + content: string, + id = 'ses-legacy', +): Promise<{ + session: BaselineSession; + legacySessionDir: string; + baselinePath: string; +}> { + const legacySessionDir = join(root, `legacy-${id}`); + const baselinePath = join(legacySessionDir, 'baseline', 'app.ts'); + await mkdir(join(legacySessionDir, 'baseline'), { recursive: true }); + await writeFile(baselinePath, content, 'utf-8'); + return { + session: createSession(id, { kimi_cli_source_path: legacySessionDir }), + legacySessionDir, + baselinePath, + }; +} diff --git a/apps/vscode/test/bridge-handler.test.ts b/apps/vscode/test/bridge-handler.test.ts new file mode 100644 index 0000000000..a877efc600 --- /dev/null +++ b/apps/vscode/test/bridge-handler.test.ts @@ -0,0 +1,465 @@ +/** + * Scenario: untrusted Webview RPC messages cross into the VS Code extension host. + * Responsibilities: validate requests, preserve public model metadata, omit private paths, and recover visibly from persisted state errors. + * Wiring: the real BridgeHandler and handlers; VS Code and the public Node SDK harness boundary are replaced. + * Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/bridge-handler.test.ts + */ +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import type * as vscode from "vscode"; + +import { Methods } from "../shared/bridge"; +import { BridgeHandler } from "../src/bridge-handler"; + +const host = vi.hoisted(() => { + const watcher = { + onDidChange: vi.fn(), + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }; + const harness = { + homeDir: "/tmp/kimi-code-test-home", + close: vi.fn(async () => undefined), + getConfig: vi.fn(), + listSessions: vi.fn(async () => []), + resumeSession: vi.fn(), + forkSession: vi.fn(), + deleteSession: vi.fn(async () => undefined), + }; + const showWarningMessage = vi.fn(async () => undefined as string | undefined); + + class Uri { + readonly scheme = "file"; + readonly authority = ""; + readonly path: string; + + constructor(readonly fsPath: string) { + this.path = fsPath; + } + + static joinPath(base: Uri, ...segments: string[]): Uri { + return new Uri(join(base.fsPath, ...segments)); + } + + toString(): string { + return `file://${this.path}`; + } + } + + return { + Uri, + watcher, + harness, + showWarningMessage, + workspaceFolders: [] as Array<{ uri: Uri }>, + }; +}); + +vi.mock("vscode", () => ({ + Uri: host.Uri, + workspace: { + get workspaceFolders() { + return host.workspaceFolders; + }, + getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }), + createFileSystemWatcher: () => host.watcher, + textDocuments: [], + }, + window: { showWarningMessage: host.showWarningMessage }, +})); + +vi.mock("@moonshot-ai/kimi-code-sdk", async (importOriginal) => { + const original = await importOriginal(); + return { ...original, createKimiHarness: () => host.harness }; +}); + +let bridge: BridgeHandler; +let root: string; +let showLogs: Mock<() => void>; +let writeLog: Mock<(message: string) => void>; +let workspaceState: { get: ReturnType; update: ReturnType }; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "kimi-vscode-bridge-")); + host.workspaceFolders.splice(0, host.workspaceFolders.length, { uri: new host.Uri(root) }); + showLogs = vi.fn(); + writeLog = vi.fn(); + host.harness.resumeSession.mockReset(); + host.harness.getConfig.mockReset(); + host.harness.getConfig.mockResolvedValue({ models: {} }); + host.showWarningMessage.mockReset(); + host.showWarningMessage.mockResolvedValue(undefined); + workspaceState = { get: vi.fn((_key, fallback) => fallback), update: vi.fn() }; + bridge = new BridgeHandler( + vi.fn(), + workspaceState as unknown as vscode.Memento, + join(root, "global-storage"), + vi.fn(), + showLogs, + writeLog, + ); +}); + +afterEach(async () => { + await bridge.dispose(); + vi.clearAllMocks(); + await rm(root, { recursive: true, force: true }); +}); + +describe("Webview RPC boundary (validates requests before host dispatch)", () => { + it("returns a readable error when the envelope is not a plain object", async () => { + const result = await bridge.handle([], "view-1"); + + expect(result).toEqual({ + id: "", + error: "Invalid bridge request: expected a plain object.", + }); + }); + + it("does not execute a known handler when the request id is blank", async () => { + const result = await bridge.handle({ id: " ", method: Methods.ShowLogs }, "view-1"); + + expect(result).toEqual({ + id: "", + error: "Invalid bridge request: id must be a non-empty string.", + }); + expect(showLogs).not.toHaveBeenCalled(); + }); + + it.each(["missingMethod", "toString", "constructor", "__proto__"])( + "does not dispatch the unknown or prototype method %s", + async (method) => { + const result = await bridge.handle({ id: "rpc-1", method }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", error: `Unknown bridge method: ${method}` }); + expect(showLogs).not.toHaveBeenCalled(); + }, + ); + + it("does not execute a no-params handler when a payload is supplied", async () => { + const result = await bridge.handle( + { id: "rpc-1", method: Methods.ShowLogs, params: {} }, + "view-1", + ); + + expect(result).toEqual({ + id: "rpc-1", + error: "Invalid bridge params for method: showLogs", + }); + expect(showLogs).not.toHaveBeenCalled(); + }); + + it("does not execute an object-payload handler when a required field has the wrong type", async () => { + const result = await bridge.handle( + { id: "rpc-1", method: Methods.AddInputHistory, params: { text: 42 } }, + "view-1", + ); + + expect(result).toEqual({ + id: "rpc-1", + error: "Invalid bridge params for method: addInputHistory", + }); + expect(workspaceState.update).not.toHaveBeenCalled(); + }); + + it("dispatches a valid request through the existing bridge surface", async () => { + const result = await bridge.handle({ id: "rpc-1", method: Methods.ShowLogs }, "view-1"); + + expect(result).toEqual({ id: "rpc-1", result: { ok: true } }); + expect(showLogs).toHaveBeenCalledOnce(); + }); + + it("keeps provider identity when configured models share a display name", async () => { + host.harness.getConfig.mockResolvedValueOnce({ + defaultModel: "openai/shared", + models: { + "openai/shared": { + provider: "openai", + model: "shared", + displayName: "Shared", + maxContextSize: 128_000, + }, + "proxy/shared": { + provider: "company-proxy", + model: "shared", + displayName: "Shared", + maxContextSize: 128_000, + }, + }, + }); + + const result = await bridge.handle({ id: "rpc-models", method: Methods.GetModels }, "view-1"); + + expect(result).toMatchObject({ + id: "rpc-models", + result: { + defaultModel: "openai/shared", + models: [ + { id: "openai/shared", name: "Shared", provider: "openai" }, + { id: "proxy/shared", name: "Shared", provider: "company-proxy" }, + ], + }, + }); + }); + + it("preserves adaptive thinking metadata in the Webview model list", async () => { + host.harness.getConfig.mockResolvedValueOnce({ + defaultModel: "anthropic/claude", + models: { + "anthropic/claude": { + provider: "anthropic", + model: "claude-sonnet", + maxContextSize: 200_000, + adaptiveThinking: true, + }, + }, + }); + + const result = await bridge.handle({ id: "rpc-models", method: Methods.GetModels }, "view-1"); + + expect(result).toMatchObject({ + result: { + models: [{ + id: "anthropic/claude", + name: "claude-sonnet", + provider: "anthropic", + adaptive_thinking: true, + }], + }, + }); + }); + + it("does not expose the session storage path when listing sessions", async () => { + host.harness.listSessions.mockResolvedValueOnce([ + { + id: "session-1", + workDir: root, + sessionDir: "/private/kimi/sessions/session-1", + updatedAt: 123, + title: "Visible title", + }, + ] as never); + + const result = await bridge.handle( + { id: "rpc-1", method: Methods.GetKimiSessions }, + "view-1", + ); + + expect(result).toEqual({ + id: "rpc-1", + result: [{ id: "session-1", workDir: root, updatedAt: 123, brief: "Visible title" }], + }); + expect(JSON.stringify(result)).not.toContain("/private/kimi/sessions"); + }); + + it("does not expose the session storage path when forking a session", async () => { + const source = { + id: "session-1", + workDir: root, + sessionDir: "/private/kimi/sessions/session-1", + updatedAt: 123, + }; + const target = { + id: "session-2", + workDir: root, + sessionDir: "/private/kimi/sessions/session-2", + updatedAt: 124, + }; + host.harness.listSessions.mockResolvedValueOnce([source] as never); + host.harness.forkSession.mockResolvedValueOnce({ summary: target, close: vi.fn() }); + + const result = await bridge.handle( + { + id: "rpc-1", + method: Methods.ForkKimiSession, + params: { sessionId: "session-1", turnIndex: 0 }, + }, + "view-1", + ); + + expect(result).toEqual({ id: "rpc-1", result: { sessionId: "session-2" } }); + expect(JSON.stringify(result)).not.toContain("/private/kimi/sessions"); + }); + + it("runs a fork through the active session cancellation boundary", async () => { + const source = { + id: "session-1", + workDir: root, + sessionDir: "/private/kimi/sessions/session-1", + updatedAt: 123, + }; + const target = { + id: "session-2", + workDir: root, + sessionDir: "/private/kimi/sessions/session-2", + updatedAt: 124, + }; + const runExclusiveAfterCancelling = vi.fn(async (action: () => Promise) => action()); + vi.spyOn(bridge.runtime, "getSession").mockReturnValue({ + runExclusiveAfterCancelling, + } as never); + host.harness.listSessions.mockResolvedValueOnce([source] as never); + host.harness.forkSession.mockResolvedValueOnce({ summary: target, close: vi.fn() }); + + const result = await bridge.handle( + { + id: "rpc-1", + method: Methods.ForkKimiSession, + params: { sessionId: "session-1", turnIndex: 0 }, + }, + "view-1", + ); + + expect(result).toEqual({ id: "rpc-1", result: { sessionId: "session-2" } }); + expect(runExclusiveAfterCancelling).toHaveBeenCalledOnce(); + expect(host.harness.forkSession).toHaveBeenCalledOnce(); + }); + + it("closes and removes a fork when its baseline cannot be materialized", async () => { + const source = { id: "session-1", workDir: root, updatedAt: 123 }; + const target = { id: "session-2", workDir: root, updatedAt: 124 }; + const close = vi.fn(async () => undefined); + host.harness.listSessions.mockResolvedValueOnce([source] as never); + host.harness.forkSession.mockResolvedValueOnce({ summary: target, close }); + vi.spyOn(bridge.baselineManager, "materializeToFork").mockRejectedValueOnce( + new Error("baseline unavailable"), + ); + const deleteBaseline = vi.spyOn(bridge.baselineManager, "deleteSession"); + + const result = await bridge.handle( + { + id: "rpc-1", + method: Methods.ForkKimiSession, + params: { sessionId: "session-1", turnIndex: 0 }, + }, + "view-1", + ); + + expect(result).toEqual({ id: "rpc-1", error: "baseline unavailable" }); + expect(close).toHaveBeenCalledOnce(); + expect(host.harness.deleteSession).toHaveBeenCalledWith("session-2"); + expect(deleteBaseline).toHaveBeenCalledWith("session-2"); + }); + + it("keeps conversation history available when its baseline snapshot disappears", async () => { + const session = createResumedSession("session-1", root); + host.harness.resumeSession.mockResolvedValueOnce(session as never); + host.showWarningMessage.mockResolvedValueOnce("Show Logs"); + const sourcePath = join(root, "app.ts"); + await writeFile(sourcePath, "original\n", "utf-8"); + await bridge.baselineManager.capture(session.summary, sourcePath); + const baselinesRoot = join(root, "global-storage", "baselines"); + const [homeDirectory] = await readdir(baselinesRoot); + const [sessionDirectory] = await readdir(join(baselinesRoot, homeDirectory!)); + const snapshotsDirectory = join( + baselinesRoot, + homeDirectory!, + sessionDirectory!, + "snapshots", + ); + const [snapshot] = await readdir(snapshotsDirectory); + await rm(join(snapshotsDirectory, snapshot!)); + + const result = await bridge.handle( + { + id: "rpc-1", + method: Methods.LoadKimiSessionHistory, + params: { kimiSessionId: "session-1" }, + }, + "view-1", + ); + + expect(result).toEqual({ + id: "rpc-1", + result: expect.arrayContaining([ + expect.objectContaining({ type: "StatusUpdate", _sessionId: "session-1" }), + ]), + }); + expect(writeLog).toHaveBeenCalledWith( + expect.stringMatching(/Unable to restore session file changes.*Unable to read baseline snapshot/), + ); + await vi.waitFor(() => expect(showLogs).toHaveBeenCalledOnce()); + }); + + it("returns a readable error when persisted session state is corrupt without wedging the bridge", async () => { + host.harness.resumeSession.mockRejectedValueOnce( + new Error("Session state is invalid JSON at line 4"), + ); + + const failed = await bridge.handle( + { + id: "rpc-1", + method: Methods.LoadKimiSessionHistory, + params: { kimiSessionId: "session-1" }, + }, + "view-1", + ); + const next = await bridge.handle({ id: "rpc-2", method: Methods.ShowLogs }, "view-1"); + + expect(failed).toEqual({ + id: "rpc-1", + error: "Session state is invalid JSON at line 4", + }); + expect(writeLog).toHaveBeenCalledWith( + expect.stringContaining("Session state is invalid JSON at line 4"), + ); + expect(next).toEqual({ id: "rpc-2", result: { ok: true } }); + }); +}); + +function createResumedSession(id: string, workDir: string) { + const close = vi.fn(async () => undefined); + const summary = { + id, + workDir, + sessionDir: join("/private/kimi/sessions", id), + createdAt: 1, + updatedAt: 2, + metadata: { vscode_legacy_approval: { yolo: false, afk: false } }, + }; + return { + id, + workDir, + summary, + close, + getResumeState: () => ({ + sessionMetadata: { agents: {} }, + agents: { + main: { + type: "main", + config: { + cwd: workDir, + modelAlias: "test-model", + modelCapabilities: { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + max_context_tokens: 128_000, + }, + thinkingEffort: "off", + systemPrompt: "", + }, + context: { history: [], tokenCount: 0 }, + replay: [], + permission: { mode: "manual", rules: [] }, + plan: null, + usage: {}, + tools: [], + background: [], + }, + }, + }), + getStatus: async () => ({ permission: "manual" }), + setPermission: async () => undefined, + updateMetadata: async () => undefined, + setApprovalHandler: () => undefined, + setQuestionHandler: () => undefined, + onEvent: () => () => undefined, + }; +} diff --git a/apps/vscode/test/event-adapter.test.ts b/apps/vscode/test/event-adapter.test.ts new file mode 100644 index 0000000000..e910edfa3e --- /dev/null +++ b/apps/vscode/test/event-adapter.test.ts @@ -0,0 +1,526 @@ +/** + * Scenario: public Node SDK events are projected into the released VS Code Webview protocol. + * Responsibilities: verify legacy shapes, routing state, and terminal metadata one event at a time. + * Wiring: the pure adapter and real protocol types are used directly; there are no stubs. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts apps/vscode/test/event-adapter.test.ts + */ + +import { describe, expect, it } from 'vitest'; + +import { isPreflightError } from '../shared/errors'; +import { + adaptSdkEvent, + createEventAdapterState, +} from '../src/runtime/event-adapter'; + +describe('event adapter (projects SDK events into the legacy Webview contract)', () => { + it('emits the pending input when a main-agent turn starts', () => { + const result = adaptSdkEvent( + createEventAdapterState(), + { + type: 'turn.started', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + origin: { kind: 'user' }, + }, + { pendingInput: 'Fix the failing test' }, + ); + + expect(result.event).toEqual({ + type: 'TurnBegin', + payload: { user_input: 'Fix the failing test' }, + _sessionId: 'session-1', + }); + }); + + it('emits text content when the assistant streams a delta', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'assistant.delta', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + delta: 'Done', + }); + + expect(result.event).toEqual({ + type: 'ContentPart', + payload: { type: 'text', text: 'Done' }, + _sessionId: 'session-1', + }); + }); + + it('emits thinking content when the model streams a thinking delta', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'thinking.delta', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + delta: 'Checking the types', + }); + + expect(result.event).toEqual({ + type: 'ContentPart', + payload: { type: 'think', think: 'Checking the types' }, + _sessionId: 'session-1', + }); + }); + + it('emits a numbered legacy step when an SDK step starts', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'turn.step.started', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + step: 2, + stepId: 'step-2', + }); + + expect(result.event).toEqual({ + type: 'StepBegin', + payload: { n: 2 }, + _sessionId: 'session-1', + }); + }); + + it.each([ + ['Bash', 'Shell'], + ['Read', 'ReadFile'], + ['Write', 'WriteFile'], + ['Edit', 'StrReplaceFile'], + ['TodoList', 'SetTodoList'], + ['Glob', 'Glob'], + ] as const)('maps the %s tool name to %s when a tool starts', (sdkName, legacyName) => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'tool.call.started', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'tool-1', + name: sdkName, + args: { path: 'src/index.ts' }, + }); + + expect(result.event).toEqual({ + type: 'ToolCall', + payload: { + type: 'function', + id: 'tool-1', + function: { + name: legacyName, + arguments: '{"path":"src/index.ts"}', + }, + }, + _sessionId: 'session-1', + }); + }); + + it('preserves each tool-call ID when argument deltas are interleaved', () => { + const state = createEventAdapterState(); + const first = adaptSdkEvent(state, { + type: 'tool.call.delta', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'tool-a', + name: 'Read', + argumentsPart: '{"path":"a', + }); + const second = adaptSdkEvent(first.state, { + type: 'tool.call.delta', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'tool-b', + name: 'Read', + argumentsPart: '{"path":"b', + }); + + expect([first.event, second.event]).toEqual([ + { + type: 'ToolCallPart', + payload: { tool_call_id: 'tool-a', arguments_part: '{"path":"a' }, + _sessionId: 'session-1', + }, + { + type: 'ToolCallPart', + payload: { tool_call_id: 'tool-b', arguments_part: '{"path":"b' }, + _sessionId: 'session-1', + }, + ]); + }); + + it('emits a legacy result when an SDK tool call finishes', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'tool.result', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'tool-1', + output: { exitCode: 0 }, + isError: false, + }); + + expect(result.event).toEqual({ + type: 'ToolResult', + payload: { + tool_call_id: 'tool-1', + return_value: { + is_error: false, + output: '{\n "exitCode": 0\n}', + message: '', + display: [], + }, + }, + _sessionId: 'session-1', + }); + }); + + it('carries a file diff display from tool start into its matching result', () => { + const started = adaptSdkEvent(createEventAdapterState(), { + type: 'tool.call.started', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'tool-1', + name: 'Edit', + args: { path: 'src/index.ts' }, + display: { + kind: 'diff', + path: 'src/index.ts', + before: 'old', + after: 'new', + }, + }); + const finished = adaptSdkEvent(started.state, { + type: 'tool.result', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'tool-1', + output: 'updated', + }); + + expect(finished.event).toMatchObject({ + type: 'ToolResult', + payload: { + return_value: { + display: [{ + type: 'diff', + path: 'src/index.ts', + old_text: 'old', + new_text: 'new', + }], + }, + }, + }); + }); + + it('carries a todo display only to the result with the same tool-call ID', () => { + const started = adaptSdkEvent(createEventAdapterState(), { + type: 'tool.call.started', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'todo-1', + name: 'TodoList', + args: {}, + display: { + kind: 'todo_list', + items: [{ title: 'Ship it', status: 'done' }], + }, + }); + const unrelated = adaptSdkEvent(started.state, { + type: 'tool.result', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'other', + output: 'other result', + }); + + expect(unrelated.event).toMatchObject({ + payload: { return_value: { display: [] } }, + }); + expect(unrelated.state.toolDisplays['todo-1']).toEqual([ + { type: 'todo', items: [{ title: 'Ship it', status: 'done' }] }, + ]); + const matching = adaptSdkEvent(unrelated.state, { + type: 'tool.result', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + toolCallId: 'todo-1', + output: 'updated', + }); + expect(matching.event).toMatchObject({ + payload: { + return_value: { + display: [{ type: 'todo', items: [{ title: 'Ship it', status: 'done' }] }], + }, + }, + }); + }); + + it('emits snake-case status fields when agent status changes', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'agent.status.updated', + sessionId: 'session-1', + agentId: 'main', + contextUsage: 0.25, + planMode: true, + usage: { + currentTurn: { + inputOther: 10, + output: 4, + inputCacheRead: 3, + inputCacheCreation: 2, + }, + }, + }); + + expect(result.event).toEqual({ + type: 'StatusUpdate', + payload: { + context_usage: 0.25, + plan_mode: true, + token_usage: { + input_other: 10, + output: 4, + input_cache_read: 3, + input_cache_creation: 2, + }, + }, + _sessionId: 'session-1', + }); + }); + + it('emits only new token usage when SDK status carries cumulative turn usage', () => { + const first = adaptSdkEvent(createEventAdapterState(), { + type: 'agent.status.updated', + sessionId: 'session-1', + agentId: 'main', + usage: { + currentTurn: { + inputOther: 10, + output: 4, + inputCacheRead: 3, + inputCacheCreation: 2, + }, + }, + }); + const second = adaptSdkEvent(first.state, { + type: 'agent.status.updated', + sessionId: 'session-1', + agentId: 'main', + usage: { + currentTurn: { + inputOther: 14, + output: 7, + inputCacheRead: 8, + inputCacheCreation: 2, + }, + }, + }); + + expect(second.event).toEqual({ + type: 'StatusUpdate', + payload: { + token_usage: { + input_other: 4, + output: 3, + input_cache_read: 5, + input_cache_creation: 0, + }, + }, + _sessionId: 'session-1', + }); + }); + + it('routes a child-agent event through its parent tool after spawn', () => { + const spawned = adaptSdkEvent(createEventAdapterState(), { + type: 'subagent.spawned', + sessionId: 'session-1', + agentId: 'main', + subagentId: 'child-1', + subagentName: 'coder', + parentToolCallId: 'agent-call-1', + parentAgentId: 'main', + runInBackground: false, + }); + const childEvent = adaptSdkEvent(spawned.state, { + type: 'assistant.delta', + sessionId: 'session-1', + agentId: 'child-1', + turnId: 1, + delta: 'Child result', + }); + + expect(childEvent.event).toEqual({ + type: 'SubagentEvent', + payload: { + parent_tool_call_id: 'agent-call-1', + event: { + type: 'ContentPart', + payload: { type: 'text', text: 'Child result' }, + }, + }, + _sessionId: 'session-1', + }); + }); + + it('scopes a child tool-call ID when the child starts a tool', () => { + const spawned = adaptSdkEvent(createEventAdapterState(), { + type: 'subagent.spawned', + sessionId: 'session-1', + agentId: 'main', + subagentId: 'child-1', + subagentName: 'coder', + parentToolCallId: 'agent-call-1', + parentAgentId: 'main', + runInBackground: false, + }); + const childEvent = adaptSdkEvent(spawned.state, { + type: 'tool.call.started', + sessionId: 'session-1', + agentId: 'child-1', + turnId: 1, + toolCallId: 'tool-1', + name: 'Read', + args: { path: 'README.md' }, + }); + + expect(childEvent.event).toEqual({ + type: 'SubagentEvent', + payload: { + parent_tool_call_id: 'agent-call-1', + event: { + type: 'ToolCall', + payload: { + type: 'function', + id: 'child-1:tool-1', + function: { + name: 'ReadFile', + arguments: '{"path":"README.md"}', + }, + }, + }, + }, + _sessionId: 'session-1', + }); + }); + + it('emits compaction begin when SDK compaction starts', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'compaction.started', + sessionId: 'session-1', + agentId: 'main', + trigger: 'manual', + }); + + expect(result.event).toEqual({ + type: 'CompactionBegin', + payload: {}, + _sessionId: 'session-1', + }); + }); + + it.each([ + { + type: 'compaction.completed' as const, + result: { summary: 'Summary', compactedCount: 3, tokensBefore: 100, tokensAfter: 30 }, + }, + { type: 'compaction.cancelled' as const }, + { type: 'compaction.blocked' as const, turnId: 7 }, + ])('emits compaction end when SDK reports $type', (event) => { + const result = adaptSdkEvent(createEventAdapterState(), { + ...event, + sessionId: 'session-1', + agentId: 'main', + }); + + expect(result.event).toEqual({ + type: 'CompactionEnd', + payload: {}, + _sessionId: 'session-1', + }); + }); + + it('returns terminal metadata when the main turn completes', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'turn.ended', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + reason: 'completed', + durationMs: 50, + }); + + expect(result.event).toBeUndefined(); + expect(result.terminal).toEqual({ + key: 'session-1:main:7', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + reason: 'completed', + error: undefined, + }); + }); + + it('preserves the SDK error when a main turn fails', () => { + const result = adaptSdkEvent(createEventAdapterState(), { + type: 'turn.ended', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + reason: 'failed', + error: { + code: 'internal', + message: 'Provider failed', + retryable: false, + }, + }); + + expect(result.terminal).toEqual({ + key: 'session-1:main:7', + sessionId: 'session-1', + agentId: 'main', + turnId: 7, + reason: 'failed', + error: { + code: 'internal', + message: 'Provider failed', + retryable: false, + }, + }); + }); + + it('emits a bridge error with the caller-selected phase when the SDK reports an error', () => { + const result = adaptSdkEvent( + createEventAdapterState(), + { + type: 'error', + sessionId: 'session-1', + agentId: 'main', + code: 'internal', + message: 'Configuration failed', + details: { path: 'config.toml' }, + retryable: false, + }, + { errorPhase: 'preflight' }, + ); + + expect(result.event).toEqual({ + type: 'error', + code: 'internal', + message: 'Configuration failed', + detail: '{\n "path": "config.toml"\n}', + phase: 'preflight', + _sessionId: 'session-1', + }); + }); + + it('classifies a missing Windows Git Bash runtime as a preflight error', () => { + expect(isPreflightError('shell.git_bash_not_found')).toBe(true); + }); +}); diff --git a/apps/vscode/test/extension-host-smoke.test.ts b/apps/vscode/test/extension-host-smoke.test.ts new file mode 100644 index 0000000000..4467746355 --- /dev/null +++ b/apps/vscode/test/extension-host-smoke.test.ts @@ -0,0 +1,105 @@ +/** + * Scenario: maintainers run the installed-VSIX smoke from a developer machine or CI. + * Responsibilities: stable downloads cannot reuse stale caches, and the Extension + * Host cannot discover the developer's real legacy Kimi home. + * Wiring: real smoke orchestration and filesystem; @vscode/test-electron is the + * external process/download boundary. + * Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/extension-host-smoke.test.ts + */ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const vscodeTest = vi.hoisted(() => ({ + runTests: vi.fn(), + runVSCodeCommand: vi.fn(), +})); + +vi.mock('@vscode/test-electron', () => vscodeTest); + +const { runExtensionHostSmoke } = await import('../scripts/extension-host-smoke.mjs'); +const tempDirs: string[] = []; + +beforeEach(() => { + vscodeTest.runVSCodeCommand.mockResolvedValue({ + stdout: 'Extension was successfully installed.\n', + stderr: '', + }); + vscodeTest.runTests.mockImplementation(async (options) => { + await writeFile( + options.extensionTestsEnv.KIMI_VSCODE_SMOKE_REPORT, + JSON.stringify({ vscode: options.version === 'stable' ? '1.127.0' : options.version }), + 'utf8', + ); + }); +}); + +afterEach(async () => { + vi.clearAllMocks(); + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('installed VSIX Extension Host smoke', () => { + it('uses a fresh disposable download cache for every stable run', async () => { + const fixture = await makeFixture(); + + const first = await runExtensionHostSmoke({ + version: 'stable', + vsixPath: fixture.vsixPath, + cachePath: fixture.cachePath, + }); + const second = await runExtensionHostSmoke({ + version: 'stable', + vsixPath: fixture.vsixPath, + cachePath: fixture.cachePath, + }); + + expect(first.cachePath).not.toBe(second.cachePath); + expect(first.vscodeVersion).toBe('1.127.0'); + expect(second.vscodeVersion).toBe('1.127.0'); + }); + + it('gives the harness separate Kimi and operating-system homes before activation', async () => { + const fixture = await makeFixture(); + + await runExtensionHostSmoke({ + version: '1.100.0', + vsixPath: fixture.vsixPath, + cachePath: fixture.cachePath, + }); + + const options = vscodeTest.runTests.mock.calls[0]?.[0]; + const env = options.extensionTestsEnv; + expect(env.KIMI_CODE_HOME).not.toBe(env.KIMI_VSCODE_SMOKE_OS_HOME); + expect(env.KIMI_VSCODE_SMOKE_OS_HOME).toContain('os-home'); + }); + + it('rejects a cached host that does not match an exact requested version', async () => { + const fixture = await makeFixture(); + vscodeTest.runTests.mockImplementationOnce(async (options) => { + await writeFile( + options.extensionTestsEnv.KIMI_VSCODE_SMOKE_REPORT, + JSON.stringify({ vscode: '1.99.3' }), + 'utf8', + ); + }); + + await expect(runExtensionHostSmoke({ + version: '1.100.0', + vsixPath: fixture.vsixPath, + cachePath: fixture.cachePath, + })).rejects.toThrow('Extension Host ran VS Code 1.99.3, expected requested version 1.100.0'); + }); +}); + +async function makeFixture(): Promise<{ vsixPath: string; cachePath: string }> { + const root = await mkdtemp(join(tmpdir(), 'kimi-extension-host-smoke-')); + tempDirs.push(root); + const cachePath = join(root, 'cache'); + const vsixPath = join(root, 'kimi-code-test.vsix'); + await mkdir(cachePath, { recursive: true }); + await writeFile(vsixPath, 'fixture', 'utf8'); + return { vsixPath, cachePath }; +} diff --git a/apps/vscode/test/extension-host/index.cjs b/apps/vscode/test/extension-host/index.cjs new file mode 100644 index 0000000000..9b622f0698 --- /dev/null +++ b/apps/vscode/test/extension-host/index.cjs @@ -0,0 +1,106 @@ +const assert = require("node:assert/strict"); +const { writeFile } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const vscode = require("vscode"); + +const EXTENSION_ID = "moonshot-ai.kimi-code"; +const EXPECTED_COMMANDS = [ + "kimi.clearAllState", + "kimi.focusInput", + "kimi.insertMention", + "kimi.logout", + "kimi.migrateLegacyData", + "kimi.newConversation", + "kimi.openInSideBar", + "kimi.openInTab", + "kimi.resetKimi", + "kimi.showLogs", +]; + +exports.run = async function run() { + const isolatedHome = process.env.KIMI_VSCODE_SMOKE_OS_HOME; + assert.ok(isolatedHome, "isolated OS home must be provided"); + process.env.HOME = isolatedHome; + process.env.USERPROFILE = isolatedHome; + const root = path.parse(isolatedHome).root; + process.env.HOMEDRIVE = process.platform === "win32" ? root.replace(/[\\/]+$/, "") : root; + process.env.HOMEPATH = process.platform === "win32" + ? `${path.sep}${isolatedHome.slice(root.length)}` + : isolatedHome; + + const extension = vscode.extensions.getExtension(EXTENSION_ID); + assert.ok(extension, `${EXTENSION_ID} is not installed in the isolated Extension Host`); + assert.equal(extension.packageJSON.version, "0.6.0"); + assert.equal(extension.packageJSON.main, "./dist/extension.js"); + assert.ok(process.env.KIMI_CODE_HOME, "KIMI_CODE_HOME must point at the isolated test home"); + assert.equal(process.env.HOME, isolatedHome); + assert.equal(process.env.USERPROFILE, isolatedHome); + assert.equal(os.homedir(), isolatedHome); + + await extension.activate(); + assert.equal(extension.isActive, true, "extension activation did not complete"); + + const commands = new Set(await vscode.commands.getCommands(true)); + for (const command of EXPECTED_COMMANDS) { + assert.ok(commands.has(command), `missing registered command: ${command}`); + } + + const config = vscode.workspace.getConfiguration("kimi"); + assert.equal(config.get("autosave"), true); + assert.equal(config.get("executablePath"), undefined, "removed Python CLI setting is still contributed"); + assert.equal(config.get("environmentVariables"), undefined, "removed global CLI env setting is still contributed"); + + await vscode.commands.executeCommand("kimi.openInTab"); + await waitFor(() => { + return vscode.window.tabGroups.all.some((group) => + group.tabs.some((tab) => + tab.input instanceof vscode.TabInputWebview && isKimiPanelViewType(tab.input.viewType))); + }, 5_000, () => `Kimi Webview tab did not open; tabs=${describeTabs()}`); + + await vscode.commands.executeCommand("kimi.showLogs"); + await vscode.commands.executeCommand("kimi.resetKimi"); + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + + console.log( + JSON.stringify({ + extension: EXTENSION_ID, + version: extension.packageJSON.version, + vscode: vscode.version, + remoteName: vscode.env.remoteName ?? null, + commands: EXPECTED_COMMANDS.length, + webview: "opened", + }), + ); + assert.ok(process.env.KIMI_VSCODE_SMOKE_REPORT, "Extension Host report path must be provided"); + await writeFile( + process.env.KIMI_VSCODE_SMOKE_REPORT, + JSON.stringify({ vscode: vscode.version }), + "utf8", + ); +}; + +async function waitFor(predicate, timeoutMs, message) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(typeof message === "function" ? message() : message); +} + +function describeTabs() { + return JSON.stringify(vscode.window.tabGroups.all.map((group) => + group.tabs.map((tab) => ({ + label: tab.label, + input: tab.input?.constructor?.name, + viewType: tab.input instanceof vscode.TabInputWebview ? tab.input.viewType : undefined, + active: tab.isActive, + })))); +} + +function isKimiPanelViewType(viewType) { + // VS Code 1.100 exposes the internal `mainThreadWebview-` prefix here; + // newer hosts expose the extension's original view type. + return viewType === "kimiPanel" || viewType.endsWith("-kimiPanel"); +} diff --git a/apps/vscode/test/fork-turn-index.test.ts b/apps/vscode/test/fork-turn-index.test.ts new file mode 100644 index 0000000000..7bfa5bb395 --- /dev/null +++ b/apps/vscode/test/fork-turn-index.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { getForkTurnIndex } from "../shared/fork-turn-index"; + +interface TestMessage { + readonly id: string; + readonly role: "user" | "assistant"; + readonly content: string; + readonly timestamp: number; + readonly forkable?: boolean; + readonly steps?: readonly { + readonly n: number; + readonly items: readonly { readonly type: string; readonly content?: string }[]; + }[]; +} + +function message( + role: TestMessage["role"], + options: Partial = {}, +): TestMessage { + return { + id: crypto.randomUUID(), + role, + content: "", + timestamp: 1, + ...options, + }; +} + +describe("fork turn index", () => { + it("counts a steer embedded in the current assistant response", () => { + const messages = [ + message("user"), + message("assistant", { + steps: [{ n: 1, items: [{ type: "steer", content: "also fix tests" }] }], + }), + ]; + + expect(getForkTurnIndex(messages, 1)).toBe(1); + }); + + it("carries prior steer turns into later assistant responses", () => { + const messages = [ + message("user"), + message("assistant", { + steps: [{ n: 1, items: [{ type: "steer", content: "also fix tests" }] }], + }), + message("user"), + message("assistant"), + ]; + + expect(getForkTurnIndex(messages, 3)).toBe(2); + }); + + it("does not count or offer forks for host-only command output", () => { + const messages = [ + message("user", { content: "/compact", forkable: false }), + message("assistant", { content: "The context has been compacted.", forkable: false }), + message("user"), + message("assistant"), + ]; + + expect(getForkTurnIndex(messages, 1)).toBeUndefined(); + expect(getForkTurnIndex(messages, 3)).toBe(0); + }); +}); diff --git a/apps/vscode/test/kimi-harness.integration.test.ts b/apps/vscode/test/kimi-harness.integration.test.ts new file mode 100644 index 0000000000..91dc88467b --- /dev/null +++ b/apps/vscode/test/kimi-harness.integration.test.ts @@ -0,0 +1,1280 @@ +/** + * Scenario: the VS Code host and another Node SDK client share one in-process Kimi home. + * Responsibilities: outbound host identity, config/session interoperability, MCP credential/edit compatibility, and terminal provider failures. + * Wiring: KimiRuntime, KimiHarness, core, storage, and HTTP provider adapter are real; only the remote provider is local. + * Run: pnpm --filter kimi-code exec vitest run test/kimi-harness.integration.test.ts + */ + +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + createKimiHarness, + type KimiHarness, +} from "@moonshot-ai/kimi-code-sdk"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("vscode", () => ({ + Uri: { file: (path: string) => ({ fsPath: path }) }, + window: { + showInformationMessage: async () => undefined, + showWarningMessage: async () => undefined, + showTextDocument: async () => undefined, + }, +})); + +import { + createFakeProviderHarness, + type FakeProviderHarness, +} from "../../../packages/kosong/test/e2e/fake-provider-harness"; +import { Events, Methods } from "../shared/bridge"; +import { + MCP_SECRET_MASK, + type MCPServerConfig, + type UpdateMCPServerRequest, +} from "../shared/legacy-sdk"; +import { configHandlers } from "../src/handlers/config.handler"; +import { mcpHandlers } from "../src/handlers/mcp.handler"; +import { parseHostSlashCommand, runHostSlashCommand } from "../src/handlers/slash-command"; +import type { HandlerContext } from "../src/handlers/types"; +import { KimiRuntime } from "../src/runtime/kimi-runtime"; +import type { SessionRuntime } from "../src/runtime/session-runtime"; + +const MODEL_ALIAS = "vscode-test"; +const PROVIDER_TOKEN = "sk-vscode-boundary-secret"; + +interface BroadcastRecord { + readonly event: string; + readonly data: unknown; + readonly webviewId?: string; +} + +interface LogRecord { + readonly message: string; + readonly error?: unknown; +} + +interface RuntimeRig { + readonly homeDir: string; + readonly workDir: string; + readonly provider: FakeProviderHarness; + readonly runtime: KimiRuntime; + readonly broadcasts: BroadcastRecord[]; + readonly logs: LogRecord[]; + closeProvider(): Promise; +} + +interface McpHandlerRig { + readonly harness: KimiHarness; + readonly broadcasts: BroadcastRecord[]; + readonly logs: LogRecord[]; +} + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } +}); + +async function createRuntimeRig(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), "kimi-vscode-harness-")); + const homeDir = join(rootDir, "home"); + const workDir = join(rootDir, "workspace"); + await Promise.all([mkdir(homeDir), mkdir(workDir)]); + + const provider = await createFakeProviderHarness(); + let providerOpen = true; + const closeProvider = async (): Promise => { + if (!providerOpen) return; + providerOpen = false; + await provider.close(); + }; + + await writeProviderConfig(homeDir, `${provider.baseUrl}/v1`); + const version = await readExtensionVersion(); + const broadcasts: BroadcastRecord[] = []; + const logs: LogRecord[] = []; + const runtime = new KimiRuntime({ + version, + homeDir, + broadcast: (event: string, data: unknown, webviewId?: string) => { + broadcasts.push({ event, data, webviewId }); + }, + captureBaseline: () => undefined, + log: (message, error) => { + logs.push({ message, error }); + }, + }); + + cleanups.push(async () => { + try { + await runtime.dispose(); + } finally { + try { + await closeProvider(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + } + }); + + return { + homeDir, + workDir, + provider, + runtime, + broadcasts, + logs, + closeProvider, + }; +} + +async function createPlainHarness(homeDir: string): Promise { + const harness = createKimiHarness({ + homeDir, + identity: { userAgentProduct: "kimi-code-cli", version: "test" }, + }); + cleanups.push(() => harness.close()); + return harness; +} + +async function createMcpHandlerRig(): Promise { + const homeDir = await mkdtemp(join(tmpdir(), "kimi-vscode-mcp-handler-")); + cleanups.push(() => rm(homeDir, { recursive: true, force: true })); + const harness = await createPlainHarness(homeDir); + const broadcasts: BroadcastRecord[] = []; + const logs: LogRecord[] = []; + return { harness, broadcasts, logs }; +} + +async function updateMcpServer( + rig: McpHandlerRig, + request: UpdateMCPServerRequest | MCPServerConfig, +): Promise { + return mcpHandlers[Methods.UpdateMCPServer]!(request, mcpHandlerContext(rig)) as Promise; +} + +async function getMcpServers(rig: McpHandlerRig): Promise { + return mcpHandlers[Methods.GetMCPServers]!(undefined, mcpHandlerContext(rig)) as Promise; +} + +function mcpHandlerContext(rig: McpHandlerRig): HandlerContext { + return { + harness: rig.harness, + broadcast: (event: string, data: unknown, webviewId?: string) => { + rig.broadcasts.push({ event, data, webviewId }); + }, + logError: (message: string, error: unknown) => { + rig.logs.push({ message, error }); + }, + } as unknown as HandlerContext; +} + +async function readExtensionVersion(): Promise { + const text = await readFile(new URL("../package.json", import.meta.url), "utf8"); + const parsed = JSON.parse(text) as { version?: unknown }; + if (typeof parsed.version !== "string") { + throw new TypeError("VS Code package version is missing"); + } + return parsed.version; +} + +async function writeProviderConfig(homeDir: string, baseUrl: string): Promise { + await writeFile( + join(homeDir, "config.toml"), + `default_model = "${MODEL_ALIAS}" + +[providers.local] +type = "kimi" +base_url = "${baseUrl}" +api_key = "${PROVIDER_TOKEN}" + +[models."${MODEL_ALIAS}"] +provider = "local" +model = "mock-model" +max_context_size = 128000 + +[loop_control] +max_retries_per_step = 1 +`, + "utf8", + ); +} + +function routeSuccessfulPrompt(provider: FakeProviderHarness): void { + provider.route("POST", "/v1/chat/completions", async (_request, reply) => { + await reply.sseJson(200, [ + completionChunk({ content: "mock response" }), + completionChunk({}, "stop"), + ]); + }); +} + +function routeBadRequest(provider: FakeProviderHarness): void { + provider.route("POST", "/v1/chat/completions", async (_request, reply) => { + await reply.json(400, { + error: { + message: "mock request rejected", + type: "invalid_request_error", + }, + }); + }); +} + +function routeBlockedPrompt(provider: FakeProviderHarness): { + readonly started: Promise; + readonly release: () => void; +} { + let markStarted!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const blocked = new Promise((resolve) => { + release = resolve; + }); + provider.route("POST", "/v1/chat/completions", async (_request, reply) => { + markStarted(); + await blocked; + await reply.sseJson(200, [completionChunk({ content: "late response" }), completionChunk({}, "stop")]); + }); + return { started, release }; +} + +function completionChunk( + delta: Record, + finishReason: string | null = null, +): Record { + return { + id: "chatcmpl-vscode-test", + object: "chat.completion.chunk", + created: 1, + model: "mock-model", + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; +} + +async function openRuntimeSession(rig: RuntimeRig, sessionId?: string, yoloMode = false) { + return rig.runtime.openSession({ + webviewId: "view-1", + workDir: rig.workDir, + sessionId, + model: MODEL_ALIAS, + effort: "off", + yoloMode, + }); +} + +function streamEvents(broadcasts: readonly BroadcastRecord[]): unknown[] { + return broadcasts + .filter((record) => record.event === Events.StreamEvent) + .map((record) => record.data); +} + +function diagnosticText(rig: RuntimeRig): string { + const logText = rig.logs + .map(({ message, error }) => { + const detail = error instanceof Error ? error.message : JSON.stringify(error ?? ""); + return `${message} ${detail}`; + }) + .join("\n"); + return `${logText}\n${JSON.stringify(streamEvents(rig.broadcasts))}`; +} + +async function runSlash( + runtime: SessionRuntime, + raw: string, + ctx = {} as HandlerContext, +): Promise { + const command = parseHostSlashCommand(raw); + if (command === undefined) throw new Error(`Expected host slash command: ${raw}`); + return runHostSlashCommand(runtime, command, ctx); +} + +describe("VS Code Kimi harness integration (shares one in-process SDK home)", () => { + it("only intercepts released slash commands and user-invoked skills", () => { + expect(parseHostSlashCommand("/plan on")).toEqual({ name: "plan", args: "on", raw: "/plan on" }); + expect(parseHostSlashCommand(" /skill:review carefully ")).toEqual({ + name: "skill:review", + args: "carefully", + raw: "/skill:review carefully", + }); + expect(parseHostSlashCommand("/not-a-host-command")).toBeUndefined(); + expect(parseHostSlashCommand([{ type: "text", text: "/clear" }])).toBeUndefined(); + }); + + it("combines the released slash commands with user-activatable workspace skills", async () => { + const commands = await configHandlers[Methods.GetSlashCommands]!(undefined, { + workDir: "/workspace", + harness: { + listWorkspaceSkills: async () => [ + { name: "review", description: "Review changes", path: "/skills/review", source: "user", type: "prompt" }, + { name: "reference-only", description: "Reference", path: "/skills/ref", source: "user", type: "reference" }, + ], + }, + logError: () => undefined, + } as unknown as HandlerContext); + + expect((commands as Array<{ name: string }>).map((command) => command.name)).toEqual([ + "init", + "compact", + "clear", + "yolo", + "afk", + "plan", + "add-dir", + "export", + "import", + "skill:review", + ]); + }); + + it("sends the package version in User-Agent when VS Code prompts the provider", async () => { + const rig = await createRuntimeRig(); + routeSuccessfulPrompt(rig.provider); + const session = await openRuntimeSession(rig); + + await expect(session.prompt("hello")).resolves.toEqual({ status: "finished" }); + + expect(rig.provider.requests[0]?.headers["user-agent"]).toBe( + "kimi-code-vscode/0.6.0", + ); + }); + + it("reloads sequential config writes from either harness sharing one home", async () => { + const rig = await createRuntimeRig(); + const plain = await createPlainHarness(rig.homeDir); + + await plain.setConfig({ thinking: { enabled: true, effort: "high" } }); + await expect(rig.runtime.harness.getConfig({ reload: true })).resolves.toMatchObject({ + thinking: { enabled: true, effort: "high" }, + }); + + await rig.runtime.harness.setConfig({ yolo: true }); + await expect(plain.getConfig({ reload: true })).resolves.toMatchObject({ yolo: true }); + }); + + it("masks credential-valued MCP fields at the Webview list boundary while leaving ordinary values visible", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "remote", + transport: "http", + url: "https://example.test/mcp", + headers: { + Authorization: "Bearer header-secret", + Cookie: "session=cookie-secret", + "X-API-Key": "api-key-secret", + "X-Workspace": "workspace-visible", + }, + }); + await rig.harness.addMcpServer({ + name: "local", + transport: "stdio", + command: "example-mcp", + env: { + SERVICE_TOKEN: "env-secret", + DEBUG: "debug-visible", + }, + }); + + const servers = await getMcpServers(rig); + + expect(servers).toEqual([ + { + name: "remote", + transport: "http", + url: "https://example.test/mcp", + headers: { + Authorization: MCP_SECRET_MASK, + Cookie: MCP_SECRET_MASK, + "X-API-Key": MCP_SECRET_MASK, + "X-Workspace": "workspace-visible", + }, + }, + { + name: "local", + transport: "stdio", + command: "example-mcp", + env: { + SERVICE_TOKEN: MCP_SECRET_MASK, + DEBUG: "debug-visible", + }, + }, + ]); + expect(JSON.stringify(servers)).not.toMatch(/header-secret|cookie-secret|api-key-secret|env-secret/); + }); + + it("logs a failed MCP test without returning credential values to the Webview", async () => { + const rig = await createMcpHandlerRig(); + vi.spyOn(rig.harness, "testMcpServer").mockResolvedValue({ + success: false, + output: [ + "spawn missing-mcp ENOENT", + "Authorization: Bearer header-secret", + "TOKEN=env-secret", + "Cookie: session=cookie-secret", + ].join("\n"), + }); + + const result = await mcpHandlers[Methods.TestMCP]!( + { name: "broken" }, + mcpHandlerContext(rig), + ); + + expect(result).toMatchObject({ success: false, output: expect.stringContaining("ENOENT") }); + expect(JSON.stringify(result)).not.toMatch(/header-secret|env-secret|cookie-secret/); + expect(rig.logs).toHaveLength(1); + expect(rig.logs[0]?.message).toBe('MCP server test failed for "broken"'); + expect(rig.logs[0]?.error).toBeInstanceOf(Error); + expect((rig.logs[0]?.error as Error).message).toContain("ENOENT"); + expect((rig.logs[0]?.error as Error).message).not.toMatch(/header-secret|env-secret|cookie-secret/); + }); + + it("preserves an unchanged masked HTTP credential without exposing it in the response or broadcast", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "remote", + transport: "http", + url: "https://old.example.test/mcp", + headers: { + Authorization: "Bearer stored-header-secret", + "X-Workspace": "old-workspace", + }, + }); + + const servers = await updateMcpServer(rig, { + originalName: "remote", + server: { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + headers: { + Authorization: MCP_SECRET_MASK, + "X-Workspace": "new-workspace", + }, + }, + }); + + expect(servers).toEqual([ + { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + headers: { + Authorization: MCP_SECRET_MASK, + "X-Workspace": "new-workspace", + }, + }, + ]); + expect(rig.broadcasts).toEqual([ + { event: Events.MCPServersChanged, data: servers, webviewId: undefined }, + ]); + await expect(rig.harness.listMcpServers()).resolves.toEqual([ + { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + headers: { + Authorization: "Bearer stored-header-secret", + "X-Workspace": "new-workspace", + }, + }, + ]); + }); + + it("preserves an unchanged masked stdio credential in the host configuration", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "local", + transport: "stdio", + command: "old-command", + env: { + SERVICE_TOKEN: "stored-env-secret", + DEBUG: "old-debug", + }, + }); + + const servers = await updateMcpServer(rig, { + originalName: "local", + server: { + name: "local", + transport: "stdio", + command: "new-command", + env: { + SERVICE_TOKEN: MCP_SECRET_MASK, + DEBUG: "new-debug", + }, + }, + }); + + expect(servers).toEqual([ + { + name: "local", + transport: "stdio", + command: "new-command", + env: { + SERVICE_TOKEN: MCP_SECRET_MASK, + DEBUG: "new-debug", + }, + }, + ]); + await expect(rig.harness.listMcpServers()).resolves.toEqual([ + { + name: "local", + transport: "stdio", + command: "new-command", + env: { + SERVICE_TOKEN: "stored-env-secret", + DEBUG: "new-debug", + }, + }, + ]); + }); + + it("replaces an HTTP credential when the Webview submits a new literal value", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "remote", + transport: "http", + url: "https://example.test/mcp", + headers: { Authorization: "Bearer old-secret" }, + }); + + const servers = await updateMcpServer(rig, { + originalName: "remote", + server: { + name: "remote", + transport: "http", + url: "https://example.test/mcp", + headers: { Authorization: "Bearer new-secret" }, + }, + }); + + expect(servers[0]?.headers).toEqual({ Authorization: MCP_SECRET_MASK }); + await expect(rig.harness.listMcpServers()).resolves.toEqual([ + { + name: "remote", + transport: "http", + url: "https://example.test/mcp", + headers: { Authorization: "Bearer new-secret" }, + }, + ]); + }); + + it("replaces a stdio credential when the Webview submits a new literal value", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "local", + transport: "stdio", + command: "example-mcp", + env: { SERVICE_TOKEN: "old-secret" }, + }); + + const servers = await updateMcpServer(rig, { + originalName: "local", + server: { + name: "local", + transport: "stdio", + command: "example-mcp", + env: { SERVICE_TOKEN: "new-secret" }, + }, + }); + + expect(servers[0]?.env).toEqual({ SERVICE_TOKEN: MCP_SECRET_MASK }); + await expect(rig.harness.listMcpServers()).resolves.toEqual([ + { + name: "local", + transport: "stdio", + command: "example-mcp", + env: { SERVICE_TOKEN: "new-secret" }, + }, + ]); + }); + + it("preserves existing HTTP MCP headers when the released form updates the server", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "remote", + transport: "http", + url: "https://old.example.test/mcp", + headers: { "X-Workspace": "kept" }, + bearerTokenEnvVar: "REMOTE_MCP_TOKEN", + }); + + await updateMcpServer(rig, { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + auth: "oauth", + }); + + await expect(rig.harness.listMcpServers()).resolves.toEqual([ + { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + headers: { "X-Workspace": "kept" }, + bearerTokenEnvVar: "REMOTE_MCP_TOKEN", + auth: "oauth", + }, + ]); + }); + + it("removes stored stdio arguments when the structured edit omits them", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "local", + transport: "stdio", + command: "old-command", + args: ["--old"], + env: { KEEP: "yes" }, + }); + + const servers = await updateMcpServer(rig, { + originalName: "local", + server: { + name: "local", + transport: "stdio", + command: "new-command", + env: { KEEP: "yes" }, + }, + }); + + expect(servers).toEqual([ + { + name: "local", + transport: "stdio", + command: "new-command", + env: { KEEP: "yes" }, + }, + ]); + }); + + it("removes stored stdio environment variables when the structured edit omits them", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "local", + transport: "stdio", + command: "old-command", + args: ["--keep"], + env: { REMOVE_TOKEN: "stored-secret" }, + }); + + const servers = await updateMcpServer(rig, { + originalName: "local", + server: { + name: "local", + transport: "stdio", + command: "new-command", + args: ["--keep"], + }, + }); + + expect(servers).toEqual([ + { + name: "local", + transport: "stdio", + command: "new-command", + args: ["--keep"], + }, + ]); + }); + + it("removes stored HTTP headers when the structured edit omits them", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "remote", + transport: "http", + url: "https://old.example.test/mcp", + headers: { Authorization: "Bearer stored-secret" }, + bearerTokenEnvVar: "KEEP_TOKEN", + auth: "oauth", + }); + + const servers = await updateMcpServer(rig, { + originalName: "remote", + server: { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + bearerTokenEnvVar: "KEEP_TOKEN", + auth: "oauth", + }, + }); + + expect(servers).toEqual([ + { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + bearerTokenEnvVar: "KEEP_TOKEN", + auth: "oauth", + }, + ]); + }); + + it("removes the stored bearer token reference when the structured edit omits it", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "remote", + transport: "http", + url: "https://old.example.test/mcp", + headers: { "X-Keep": "yes" }, + bearerTokenEnvVar: "REMOVE_TOKEN", + auth: "oauth", + }); + + const servers = await updateMcpServer(rig, { + originalName: "remote", + server: { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + headers: { "X-Keep": "yes" }, + auth: "oauth", + }, + }); + + expect(servers).toEqual([ + { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + headers: { "X-Keep": "yes" }, + auth: "oauth", + }, + ]); + }); + + it("switches an OAuth HTTP server back to ordinary HTTP when auth is omitted", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "remote", + transport: "http", + url: "https://old.example.test/mcp", + headers: { "X-Keep": "yes" }, + bearerTokenEnvVar: "KEEP_TOKEN", + auth: "oauth", + }); + + const servers = await updateMcpServer(rig, { + originalName: "remote", + server: { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + headers: { "X-Keep": "yes" }, + bearerTokenEnvVar: "KEEP_TOKEN", + }, + }); + + expect(servers).toEqual([ + { + name: "remote", + transport: "http", + url: "https://new.example.test/mcp", + headers: { "X-Keep": "yes" }, + bearerTokenEnvVar: "KEEP_TOKEN", + }, + ]); + }); + + it("moves an edited server from its original name to the new name", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "old-name", + transport: "stdio", + command: "old-command", + env: { API_TOKEN: "stored-secret" }, + enabled: false, + }); + + const servers = await updateMcpServer(rig, { + originalName: "old-name", + server: { + name: "new-name", + transport: "stdio", + command: "new-command", + env: { API_TOKEN: MCP_SECRET_MASK }, + }, + }); + + expect(servers).toEqual([ + { + name: "new-name", + transport: "stdio", + command: "new-command", + env: { API_TOKEN: MCP_SECRET_MASK }, + enabled: false, + }, + ]); + await expect(rig.harness.listMcpServers()).resolves.toEqual([ + { + name: "new-name", + transport: "stdio", + command: "new-command", + env: { API_TOKEN: "stored-secret" }, + enabled: false, + }, + ]); + }); + + it("preserves a Windows executable path containing spaces through a structured edit", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "windows", + transport: "stdio", + command: "old-command", + }); + + const servers = await updateMcpServer(rig, { + originalName: "windows", + server: { + name: "windows", + transport: "stdio", + command: "C:\\Program Files\\Example MCP\\server.exe", + }, + }); + + expect(servers).toEqual([ + { + name: "windows", + transport: "stdio", + command: "C:\\Program Files\\Example MCP\\server.exe", + }, + ]); + }); + + it("preserves Windows arguments containing spaces through a structured edit", async () => { + const rig = await createMcpHandlerRig(); + await rig.harness.addMcpServer({ + name: "windows", + transport: "stdio", + command: "node.exe", + }); + + const servers = await updateMcpServer(rig, { + originalName: "windows", + server: { + name: "windows", + transport: "stdio", + command: "node.exe", + args: ["--config", "C:\\Users\\Example User\\mcp config.json", "literal value"], + }, + }); + + expect(servers).toEqual([ + { + name: "windows", + transport: "stdio", + command: "node.exe", + args: ["--config", "C:\\Users\\Example User\\mcp config.json", "literal value"], + }, + ]); + }); + + it("lists a closed VS Code session from a plain harness", async () => { + const rig = await createRuntimeRig(); + const vscodeSession = await openRuntimeSession(rig); + await rig.runtime.detachView("view-1"); + const plain = await createPlainHarness(rig.homeDir); + + const listed = await plain.listSessions({ workDir: rig.workDir }); + + expect(listed).toContainEqual(expect.objectContaining({ id: vscodeSession.id })); + }); + + it("resumes a closed VS Code session from a plain harness", async () => { + const rig = await createRuntimeRig(); + const vscodeSession = await openRuntimeSession(rig); + await rig.runtime.detachView("view-1"); + const plain = await createPlainHarness(rig.homeDir); + + const resumed = await plain.resumeSession({ id: vscodeSession.id }); + + expect(resumed.id).toBe(vscodeSession.id); + }); + + it("lists a closed plain-harness session from VS Code", async () => { + const rig = await createRuntimeRig(); + const plain = await createPlainHarness(rig.homeDir); + const plainSession = await plain.createSession({ + id: "ses_plain_to_vscode", + workDir: rig.workDir, + model: MODEL_ALIAS, + }); + await plainSession.close(); + + const listed = await rig.runtime.harness.listSessions({ workDir: rig.workDir }); + + expect(listed).toContainEqual(expect.objectContaining({ id: plainSession.id })); + }); + + it("resumes a closed plain-harness session from VS Code", async () => { + const rig = await createRuntimeRig(); + const plain = await createPlainHarness(rig.homeDir); + const plainSession = await plain.createSession({ + id: "ses_plain_to_vscode", + workDir: rig.workDir, + model: MODEL_ALIAS, + }); + await plainSession.close(); + + const resumed = await openRuntimeSession(rig, plainSession.id); + + expect(resumed.id).toBe(plainSession.id); + }); + + it("backfills approval flags for a session migrated before the metadata field existed", async () => { + const rig = await createRuntimeRig(); + const legacySessionDir = join(rig.workDir, "legacy-session"); + await mkdir(legacySessionDir); + await writeFile( + join(legacySessionDir, "state.json"), + JSON.stringify({ approval: { yolo: false, afk: true } }), + "utf8", + ); + const plain = await createPlainHarness(rig.homeDir); + const migrated = await plain.createSession({ + id: "ses_preexisting_migration", + workDir: rig.workDir, + metadata: { kimi_cli_source_path: legacySessionDir }, + }); + await migrated.close(); + + const resumed = await openRuntimeSession(rig, migrated.id); + + expect(resumed.legacyApprovalFlags).toEqual({ yolo: false, afk: true }); + expect(resumed.summary?.metadata?.["vscode_legacy_approval"]).toEqual({ + yolo: false, + afk: true, + }); + }); + + it("reports corrupt legacy approval state and still opens the migrated session", async () => { + const rig = await createRuntimeRig(); + const legacySessionDir = join(rig.workDir, "corrupt-legacy-session"); + await mkdir(legacySessionDir); + await writeFile(join(legacySessionDir, "state.json"), "{not-json", "utf8"); + const plain = await createPlainHarness(rig.homeDir); + const migrated = await plain.createSession({ + id: "ses_corrupt_preexisting_migration", + workDir: rig.workDir, + metadata: { kimi_cli_source_path: legacySessionDir }, + }); + await migrated.close(); + + const resumed = await openRuntimeSession(rig, migrated.id); + + expect(resumed.legacyApprovalFlags).toEqual({ yolo: false, afk: false }); + expect(rig.logs).toContainEqual({ + message: "Unable to restore legacy session approval settings", + error: expect.any(SyntaxError), + }); + }); + + it("imports a UTF-8 text file into the same session without calling the model", async () => { + const rig = await createRuntimeRig(); + await writeFile(join(rig.workDir, "notes.md"), "Keep the public API stable.", "utf8"); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, "/import notes.md")).resolves.toBe(true); + + await expect(runtime.session.getContext()).resolves.toMatchObject({ + history: [ + { + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining("Keep the public API stable."), + }), + ]), + }, + ], + }); + expect(rig.provider.requests).toHaveLength(0); + expect(streamEvents(rig.broadcasts)).toContainEqual({ + type: "TurnBegin", + payload: { user_input: "/import notes.md", forkable: true }, + _sessionId: runtime.id, + }); + }); + + it("clears imported context without replacing the current session", async () => { + const rig = await createRuntimeRig(); + const runtime = await openRuntimeSession(rig); + await runtime.session.importContext("Prior context.", "file 'prior.md'"); + const sessionId = runtime.id; + + await expect(runSlash(runtime, "/clear")).resolves.toBe(true); + + expect(runtime.id).toBe(sessionId); + await expect(runtime.session.getContext()).resolves.toEqual({ history: [], tokenCount: 0 }); + }); + + it("toggles plan mode through the public session without calling the model", async () => { + const rig = await createRuntimeRig(); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, "/plan on")).resolves.toBe(true); + await expect(runtime.session.getStatus()).resolves.toMatchObject({ planMode: true }); + await expect(runSlash(runtime, "/plan off")).resolves.toBe(true); + await expect(runtime.session.getStatus()).resolves.toMatchObject({ planMode: false }); + expect(rig.provider.requests).toHaveLength(0); + }); + + it("keeps a slash-added directory after VS Code closes and resumes the session", async () => { + const rig = await createRuntimeRig(); + const additionalDir = join(rig.workDir, "directory with spaces"); + await mkdir(additionalDir); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, `/add-dir "${additionalDir}"`)).resolves.toBe(true); + const sessionId = runtime.id; + await rig.runtime.detachView("view-1"); + const resumed = await openRuntimeSession(rig, sessionId); + + expect(resumed.session.summary?.additionalDirs).toContain(additionalDir); + }); + + it("rejects an invalid plan subcommand without leaving the runtime busy", async () => { + const rig = await createRuntimeRig(); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, "/plan sideways")).rejects.toThrow( + "Unknown plan subcommand: sideways", + ); + + expect(runtime.isBusy).toBe(false); + }); + + it("stops a running init command without surfacing its late result", async () => { + const rig = await createRuntimeRig(); + const blocked = routeBlockedPrompt(rig.provider); + const runtime = await openRuntimeSession(rig); + const command = runSlash(runtime, "/init"); + await blocked.started; + + await runtime.cancel(); + blocked.release(); + + await expect(command).resolves.toBe(false); + expect(runtime.isBusy).toBe(false); + expect(JSON.stringify(streamEvents(rig.broadcasts))).not.toContain("late response"); + }); + + it("stops a running manual compaction through the compaction cancellation API", async () => { + const rig = await createRuntimeRig(); + const blocked = routeBlockedPrompt(rig.provider); + const runtime = await openRuntimeSession(rig); + await runtime.session.importContext("Enough prior context to compact.", "file 'prior.md'"); + const command = runSlash(runtime, "/compact keep decisions"); + await blocked.started; + + await runtime.cancel(); + blocked.release(); + + await expect(command).resolves.toBe(false); + expect(runtime.isBusy).toBe(false); + }); + + it("keeps the host action busy until manual compaction completes", async () => { + const rig = await createRuntimeRig(); + routeSuccessfulPrompt(rig.provider); + const runtime = await openRuntimeSession(rig); + await runtime.session.importContext("Enough prior context to compact.", "file 'prior.md'"); + + const command = runSlash(runtime, "/compact keep decisions"); + expect(runtime.isBusy).toBe(true); + + await expect(command).resolves.toBe(true); + expect(runtime.isBusy).toBe(false); + expect(streamEvents(rig.broadcasts)).toContainEqual({ + type: "CompactionEnd", + payload: {}, + _sessionId: runtime.id, + }); + }); + + it("keeps /yolo and /afk independent when they are combined", async () => { + const rig = await createRuntimeRig(); + const runtime = await openRuntimeSession(rig); + + await runSlash(runtime, "/yolo"); + expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); + await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + + await runSlash(runtime, "/afk"); + expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); + await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "auto" }); + + await runSlash(runtime, "/afk"); + expect(runtime.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); + await expect(runtime.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + }); + + it("applies the global yolo setting when a closed VS Code session reopens", async () => { + const rig = await createRuntimeRig(); + const first = await openRuntimeSession(rig); + await runSlash(first, "/yolo"); + await rig.runtime.detachView("view-1"); + + const reopened = await openRuntimeSession(rig, first.id); + expect(reopened.legacyApprovalFlags).toEqual({ yolo: false, afk: false }); + await expect(reopened.session.getStatus()).resolves.toMatchObject({ permission: "manual" }); + await rig.runtime.detachView("view-1"); + + const yoloReopened = await openRuntimeSession(rig, first.id, true); + expect(yoloReopened.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); + await expect(yoloReopened.session.getStatus()).resolves.toMatchObject({ permission: "yolo" }); + }); + + it("exports current context as Markdown under the workspace", async () => { + const rig = await createRuntimeRig(); + const runtime = await openRuntimeSession(rig); + await runtime.session.importContext("Prior context.", "file 'prior.md'"); + + await expect(runSlash(runtime, "/export exported.md")).resolves.toBe(true); + + const markdown = await readFile(join(rig.workDir, "exported.md"), "utf8"); + expect(markdown).toContain("# Kimi Session Export"); + expect(markdown).toContain("Prior context."); + }); + + it("releases the host action after an invalid import so another command can run", async () => { + const rig = await createRuntimeRig(); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, "/import missing.md", { + harness: rig.runtime.harness, + runtime: rig.runtime, + } as HandlerContext)).rejects.toThrow( + "is not a valid file path or session ID", + ); + + expect(runtime.isBusy).toBe(false); + await expect(runSlash(runtime, "/clear")).resolves.toBe(true); + }); + + it("rejects a non-text import without changing the session context", async () => { + const rig = await createRuntimeRig(); + await writeFile(join(rig.workDir, "archive.zip"), "not really a zip", "utf8"); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, "/import archive.zip")).rejects.toThrow( + "/import only supports text-based files", + ); + await expect(runtime.session.getContext()).resolves.toEqual({ history: [], tokenCount: 0 }); + }); + + it("rejects invalid UTF-8 import bytes with a readable error", async () => { + const rig = await createRuntimeRig(); + await writeFile(join(rig.workDir, "broken.txt"), Buffer.from([0xc3, 0x28])); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, "/import broken.txt")).rejects.toThrow( + "the file is not valid UTF-8 text", + ); + }); + + it("rejects an import larger than the public 10 MB limit", async () => { + const rig = await createRuntimeRig(); + await writeFile(join(rig.workDir, "large.txt"), Buffer.alloc(10 * 1024 * 1024 + 1, 0x61)); + const runtime = await openRuntimeSession(rig); + + await expect(runSlash(runtime, "/import large.txt")).rejects.toThrow( + "Maximum import size is 10 MB", + ); + }); + + it("reports an unwritable export path and leaves the runtime usable", async () => { + const rig = await createRuntimeRig(); + await writeFile(join(rig.workDir, "not-a-directory"), "blocking file", "utf8"); + const runtime = await openRuntimeSession(rig); + await runtime.session.importContext("Prior context.", "file 'prior.md'"); + + await expect(runSlash(runtime, "/export not-a-directory/export.md")).rejects.toThrow(); + + expect(runtime.isBusy).toBe(false); + await expect(runSlash(runtime, "/clear")).resolves.toBe(true); + }); + + it("settles the prompt as failed when the provider returns 400", async () => { + const rig = await createRuntimeRig(); + routeBadRequest(rig.provider); + const session = await openRuntimeSession(rig); + + await expect(session.prompt("reject this request")).resolves.toEqual({ status: "failed" }); + + expect(session.isBusy).toBe(false); + expect(streamEvents(rig.broadcasts)).toContainEqual( + expect.objectContaining({ type: "error", phase: "runtime" }), + ); + }); + + it("accepts a new prompt after a provider 400 ends the previous turn", async () => { + const rig = await createRuntimeRig(); + let calls = 0; + rig.provider.route("POST", "/v1/chat/completions", async (_request, reply) => { + calls += 1; + if (calls === 1) { + await reply.json(400, { + error: { message: "mock request rejected", type: "invalid_request_error" }, + }); + return; + } + await reply.sseJson(200, [ + completionChunk({ content: "recovered" }), + completionChunk({}, "stop"), + ]); + }); + const session = await openRuntimeSession(rig); + await session.prompt("first request"); + + await expect(session.prompt("second request")).resolves.toEqual({ status: "finished" }); + }); + + it("does not expose the provider token when reporting a provider 400", async () => { + const rig = await createRuntimeRig(); + routeBadRequest(rig.provider); + const session = await openRuntimeSession(rig); + + await session.prompt("reject this request"); + + expect(diagnosticText(rig)).not.toContain(PROVIDER_TOKEN); + }); + + it("keeps the provider's 400 detail in the extension-host log", async () => { + const rig = await createRuntimeRig(); + routeBadRequest(rig.provider); + const session = await openRuntimeSession(rig); + + await session.prompt("reject this request"); + + expect(rig.logs).toContainEqual(expect.objectContaining({ + message: "Session turn failed", + error: expect.objectContaining({ message: expect.stringContaining("mock request rejected") }), + })); + }); + + it("settles the prompt as failed when the provider connection is unavailable", async () => { + const rig = await createRuntimeRig(); + const session = await openRuntimeSession(rig); + await rig.closeProvider(); + + await expect(session.prompt("connection test")).resolves.toEqual({ status: "failed" }); + + expect(session.isBusy).toBe(false); + expect(streamEvents(rig.broadcasts)).toContainEqual( + expect.objectContaining({ + type: "error", + code: "provider.connection_error", + phase: "runtime", + }), + ); + }); +}); diff --git a/apps/vscode/test/kimi-runtime.test.ts b/apps/vscode/test/kimi-runtime.test.ts new file mode 100644 index 0000000000..6ea9044679 --- /dev/null +++ b/apps/vscode/test/kimi-runtime.test.ts @@ -0,0 +1,554 @@ +/** + * Scenario: the VS Code host owns one Kimi harness and routes Webviews to shared SDK sessions. + * Responsibilities: create/resume/switch, per-session settings, multi-view ownership, detach, and disposal. + * Wiring: KimiRuntime and SessionRuntime are real; in-memory Session/KimiHarness fakes form the public SDK boundary. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/kimi-runtime.test.ts + */ + +import type { + ApprovalHandler, + CreateSessionOptions, + Event, + JsonObject, + KimiHarness, + PermissionMode, + PromptInput, + QuestionHandler, + ResumeSessionInput, + Session, + SessionStatus, + SessionSummary, + ThinkingEffort, +} from "@moonshot-ai/kimi-code-sdk"; +import { describe, expect, it } from "vitest"; + +import { Events } from "../shared/bridge"; +import { KimiRuntime, type OpenSessionOptions } from "../src/runtime/kimi-runtime"; + +interface FakeSessionBoundary { + readonly session: Session; + readonly setModels: string[]; + readonly setThinkingEfforts: ThinkingEffort[]; + readonly setPermissions: PermissionMode[]; + readonly metadataUpdates: JsonObject[]; + readonly handlerInstallations: { approval: number; question: number }; + readonly subscriptionCount: () => number; + readonly closeCount: () => number; +} + +function createFakeSession( + id: string, + workDir: string, + initial: Partial = {}, + metadata?: JsonObject, +): FakeSessionBoundary { + const listeners = new Set<(event: Event) => void>(); + const setModels: string[] = []; + const setThinkingEfforts: ThinkingEffort[] = []; + const setPermissions: PermissionMode[] = []; + const metadataUpdates: JsonObject[] = []; + const handlerInstallations = { approval: 0, question: 0 }; + let subscriptions = 0; + let closes = 0; + let status: SessionStatus = { + model: initial.model ?? "kimi-test", + thinkingEffort: initial.thinkingEffort ?? "off", + permission: initial.permission ?? "manual", + planMode: initial.planMode ?? false, + contextTokens: 0, + maxContextTokens: 128_000, + contextUsage: 0, + }; + let summary: SessionSummary = { + id, + workDir, + sessionDir: `/home/sessions/${id}`, + createdAt: 1, + updatedAt: 2, + metadata, + }; + + const session = { + id, + workDir, + get summary() { + return summary; + }, + set summary(value: SessionSummary | undefined) { + if (value !== undefined) summary = value; + }, + setApprovalHandler(handler: ApprovalHandler | undefined) { + if (handler !== undefined) handlerInstallations.approval += 1; + }, + setQuestionHandler(handler: QuestionHandler | undefined) { + if (handler !== undefined) handlerInstallations.question += 1; + }, + onEvent(listener: (event: Event) => void) { + subscriptions += 1; + listeners.add(listener); + return () => listeners.delete(listener); + }, + async prompt(_input: string | PromptInput) {}, + async steer(_input: string | PromptInput) {}, + async cancel() {}, + async getStatus() { + return status; + }, + async setModel(model: string) { + setModels.push(model); + status = { ...status, model }; + }, + async setThinking(effort: ThinkingEffort) { + setThinkingEfforts.push(effort); + status = { ...status, thinkingEffort: effort }; + }, + async setPermission(permission: PermissionMode) { + setPermissions.push(permission); + status = { ...status, permission }; + }, + async updateMetadata(patch: JsonObject) { + metadataUpdates.push(patch); + summary = { ...summary, metadata: { ...summary.metadata, ...patch } }; + }, + async close() { + closes += 1; + }, + } as unknown as Session; + + return { + session, + setModels, + setThinkingEfforts, + setPermissions, + metadataUpdates, + handlerInstallations, + subscriptionCount: () => subscriptions, + closeCount: () => closes, + }; +} + +interface FakeHarnessBoundary { + readonly harness: KimiHarness; + readonly createInputs: CreateSessionOptions[]; + readonly resumeInputs: ResumeSessionInput[]; + readonly closeSessionIds: string[]; + readonly deleteSessionIds: string[]; + readonly closeCount: () => number; + readonly sessions: Map; + addSession( + id: string, + workDir: string, + initial?: Partial, + metadata?: JsonObject, + ): FakeSessionBoundary; +} + +function createFakeHarness( + normalizeCreatedWorkDir: (workDir: string) => string = (workDir) => workDir, +): FakeHarnessBoundary { + const sessions = new Map(); + const createInputs: CreateSessionOptions[] = []; + const resumeInputs: ResumeSessionInput[] = []; + const closeSessionIds: string[] = []; + const deleteSessionIds: string[] = []; + let creates = 0; + let closes = 0; + + const addSession = ( + id: string, + workDir: string, + initial?: Partial, + metadata?: JsonObject, + ) => { + const boundary = createFakeSession(id, workDir, initial, metadata); + sessions.set(id, boundary); + return boundary; + }; + + const harness = { + async createSession(options: CreateSessionOptions) { + createInputs.push(options); + creates += 1; + return addSession( + `created-${creates}`, + normalizeCreatedWorkDir(options.workDir), + { + model: options.model, + thinkingEffort: options.thinking, + permission: options.permission, + }, + options.metadata, + ).session; + }, + async resumeSession(input: ResumeSessionInput) { + resumeInputs.push(input); + const boundary = sessions.get(input.id); + if (boundary === undefined) throw new Error(`Unknown session: ${input.id}`); + return boundary.session; + }, + async closeSession(id: string) { + closeSessionIds.push(id); + }, + async deleteSession(id: string) { + deleteSessionIds.push(id); + }, + async close() { + closes += 1; + }, + } as unknown as KimiHarness; + + return { + harness, + createInputs, + resumeInputs, + closeSessionIds, + deleteSessionIds, + closeCount: () => closes, + sessions, + addSession, + }; +} + +function openOptions(overrides: Partial = {}): OpenSessionOptions { + return { + webviewId: "view-1", + workDir: "/workspace", + model: "kimi-test", + effort: "off", + yoloMode: false, + ...overrides, + }; +} + +function createRuntime( + normalizeCreatedWorkDir?: (workDir: string) => string, +) { + const sdk = createFakeHarness(normalizeCreatedWorkDir); + const runtime = new KimiRuntime({ + version: "0.6.0", + harness: sdk.harness, + broadcast: () => undefined, + captureBaseline: () => undefined, + log: () => undefined, + }); + return { runtime, sdk }; +} + +describe("Kimi runtime (owns shared SDK sessions for Webviews)", () => { + it("forwards the requested settings when creating an SDK session", async () => { + const { runtime, sdk } = createRuntime(); + + const opened = await runtime.openSession( + openOptions({ model: "kimi-k2", effort: "high", yoloMode: true }), + ); + + expect(sdk.createInputs).toEqual([ + { + workDir: "/workspace", + model: "kimi-k2", + thinking: "high", + permission: "yolo", + metadata: { vscode_legacy_approval: { yolo: true, afk: false } }, + }, + ]); + expect(opened.subscribers).toEqual(["view-1"]); + }); + + it("accepts the normalized SDK workDir when a Windows session is created", async () => { + const { runtime } = createRuntime((workDir) => workDir.replaceAll("\\", "/")); + + const opened = await runtime.openSession(openOptions({ + workDir: "C:\\Users\\Example User\\项目", + })); + + expect(opened.session.workDir).toBe("C:/Users/Example User/项目"); + }); + + it("resumes a Windows session when only separators and casing differ", async () => { + const { runtime, sdk } = createRuntime(); + sdk.addSession("saved-win", "C:/Users/Example User/项目"); + + const opened = await runtime.openSession(openOptions({ + sessionId: "saved-win", + workDir: "c:\\users\\example user\\项目", + })); + + expect(opened.id).toBe("saved-win"); + }); + + it("uses off thinking when a new session receives an empty effort", async () => { + const { runtime, sdk } = createRuntime(); + + await runtime.openSession(openOptions({ effort: " " })); + + expect(sdk.createInputs[0]?.thinking).toBe("off"); + }); + + it("resumes the requested SDK session instead of creating a replacement", async () => { + const { runtime, sdk } = createRuntime(); + sdk.addSession("saved-1", "/workspace"); + + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1" })); + + expect(opened.id).toBe("saved-1"); + expect(sdk.resumeInputs).toEqual([{ id: "saved-1", includeSubagents: true }]); + expect(sdk.createInputs).toEqual([]); + }); + + it("switches a Webview to the requested session", async () => { + const { runtime, sdk } = createRuntime(); + await runtime.openSession(openOptions()); + sdk.addSession("saved-2", "/workspace"); + + const selected = await runtime.openSession(openOptions({ sessionId: "saved-2" })); + + expect(runtime.getSessionForView("view-1")?.id).toBe("saved-2"); + expect(selected.id).toBe("saved-2"); + }); + + it("closes an unshared old session when its Webview switches away", async () => { + const { runtime, sdk } = createRuntime(); + const old = await runtime.openSession(openOptions()); + const oldBoundary = sdk.sessions.get(old.id)!; + sdk.addSession("saved-2", "/workspace"); + + await runtime.openSession(openOptions({ sessionId: "saved-2" })); + + expect(oldBoundary.closeCount()).toBe(1); + }); + + it("shares one SDK subscription when two Webviews attach to the same session", async () => { + const { runtime, sdk } = createRuntime(); + const first = await runtime.openSession(openOptions({ webviewId: "view-1" })); + const boundary = sdk.sessions.get(first.id)!; + + const second = await runtime.openSession( + openOptions({ webviewId: "view-2", sessionId: first.id }), + ); + + expect(second).toBe(first); + expect(first.subscribers).toEqual(["view-1", "view-2"]); + expect(boundary.subscriptionCount()).toBe(1); + expect(boundary.handlerInstallations).toEqual({ approval: 1, question: 1 }); + }); + + it("updates the SDK model when the resumed session has a different model", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession("saved-1", "/workspace", { model: "old-model" }); + + await runtime.openSession(openOptions({ sessionId: "saved-1", model: "new-model" })); + + expect(session.setModels).toEqual(["new-model"]); + }); + + it("preserves the resumed session's thinking effort instead of reapplying the configured default", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession("saved-1", "/workspace", { thinkingEffort: "max" }); + + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", effort: "medium" })); + + expect(session.setThinkingEfforts).toEqual([]); + await expect(opened.session.getStatus()).resolves.toMatchObject({ thinkingEffort: "max" }); + }); + + it("announces the session's actual status to the attaching view so the display matches it", async () => { + const sdk = createFakeHarness(); + const broadcasts: { event: string; data: unknown; webviewId?: string }[] = []; + const runtime = new KimiRuntime({ + version: "0.6.0", + harness: sdk.harness, + broadcast: (event, data, webviewId) => { + broadcasts.push({ event, data, webviewId }); + }, + captureBaseline: () => undefined, + log: () => undefined, + }); + sdk.addSession("saved-1", "/workspace", { + model: "kimi-test", + thinkingEffort: "max", + planMode: true, + }); + + await runtime.openSession(openOptions({ sessionId: "saved-1", effort: "medium" })); + + expect(broadcasts).toContainEqual({ + event: Events.StreamEvent, + data: { + type: "StatusUpdate", + payload: { model: "kimi-test", thinking_effort: "max", plan_mode: true }, + _sessionId: "saved-1", + }, + webviewId: "view-1", + }); + }); + + it("uses the yolo setting as the initial value for an unmarked resumed session", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession("saved-1", "/workspace", { permission: "manual" }); + + await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); + + expect(session.metadataUpdates).toEqual([ + { vscode_legacy_approval: { yolo: true, afk: false } }, + ]); + }); + + it("lets the global yolo setting override a persisted off flag on resume", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession( + "saved-1", + "/workspace", + { permission: "manual" }, + { vscode_legacy_approval: { yolo: false, afk: false } }, + ); + + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); + + expect(session.setPermissions).toEqual(["yolo"]); + expect(session.metadataUpdates).toEqual([ + { vscode_legacy_approval: { yolo: true, afk: false } }, + ]); + expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: false }); + }); + + it("lets the global yolo setting disable a persisted session yolo flag on resume", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession( + "saved-1", + "/workspace", + { permission: "yolo" }, + { vscode_legacy_approval: { yolo: true, afk: false } }, + ); + + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); + + expect(session.setPermissions).toEqual(["manual"]); + expect(session.metadataUpdates).toEqual([ + { vscode_legacy_approval: { yolo: false, afk: false } }, + ]); + expect(opened.legacyApprovalFlags).toEqual({ yolo: false, afk: false }); + }); + + it("keeps the persisted afk flag while applying the global yolo setting on resume", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession( + "saved-1", + "/workspace", + { permission: "manual" }, + { vscode_legacy_approval: { yolo: false, afk: true } }, + ); + + const opened = await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: true })); + + expect(session.setPermissions).toEqual(["auto"]); + expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); + }); + + it("restores persisted afk with core auto permission", async () => { + const { runtime, sdk } = createRuntime(); + const session = sdk.addSession( + "saved-1", + "/workspace", + { permission: "manual" }, + { vscode_legacy_approval: { yolo: false, afk: true } }, + ); + + await runtime.openSession(openOptions({ sessionId: "saved-1", yoloMode: false })); + + expect(session.setPermissions).toEqual(["auto"]); + }); + + it("changes the setting-backed yolo flag without clearing session afk", async () => { + const { runtime } = createRuntime(); + const opened = await runtime.openSession(openOptions()); + await opened.toggleLegacyApproval("afk"); + + await runtime.setYoloModeForActiveSessions(true); + + expect(opened.legacyApprovalFlags).toEqual({ yolo: true, afk: true }); + await expect(opened.session.getStatus()).resolves.toMatchObject({ permission: "auto" }); + }); + + it("keeps a shared session open when one of its Webviews detaches", async () => { + const { runtime, sdk } = createRuntime(); + const opened = await runtime.openSession(openOptions({ webviewId: "view-1" })); + await runtime.openSession(openOptions({ webviewId: "view-2", sessionId: opened.id })); + const boundary = sdk.sessions.get(opened.id)!; + + await runtime.detachView("view-1"); + + expect(boundary.closeCount()).toBe(0); + expect(runtime.getSessionForView("view-2")?.id).toBe(opened.id); + }); + + it("closes an SDK session when its last Webview detaches", async () => { + const { runtime, sdk } = createRuntime(); + const opened = await runtime.openSession(openOptions()); + const boundary = sdk.sessions.get(opened.id)!; + + await runtime.detachView("view-1"); + + expect(boundary.closeCount()).toBe(1); + expect(runtime.getSession(opened.id)).toBeUndefined(); + }); + + it("reattaches the same resumed session without replacing its handlers", async () => { + const { runtime, sdk } = createRuntime(); + const boundary = sdk.addSession("saved-1", "/workspace"); + const first = await runtime.attachResumedSession("view-1", boundary.session); + + const second = await runtime.attachResumedSession("view-1", boundary.session); + + expect(second).toBe(first); + expect(boundary.subscriptionCount()).toBe(1); + expect(boundary.handlerInstallations).toEqual({ approval: 1, question: 1 }); + expect(boundary.closeCount()).toBe(0); + }); + + it("removes every Webview mapping when a shared session is closed", async () => { + const { runtime } = createRuntime(); + const opened = await runtime.openSession(openOptions({ webviewId: "view-1" })); + await runtime.openSession(openOptions({ webviewId: "view-2", sessionId: opened.id })); + + await runtime.closeSession(opened.id); + + expect(runtime.getSessionForView("view-1")).toBeUndefined(); + expect(runtime.getSessionForView("view-2")).toBeUndefined(); + }); + + it("delegates deletion after the active session has been closed", async () => { + const { runtime, sdk } = createRuntime(); + const opened = await runtime.openSession(openOptions()); + const boundary = sdk.sessions.get(opened.id)!; + + await runtime.deleteSession(opened.id); + + expect(boundary.closeCount()).toBe(1); + expect(sdk.deleteSessionIds).toEqual([opened.id]); + }); + + it("closes every active SDK session when the host runtime is disposed", async () => { + const { runtime, sdk } = createRuntime(); + const first = await runtime.openSession(openOptions({ webviewId: "view-1" })); + const secondBoundary = sdk.addSession("saved-2", "/workspace"); + await runtime.openSession(openOptions({ webviewId: "view-2", sessionId: "saved-2" })); + + await runtime.dispose(); + + expect(sdk.sessions.get(first.id)?.closeCount()).toBe(1); + expect(secondBoundary.closeCount()).toBe(1); + expect(sdk.closeCount()).toBe(1); + }); + + it("does not retain a resumed session when it belongs to a different working directory", async () => { + const { runtime, sdk } = createRuntime(); + const foreign = sdk.addSession("foreign-1", "/other-workspace"); + + await expect( + runtime.openSession(openOptions({ sessionId: "foreign-1" })), + ).rejects.toThrow("The selected session belongs to a different working directory."); + + expect(runtime.getSession("foreign-1")).toBeUndefined(); + expect(foreign.closeCount()).toBe(1); + }); +}); diff --git a/apps/vscode/test/legacy-migration.manager.test.ts b/apps/vscode/test/legacy-migration.manager.test.ts new file mode 100644 index 0000000000..d246759c60 --- /dev/null +++ b/apps/vscode/test/legacy-migration.manager.test.ts @@ -0,0 +1,489 @@ +/** + * Scenario: VS Code discovers and runs legacy kimi-cli migration without touching a real home. + * Responsibilities: source selection, shared-marker suppression, real migration, retry, and clear reports. + * Wiring: real temporary files and the public migration package; no stubbed collaborators. + * Run: pnpm --filter kimi-code test -- legacy-migration.manager.test.ts + */ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { LegacyMigrationManager } from "../src/migration"; + +const temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +describe("legacy migration manager (discovery and migration coordination)", () => { + it("returns an actionable prompt when the default legacy home contains data", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt).toMatchObject({ + kind: "legacy-migration", + actions: [ + { id: "now", label: "Migrate Now" }, + { id: "later", label: "Later" }, + ], + sources: [ + { + sourceHome: rig.sourceHome, + origin: "default", + hasConfig: true, + }, + ], + }); + }); + + it("suppresses the prompt when the shared marker names the same target", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + await writeSharedMarker(rig.sourceHome, rig.targetHome); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt).toBeNull(); + expect(discovery.suppressedSources).toEqual([ + expect.objectContaining({ sourceHome: rig.sourceHome, hasConfig: true }), + ]); + }); + + it("returns the prompt when the shared marker names a different target", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + await writeSharedMarker(rig.sourceHome, join(rig.root, "another-target")); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt?.sources).toEqual([ + expect.objectContaining({ sourceHome: rig.sourceHome }), + ]); + }); + + it("does not duplicate data when the TUI already migrated the same source", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + const existingSession = join(rig.targetHome, "sessions", "existing", "state.json"); + await mkdir(join(existingSession, ".."), { recursive: true }); + await writeFile(existingSession, '{"custom":{"imported_from_kimi_cli":true}}'); + await writeSharedMarker(rig.sourceHome, rig.targetHome); + + const result = await rig.manager.migrateNow(); + + expect(result.status).toBe("nothing-to-migrate"); + await expect(readFile(existingSession, "utf-8")).resolves.toContain( + "imported_from_kimi_cli", + ); + await expect(readFile(join(rig.targetHome, "config.toml"), "utf-8")).rejects.toThrow(); + }); + + it("conservatively suppresses the prompt when the shared marker is corrupt", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + await writeFile(join(rig.sourceHome, ".migrated-to-kimi-code"), "not-json"); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt).toBeNull(); + expect(discovery.suppressedSources).toHaveLength(1); + }); + + it("discovers a relative legacy KIMI_SHARE_DIR from the workspace and warns about its resolution", async () => { + const rig = await createRig({ + legacyEnvironmentVariables: { KIMI_SHARE_DIR: "legacy-kimi" }, + }); + const shareHome = join(rig.workspaceRoot, "legacy-kimi"); + await writeLegacyConfig(shareHome); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt?.sources).toEqual([ + expect.objectContaining({ + sourceHome: resolve(shareHome), + origin: "legacy-vscode-setting", + }), + ]); + expect(discovery.warnings).toEqual([ + expect.objectContaining({ code: "relative-share-dir", sourceHome: resolve(shareHome) }), + ]); + }); + + it("migrates both the default home and the extra legacy KIMI_SHARE_DIR source", async () => { + const rig = await createRig({ + legacyEnvironmentVariables: { KIMI_SHARE_DIR: "legacy-kimi" }, + }); + await writeLegacyConfig(rig.sourceHome); + const shareHome = join(rig.workspaceRoot, "legacy-kimi"); + await mkdir(join(shareHome, "skills", "example-skill"), { recursive: true }); + await writeFile( + join(shareHome, "skills", "example-skill", "SKILL.md"), + "---\nname: example-skill\ndescription: test fixture\n---\n", + ); + + const result = await rig.manager.retry(); + + expect(result.status).toBe("completed"); + expect(result.sources).toHaveLength(2); + expect(result.totals).toMatchObject({ configFiles: 1, skills: 1 }); + await expect( + readFile(join(rig.targetHome, "skills", "example-skill", "SKILL.md"), "utf-8"), + ).resolves.toContain("example-skill"); + }); + + it("ignores legacy environment variables other than KIMI_SHARE_DIR", async () => { + const rig = await createRig({ + legacyEnvironmentVariables: { + KIMI_CODE_HOME: join(tmpdir(), "must-not-be-read"), + PATH: join(tmpdir(), "must-not-be-used"), + }, + }); + + const discovery = await rig.manager.discover(); + + expect(discovery).toMatchObject({ prompt: null, warnings: [] }); + }); + + it("ignores a non-string legacy KIMI_SHARE_DIR with a clear warning", async () => { + const rig = await createRig({ + legacyEnvironmentVariables: { KIMI_SHARE_DIR: 42, HTTPS_PROXY: "https://example.test" }, + }); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt).toBeNull(); + expect(discovery.warnings).toEqual([ + expect.objectContaining({ + code: "invalid-share-dir", + message: expect.stringContaining("non-empty string"), + }), + ]); + }); + + it("ignores a relative legacy KIMI_SHARE_DIR when no workspace can resolve it", async () => { + const rig = await createRig({ + workspaceRoot: null, + legacyEnvironmentVariables: { KIMI_SHARE_DIR: "legacy-kimi" }, + }); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt).toBeNull(); + expect(discovery.warnings).toEqual([ + expect.objectContaining({ + code: "invalid-share-dir", + message: expect.stringContaining("no workspace is open"), + }), + ]); + }); + + it("bypasses a completed marker on explicit retry and runs the real migration", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + await writeSharedMarker(rig.sourceHome, rig.targetHome); + + const result = await rig.manager.retry(); + + expect(result.status).toBe("completed"); + expect(result.totals.configFiles).toBe(1); + await expect(readFile(join(rig.targetHome, "config.toml"), "utf-8")).resolves.toContain( + "merge_all_available_skills", + ); + }); + + it("keeps the migrated target unchanged when an explicit retry is repeated", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + await rig.manager.retry(); + const firstTarget = await readFile(join(rig.targetHome, "config.toml"), "utf-8"); + + const secondResult = await rig.manager.retry(); + + expect(secondResult.status).toBe("completed"); + await expect(readFile(join(rig.targetHome, "config.toml"), "utf-8")).resolves.toBe( + firstTarget, + ); + }); + + it("reports a corrupt session as a partial migration without rolling back valid data", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + await writeCorruptLegacySession(rig.sourceHome, rig.workspaceRoot); + + const result = await rig.manager.retry(); + + expect(result.status).toBe("partial"); + expect(result.sources[0]?.failures).toEqual([ + expect.objectContaining({ + code: "session-failed", + message: expect.stringMatching(/corrupt|parseable/i), + }), + ]); + await expect(readFile(join(rig.targetHome, "config.toml"), "utf-8")).resolves.toContain( + "merge_all_available_skills", + ); + }); + + it("discovers an unknown workdir bucket as actionable legacy data", async () => { + const rig = await createRig(); + const bucket = await writeUnknownWorkdirBucket(rig.sourceHome); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt?.sources).toEqual([ + expect.objectContaining({ + sourceHome: rig.sourceHome, + totalSessions: 0, + sessionIssues: 1, + }), + ]); + expect(discovery.warnings).toEqual([ + expect.objectContaining({ + code: "legacy-session-unreadable", + message: expect.stringContaining(bucket), + }), + ]); + }); + + it("returns a partial result with a manual action for an unknown workdir bucket", async () => { + const rig = await createRig(); + const bucket = await writeUnknownWorkdirBucket(rig.sourceHome); + + const result = await rig.manager.retry(); + + expect(result.status).toBe("partial"); + expect(result.sources[0]?.failures).toEqual([ + expect.objectContaining({ code: "session-failed", item: bucket }), + ]); + expect(result.manualActions).toEqual([expect.stringContaining(bucket)]); + expect(result.warnings).toEqual([ + expect.objectContaining({ code: "legacy-session-unreadable" }), + ]); + }); + + it("returns a partial result with a warning when a registered bucket is unreadable", async () => { + const rig = await createRig(); + const workDir = rig.workspaceRoot; + await mkdir(rig.sourceHome, { recursive: true }); + await writeFile( + join(rig.sourceHome, "kimi.json"), + JSON.stringify({ work_dirs: [{ path: workDir, kaos: "local" }] }), + ); + const bucket = join( + rig.sourceHome, + "sessions", + createHash("md5").update(workDir).digest("hex"), + ); + await mkdir(join(rig.sourceHome, "sessions"), { recursive: true }); + await writeFile(bucket, "not a directory"); + + const result = await rig.manager.retry(); + + expect(result.status).toBe("partial"); + expect(result.sources[0]?.failures).toEqual([ + expect.objectContaining({ code: "session-failed", item: bucket }), + ]); + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: "legacy-session-unreadable", + message: expect.stringContaining(bucket), + }), + ]); + }); + + it("keeps an unresolved session source discoverable after a partial run", async () => { + const rig = await createRig(); + await writeUnknownWorkdirBucket(rig.sourceHome); + await rig.manager.retry(); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt?.sources).toEqual([ + expect.objectContaining({ sourceHome: rig.sourceHome, sessionIssues: 1 }), + ]); + }); + + it("reports an unresolved session warning when a shared marker suppresses the prompt", async () => { + const rig = await createRig(); + const bucket = await writeUnknownWorkdirBucket(rig.sourceHome); + await writeSharedMarker(rig.sourceHome, rig.targetHome); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt).toBeNull(); + expect(discovery.suppressedSources).toEqual([ + expect.objectContaining({ sourceHome: rig.sourceHome, sessionIssues: 1 }), + ]); + expect(discovery.warnings).toEqual([ + expect.objectContaining({ + code: "legacy-session-unreadable", + message: expect.stringContaining(bucket), + }), + ]); + }); + + it("reports an unparseable legacy config with a manual review action", async () => { + const rig = await createRig(); + await mkdir(rig.sourceHome, { recursive: true }); + await writeFile(join(rig.sourceHome, "config.toml"), 'broken = "unterminated'); + + const result = await rig.manager.retry(); + + expect(result.status).toBe("partial"); + expect(result.sources[0]?.failures).toEqual([ + expect.objectContaining({ code: "legacy-config-unreadable", item: "config.toml" }), + ]); + expect(result.manualActions).toEqual([ + expect.stringContaining("review it manually"), + ]); + }); + + it("reports an unparseable legacy MCP file with a manual review action", async () => { + const rig = await createRig(); + await mkdir(rig.sourceHome, { recursive: true }); + await writeFile(join(rig.sourceHome, "mcp.json"), "{broken"); + + const result = await rig.manager.retry(); + + expect(result.status).toBe("partial"); + expect(result.sources[0]?.failures).toEqual([ + expect.objectContaining({ code: "legacy-mcp-unreadable", item: "mcp.json" }), + ]); + }); + + it("returns a failed result with the original filesystem reason when the target is blocked", async () => { + const rig = await createRig(); + await writeLegacyConfig(rig.sourceHome); + await writeFile(rig.targetHome, "this file blocks the target directory"); + + const result = await rig.manager.retry(); + + expect(result.status).toBe("failed"); + expect(result.sources[0]?.failures).toEqual([ + expect.objectContaining({ + code: "run-failed", + message: expect.stringMatching(/file|directory|exist|not a directory/i), + }), + ]); + expect(result.manualActions).toEqual([ + expect.stringContaining("Migrate Legacy Data"), + ]); + }); + + it("reports legacy OAuth login as requiring a new login without treating it as migratable data", async () => { + const rig = await createRig(); + await mkdir(join(rig.sourceHome, "credentials"), { recursive: true }); + await writeFile(join(rig.sourceHome, "credentials", "kimi-code.json"), "{}"); + + const discovery = await rig.manager.discover(); + + expect(discovery.prompt).toBeNull(); + expect(discovery.notices.oauthLoginsRequiringRelogin).toEqual([ + { sourceHome: rig.sourceHome, name: "kimi-code.json" }, + ]); + }); + + it("reports legacy MCP OAuth state as requiring reauthorization", async () => { + const rig = await createRig(); + await mkdir(join(rig.sourceHome, "mcp-oauth"), { recursive: true }); + await writeFile(join(rig.sourceHome, "mcp-oauth", "example-server"), "{}"); + + const discovery = await rig.manager.discover(); + + expect(discovery.notices.mcpOauthServersRequiringReauth).toEqual([ + { sourceHome: rig.sourceHome, name: "example-server" }, + ]); + }); + + it("reports a configured migration source that is not a directory", async () => { + const rig = await createRig(); + await writeFile(rig.sourceHome, "not a directory"); + + const discovery = await rig.manager.discover(); + + expect(discovery.warnings).toEqual([ + expect.objectContaining({ + code: "source-not-directory", + sourceHome: rig.sourceHome, + }), + ]); + }); +}); + +interface RigOptions { + readonly workspaceRoot?: string | null; + readonly legacyEnvironmentVariables?: unknown; +} + +async function createRig(options: RigOptions = {}): Promise<{ + readonly root: string; + readonly sourceHome: string; + readonly targetHome: string; + readonly workspaceRoot: string; + readonly manager: LegacyMigrationManager; +}> { + const root = await mkdtemp(join(tmpdir(), "vscode-legacy-migration-")); + temporaryRoots.push(root); + const sourceHome = join(root, ".kimi"); + const targetHome = join(root, ".kimi-code"); + const workspaceRoot = join(root, "workspace"); + await mkdir(options.workspaceRoot === null ? root : workspaceRoot, { recursive: true }); + const manager = new LegacyMigrationManager({ + targetHome, + defaultSourceHome: sourceHome, + workspaceRoot: options.workspaceRoot === undefined ? workspaceRoot : options.workspaceRoot, + legacyEnvironmentVariables: options.legacyEnvironmentVariables, + }); + return { root, sourceHome, targetHome, workspaceRoot, manager }; +} + +async function writeLegacyConfig(sourceHome: string): Promise { + await mkdir(sourceHome, { recursive: true }); + await writeFile(join(sourceHome, "config.toml"), "merge_all_available_skills = true\n"); +} + +async function writeSharedMarker(sourceHome: string, targetHome: string): Promise { + await mkdir(sourceHome, { recursive: true }); + await writeFile( + join(sourceHome, ".migrated-to-kimi-code"), + JSON.stringify({ version: 1, target_path: targetHome, runs: [] }), + ); +} + +async function writeCorruptLegacySession( + sourceHome: string, + workDir: string, +): Promise { + await mkdir(sourceHome, { recursive: true }); + await writeFile( + join(sourceHome, "kimi.json"), + JSON.stringify({ work_dirs: [{ path: workDir, kaos: "local" }] }), + ); + const bucket = createHash("md5").update(workDir).digest("hex"); + const sessionDir = join(sourceHome, "sessions", bucket, "corrupt-session"); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, "context.jsonl"), "not-json\n{broken\n}}}\n"); + await writeFile(join(sessionDir, "state.json"), "{}"); +} + +async function writeUnknownWorkdirBucket(sourceHome: string): Promise { + const bucket = join( + sourceHome, + "sessions", + createHash("md5").update("/workspace/not-registered").digest("hex"), + ); + await mkdir(join(bucket, "legacy-session"), { recursive: true }); + await writeFile( + join(bucket, "legacy-session", "context.jsonl"), + '{"role":"user","content":"hello"}\n', + ); + return bucket; +} diff --git a/apps/vscode/test/replay-adapter.test.ts b/apps/vscode/test/replay-adapter.test.ts new file mode 100644 index 0000000000..34ea632b82 --- /dev/null +++ b/apps/vscode/test/replay-adapter.test.ts @@ -0,0 +1,569 @@ +/** + * Scenario: a resumed Node SDK transcript is rendered through the released VS Code Webview protocol. + * Responsibilities: visible turns, media, assistant/tool output, subagent routing, compaction, plan state, and hidden injections. + * Wiring: the pure replay adapter and public SDK replay types are used directly; there are no stubs. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/replay-adapter.test.ts + */ + +import type { + AgentReplayRecord, + ContentPart, + ResumedAgentState, + ResumedSessionState, + ToolCall, +} from "@moonshot-ai/kimi-code-sdk"; +import { describe, expect, it } from "vitest"; + +import { + replayRecordTurnCount, + replaySessionToWebviewEvents, + replayToWebviewEvents, +} from "../src/runtime/replay-adapter"; + +type ReplayMessage = Extract["message"]; + +function message( + role: ReplayMessage["role"], + content: ContentPart[], + options: { + readonly toolCalls?: ToolCall[]; + readonly toolCallId?: string; + readonly isError?: boolean; + readonly origin?: ReplayMessage["origin"]; + } = {}, +): ReplayMessage { + return { + role, + content, + toolCalls: options.toolCalls ?? [], + toolCallId: options.toolCallId, + isError: options.isError, + origin: options.origin, + }; +} + +function record(messageValue: ReplayMessage, time: number = 1): AgentReplayRecord { + return { type: "message", message: messageValue, time }; +} + +function resumedAgent( + replay: readonly AgentReplayRecord[], + options: { + readonly modelAlias?: string; + readonly thinkingEffort?: string; + readonly plan?: ResumedAgentState["plan"]; + readonly contextTokenCount?: number; + readonly usage?: ResumedAgentState["usage"]; + readonly type?: ResumedAgentState["type"]; + } = {}, +): ResumedAgentState { + return { + type: options.type ?? "main", + config: { + cwd: "/workspace", + modelAlias: options.modelAlias ?? "kimi-test", + modelCapabilities: { + image_in: true, + video_in: true, + audio_in: false, + thinking: true, + tool_use: true, + max_context_tokens: 128_000, + }, + thinkingEffort: options.thinkingEffort ?? "off", + systemPrompt: "", + }, + context: { history: [], tokenCount: options.contextTokenCount ?? 0 }, + replay, + permission: { mode: "manual", rules: [] }, + plan: options.plan ?? null, + usage: options.usage ?? {}, + tools: [], + background: [], + }; +} + +function replay(records: readonly AgentReplayRecord[]) { + return replayToWebviewEvents(resumedAgent(records), "session-1"); +} + +describe("replay adapter (renders the public SDK resume state for the Webview)", () => { + it("restores the selected model before transcript events", () => { + const agent = resumedAgent([]); + + expect(replayToWebviewEvents(agent, "session-1")[0]).toMatchObject({ + type: "StatusUpdate", + payload: { model: "kimi-test" }, + }); + }); + + it("restores thinking effort before transcript events", () => { + const agent = resumedAgent([], { thinkingEffort: "high" }); + + expect(replayToWebviewEvents(agent, "session-1")[0]).toMatchObject({ + type: "StatusUpdate", + payload: { thinking_effort: "high" }, + }); + }); + + it("restores active plan mode before transcript events", () => { + const agent = resumedAgent([], { + plan: { id: "plan-1", content: "Plan", path: "/workspace/plan.md" }, + }); + + expect(replayToWebviewEvents(agent, "session-1")[0]).toMatchObject({ + type: "StatusUpdate", + payload: { plan_mode: true }, + }); + }); + + it("restores context usage after all transcript turns", () => { + const agent = resumedAgent([], { contextTokenCount: 32_000 }); + + expect(replayToWebviewEvents(agent, "session-1").at(-1)).toMatchObject({ + type: "StatusUpdate", + payload: { context_usage: 0.25 }, + }); + }); + + it("restores cumulative token usage exactly once after all transcript turns", () => { + const agent = resumedAgent([], { + usage: { + total: { + inputOther: 10, + output: 4, + inputCacheRead: 3, + inputCacheCreation: 2, + }, + }, + }); + + const statusEvents = replayToWebviewEvents(agent, "session-1").filter( + (event) => + event.type === "StatusUpdate" && + typeof event.payload === "object" && + event.payload !== null && + "token_usage" in event.payload, + ); + expect(statusEvents).toEqual([ + { + type: "StatusUpdate", + payload: { + context_usage: 0, + token_usage: { + input_other: 10, + output: 4, + input_cache_read: 3, + input_cache_creation: 2, + }, + }, + _sessionId: "session-1", + }, + ]); + }); + + it("opens one visible turn when replay contains a user prompt", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Fix the test" }], { origin: { kind: "user" } })), + ]); + + expect(events.filter((event) => event.type !== "StatusUpdate")).toEqual([ + { + type: "TurnBegin", + payload: { user_input: [{ type: "text", text: "Fix the test" }] }, + _sessionId: "session-1", + }, + { + type: "stream_complete", + result: { status: "finished" }, + _sessionId: "session-1", + }, + ]); + }); + + it("converts SDK media keys when replay renders a user prompt", () => { + const events = replay([ + record( + message( + "user", + [ + { type: "image_url", imageUrl: { url: "file:///workspace/a.png", id: "image-1" } }, + { type: "audio_url", audioUrl: { url: "file:///workspace/a.mp3", id: "audio-1" } }, + { type: "video_url", videoUrl: { url: "file:///workspace/a.mp4", id: "video-1" } }, + ], + { origin: { kind: "user" } }, + ), + ), + ]); + + expect(events.find((event) => event.type === "TurnBegin")).toEqual({ + type: "TurnBegin", + payload: { + user_input: [ + { type: "image_url", image_url: { url: "file:///workspace/a.png", id: "image-1" } }, + { type: "audio_url", audio_url: { url: "file:///workspace/a.mp3", id: "audio-1" } }, + { type: "video_url", video_url: { url: "file:///workspace/a.mp4", id: "video-1" } }, + ], + }, + _sessionId: "session-1", + }); + }); + + it("renders assistant text inside the open turn", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Explain" }], { origin: { kind: "user" } })), + record(message("assistant", [{ type: "text", text: "Here is the answer" }]), 2), + ]); + + expect(events.filter((event) => event.type === "ContentPart")).toEqual([ + { + type: "ContentPart", + payload: { type: "text", text: "Here is the answer" }, + _sessionId: "session-1", + }, + ]); + }); + + it("renders signed thinking inside the open turn", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Explain" }], { origin: { kind: "user" } })), + record( + message("assistant", [{ type: "think", think: "Reviewing", encrypted: "signature" }]), + 2, + ), + ]); + + expect(events.filter((event) => event.type === "ContentPart")).toEqual([ + { + type: "ContentPart", + payload: { type: "think", think: "Reviewing", encrypted: "signature" }, + _sessionId: "session-1", + }, + ]); + }); + + it("renders assistant media when a resumed answer contains media parts", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Show it" }], { origin: { kind: "user" } })), + record( + message("assistant", [ + { type: "image_url", imageUrl: { url: "https://example.test/result.png" } }, + { type: "video_url", videoUrl: { url: "https://example.test/result.mp4" } }, + ]), + 2, + ), + ]); + + expect(events.filter((event) => event.type === "ContentPart")).toEqual([ + { + type: "ContentPart", + payload: { type: "image_url", image_url: { url: "https://example.test/result.png" } }, + _sessionId: "session-1", + }, + { + type: "ContentPart", + payload: { type: "video_url", video_url: { url: "https://example.test/result.mp4" } }, + _sessionId: "session-1", + }, + ]); + }); + + it("maps an assistant tool call to the released tool name", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Read it" }], { origin: { kind: "user" } })), + record( + message("assistant", [], { + toolCalls: [ + { + type: "function", + id: "tool-1", + name: "Read", + arguments: '{"path":"README.md"}', + }, + ], + }), + 2, + ), + ]); + + expect(events).toContainEqual({ + type: "ToolCall", + payload: { + type: "function", + id: "tool-1", + function: { name: "ReadFile", arguments: '{"path":"README.md"}' }, + }, + _sessionId: "session-1", + }); + }); + + it("renders a failed tool result in the open turn", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Run it" }], { origin: { kind: "user" } })), + record( + message("tool", [{ type: "text", text: "command failed" }], { + toolCallId: "tool-1", + isError: true, + }), + 2, + ), + ]); + + expect(events).toContainEqual({ + type: "ToolResult", + payload: { + tool_call_id: "tool-1", + return_value: { + is_error: true, + output: [{ type: "text", text: "command failed" }], + message: "", + display: [], + }, + }, + _sessionId: "session-1", + }); + }); + + it("closes a completed compaction when its replay record has a result", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Continue" }], { origin: { kind: "user" } })), + { + type: "compaction", + time: 2, + result: { + summary: "Earlier work", + compactedCount: 6, + tokensBefore: 1000, + tokensAfter: 200, + }, + }, + ]); + + expect(events.filter((event) => event.type.startsWith("Compaction"))).toEqual([ + { type: "CompactionBegin", payload: {}, _sessionId: "session-1" }, + { type: "CompactionEnd", payload: {}, _sessionId: "session-1" }, + ]); + }); + + it("leaves an unfinished compaction open when its replay record has no result", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Continue" }], { origin: { kind: "user" } })), + { type: "compaction", time: 2, instruction: "Keep decisions" }, + ]); + + expect(events.filter((event) => event.type.startsWith("Compaction"))).toEqual([ + { type: "CompactionBegin", payload: {}, _sessionId: "session-1" }, + ]); + }); + + it("renders plan mode when replay records a plan update inside a turn", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "Make a plan" }], { origin: { kind: "user" } })), + { type: "plan_updated", time: 2, enabled: true }, + ]); + + expect(events).toContainEqual({ + type: "StatusUpdate", + payload: { plan_mode: true }, + _sessionId: "session-1", + }); + }); + + it("separates consecutive user prompts into independently completed turns", () => { + const events = replay([ + record(message("user", [{ type: "text", text: "First" }], { origin: { kind: "user" } }), 1), + record(message("assistant", [{ type: "text", text: "One" }]), 2), + record(message("user", [{ type: "text", text: "Second" }], { origin: { kind: "user" } }), 3), + record(message("assistant", [{ type: "text", text: "Two" }]), 4), + ]); + + expect(events.filter((event) => event.type !== "StatusUpdate").map((event) => event.type)).toEqual([ + "TurnBegin", + "StepBegin", + "ContentPart", + "stream_complete", + "TurnBegin", + "StepBegin", + "ContentPart", + "stream_complete", + ]); + }); + + it("does not expose an injected user message in resumed history", () => { + const events = replay([ + record( + message("user", [{ type: "text", text: "hidden reminder" }], { + origin: { kind: "injection", variant: "system_reminder" }, + }), + ), + record(message("user", [{ type: "text", text: "Visible prompt" }], { origin: { kind: "user" } }), 2), + ]); + + expect(events.filter((event) => event.type === "TurnBegin")).toEqual([ + { + type: "TurnBegin", + payload: { user_input: [{ type: "text", text: "Visible prompt" }] }, + _sessionId: "session-1", + }, + ]); + }); + + it("restores a user-invoked skill as its original slash command", () => { + const events = replay([ + record( + message("user", [{ type: "text", text: "" }], { + origin: { + kind: "skill_activation", + activationId: "activation-1", + skillName: "review", + skillArgs: "focus on errors", + trigger: "user-slash", + }, + }), + ), + ]); + + expect(events).toContainEqual({ + type: "TurnBegin", + payload: { user_input: [{ type: "text", text: "/skill:review focus on errors" }] }, + _sessionId: "session-1", + }); + }); + + it("restores imported context as the original command and confirmation", () => { + const events = replay([ + record( + message( + "user", + [ + { + type: "text", + text: "The user imported prior context.", + }, + { + type: "text", + text: '\nPrior decision.\n', + }, + ], + { origin: { kind: "user" } }, + ), + ), + ]); + + expect(events).toContainEqual({ + type: "TurnBegin", + payload: { user_input: [{ type: "text", text: "/import notes.md" }] }, + _sessionId: "session-1", + }); + expect(events).toContainEqual({ + type: "ContentPart", + payload: { type: "text", text: "Imported context from file 'notes.md' (15 chars)." }, + _sessionId: "session-1", + }); + expect(JSON.stringify(events)).not.toContain("Prior decision."); + }); + + it("does not count a model-invoked skill as a user-visible fork turn", () => { + const records: AgentReplayRecord[] = [ + record( + message("user", [{ type: "text", text: "" }], { + origin: { + kind: "skill_activation", + activationId: "activation-model", + skillName: "helper", + trigger: "model-tool", + }, + }), + ), + ]; + + expect(replayRecordTurnCount(records)).toBe(0); + expect(replay(records).filter((event) => event.type === "TurnBegin")).toEqual([]); + }); + + it("counts only visible prompts when replay reports the number of turns", () => { + const records: AgentReplayRecord[] = [ + record(message("user", [{ type: "text", text: "First" }], { origin: { kind: "user" } }), 1), + record( + message("user", [{ type: "text", text: "Hidden" }], { + origin: { kind: "injection", variant: "system_reminder" }, + }), + 2, + ), + record( + message("user", [{ type: "text", text: "/review" }], { + origin: { + kind: "plugin_command", + activationId: "activation-1", + pluginId: "review-plugin", + commandName: "review", + trigger: "user-slash", + }, + }), + 3, + ), + ]; + + expect(replayRecordTurnCount(records)).toBe(2); + }); + + it("routes repeated runs of one subagent to their corresponding Agent calls", () => { + const main = resumedAgent([ + record(message("user", [{ type: "text", text: "First" }], { origin: { kind: "user" } }), 1), + record(message("assistant", [], { + toolCalls: [{ type: "function", id: "agent-call-1", name: "Agent", arguments: "{}" }], + }), 2), + record(message("tool", [{ type: "text", text: "agent_id: sub-1\nstatus: completed" }], { + toolCallId: "agent-call-1", + }), 5), + record(message("user", [{ type: "text", text: "Second" }], { origin: { kind: "user" } }), 10), + record(message("assistant", [], { + toolCalls: [{ type: "function", id: "agent-call-2", name: "Agent", arguments: "{}" }], + }), 11), + record(message("tool", [{ type: "text", text: "agent_id: sub-1\nstatus: completed" }], { + toolCallId: "agent-call-2", + }), 14), + ]); + const child = resumedAgent([ + record(message("user", [{ type: "text", text: "child one" }], { + origin: { kind: "system_trigger", name: "subagent" }, + }), 3), + record(message("assistant", [{ type: "text", text: "first child answer" }]), 4), + record(message("user", [{ type: "text", text: "child two" }], { + origin: { kind: "system_trigger", name: "subagent" }, + }), 12), + record(message("assistant", [{ type: "text", text: "second child answer" }]), 13), + ], { type: "sub" }); + const state: ResumedSessionState = { + sessionMetadata: { + createdAt: "", + updatedAt: "", + title: "", + isCustomTitle: false, + agents: { + main: { type: "main", parentAgentId: null }, + "sub-1": { type: "sub", parentAgentId: "main" }, + }, + custom: {}, + }, + agents: { main, "sub-1": child }, + }; + + const events = replaySessionToWebviewEvents(state, "session-1"); + + expect(events).toContainEqual(expect.objectContaining({ + type: "SubagentEvent", + payload: { + parent_tool_call_id: "agent-call-1", + event: { type: "ContentPart", payload: { type: "text", text: "first child answer" } }, + }, + })); + expect(events).toContainEqual(expect.objectContaining({ + type: "SubagentEvent", + payload: { + parent_tool_call_id: "agent-call-2", + event: { type: "ContentPart", payload: { type: "text", text: "second child answer" } }, + }, + })); + }); +}); diff --git a/apps/vscode/test/replay-resume.integration.test.ts b/apps/vscode/test/replay-resume.integration.test.ts new file mode 100644 index 0000000000..01706ea44a --- /dev/null +++ b/apps/vscode/test/replay-resume.integration.test.ts @@ -0,0 +1,317 @@ +/** + * Scenario: persisted Node SDK sessions are reopened and rendered by the VS Code replay adapter. + * Responsibilities: restored tool displays and child-agent steps through the public resume state. + * Wiring: Node SDK, core, storage, and HTTP provider adapter are real; only the remote provider is local. + * Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/replay-resume.integration.test.ts + */ + +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + createKimiHarness, + type Event, + type KimiHarness, + type Session, +} from "@moonshot-ai/kimi-code-sdk"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + createFakeProviderHarness, + type FakeProviderHarness, +} from "../../../packages/kosong/test/e2e/fake-provider-harness"; +import { replaySessionToWebviewEvents } from "../src/runtime/replay-adapter"; + +const MODEL_ALIAS = "vscode-replay-test"; + +interface ReplayRig { + readonly rootDir: string; + readonly workDir: string; + readonly harness: KimiHarness; + readonly provider: FakeProviderHarness; +} + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanups.length > 0) await cleanups.pop()?.(); +}); + +async function createReplayRig(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), "kimi-vscode-replay-")); + const homeDir = join(rootDir, "home"); + const workDir = join(rootDir, "workspace"); + await Promise.all([mkdir(homeDir), mkdir(workDir)]); + const provider = await createFakeProviderHarness(); + const harness = createKimiHarness({ + homeDir, + identity: { userAgentProduct: "kimi-code-vscode", version: "test" }, + }); + await harness.setConfig({ + providers: { + local: { + type: "kimi", + baseUrl: `${provider.baseUrl}/v1`, + apiKey: "sk-test", + }, + }, + models: { + [MODEL_ALIAS]: { + provider: "local", + model: "mock-model", + maxContextSize: 128_000, + }, + }, + defaultModel: MODEL_ALIAS, + }); + cleanups.push(async () => { + try { + await harness.close(); + } finally { + try { + await provider.close(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + } + }); + return { rootDir, workDir, harness, provider }; +} + +function completionChunk( + delta: Record, + finishReason: string | null = null, +): Record { + return { + id: "chatcmpl-vscode-replay", + object: "chat.completion.chunk", + created: 1, + model: "mock-model", + choices: [{ index: 0, delta, finish_reason: finishReason }], + }; +} + +async function runPrompt(session: Session, prompt: string): Promise { + const ended = waitForEvent( + session, + (event) => event.type === "turn.ended" && event.agentId === "main", + ); + await session.prompt(prompt); + await ended; +} + +function waitForEvent( + session: Session, + predicate: (event: Event) => boolean, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error("Timed out waiting for session event")); + }, 5_000); + const unsubscribe = session.onEvent((event) => { + if (!predicate(event)) return; + clearTimeout(timeout); + unsubscribe(); + resolve(event); + }); + }); +} + +describe("VS Code replay from a public Node SDK resume state", () => { + it("restores persisted file and todo displays", async () => { + const rig = await createReplayRig(); + const filePath = join(rig.workDir, "sample.txt"); + await writeFile(filePath, "before\n", "utf8"); + let requestCount = 0; + rig.provider.route("POST", "/v1/chat/completions", async (_request, reply) => { + requestCount += 1; + if (requestCount === 1) { + await reply.sseJson(200, [ + completionChunk({ + tool_calls: [ + { + index: 0, + id: "edit-call-1", + type: "function", + function: { + name: "Edit", + arguments: JSON.stringify({ + path: "sample.txt", + old_string: "before", + new_string: "after", + }), + }, + }, + { + index: 1, + id: "write-call-1", + type: "function", + function: { + name: "Write", + arguments: JSON.stringify({ + path: "created.txt", + content: "created content\n", + }), + }, + }, + { + index: 2, + id: "todo-call-1", + type: "function", + function: { + name: "TodoList", + arguments: JSON.stringify({ + todos: [{ title: "Verify resume", status: "done" }], + }), + }, + }, + ], + }), + completionChunk({}, "tool_calls"), + ]); + return; + } + await reply.sseJson(200, [ + completionChunk({ content: "Changes complete." }), + completionChunk({}, "stop"), + ]); + }); + const session = await rig.harness.createSession({ + id: "ses_vscode_replay_displays", + workDir: rig.workDir, + model: MODEL_ALIAS, + }); + await session.setPermission("yolo"); + await runPrompt(session, "Update the file and checklist"); + await session.close(); + + const resumed = await rig.harness.resumeSession({ + id: session.id, + includeSubagents: true, + }); + const state = resumed.getResumeState(); + if (state === undefined) throw new Error("Expected public resume state"); + const events = replaySessionToWebviewEvents(state, resumed.id); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "ToolResult", + payload: expect.objectContaining({ + tool_call_id: "write-call-1", + return_value: expect.objectContaining({ + display: [{ + type: "diff", + path: join(rig.workDir, "created.txt"), + old_text: "", + new_text: "created content\n", + }], + }), + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "ToolResult", + payload: expect.objectContaining({ + tool_call_id: "edit-call-1", + return_value: expect.objectContaining({ + display: [{ type: "diff", path: filePath, old_text: "before", new_text: "after" }], + }), + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "ToolResult", + payload: expect.objectContaining({ + tool_call_id: "todo-call-1", + return_value: expect.objectContaining({ + display: [{ + type: "todo", + items: [{ title: "Verify resume", status: "done" }], + }], + }), + }), + }), + ); + }); + + it("restores a child step under its original Agent tool call", async () => { + const rig = await createReplayRig(); + const childAnswer = `Subagent restored evidence. ${"Detailed persisted finding. ".repeat(10)}`; + let requestCount = 0; + rig.provider.route("POST", "/v1/chat/completions", async (_request, reply) => { + requestCount += 1; + if (requestCount === 1) { + await reply.sseJson(200, [ + completionChunk({ + tool_calls: [{ + index: 0, + id: "agent-call-1", + type: "function", + function: { + name: "Agent", + arguments: JSON.stringify({ + prompt: "Inspect the workspace and report one finding.", + description: "inspect workspace", + subagent_type: "coder", + run_in_background: false, + }), + }, + }], + }), + completionChunk({}, "tool_calls"), + ]); + return; + } + if (requestCount === 2) { + await reply.sseJson(200, [ + completionChunk({ content: childAnswer }), + completionChunk({}, "stop"), + ]); + return; + } + await reply.sseJson(200, [ + completionChunk({ content: "Parent received the finding." }), + completionChunk({}, "stop"), + ]); + }); + const session = await rig.harness.createSession({ + id: "ses_vscode_replay_subagent", + workDir: rig.workDir, + model: MODEL_ALIAS, + }); + await session.setPermission("yolo"); + await runPrompt(session, "Delegate this inspection"); + await session.close(); + + const resumed = await rig.harness.resumeSession({ + id: session.id, + includeSubagents: true, + }); + const state = resumed.getResumeState(); + if (state === undefined) throw new Error("Expected public resume state"); + const events = replaySessionToWebviewEvents(state, resumed.id); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "SubagentEvent", + payload: { + parent_tool_call_id: "agent-call-1", + event: { type: "StepBegin", payload: { n: 1 } }, + }, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "SubagentEvent", + payload: { + parent_tool_call_id: "agent-call-1", + event: { type: "ContentPart", payload: { type: "text", text: childAnswer } }, + }, + }), + ); + }); +}); diff --git a/apps/vscode/test/session-runtime.test.ts b/apps/vscode/test/session-runtime.test.ts new file mode 100644 index 0000000000..6caadfabb1 --- /dev/null +++ b/apps/vscode/test/session-runtime.test.ts @@ -0,0 +1,733 @@ +/** + * Scenario: one VS Code session runtime adapts the public Node SDK session for one or more Webviews. + * Responsibilities: prompt conversion, event/terminal delivery, reverse RPC, cancellation, and baseline capture. + * Wiring: SessionRuntime is real; a small in-memory Session implements only the public SDK boundary. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts apps/vscode/test/session-runtime.test.ts + */ + +import type { + ApprovalHandler, + ApprovalRequest, + Event, + JsonObject, + PermissionMode, + PromptInput, + QuestionHandler, + QuestionRequest, + Session, + SessionSummary, +} from "@moonshot-ai/kimi-code-sdk"; +import { describe, expect, it } from "vitest"; + +import { Events } from "../shared/bridge"; +import type { LegacyApprovalFlags } from "../src/runtime/legacy-approval"; +import { SessionRuntime } from "../src/runtime/session-runtime"; + +interface BroadcastRecord { + readonly event: string; + readonly data: unknown; + readonly webviewId?: string; +} + +interface BaselineRecord { + readonly session: Pick; + readonly filePath: string; + readonly webviewIds: readonly string[]; +} + +interface FakeSessionBoundary { + readonly session: Session; + readonly promptInputs: Array; + readonly steerInputs: Array; + readonly handlerInstallations: { approval: number; question: number }; + readonly metadataUpdates: JsonObject[]; + readonly setPermissions: PermissionMode[]; + readonly subscriptionCount: () => number; + readonly cancelCount: () => number; + readonly cancelCompactionCount: () => number; + readonly closeCount: () => number; + emit(event: Event): void; + rejectNextPrompt(error: Error): void; + rejectNextMetadataUpdate(error: Error): void; + requestApproval(request: ApprovalRequest): Promise>>; + requestQuestion(request: QuestionRequest): Promise>>; +} + +const DEFAULT_LEGACY_APPROVAL: LegacyApprovalFlags = { yolo: false, afk: false }; + +function createFakeSession(): FakeSessionBoundary { + const listeners = new Set<(event: Event) => void>(); + const promptInputs: Array = []; + const steerInputs: Array = []; + const handlerInstallations = { approval: 0, question: 0 }; + const metadataUpdates: JsonObject[] = []; + const setPermissions: PermissionMode[] = []; + let approvalHandler: ApprovalHandler | undefined; + let questionHandler: QuestionHandler | undefined; + let nextPromptError: Error | undefined; + let nextMetadataError: Error | undefined; + let subscriptions = 0; + let cancellations = 0; + let compactionCancellations = 0; + let closes = 0; + let permission: PermissionMode = "manual"; + + const summary: SessionSummary = { + id: "session-1", + workDir: "/workspace", + sessionDir: "/home/sessions/session-1", + createdAt: 1, + updatedAt: 2, + metadata: { source: "vscode-test" }, + }; + + const session = { + id: summary.id, + workDir: summary.workDir, + summary, + setApprovalHandler(handler: ApprovalHandler | undefined) { + approvalHandler = handler; + if (handler !== undefined) handlerInstallations.approval += 1; + }, + setQuestionHandler(handler: QuestionHandler | undefined) { + questionHandler = handler; + if (handler !== undefined) handlerInstallations.question += 1; + }, + onEvent(listener: (event: Event) => void) { + subscriptions += 1; + listeners.add(listener); + return () => listeners.delete(listener); + }, + async prompt(input: string | PromptInput) { + promptInputs.push(input); + if (nextPromptError !== undefined) { + const error = nextPromptError; + nextPromptError = undefined; + throw error; + } + }, + async steer(input: string | PromptInput) { + steerInputs.push(input); + }, + async cancel() { + cancellations += 1; + }, + async cancelCompaction() { + compactionCancellations += 1; + }, + async getStatus() { + return { + thinkingEffort: "off", + permission, + planMode: false, + contextTokens: 0, + maxContextTokens: 128_000, + contextUsage: 0, + }; + }, + async setPermission(mode: PermissionMode) { + permission = mode; + setPermissions.push(mode); + }, + async updateMetadata(patch: JsonObject) { + if (nextMetadataError !== undefined) { + const error = nextMetadataError; + nextMetadataError = undefined; + throw error; + } + metadataUpdates.push(patch); + }, + async close() { + closes += 1; + }, + } as unknown as Session; + + return { + session, + promptInputs, + steerInputs, + handlerInstallations, + metadataUpdates, + setPermissions, + subscriptionCount: () => subscriptions, + cancelCount: () => cancellations, + cancelCompactionCount: () => compactionCancellations, + closeCount: () => closes, + emit(event) { + for (const listener of listeners) listener(event); + }, + rejectNextPrompt(error) { + nextPromptError = error; + }, + rejectNextMetadataUpdate(error) { + nextMetadataError = error; + }, + async requestApproval(request) { + if (approvalHandler === undefined) throw new Error("Approval handler is unavailable"); + return approvalHandler(request); + }, + async requestQuestion(request) { + if (questionHandler === undefined) throw new Error("Question handler is unavailable"); + return questionHandler(request); + }, + }; +} + +function createRuntime(legacyApproval = DEFAULT_LEGACY_APPROVAL) { + const sdk = createFakeSession(); + const broadcasts: BroadcastRecord[] = []; + const baselines: BaselineRecord[] = []; + const runtime = new SessionRuntime({ + session: sdk.session, + legacyApproval, + broadcast: (event, data, webviewId) => broadcasts.push({ event, data, webviewId }), + captureBaseline: (session, filePath, webviewIds) => { + baselines.push({ session, filePath, webviewIds }); + }, + log: () => undefined, + }); + runtime.subscribe("view-1"); + return { runtime, sdk, broadcasts, baselines }; +} + +function streamData(records: readonly BroadcastRecord[]): unknown[] { + return records.filter((record) => record.event === Events.StreamEvent).map((record) => record.data); +} + +function turnStarted(): Event { + return { + type: "turn.started", + sessionId: "session-1", + agentId: "main", + turnId: 7, + origin: { kind: "user" }, + }; +} + +function turnEnded( + reason: "completed" | "cancelled" | "failed", + error?: Extract["error"], +): Event { + return { + type: "turn.ended", + sessionId: "session-1", + agentId: "main", + turnId: 7, + reason, + error, + }; +} + +describe("session runtime (adapts one SDK session for subscribed Webviews)", () => { + it("renders a host-only command without making it a forkable core turn", () => { + const { runtime, broadcasts } = createRuntime(); + + runtime.beginHostAction("/clear"); + runtime.emitHostText("The context has been cleared."); + runtime.completeHostAction(); + + expect(streamData(broadcasts)).toEqual([ + { + type: "TurnBegin", + payload: { user_input: "/clear", forkable: false }, + _sessionId: "session-1", + }, + { type: "StepBegin", payload: { n: 1 }, _sessionId: "session-1" }, + { + type: "ContentPart", + payload: { type: "text", text: "The context has been cleared." }, + _sessionId: "session-1", + }, + { + type: "stream_complete", + result: { status: "finished" }, + _sessionId: "session-1", + }, + ]); + }); + + it("cancels a long-running host action and ignores its late completion", async () => { + const { runtime, sdk, broadcasts } = createRuntime(); + const actionId = runtime.beginHostAction("/init"); + + await runtime.cancel(); + runtime.emitHostText("AGENTS.md has been generated.", actionId); + runtime.completeHostAction("finished", actionId); + + expect(sdk.cancelCount()).toBe(1); + expect(sdk.cancelCompactionCount()).toBe(1); + expect(streamData(broadcasts)).toContainEqual({ + type: "stream_complete", + result: { status: "cancelled" }, + _sessionId: "session-1", + }); + expect(JSON.stringify(streamData(broadcasts))).not.toContain("has been generated"); + }); + + it("does not let a cancelled action finish a newer host command", async () => { + const { runtime, broadcasts } = createRuntime(); + const initAction = runtime.beginHostAction("/init"); + await runtime.cancel(); + const clearAction = runtime.beginHostAction("/clear"); + + runtime.emitHostText("late init result", initAction); + runtime.completeHostAction("finished", initAction); + + expect(runtime.isBusy).toBe(true); + runtime.emitHostText("The context has been cleared.", clearAction); + runtime.completeHostAction("finished", clearAction); + expect(runtime.isBusy).toBe(false); + expect(JSON.stringify(streamData(broadcasts))).not.toContain("late init result"); + }); + + it("waits for the cancelled turn to settle before running an exclusive operation", async () => { + const { runtime, sdk } = createRuntime(); + const prompt = runtime.prompt("keep working"); + sdk.emit(turnStarted()); + let operationStarted = false; + + const operation = runtime.runExclusiveAfterCancelling(async () => { + operationStarted = true; + return "forked"; + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(sdk.cancelCount()).toBe(1); + expect(operationStarted).toBe(false); + expect(runtime.isBusy).toBe(true); + + sdk.emit(turnEnded("cancelled")); + + await expect(prompt).resolves.toEqual({ status: "cancelled" }); + await expect(operation).resolves.toBe("forked"); + expect(operationStarted).toBe(true); + expect(runtime.isBusy).toBe(false); + }); + + it("keeps a public turn action attached to its original slash input", async () => { + const { runtime, sdk, broadcasts } = createRuntime(); + + const result = runtime.runTurnAction("/skill:review carefully", async () => { + sdk.emit(turnStarted()); + sdk.emit(turnEnded("completed")); + }); + + await expect(result).resolves.toEqual({ status: "finished" }); + expect(streamData(broadcasts)).toContainEqual({ + type: "TurnBegin", + payload: { user_input: "/skill:review carefully" }, + _sessionId: "session-1", + }); + }); + + it("converts legacy media keys when a prompt crosses the SDK boundary", async () => { + const { runtime, sdk } = createRuntime(); + const completion = runtime.prompt([ + { type: "text", text: "Describe these files" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AA", id: "image-1" } }, + { type: "video_url", video_url: { url: "file:///workspace/demo.mp4", id: "video-1" } }, + ]); + + sdk.emit(turnStarted()); + sdk.emit(turnEnded("completed")); + await completion; + + expect(sdk.promptInputs).toEqual([ + [ + { type: "text", text: "Describe these files" }, + { type: "image_url", imageUrl: { url: "data:image/png;base64,AA", id: "image-1" } }, + { type: "video_url", videoUrl: { url: "file:///workspace/demo.mp4", id: "video-1" } }, + ], + ]); + }); + + it("broadcasts assistant text when the SDK streams a text delta", () => { + const { sdk, broadcasts } = createRuntime(); + + sdk.emit({ + type: "assistant.delta", + sessionId: "session-1", + agentId: "main", + turnId: 7, + delta: "Implemented", + }); + + expect(streamData(broadcasts)).toContainEqual({ + type: "ContentPart", + payload: { type: "text", text: "Implemented" }, + _sessionId: "session-1", + }); + }); + + it("broadcasts model thinking when the SDK streams a thinking delta", () => { + const { sdk, broadcasts } = createRuntime(); + + sdk.emit({ + type: "thinking.delta", + sessionId: "session-1", + agentId: "main", + turnId: 7, + delta: "Checking the edge case", + }); + + expect(streamData(broadcasts)).toContainEqual({ + type: "ContentPart", + payload: { type: "think", think: "Checking the edge case" }, + _sessionId: "session-1", + }); + }); + + it("broadcasts a legacy tool call when an SDK tool starts", () => { + const { sdk, broadcasts } = createRuntime(); + + sdk.emit({ + type: "tool.call.started", + sessionId: "session-1", + agentId: "main", + turnId: 7, + toolCallId: "tool-1", + name: "Read", + args: { path: "src/index.ts" }, + }); + + expect(streamData(broadcasts)).toContainEqual({ + type: "ToolCall", + payload: { + type: "function", + id: "tool-1", + function: { name: "ReadFile", arguments: '{"path":"src/index.ts"}' }, + }, + _sessionId: "session-1", + }); + }); + + it.each([ + ["completed", "finished"], + ["cancelled", "cancelled"], + ] as const)( + "emits one stream completion when a turn ends as %s", + async (reason, expectedStatus) => { + const { runtime, sdk, broadcasts } = createRuntime(); + const completion = runtime.prompt("hello"); + sdk.emit(turnStarted()); + + sdk.emit(turnEnded(reason)); + sdk.emit(turnEnded(reason)); + + await expect(completion).resolves.toEqual({ status: expectedStatus }); + expect( + streamData(broadcasts).filter( + (event) => + typeof event === "object" && + event !== null && + "type" in event && + event.type === "stream_complete", + ), + ).toEqual([ + { + type: "stream_complete", + result: { status: expectedStatus }, + _sessionId: "session-1", + }, + ]); + }, + ); + + it("emits one error when a failed turn terminal is repeated", async () => { + const { runtime, sdk, broadcasts } = createRuntime(); + const completion = runtime.prompt("hello"); + const error = { + code: "provider.api_error" as const, + message: "Provider rejected the request", + retryable: true, + }; + sdk.emit(turnStarted()); + + sdk.emit(turnEnded("failed", error)); + sdk.emit(turnEnded("failed", error)); + + await expect(completion).resolves.toEqual({ status: "failed" }); + expect( + streamData(broadcasts).filter( + (event) => + typeof event === "object" && event !== null && "type" in event && event.type === "error", + ), + ).toHaveLength(1); + }); + + it("suppresses the trailing SDK error when a failed terminal already reported the same error", async () => { + const { runtime, sdk, broadcasts } = createRuntime(); + const completion = runtime.prompt("hello"); + const error = { + code: "provider.api_error" as const, + message: "Provider rejected the request", + retryable: true, + }; + sdk.emit(turnStarted()); + + sdk.emit(turnEnded("failed", error)); + sdk.emit({ + type: "error", + sessionId: "session-1", + agentId: "main", + ...error, + }); + + await completion; + expect( + streamData(broadcasts).filter( + (event) => + typeof event === "object" && event !== null && "type" in event && event.type === "error", + ), + ).toHaveLength(1); + }); + + it("reports a preflight error when SDK prompt setup throws before turn start", async () => { + const { runtime, sdk, broadcasts } = createRuntime(); + sdk.rejectNextPrompt(new Error("Unable to initialize provider")); + + await expect(runtime.prompt("hello")).resolves.toEqual({ status: "failed" }); + + expect(streamData(broadcasts)).toContainEqual({ + type: "error", + code: "internal", + message: "Internal error occurred.", + detail: "Unable to initialize provider", + phase: "preflight", + _sessionId: "session-1", + }); + }); + + it("requests SDK cancellation when the active response is stopped", async () => { + const { runtime, sdk } = createRuntime(); + void runtime.prompt("hello"); + + await runtime.cancel(); + + expect(sdk.cancelCount()).toBe(1); + }); + + it("converts legacy media keys when steering an active response", async () => { + const { runtime, sdk } = createRuntime(); + void runtime.prompt("hello"); + + await runtime.steer([ + { type: "text", text: "Use this instead" }, + { type: "image_url", image_url: { url: "file:///workspace/new.png" } }, + ]); + + expect(sdk.steerInputs).toEqual([ + [ + { type: "text", text: "Use this instead" }, + { type: "image_url", imageUrl: { url: "file:///workspace/new.png" } }, + ], + ]); + }); + + it("echoes a successful steer into the subscribed Webview", async () => { + const { runtime, broadcasts } = createRuntime(); + runtime.subscribe("view-a"); + + await runtime.steer("Use this instead"); + + expect(streamData(broadcasts)).toContainEqual({ + type: "SteerInput", + payload: { user_input: "Use this instead" }, + _sessionId: "session-1", + }); + }); + + it.each([ + ["approve", { decision: "approved" }], + ["approve_for_session", { decision: "approved", scope: "session" }], + ["reject", { decision: "rejected" }], + ] as const)("resolves SDK approval when the Webview responds with %s", async (response, expected) => { + const { runtime, sdk, broadcasts } = createRuntime(); + const pending = sdk.requestApproval({ + toolCallId: "tool-1", + toolName: "Bash", + action: "Run command", + display: { kind: "command", command: "pnpm test" }, + }); + const request = streamData(broadcasts).find( + (event) => + typeof event === "object" && + event !== null && + "type" in event && + event.type === "ApprovalRequest", + ) as { payload: { id: string } }; + + expect(runtime.respondApproval(request.payload.id, response)).toBe(true); + await expect(pending).resolves.toEqual(expected); + }); + + it("auto-approves SDK approval requests in legacy yolo mode", async () => { + const { sdk, broadcasts } = createRuntime({ yolo: true, afk: false }); + + await expect(sdk.requestApproval({ + toolCallId: "tool-yolo", + toolName: "Bash", + action: "Run command", + display: { kind: "command", command: "pnpm test" }, + })).resolves.toEqual({ decision: "approved" }); + expect(streamData(broadcasts)).not.toContainEqual( + expect.objectContaining({ type: "ApprovalRequest" }), + ); + }); + + it("restores core permission when a legacy flag cannot be persisted", async () => { + const { runtime, sdk } = createRuntime(); + sdk.rejectNextMetadataUpdate(new Error("state is read-only")); + + await expect(runtime.toggleLegacyApproval("afk")).rejects.toThrow("state is read-only"); + + expect(sdk.setPermissions).toEqual(["auto", "manual"]); + expect(runtime.legacyApprovalFlags).toEqual({ yolo: false, afk: false }); + }); + + it("resolves an SDK question when the Webview submits answers", async () => { + const { runtime, sdk, broadcasts } = createRuntime(); + const pending = sdk.requestQuestion({ + toolCallId: "question-1", + questions: [ + { + question: "Choose a target", + header: "Target", + options: [{ label: "Tests", description: "Run focused tests" }], + multiSelect: false, + }, + ], + }); + const request = streamData(broadcasts).find( + (event) => + typeof event === "object" && + event !== null && + "type" in event && + event.type === "QuestionRequest", + ) as { payload: { id: string } }; + + expect(runtime.respondQuestion(request.payload.id, { "Choose a target": "Tests" })).toBe(true); + await expect(pending).resolves.toEqual({ answers: { "Choose a target": "Tests" } }); + }); + + it("keeps SDK questions interactive in legacy yolo mode", async () => { + const { runtime, sdk, broadcasts } = createRuntime({ yolo: true, afk: false }); + const pending = sdk.requestQuestion({ + toolCallId: "question-yolo", + questions: [ + { + question: "Continue?", + options: [{ label: "Yes" }], + multiSelect: false, + }, + ], + }); + const request = streamData(broadcasts).find( + (event) => + typeof event === "object" && + event !== null && + "type" in event && + event.type === "QuestionRequest", + ) as { payload: { id: string } }; + + expect(runtime.respondQuestion(request.payload.id, { "Continue?": "Yes" })).toBe(true); + await expect(pending).resolves.toEqual({ answers: { "Continue?": "Yes" } }); + }); + + it("cancels a pending SDK approval when the session closes", async () => { + const { runtime, sdk } = createRuntime(); + const pending = sdk.requestApproval({ + toolCallId: "tool-1", + toolName: "Bash", + action: "Run command", + display: { kind: "command", command: "pnpm test" }, + }); + + await runtime.close(); + + await expect(pending).resolves.toEqual({ + decision: "cancelled", + feedback: "Session closed", + }); + }); + + it("cancels a pending SDK question when the session closes", async () => { + const { runtime, sdk } = createRuntime(); + const pending = sdk.requestQuestion({ + questions: [ + { question: "Continue?", options: [{ label: "Yes" }], multiSelect: false }, + ], + }); + + await runtime.close(); + + await expect(pending).resolves.toBeNull(); + }); + + it("fans out one SDK subscription to every Webview attached to the session", () => { + const { runtime, sdk, broadcasts } = createRuntime(); + runtime.subscribe("view-2"); + + sdk.emit({ + type: "assistant.delta", + sessionId: "session-1", + agentId: "main", + turnId: 7, + delta: "Shared update", + }); + + expect(sdk.subscriptionCount()).toBe(1); + expect(sdk.handlerInstallations).toEqual({ approval: 1, question: 1 }); + expect(broadcasts.map((record) => record.webviewId)).toEqual(["view-1", "view-2"]); + }); + + it.each(["Write", "Edit"] as const)( + "captures the original file when %s starts with a path", + (name) => { + const { sdk, baselines } = createRuntime(); + + sdk.emit({ + type: "tool.call.started", + sessionId: "session-1", + agentId: "main", + turnId: 7, + toolCallId: "tool-1", + name, + args: { path: "src/index.ts" }, + }); + + expect(baselines).toEqual([ + { + session: { + id: "session-1", + workDir: "/workspace", + metadata: { source: "vscode-test" }, + }, + filePath: "src/index.ts", + webviewIds: ["view-1"], + }, + ]); + }, + ); + + it.each([ + ["Read", { path: "src/index.ts" }], + ["Write", {}], + ["Edit", { path: "" }], + ] as const)("does not capture a baseline when %s receives non-write input %#", (name, args) => { + const { sdk, baselines } = createRuntime(); + + sdk.emit({ + type: "tool.call.started", + sessionId: "session-1", + agentId: "main", + turnId: 7, + toolCallId: "tool-1", + name, + args, + }); + + expect(baselines).toEqual([]); + }); +}); diff --git a/apps/vscode/test/settings-store.test.ts b/apps/vscode/test/settings-store.test.ts new file mode 100644 index 0000000000..1978e2df5e --- /dev/null +++ b/apps/vscode/test/settings-store.test.ts @@ -0,0 +1,371 @@ +/** + * Scenario: Webview state crosses the VS Code bridge during settings changes, MCP edits, and chat failures. + * Responsibilities: model metadata and selections remain provider-aware; MCP edits stay lossless; chat errors recover visibly. + * Wiring: the real Zustand store and MCP bridge; settings saves, toast, and the VS Code messaging API are the only replaced boundaries. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/settings-store.test.ts + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { MCP_SECRET_MASK } from "../shared/legacy-sdk"; + +const boundary = vi.hoisted(() => ({ + saveConfig: vi.fn(), + streamChat: vi.fn(), + abortChat: vi.fn(), + trackFiles: vi.fn(), + toastError: vi.fn(), +})); + +vi.mock("@/services", () => ({ + bridge: { + saveConfig: boundary.saveConfig, + streamChat: boundary.streamChat, + abortChat: boundary.abortChat, + trackFiles: boundary.trackFiles, + }, +})); +vi.mock("@/components/ui/sonner", () => ({ + toast: { error: boundary.toastError }, +})); + +import { + getMediaFallbackModel, + getModelThinkingMode, + groupModelsByProvider, + requiresManagedProviderLogin, + useSettingsStore, +} from "../webview-ui/src/stores/settings.store"; +import { useChatStore } from "../webview-ui/src/stores/chat.store"; + +const MODELS = [ + { id: "plain", name: "Plain", provider: "managed:kimi-code", capabilities: [] }, + { + id: "reasoning", + name: "Reasoning", + provider: "managed:kimi-code", + capabilities: ["thinking"], + support_efforts: ["low", "high"], + default_effort: "high", + }, + { id: "always", name: "Always", provider: "managed:kimi-code", capabilities: ["always_thinking"] }, +]; + +beforeEach(() => { + boundary.saveConfig.mockReset(); + boundary.streamChat.mockReset(); + boundary.streamChat.mockResolvedValue({ done: false }); + boundary.abortChat.mockReset(); + boundary.abortChat.mockResolvedValue({ aborted: true }); + boundary.trackFiles.mockReset(); + boundary.toastError.mockReset(); + useSettingsStore.getState().initModels(MODELS, "plain", false); + useChatStore.setState({ + sessionId: null, + messages: [], + isStreaming: false, + isCompacting: false, + handshakeReceived: false, + draftMedia: [], + lastStatus: null, + tokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 }, + activeTokenUsage: { input_other: 0, output: 0, input_cache_read: 0, input_cache_creation: 0 }, + pendingInput: null, + queue: [], + pendingQuestion: null, + planMode: false, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Webview model settings persistence", () => { + it("persists the selected alias when display names collide across providers", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels([ + { id: "openai/shared", name: "Shared", provider: "openai", capabilities: [] }, + { id: "proxy/shared", name: "Shared", provider: "company-proxy", capabilities: [] }, + ], "openai/shared", false); + + useSettingsStore.getState().updateModel("proxy/shared"); + + expect(boundary.saveConfig).toHaveBeenCalledWith({ + model: "proxy/shared", + thinking: false, + effort: "off", + }); + }); + + it("rolls back the optimistic model selection when saving fails", async () => { + let rejectSave!: (error: Error) => void; + boundary.saveConfig.mockReturnValue(new Promise((_resolve, reject) => { + rejectSave = reject; + })); + + useSettingsStore.getState().updateModel("reasoning"); + expect(useSettingsStore.getState()).toMatchObject({ + currentModel: "reasoning", + thinkingEffort: "off", + }); + + rejectSave(new Error("config.toml is read-only")); + await vi.waitFor(() => { + expect(useSettingsStore.getState()).toMatchObject({ + currentModel: "plain", + thinkingEffort: "off", + }); + }); + expect(boundary.toastError).toHaveBeenCalledWith( + "Failed to save model settings: config.toml is read-only", + ); + }); + + it("does not let an older failed save overwrite a newer selection", async () => { + let rejectFirst!: (error: Error) => void; + boundary.saveConfig + .mockReturnValueOnce(new Promise((_resolve, reject) => { + rejectFirst = reject; + })) + .mockResolvedValueOnce({ ok: true }); + + useSettingsStore.getState().updateModel("reasoning"); + useSettingsStore.getState().updateModel("always"); + rejectFirst(new Error("older request failed")); + await Promise.resolve(); + + expect(useSettingsStore.getState().currentModel).toBe("always"); + expect(boundary.toastError).not.toHaveBeenCalled(); + }); +}); + +describe("Webview model metadata", () => { + it("keeps same-named models in separate provider groups", () => { + const groups = groupModelsByProvider([ + { id: "kimi/shared", name: "Shared", provider: "managed:kimi-code", capabilities: [] }, + { id: "proxy/shared", name: "Shared", provider: "company-proxy", capabilities: [] }, + ]); + + expect(groups.map((group) => ({ + provider: group.provider, + label: group.label, + models: group.models.map((model) => model.id), + }))).toEqual([ + { provider: "company-proxy", label: "company-proxy", models: ["proxy/shared"] }, + { provider: "managed:kimi-code", label: "Kimi Code", models: ["kimi/shared"] }, + ]); + }); + + it("offers a thinking toggle when a model declares adaptive thinking", () => { + expect(getModelThinkingMode({ + id: "anthropic/claude", + name: "Claude", + provider: "anthropic", + capabilities: [], + adaptive_thinking: true, + })).toBe("switch"); + }); + + it("prefers a compatible model from the current provider for media fallback", () => { + const current = { + id: "openai/text", + name: "Text", + provider: "openai", + capabilities: [], + }; + const fallback = getMediaFallbackModel([ + { id: "other/vision", name: "Vision A", provider: "other", capabilities: ["image_in"] }, + { id: "openai/vision", name: "Vision B", provider: "openai", capabilities: ["image_in"] }, + ], current); + + expect(fallback?.id).toBe("openai/vision"); + }); + + it("does not require Kimi login when the default model uses a custom provider", () => { + expect(requiresManagedProviderLogin([ + { id: "local/model", name: "Local", provider: "local", capabilities: [] }, + ], "local/model", false)).toBe(false); + }); + + it("requires Kimi login when the default model uses the managed provider", () => { + expect(requiresManagedProviderLogin([ + { id: "kimi/model", name: "Kimi", provider: "managed:kimi-code", capabilities: [] }, + ], "kimi/model", false)).toBe(true); + }); +}); + +describe("Webview MCP update bridge", () => { + it("sends a lossless structured MCP edit request to the extension host", async () => { + const posted: unknown[] = []; + let receiveMessage: ((event: { data: unknown }) => void) | undefined; + vi.stubGlobal("document", { + body: { getAttribute: () => "mcp-test-view" }, + }); + vi.stubGlobal("window", { + addEventListener: (_type: string, listener: (event: { data: unknown }) => void) => { + receiveMessage = listener; + }, + }); + vi.stubGlobal("acquireVsCodeApi", () => ({ + postMessage: (message: { id: string }) => { + posted.push(message); + queueMicrotask(() => receiveMessage?.({ data: { id: message.id, result: [] } })); + }, + getState: () => undefined, + setState: () => undefined, + })); + vi.resetModules(); + const { bridge } = await import("../webview-ui/src/services/bridge"); + + await bridge.updateMCPServer("old-name", { + name: "new-name", + transport: "stdio", + command: "C:\\Program Files\\Example MCP\\server.exe", + args: ["--config", "C:\\Users\\Example User\\mcp config.json"], + env: { SERVICE_TOKEN: MCP_SECRET_MASK, DEBUG: "1" }, + }); + + expect(posted).toEqual([ + expect.objectContaining({ + method: "updateMCPServer", + webviewId: "mcp-test-view", + params: { + originalName: "old-name", + server: { + name: "new-name", + transport: "stdio", + command: "C:\\Program Files\\Example MCP\\server.exe", + args: ["--config", "C:\\Users\\Example User\\mcp config.json"], + env: { SERVICE_TOKEN: MCP_SECRET_MASK, DEBUG: "1" }, + }, + }, + }), + ]); + }); +}); + +describe("Webview chat error recovery", () => { + it("stops the pending state and keeps the input available when session setup fails", () => { + useChatStore.getState().sendMessage("retry this request"); + + useChatStore.getState().processEvent({ + type: "error", + code: "session.state_invalid", + message: "Session data is invalid.", + detail: "state.json: Unexpected token at line 4", + phase: "preflight", + }); + + expect(useChatStore.getState()).toMatchObject({ + isStreaming: false, + isCompacting: false, + pendingInput: { content: "retry this request", model: "plain" }, + }); + }); + + it("stops the response and retains provider detail when a running turn fails", () => { + useChatStore.getState().sendMessage("start a turn"); + useChatStore.getState().processEvent({ + type: "TurnBegin", + payload: { user_input: "start a turn" }, + }); + useChatStore.getState().processEvent({ type: "StepBegin", payload: { n: 1 } }); + + useChatStore.getState().processEvent({ + type: "error", + code: "provider.api_error", + message: "Service temporarily unavailable.", + detail: "HTTP 400: function name is invalid", + phase: "runtime", + }); + + expect(useChatStore.getState().isStreaming).toBe(false); + expect(useChatStore.getState().messages.at(-1)?.inlineError).toEqual({ + code: "provider.api_error", + message: "Service temporarily unavailable.", + detail: "HTTP 400: function name is invalid", + }); + }); +}); + +describe("Webview thinking mode parity with the TUI", () => { + it("derives thinking modes from metadata only, mirroring the TUI rules", () => { + const base = { id: "m", name: "M", provider: "p", capabilities: [] as string[] }; + expect(getModelThinkingMode({ ...base, capabilities: ["thinking"], support_efforts: ["low", "high"] })).toBe("effort"); + expect(getModelThinkingMode({ ...base, capabilities: ["always_thinking"] })).toBe("always"); + expect(getModelThinkingMode({ ...base, capabilities: ["thinking"] })).toBe("switch"); + expect(getModelThinkingMode({ ...base, adaptive_thinking: true })).toBe("switch"); + expect(getModelThinkingMode({ ...base, name: "Kimi Thinking Pro" })).toBe("none"); + expect(getModelThinkingMode(base)).toBe("none"); + }); +}); + +describe("Webview thinking effort parity with the TUI", () => { + it("resolves a boolean \"on\" to the model default for effort-capable models", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(MODELS, "reasoning", false); + + useSettingsStore.getState().selectThinkingEffort("on"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("high"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: true, effort: "high" }); + }); + + it("prefers the persisted configured effort when resolving \"on\"", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(MODELS, "reasoning", false); + useSettingsStore.setState({ defaultThinkingEffort: "low" }); + + useSettingsStore.getState().selectThinkingEffort("on"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("low"); + }); + + it("keeps \"on\" for genuine boolean models", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels([ + { id: "bool", name: "Bool", provider: "openai", capabilities: ["thinking"] }, + ], "bool", false); + + useSettingsStore.getState().selectThinkingEffort("on"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("on"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "bool", thinking: true, effort: "on" }); + }); + + it("persists disabling thinking with thinking false", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(MODELS, "reasoning", true); + + useSettingsStore.getState().selectThinkingEffort("off"); + + expect(useSettingsStore.getState().thinkingEffort).toBe("off"); + expect(boundary.saveConfig).toHaveBeenCalledWith({ model: "reasoning", thinking: false, effort: "off" }); + }); + + it("rejects \"off\" for always-on effort models", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels([ + { id: "always-effort", name: "AE", provider: "openai", capabilities: ["always_thinking"], support_efforts: ["low", "high"] }, + ], "always-effort", true); + const previous = useSettingsStore.getState().thinkingEffort; + boundary.saveConfig.mockClear(); + + useSettingsStore.getState().selectThinkingEffort("off"); + + expect(useSettingsStore.getState().thinkingEffort).toBe(previous); + expect(boundary.saveConfig).not.toHaveBeenCalled(); + }); + + it("rejects efforts outside support_efforts", () => { + boundary.saveConfig.mockResolvedValue({ ok: true }); + useSettingsStore.getState().initModels(MODELS, "reasoning", false); + const previous = useSettingsStore.getState().thinkingEffort; + boundary.saveConfig.mockClear(); + + useSettingsStore.getState().selectThinkingEffort("ultra"); + + expect(useSettingsStore.getState().thinkingEffort).toBe(previous); + expect(boundary.saveConfig).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/vscode/test/vsix-package.test.ts b/apps/vscode/test/vsix-package.test.ts new file mode 100644 index 0000000000..dc1ccb30b2 --- /dev/null +++ b/apps/vscode/test/vsix-package.test.ts @@ -0,0 +1,254 @@ +/** + * Scenario: maintainers package and inspect the VS Code extension on any host OS. + * Responsibilities: six-target argument handling, actionable failures, isolated + * dev state, manifest/resource hygiene, unresolved imports, and entry loading. + * Wiring: real Node packaging/verifier CLIs and filesystem; VSIX directory + * fixtures replace only the external Marketplace archive producer. + * Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/vsix-package.test.ts + */ +import { spawnSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const appRoot = resolve(import.meta.dirname, '..'); +const packageScript = join(appRoot, 'scripts', 'vsix-package.mjs'); +const verifierScript = join(appRoot, 'scripts', 'vsix-verify.mjs'); +const prepareDevScript = join(appRoot, 'scripts', 'prepare-dev.mjs'); +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('VSIX package CLI (target planning and validation)', () => { + it('plans all six supported targets when no target is supplied', async () => { + const outputDir = await makeTempDir('kimi-package-plan-'); + + const result = runNode(packageScript, ['--dry-run', '--out-dir', outputDir]); + + expect(result.status).toBe(0); + expect(result.stdout.match(/Would package /g)).toHaveLength(6); + expect(result.stdout).toContain('kimi-code-darwin-x64.vsix'); + expect(result.stdout).toContain('kimi-code-darwin-arm64.vsix'); + expect(result.stdout).toContain('kimi-code-linux-x64.vsix'); + expect(result.stdout).toContain('kimi-code-linux-arm64.vsix'); + expect(result.stdout).toContain('kimi-code-win32-x64.vsix'); + expect(result.stdout).toContain('kimi-code-win32-arm64.vsix'); + }); + + it('accepts a Windows ARM target when the output path contains spaces', async () => { + const root = await makeTempDir('kimi-package-windows-'); + const outputDir = join(root, 'output with spaces'); + + const result = runNode(packageScript, [ + '--', + '--dry-run', + '--target', + 'win32-arm64', + '--out-dir', + outputDir, + ]); + + expect(result.status).toBe(0); + expect(result.stdout.match(/Would package /g)).toHaveLength(1); + expect(result.stdout).toContain('kimi-code-win32-arm64.vsix'); + }); + + it('rejects an unknown target before a build starts', () => { + const result = runNode(packageScript, ['plan9-x64', '--dry-run']); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Unknown VSIX target "plan9-x64"'); + expect(result.stderr).toContain('win32-arm64'); + }); +}); + +describe('VSIX verifier CLI (package contract and failure details)', () => { + it('passes an unpacked Windows package when the entry is self-contained', async () => { + const fixture = await makeVsixFixture('win32-x64'); + + const result = runNode(verifierScript, [ + '--target', + 'win32-x64', + '--directory', + fixture, + ]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('static audit and entry import smoke passed (package-only)'); + }); + + it('reports the expected target when the VSIX manifest has a different target', async () => { + const fixture = await makeVsixFixture('darwin-arm64'); + + const result = runNode(verifierScript, [ + '--target', + 'linux-x64', + '--directory', + fixture, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('VSIX manifest target is darwin-arm64, expected linux-x64'); + }); + + it('names a missing required Webview resource', async () => { + const fixture = await makeVsixFixture('darwin-x64'); + await rm(join(fixture, 'extension', 'dist', 'webview.js')); + + const result = runNode(verifierScript, [ + '--target', + 'darwin-x64', + '--directory', + fixture, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Required VSIX resource is missing: extension/dist/webview.js'); + }); + + it('reports a bare runtime dependency left in the extension bundle', async () => { + const fixture = await makeVsixFixture('linux-arm64'); + await writeFile( + join(fixture, 'extension', 'dist', 'extension.js'), + "import leftPad from 'left-pad';\nexport function activate() { return leftPad; }\n", + ); + + const result = runNode(verifierScript, [ + '--target', + 'linux-arm64', + '--directory', + fixture, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Bare runtime dependency "left-pad"'); + }); + + it('rejects generated session state inside the package', async () => { + const fixture = await makeVsixFixture('win32-arm64'); + const stateDir = join(fixture, 'extension', 'runtime', 'profile'); + await mkdir(stateDir, { recursive: true }); + await writeFile(join(stateDir, 'session.json'), '{}'); + + const result = runNode(verifierScript, [ + '--target', + 'win32-arm64', + '--directory', + fixture, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Forbidden package path segment "runtime"'); + }); + + it('rejects a persisted state directory inside the package', async () => { + const fixture = await makeVsixFixture('win32-x64'); + const stateDir = join(fixture, 'extension', 'state'); + await mkdir(stateDir, { recursive: true }); + await writeFile(join(stateDir, 'extension.json'), '{}'); + + const result = runNode(verifierScript, [ + '--target', + 'win32-x64', + '--directory', + fixture, + ]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Forbidden package path segment "state"'); + }); +}); + +describe('Extension Development Host setup (isolated local state)', () => { + it('creates the complete isolated directory layout for a debug launch', async () => { + const parent = await makeTempDir('kimi-dev-profile-'); + const baseDir = join(parent, 'vscode-extension-dev'); + + const result = runNode(prepareDevScript, ['--base-dir', baseDir]); + + expect(result.status).toBe(0); + await expect(readFile(join(baseDir, 'workspace', 'README.md'), 'utf8')).resolves.toContain( + 'Isolated Kimi Code extension development workspace', + ); + await expect(directoryExists(join(baseDir, 'user-data'))).resolves.toBe(true); + await expect(directoryExists(join(baseDir, 'extensions'))).resolves.toBe(true); + await expect(directoryExists(join(baseDir, 'kimi-home'))).resolves.toBe(true); + }); + + it('refuses to clear a directory without the dedicated safety suffix', async () => { + const unsafeDir = await makeTempDir('kimi-dev-unsafe-'); + await writeFile(join(unsafeDir, 'keep.txt'), 'keep'); + + const result = runNode(prepareDevScript, ['--base-dir', unsafeDir]); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Refusing to reset unsafe development directory'); + await expect(readFile(join(unsafeDir, 'keep.txt'), 'utf8')).resolves.toBe('keep'); + }); +}); + +async function makeTempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function makeVsixFixture(target: string): Promise { + const root = await makeTempDir('kimi-vsix-fixture-'); + const extensionDir = join(root, 'extension'); + const distDir = join(extensionDir, 'dist'); + const resourcesDir = join(extensionDir, 'resources'); + await Promise.all([ + mkdir(distDir, { recursive: true }), + mkdir(resourcesDir, { recursive: true }), + ]); + + const packageJson = await readFile(join(appRoot, 'package.json'), 'utf8'); + await Promise.all([ + writeFile(join(root, '[Content_Types].xml'), ''), + writeFile( + join(root, 'extension.vsixmanifest'), + ``, + ), + writeFile(join(extensionDir, 'package.json'), packageJson), + writeFile(join(extensionDir, 'readme.md'), '# Fixture\n'), + writeFile(join(extensionDir, 'LICENSE.txt'), 'fixture license\n'), + writeFile( + join(distDir, 'extension.js'), + "/** @type {import('../types/index').Extension} */\nimport * as vscode from 'vscode';\nexport function activate() { return vscode; }\n", + ), + writeFile(join(distDir, 'webview.js'), 'globalThis.__kimiWebview = true;\n'), + writeFile(join(distDir, 'kimi-banner-dark.svg'), ''), + writeFile(join(distDir, 'kimi-banner-light.svg'), ''), + writeFile(join(distDir, 'kimi-logo.png'), 'fixture'), + writeFile(join(resourcesDir, 'kimi-icon-storefront.png'), 'fixture'), + writeFile(join(resourcesDir, 'kimi-icon.svg'), ''), + ]); + return root; +} + +function runNode(script: string, args: string[]) { + const result = spawnSync(process.execPath, [script, ...args], { + cwd: appRoot, + encoding: 'utf8', + env: { ...process.env, VSCE_PAT: '', OVSX_PAT: '' }, + }); + if (result.error !== undefined) throw result.error; + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +async function directoryExists(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} diff --git a/apps/vscode/test/workspace-paths.test.ts b/apps/vscode/test/workspace-paths.test.ts new file mode 100644 index 0000000000..008d06621b --- /dev/null +++ b/apps/vscode/test/workspace-paths.test.ts @@ -0,0 +1,438 @@ +/** + * Scenario: Webview file paths stay inside the selected working directory. + * Responsibilities: directory/search/open/mention paths are scoped, normalized, and symlink-safe. + * Wiring: real temporary local files plus the public handler/bridge surfaces; + * VS Code host APIs are the only stubbed boundary. + * Run: pnpm --filter kimi-code exec vitest run --config vitest.config.ts test/workspace-paths.test.ts + */ +import { mkdtemp, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Event, Session } from "@moonshot-ai/kimi-code-sdk"; +import type * as vscode from "vscode"; +import { Methods } from "../shared/bridge"; +import { BridgeHandler } from "../src/bridge-handler"; +import { fileHandlers } from "../src/handlers/file.handler"; +import type { HandlerContext } from "../src/handlers/types"; +import { FileManager } from "../src/managers/file.manager"; +import { SessionRuntime } from "../src/runtime/session-runtime"; +import { areSameFsPath, isFsPathInsideOrEqual } from "../src/utils/fs-path"; +import { relativeWorkspacePath } from "../src/utils/workspace-path"; + +const vscodeHost = vi.hoisted(() => { + function normalizeUriPath(value: string): string { + const segments: string[] = []; + for (const segment of value.replaceAll("\\", "/").split("/")) { + if (!segment || segment === ".") continue; + if (segment === "..") segments.pop(); + else segments.push(segment); + } + return `/${segments.join("/")}`; + } + + class Uri { + readonly query: string; + + constructor( + readonly scheme: string, + readonly authority: string, + readonly path: string, + readonly fsPath: string, + query = "", + ) { + this.query = query; + } + + static file(fsPath: string): Uri { + return new Uri("file", "", normalizeUriPath(fsPath), fsPath); + } + + static remote(authority: string, fsPath: string): Uri { + return new Uri("vscode-remote", authority, normalizeUriPath(fsPath), fsPath); + } + + static joinPath(base: Uri, ...segments: string[]): Uri { + const path = normalizeUriPath(`${base.path}/${segments.join("/")}`); + const suffix = segments.join("/"); + const separator = base.fsPath.endsWith("/") ? "" : "/"; + return new Uri(base.scheme, base.authority, path, `${base.fsPath}${separator}${suffix}`); + } + + static from(parts: { scheme: string; path?: string; query?: string }): Uri { + const path = parts.path ?? ""; + return new Uri(parts.scheme, "", path, path, parts.query); + } + + toString(): string { + return `${this.scheme}://${this.authority}${this.path}${this.query ? `?${this.query}` : ""}`; + } + } + + class RelativePattern { + readonly baseUri: Uri; + readonly base: string; + + constructor(base: Uri, readonly pattern: string) { + this.baseUri = base; + this.base = base.fsPath; + } + } + + const readDirectory = vi.fn(); + const stat = vi.fn(); + const readFile = vi.fn(); + const findFiles = vi.fn(); + const executeCommand = vi.fn(); + const showWarningMessage = vi.fn(); + + return { + Uri, + RelativePattern, + readDirectory, + stat, + readFile, + findFiles, + executeCommand, + showWarningMessage, + workspaceFolders: [] as Array<{ uri: Uri }>, + }; +}); + +vi.mock("vscode", () => ({ + Uri: vscodeHost.Uri, + RelativePattern: vscodeHost.RelativePattern, + FileType: { Unknown: 0, File: 1, Directory: 2, SymbolicLink: 64 }, + QuickPickItemKind: { Separator: -1 }, + workspace: { + get workspaceFolders() { + return vscodeHost.workspaceFolders; + }, + fs: { + readDirectory: vscodeHost.readDirectory, + stat: vscodeHost.stat, + readFile: vscodeHost.readFile, + }, + findFiles: vscodeHost.findFiles, + createFileSystemWatcher: () => ({ + onDidChange: vi.fn(), + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + dispose: vi.fn(), + }), + getConfiguration: () => ({ get: (_key: string, fallback: unknown) => fallback }), + textDocuments: [], + }, + commands: { executeCommand: vscodeHost.executeCommand }, + window: { + activeTextEditor: undefined, + showWarningMessage: vscodeHost.showWarningMessage, + showQuickPick: vi.fn(), + showOpenDialog: vi.fn(), + }, +})); + +vi.mock("@moonshot-ai/kimi-code-sdk", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + createKimiHarness: () => ({ + homeDir: "/tmp/kimi-code-test-home", + close: vi.fn(), + }), + }; +}); + +let root: string; +let fileManager: FileManager; +let bridges: BridgeHandler[]; +let sessionRuntimes: SessionRuntime[]; +let extraRoots: string[]; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "kimi-vscode-workspace-paths-")); + vscodeHost.workspaceFolders.splice(0, vscodeHost.workspaceFolders.length, { uri: vscodeHost.Uri.file(root) }); + vscodeHost.readDirectory.mockImplementation(async (uri: { fsPath: string }) => + (await readdir(uri.fsPath, { withFileTypes: true })).map((entry) => [ + entry.name, + entry.isDirectory() ? 2 : entry.isSymbolicLink() ? 64 : 1, + ]), + ); + vscodeHost.stat.mockImplementation((uri: { fsPath: string }) => stat(uri.fsPath)); + vscodeHost.readFile.mockImplementation((uri: { fsPath: string }) => readFile(uri.fsPath)); + vscodeHost.findFiles.mockResolvedValue([]); + fileManager = new FileManager({} as never, vi.fn()); + bridges = []; + sessionRuntimes = []; + extraRoots = []; +}); + +afterEach(async () => { + await Promise.all(sessionRuntimes.map((runtime) => runtime.close())); + await Promise.all(bridges.map((bridge) => bridge.dispose())); + fileManager.dispose(); + vi.clearAllMocks(); + await Promise.all([root, ...extraRoots].map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("Webview workspace paths (selected-directory containment)", () => { + it("returns no entries when directory traversal is requested", async () => { + const workDir = join(root, "project"); + await mkdir(workDir); + + const files = await getProjectFiles(workDir, { directory: "../" }); + + expect(files).toEqual([]); + expect(vscodeHost.readDirectory).not.toHaveBeenCalled(); + }); + + it("returns no entries when an absolute directory is requested", async () => { + const workDir = join(root, "project"); + await mkdir(workDir); + + const files = await getProjectFiles(workDir, { directory: join(root, "outside") }); + + expect(files).toEqual([]); + expect(vscodeHost.readDirectory).not.toHaveBeenCalled(); + }); + + it("returns no entries when a Windows absolute directory is requested", async () => { + const workDir = join(root, "project"); + await mkdir(workDir); + + const files = await getProjectFiles(workDir, { directory: "C:\\outside" }); + + expect(files).toEqual([]); + expect(vscodeHost.readDirectory).not.toHaveBeenCalled(); + }); + + it("returns no entries when a Windows drive-relative directory is requested", async () => { + const workDir = join(root, "project"); + await mkdir(workDir); + + const files = await getProjectFiles(workDir, { directory: "C:outside" }); + + expect(files).toEqual([]); + expect(vscodeHost.readDirectory).not.toHaveBeenCalled(); + }); + + it("omits a symlink when its target is outside the selected working directory", async () => { + const workDir = join(root, "project"); + const outside = join(root, "outside"); + await Promise.all([mkdir(workDir), mkdir(outside)]); + await writeFile(join(outside, "secret.txt"), "secret"); + await symlink(outside, join(workDir, "outside-link")); + + const files = await getProjectFiles(workDir, { directory: "." }); + + expect(files).toEqual([]); + }); + + it("searches within the selected subdirectory using work-directory-relative results", async () => { + const workDir = join(root, "project", "subproject"); + const inside = join(workDir, "src", "inside.ts"); + const sibling = join(root, "project", "sibling.ts"); + await mkdir(join(workDir, "src"), { recursive: true }); + await Promise.all([writeFile(inside, "inside"), writeFile(sibling, "sibling")]); + vscodeHost.findFiles.mockResolvedValue([vscodeHost.Uri.file(inside), vscodeHost.Uri.file(sibling)]); + + const files = await getProjectFiles(workDir, { query: "inside" }); + + const include = vscodeHost.findFiles.mock.calls[0]?.[0] as InstanceType; + expect(include.baseUri.fsPath).toBe(workDir); + expect(files).toEqual([{ path: "src/inside.ts", name: "inside.ts", isDirectory: false }]); + }); + + it("normalizes native Windows separators during directory navigation", async () => { + const workDir = join(root, "project"); + await mkdir(join(workDir, "src", "nested"), { recursive: true }); + await writeFile(join(workDir, "src", "nested", "app.ts"), "app"); + + const files = await getProjectFiles(workDir, { directory: "src\\nested" }); + + expect(files).toEqual([{ path: "src/nested/app.ts", name: "app.ts", isDirectory: false }]); + }); + + it("preserves the remote workspace URI while listing a directory", async () => { + const remoteRoot = vscodeHost.Uri.remote("ssh-remote+example", "/workspace/project"); + vscodeHost.readDirectory.mockResolvedValue([["remote.ts", 1]]); + const ctx = createContext(remoteRoot); + + const files = await fileHandlers[Methods.GetProjectFiles]!({ directory: "." }, ctx); + + const requestedUri = vscodeHost.readDirectory.mock.calls[0]?.[0]; + expect(requestedUri).toMatchObject({ scheme: "vscode-remote", authority: "ssh-remote+example" }); + expect(files).toEqual([{ path: "remote.ts", name: "remote.ts", isDirectory: false }]); + }); + + it("refuses to open a symlink whose target lies outside the selected working directory", async () => { + const workDir = join(root, "project"); + const outside = join(root, "outside.txt"); + await mkdir(workDir); + await writeFile(outside, "secret"); + await symlink(outside, join(workDir, "secret.txt")); + const ctx = createContext(vscodeHost.Uri.file(workDir)); + + const result = await fileHandlers[Methods.OpenFile]!({ filePath: "secret.txt" }, ctx); + + expect(result).toEqual({ ok: false }); + expect(vscodeHost.executeCommand).not.toHaveBeenCalled(); + }); + + it("omits an outside symlink when an SDK Write event requests baseline capture", async () => { + const workDir = join(root, "project"); + const outsideRoot = await mkdtemp(join(tmpdir(), "kimi-vscode-baseline-outside-")); + extraRoots.push(outsideRoot); + const outside = join(outsideRoot, "outside.txt"); + const linkedFile = join(workDir, "linked.txt"); + await mkdir(workDir); + await writeFile(outside, "secret"); + await symlink(outside, linkedFile); + const bridge = createBridge(); + let emit!: (event: Event) => void; + const session = { + id: "session-1", + workDir, + summary: { id: "session-1", workDir }, + setApprovalHandler: vi.fn(), + setQuestionHandler: vi.fn(), + onEvent(listener: (event: Event) => void) { + emit = listener; + return vi.fn(); + }, + close: vi.fn(), + } as unknown as Session; + const runtime = new SessionRuntime({ + session, + legacyApproval: { yolo: false, afk: false }, + broadcast: vi.fn(), + captureBaseline: (summary, filePath, webviewIds) => { + bridge.captureFileBaseline(summary, filePath, webviewIds); + }, + log: vi.fn(), + }); + sessionRuntimes.push(runtime); + + emit({ + type: "tool.call.started", + sessionId: "session-1", + agentId: "main", + turnId: 1, + toolCallId: "tool-1", + name: "Write", + args: { path: linkedFile }, + }); + + await expect(bridge.baselineManager.getChanges({ id: "session-1", workDir })).resolves.toEqual([]); + }); + + it("builds an editor mention relative to the selected working directory", async () => { + const workDir = join(root, "project", "subproject"); + const inside = join(workDir, "src", "inside.ts"); + await mkdir(join(workDir, "src"), { recursive: true }); + await writeFile(inside, "inside"); + const bridge = createBridge(); + await bridge.handle({ id: "set", method: Methods.SetWorkDir, params: { workDir } }, "view-1"); + + const mention = await bridge.getEditorMention( + "view-1", + vscodeHost.Uri.file(inside) as vscode.Uri, + emptySelection(), + ); + + expect(mention).toBe("@src/inside.ts"); + }); + + it("omits an editor mention when the file is outside the selected working directory", async () => { + const workDir = join(root, "project", "subproject"); + const sibling = join(root, "project", "sibling.ts"); + await mkdir(workDir, { recursive: true }); + await writeFile(sibling, "sibling"); + const bridge = createBridge(); + await bridge.handle({ id: "set", method: Methods.SetWorkDir, params: { workDir } }, "view-1"); + + const mention = await bridge.getEditorMention( + "view-1", + vscodeHost.Uri.file(sibling) as vscode.Uri, + emptySelection(), + ); + + expect(mention).toBeNull(); + }); + + it("rejects a selected working directory whose symlink target leaves the workspace", async () => { + const outside = await mkdtemp(join(tmpdir(), "kimi-vscode-outside-")); + extraRoots.push(outside); + const linkedWorkDir = join(root, "linked-project"); + await symlink(outside, linkedWorkDir); + const bridge = createBridge(); + + const result = await bridge.handle( + { id: "set", method: Methods.SetWorkDir, params: { workDir: linkedWorkDir } }, + "view-1", + ); + + expect(result).toEqual({ id: "set", result: { ok: false } }); + }); +}); + +describe("native workspace path comparison (Windows drive and UNC semantics)", () => { + it("treats slash and casing differences as the same Windows drive path", () => { + expect(areSameFsPath("C:\\Users\\Example User\\项目", "c:/users/example user/项目")).toBe(true); + }); + + it("keeps an in-share UNC child relative to its workspace", () => { + const rootUri = vscodeHost.Uri.file("\\\\Server\\Share\\Workspace"); + const childUri = vscodeHost.Uri.file("\\\\server\\share\\workspace\\src\\app.ts"); + + expect(relativeWorkspacePath(rootUri as vscode.Uri, childUri as vscode.Uri)).toBe("src/app.ts"); + }); + + it("rejects a UNC path from another share", () => { + expect( + isFsPathInsideOrEqual( + "\\\\Server\\Share\\Workspace", + "\\\\Server\\OtherShare\\Workspace\\app.ts", + ), + ).toBe(false); + }); +}); + +async function getProjectFiles(workDir: string, params: { query?: string; directory?: string }) { + return fileHandlers[Methods.GetProjectFiles]!(params, createContext(vscodeHost.Uri.file(workDir))); +} + +function createContext(workDirUri: InstanceType): HandlerContext { + return { + webviewId: "view-1", + workDir: workDirUri.fsPath, + workDirUri: workDirUri as vscode.Uri, + workspaceRoot: root, + workspaceRootUri: vscodeHost.workspaceFolders[0]!.uri as vscode.Uri, + workspaceState: {} as vscode.Memento, + requireWorkDir: () => workDirUri.fsPath, + requireWorkDirUri: () => workDirUri as vscode.Uri, + fileManager, + } as HandlerContext; +} + +function createBridge(): BridgeHandler { + const bridge = new BridgeHandler( + vi.fn(), + { get: vi.fn(), update: vi.fn() } as unknown as vscode.Memento, + join(root, "global-storage"), + vi.fn(), + vi.fn(), + vi.fn(), + ); + bridges.push(bridge); + return bridge; +} + +function emptySelection(): vscode.Selection { + return { + isEmpty: true, + start: { line: 0 }, + end: { line: 0 }, + } as vscode.Selection; +} diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json new file mode 100644 index 0000000000..bc9d284638 --- /dev/null +++ b/apps/vscode/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["ES2024"], + "types": ["node", "vscode"], + "sourceMap": true, + "declaration": false, + "noEmit": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "paths": { + "@/*": ["./src/*"], + "shared/*": ["./shared/*"], + "@moonshot-ai/kimi-code-sdk": ["../../packages/node-sdk/src/index.ts"] + } + }, + "include": ["src/**/*", "shared/**/*", "test/**/*"], + "exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts"] +} diff --git a/apps/vscode/tsdown.config.ts b/apps/vscode/tsdown.config.ts new file mode 100644 index 0000000000..2adf78dca0 --- /dev/null +++ b/apps/vscode/tsdown.config.ts @@ -0,0 +1,48 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; + +import { defineConfig } from 'tsdown'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +const require = createRequire(import.meta.url); +const pkg = require('./package.json') as { version: string }; +const root = import.meta.dirname; + +export default defineConfig({ + entry: ['./src/extension.ts'], + format: ['esm'], + target: 'node20', + outDir: 'dist', + clean: true, + dts: false, + sourcemap: false, + plugins: [rawTextPlugin()], + alias: { + '@moonshot-ai/kimi-code-sdk': resolve(root, '../../packages/node-sdk/src/index.ts'), + '@moonshot-ai/migration-legacy': resolve(root, '../../packages/migration-legacy/src/index.ts'), + '@moonshot-ai/agent-core': resolve(root, '../../packages/agent-core/src/index.ts'), + '@moonshot-ai/kaos': resolve(root, '../../packages/kaos/src/index.ts'), + '@moonshot-ai/kimi-code-oauth': resolve(root, '../../packages/oauth/src/index.ts'), + '@moonshot-ai/kosong': resolve(root, '../../packages/kosong/src/index.ts'), + }, + define: { + __EXTENSION_VERSION__: JSON.stringify(pkg.version), + }, + banner: { + js: [ + "import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';", + "import { dirname as __cjsShimDirname } from 'node:path';", + 'const __filename = __cjsShimFileURLToPath(import.meta.url);', + 'const __dirname = __cjsShimDirname(__filename);', + ].join('\n'), + }, + deps: { + onlyBundle: false, + alwaysBundle: [/^@moonshot-ai\//, 'zod'], + neverBundle: ['vscode'], + }, + outputOptions: { + entryFileNames: 'extension.js', + }, +}); diff --git a/apps/vscode/vitest.config.ts b/apps/vscode/vitest.config.ts new file mode 100644 index 0000000000..3f0b710139 --- /dev/null +++ b/apps/vscode/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + '@': resolve(import.meta.dirname, 'webview-ui/src'), + shared: resolve(import.meta.dirname, 'shared'), + }, + }, + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/apps/vscode/webview-ui/components.json b/apps/vscode/webview-ui/components.json new file mode 100644 index 0000000000..9823830a35 --- /dev/null +++ b/apps/vscode/webview-ui/components.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/index.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "tabler", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/apps/vscode/webview-ui/index.html b/apps/vscode/webview-ui/index.html new file mode 100644 index 0000000000..ed8edcd58f --- /dev/null +++ b/apps/vscode/webview-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Kimi Code + + +
+ + + diff --git a/apps/vscode/webview-ui/public/kimi-banner-dark.svg b/apps/vscode/webview-ui/public/kimi-banner-dark.svg new file mode 100644 index 0000000000..746420861b --- /dev/null +++ b/apps/vscode/webview-ui/public/kimi-banner-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/vscode/webview-ui/public/kimi-banner-light.svg b/apps/vscode/webview-ui/public/kimi-banner-light.svg new file mode 100644 index 0000000000..bdfe65a773 --- /dev/null +++ b/apps/vscode/webview-ui/public/kimi-banner-light.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/vscode/webview-ui/public/kimi-logo.png b/apps/vscode/webview-ui/public/kimi-logo.png new file mode 100644 index 0000000000..033c0f4fb2 Binary files /dev/null and b/apps/vscode/webview-ui/public/kimi-logo.png differ diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx new file mode 100644 index 0000000000..4a343fe896 --- /dev/null +++ b/apps/vscode/webview-ui/src/App.tsx @@ -0,0 +1,139 @@ +// node/vscode_extension/webview-ui/src/App.tsx +import { useEffect, useState, useCallback } from "react"; +import { Header } from "./components/Header"; +import { ChatArea } from "./components/ChatArea"; +import { InputArea } from "./components/inputarea/InputArea"; +import { MCPServersModal } from "./components/MCPServersModal"; +import { WorkDirModal } from "./components/WorkDirModal"; +import { ConfigErrorScreen } from "./components/ConfigErrorScreen"; +import { LoginScreen } from "./components/LoginScreen"; +import { Toaster, toast } from "./components/ui/sonner"; +import { useChatStore, useSettingsStore } from "./stores"; +import { bridge, Events } from "./services"; +import { useAppInit } from "./hooks/useAppInit"; +import { isPreflightError } from "shared/errors"; +import type { UIStreamEvent, StreamError, ExtensionConfig } from "shared/types"; +import "./styles/index.css"; + +function MainContent({ onAuthAction }: { onAuthAction: () => void }) { + const { processEvent, startNewConversation, sessionId } = useChatStore(); + const { setMCPServers, setExtensionConfig, extensionConfig } = useSettingsStore(); + + useEffect(() => { + return bridge.on(Events.StreamEvent, (event: UIStreamEvent) => { + // 只有当前已有 session 时才过滤,确保 session_start 能正常处理 + if (sessionId && "_sessionId" in event && event._sessionId && event._sessionId !== sessionId) { + console.log("Ignored stream event from another session:", event._sessionId); + return; + } + processEvent(event); + if (event.type === "error") { + const streamError = event as StreamError; + if (isPreflightError(streamError.code || "UNKNOWN")) { + toast.error(streamError.message); + } + } + }); + }, [processEvent, sessionId]); + + useEffect(() => { + const unsubs = [ + bridge.on(Events.MCPServersChanged, setMCPServers), + bridge.on(Events.ExtensionConfigChanged, ({ config }: { config: ExtensionConfig }) => setExtensionConfig(config)), + bridge.on(Events.FocusInput, () => document.querySelector("textarea")?.focus()), + bridge.on(Events.NewConversation, () => { + void startNewConversation().catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : String(error)); + }); + }), + ]; + return () => unsubs.forEach((u) => u()); + }, [setMCPServers, setExtensionConfig, startNewConversation]); + + useEffect(() => { + if (!extensionConfig.enableNewConversationShortcut) return; + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "n") { + e.preventDefault(); + void startNewConversation().catch((error: unknown) => { + toast.error(error instanceof Error ? error.message : String(error)); + }); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [extensionConfig.enableNewConversationShortcut, startNewConversation]); + + return ( + <> +
+ +
+
+ +
+ + + + ); +} + +export default function App() { + const { status, errorMessage, modelsCount, refresh } = useAppInit(); + const [skippedLogin, setSkippedLogin] = useState(false); + + const handleLoginSuccess = useCallback(() => { + refresh(); + }, [refresh]); + + const handleSkip = useCallback(() => { + setSkippedLogin(true); + }, []); + + const handleAuthAction = useCallback(() => { + setSkippedLogin(false); + refresh(); + }, [refresh]); + + // 未登录且未跳过 + if (status === "not-logged-in" && !skippedLogin) { + return ( +
+
+ + +
+ ); + } + + // 跳过登录但没有模型 + if (skippedLogin && modelsCount === 0) { + return ( +
+
+ setSkippedLogin(false)} /> + +
+ ); + } + + // 其他错误状态 + if (status !== "ready" && status !== "not-logged-in") { + return ( +
+
+ + +
+ ); + } + + // 正常状态 + return ( +
+
+ + +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ActionMenu.tsx b/apps/vscode/webview-ui/src/components/ActionMenu.tsx new file mode 100644 index 0000000000..01420879d4 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ActionMenu.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { IconSettings, IconServer, IconLogout, IconLogin, IconLoader2, IconRefresh, IconFileText, IconFolder } from "@tabler/icons-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { useSettingsStore } from "@/stores"; +import { bridge } from "@/services"; +import { cn } from "@/lib/utils"; + +interface ActionMenuProps { + className?: string; + onAuthAction?: () => void; +} + +function MenuSection({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) { + return ( +
+
+ {title} + {subtitle && {subtitle}} +
+ {children} +
+ ); +} + +function MenuItem({ onClick, disabled, danger, children }: { onClick: () => void; disabled?: boolean; danger?: boolean; children: React.ReactNode }) { + return ( + + ); +} + +export function ActionMenu({ className, onAuthAction }: ActionMenuProps) { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const { setMCPModalOpen, isLoggedIn, setIsLoggedIn, extensionConfig } = useSettingsStore(); + + const handleOpenSettings = () => { + void bridge.openSettings(); + setOpen(false); + }; + + const handleOpenMCPServers = () => { + setMCPModalOpen(true); + setOpen(false); + }; + + const handleChangeWorkDir = () => { + useSettingsStore.getState().setWorkDirModalOpen(true); + setOpen(false); + }; + + const handleReset = () => { + setOpen(false); + void bridge.reloadWebview(); + }; + + const handleShowLogs = () => { + void bridge.showLogs(); + setOpen(false); + }; + + const handleAuthAction = async () => { + if (isLoggedIn) { + setLoading(true); + try { + await bridge.logout(); + setIsLoggedIn(false); + } finally { + setLoading(false); + } + } + setOpen(false); + onAuthAction?.(); + }; + + return ( + + + + + + + + + Working Directory + + + + MCP Servers + + + + General Config + + + + + + + + + + Show Logs + + + + Reset Kimi + + + + + + + { + void handleAuthAction(); + }} + disabled={loading} + danger={isLoggedIn} + > + {loading ? : isLoggedIn ? : } + {loading ? "Processing..." : isLoggedIn ? "Sign out" : "Sign in"} + + + + + ); +} diff --git a/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx b/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx new file mode 100644 index 0000000000..6c2d877355 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ApprovalDialog.tsx @@ -0,0 +1,82 @@ +import { useState, useEffect } from "react"; +import { IconChevronDown, IconChevronUp } from "@tabler/icons-react"; +import { useApprovalStore } from "@/stores"; +import { DisplayBlocks } from "./DisplayBlocks"; +import { cn } from "@/lib/utils"; +import type { ApprovalResponse } from "shared/legacy-sdk"; + +export function ApprovalDialog() { + const { pending, respondToRequest } = useApprovalStore(); + const [selectedIndex, setSelectedIndex] = useState(1); + const [expanded, setExpanded] = useState(false); + + const req = pending[0]; + + // Auto-expand if there's a diff block (code change) + useEffect(() => { + if (req) { + const hasDiff = req.display?.some((b) => b.type === "diff") ?? false; + setExpanded(hasDiff); + } + }, [req?.id]); + + if (!req) return null; + const hasDisplay = req.display && req.display.length > 0; + + const handleResponse = async (response: ApprovalResponse) => { + await respondToRequest(req.id, response); + setSelectedIndex(1); + setExpanded(false); + }; + + const options = [ + { key: "approve", label: "Yes", index: 1 }, + { key: "approve_for_session", label: "Yes, for this session", index: 2 }, + { key: "reject", label: "No", index: 3 }, + ] as const; + + return ( +
+
+
+
Allow this {req.action.toLowerCase()}?
+ {hasDisplay && ( + + )} +
+ +
{req.description}
+ + {hasDisplay && ( +
+ +
+ )} + +
{req.sender}
+ +
+ {options.map((opt) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/BottomToolbar.tsx b/apps/vscode/webview-ui/src/components/BottomToolbar.tsx new file mode 100644 index 0000000000..dde016c6bd --- /dev/null +++ b/apps/vscode/webview-ui/src/components/BottomToolbar.tsx @@ -0,0 +1,96 @@ +import { useState, useEffect } from "react"; +import { IconChevronUp, IconStack2, IconFileCode } from "@tabler/icons-react"; +import { useChatStore } from "@/stores"; +import { bridge, Events } from "@/services"; +import { cn } from "@/lib/utils"; +import { FileChangesPanel } from "./FileChangesPanel"; +import { QueuedMessagesPanel } from "./QueuedMessagesPanel"; +import { ApprovalDialog } from "./ApprovalDialog"; +import { QuestionDialog } from "./QuestionDialog"; +import type { FileChange } from "shared/types"; + +type TabId = "queue" | "changes" | null; + +export function BottomToolbar() { + const { queue } = useChatStore(); + const [activeTab, setActiveTab] = useState(null); + const [fileChanges, setFileChanges] = useState([]); + + useEffect(() => { + return bridge.on(Events.FileChangesUpdated, setFileChanges); + }, []); + + // Auto-close tab when data becomes empty + useEffect(() => { + if (activeTab === "queue" && queue.length === 0) { + setActiveTab(null); + } + if (activeTab === "changes" && fileChanges.length === 0) { + setActiveTab(null); + } + }, [activeTab, queue.length, fileChanges.length]); + + const hasQueue = queue.length > 0; + const hasChanges = fileChanges.length > 0; + const hasTabs = hasQueue || hasChanges; + + const toggleTab = (tab: TabId) => { + setActiveTab((prev) => (prev === tab ? null : tab)); + }; + + const fileStats = fileChanges.reduce((a, c) => ({ additions: a.additions + c.additions, deletions: a.deletions + c.deletions }), { additions: 0, deletions: 0 }); + + return ( +
+ {/* ApprovalDialog and QuestionDialog - priority, shrink-0 */} + + + + {/* Queue/Changes panel - takes remaining space */} + {activeTab && ( +
+ {activeTab === "queue" && } + {activeTab === "changes" && } +
+ )} + + {/* Tab bar - always at bottom, shrink-0 */} + {hasTabs && ( +
+
+ {hasQueue && ( + + )} + + {hasChanges && ( + + )} +
+
+ )} +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ChatArea.tsx b/apps/vscode/webview-ui/src/components/ChatArea.tsx new file mode 100644 index 0000000000..cc086b9516 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ChatArea.tsx @@ -0,0 +1,63 @@ +import ScrollToBottom, { useScrollToBottom, useSticky } from "react-scroll-to-bottom"; +import { IconArrowDown } from "@tabler/icons-react"; +import { ChatMessage } from "./ChatMessage"; +import { WelcomeScreen } from "./WelcomeScreen"; +import { useChatStore } from "@/stores"; +import { cn } from "@/lib/utils"; +import { getForkTurnIndex } from "shared/fork-turn-index"; + +function ScrollButton() { + const scrollToBottom = useScrollToBottom(); + const [sticky] = useSticky(); + + if (sticky) return null; + + return ( + + ); +} + +function MessageList() { + const { messages, isStreaming } = useChatStore(); + + return ( + <> +
+ {messages.map((message, idx) => ( + + ))} +
+ + + ); +} + +export function ChatArea() { + const { messages } = useChatStore(); + + if (messages.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ + + +
+ ); +} diff --git a/apps/vscode/webview-ui/src/components/ChatMessage.tsx b/apps/vscode/webview-ui/src/components/ChatMessage.tsx new file mode 100644 index 0000000000..91c660776e --- /dev/null +++ b/apps/vscode/webview-ui/src/components/ChatMessage.tsx @@ -0,0 +1,338 @@ +import { useState, Fragment } from "react"; +import { IconLoader3, IconGitFork } from "@tabler/icons-react"; +import { cn } from "@/lib/utils"; +import { Content } from "@/lib/content"; +import { Markdown } from "./Markdown"; +import { ToolCallCard } from "./ToolRenderers"; +import { CopyButton } from "./CopyButton"; +import { ThinkingBlock } from "./ThinkingBlock"; +import { CompactionCard } from "./CompactionCard"; +import { MediaThumbnail } from "./MediaThumbnail"; +import { MediaPreviewModal } from "./MediaPreviewModal"; +import { InlineError } from "./InlineError"; +import { PlanCard } from "./PlanCard"; +import { StreamingConfirmDialog } from "./StreamingConfirmDialog"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/components/ui/sonner"; +import { useChatStore } from "@/stores"; +import { bridge } from "@/services"; +import type { ChatMessage as ChatMessageType, UIStep, UIStepItem } from "@/stores/chat.store"; +import type { ContentPart } from "shared/legacy-sdk"; + +interface ChatMessageProps { + message: ChatMessageType; + /** 0-indexed turn number for this message */ + turnIndex?: number; + isStreaming?: boolean; +} + +function ThinkingIndicator() { + return ( +
+ + Processing... +
+ ); +} + +function SteerBubble({ content }: { content: string | ContentPart[] }) { + const text = typeof content === "string" ? content : Content.getText(content); + return ( +
+
+

{text}

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

,

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

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

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

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

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

Open a folder to start using Kimi Code.

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

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

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

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

+
+ {url && ( +
+

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

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

Welcome to Kimi Code

+
+

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

+
+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ +

Use your Kimi account and Kimi Code subscription.

+
+ +
+ +

Use your existing API key configuration.

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

{error}

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

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

+
+
e.stopPropagation()}> + {server.auth === "oauth" && ( + <> + + + + )} + + +
+ +
+ + {expanded && ( +
+ {testOutput && ( +
+ {testOutput.split("\n").map((line, i) => ( +
+ {line} +
+ ))} +
+ )} + { void handleUpdate(); }} onCancel={() => setExpanded(false)} submitLabel="Update" /> +
+ )} +
+ ); +} + +function RecommendedItem({ server, onInstall, isInstalling }: { server: RecommendedMCPServer; onInstall: () => void; isInstalling: boolean }) { + return ( +
+
+ +
+
+
+ {server.name} + {server.github && ( + + + + )} +
+

{server.description}

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

MCP Servers

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

No MCP servers configured

+
+ )} + +
+

Recommended

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

All recommended servers installed

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

when it was the sole child of

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

    ,

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

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

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

      {enrich(children)}

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

      {children}

      , + h2: ({ children }) =>

      {children}

      , + h3: ({ children }) =>

      {children}

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

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

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

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

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

      {hint.title}

      +

      {hint.description}

      +
      + )} +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/WorkDirModal.tsx b/apps/vscode/webview-ui/src/components/WorkDirModal.tsx new file mode 100644 index 0000000000..3cba926330 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/WorkDirModal.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect } from "react"; +import { IconFolder, IconFolderOpen, IconCheck, IconHome } from "@tabler/icons-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useSettingsStore, useChatStore } from "@/stores"; +import { bridge } from "@/services"; +import { cn } from "@/lib/utils"; + +export function WorkDirModal() { + const { workDirModalOpen, setWorkDirModalOpen, currentWorkDir, workspaceRoot, setCurrentWorkDir } = useSettingsStore(); + const { startNewConversation } = useChatStore(); + const [workDirs, setWorkDirs] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (workDirModalOpen) { + void bridge.getRegisteredWorkDirs().then(setWorkDirs); + } + }, [workDirModalOpen]); + + const handleSelect = async (dir: string | null) => { + setLoading(true); + try { + const result = await bridge.setWorkDir(dir); + if (result.ok) { + setCurrentWorkDir(result.workDir === workspaceRoot ? null : result.workDir); + await startNewConversation(); + setWorkDirModalOpen(false); + } + } finally { + setLoading(false); + } + }; + + const handleBrowse = async () => { + setLoading(true); + try { + const result = await bridge.browseWorkDir(); + if (result.ok && result.workDir) { + setCurrentWorkDir(result.workDir === workspaceRoot ? null : result.workDir); + await startNewConversation(); + setWorkDirModalOpen(false); + } + } finally { + setLoading(false); + } + }; + + const displayPath = (fullPath: string) => { + if (!workspaceRoot) return fullPath; + if (fullPath === workspaceRoot) return fullPath.split("/").pop() || fullPath; + return fullPath.replace(workspaceRoot, "."); + }; + + const isSelected = (dir: string) => { + if (!currentWorkDir) return dir === workspaceRoot; + return dir === currentWorkDir; + }; + + return ( + + + + Select Working Directory + + +
      + {workDirs.map((dir) => ( + + ))} +
      + + +
      + + {currentWorkDir && ( + + )} +
      + +
      +
      +
      + ); +} diff --git a/apps/vscode/webview-ui/src/components/hooks/useExtensionImageUrl.ts b/apps/vscode/webview-ui/src/components/hooks/useExtensionImageUrl.ts new file mode 100644 index 0000000000..ca1eafd34c --- /dev/null +++ b/apps/vscode/webview-ui/src/components/hooks/useExtensionImageUrl.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +export function useExtensionImageUrl(imageName: string): string { + const [url, setUrl] = useState(""); + + useEffect(() => { + const baseUri = document.body.getAttribute("data-baseuri"); + if (baseUri) { + setUrl(`${baseUri}/dist/${imageName}`); + } + }, [imageName]); + + return url; +} diff --git a/apps/vscode/webview-ui/src/components/index.ts b/apps/vscode/webview-ui/src/components/index.ts new file mode 100644 index 0000000000..41671569ed --- /dev/null +++ b/apps/vscode/webview-ui/src/components/index.ts @@ -0,0 +1,26 @@ +export { Header } from "./Header"; +export { SessionList } from "./SessionList"; +export { WelcomeScreen } from "./WelcomeScreen"; +export { ChatMessage } from "./ChatMessage"; +export { ChatArea } from "./ChatArea"; +export { ChatStatus } from "./ChatStatus"; +export { InputArea } from "./inputarea/InputArea"; +export { ActionMenu } from "./ActionMenu"; +export { KimiLogo } from "./KimiLogo"; +export { KimiMascot } from "./KimiMascot"; +export { ToolCallCard } from "./ToolRenderers"; +export { ApprovalDialog } from "./ApprovalDialog"; +export { MCPServersModal } from "./MCPServersModal"; +export { WorkDirModal } from "./WorkDirModal"; +export { ThinkingBlock } from "./ThinkingBlock"; +export { ThinkingButton } from "./ThinkingButton"; +export { CompactionCard } from "./CompactionCard"; +export { ConfigErrorScreen } from "./ConfigErrorScreen"; +export { LoginScreen } from "./LoginScreen"; +export { MediaPreviewModal } from "./MediaPreviewModal"; +export { MediaThumbnail } from "./MediaThumbnail"; +export { BottomToolbar } from "./BottomToolbar"; +export { InlineError } from "./InlineError"; +export { QuestionDialog } from "./QuestionDialog"; +export { PlanCard } from "./PlanCard"; +export { PlanModeButton } from "./PlanModeButton"; diff --git a/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx new file mode 100644 index 0000000000..5d1b09cd64 --- /dev/null +++ b/apps/vscode/webview-ui/src/components/inputarea/InputArea.tsx @@ -0,0 +1,501 @@ +import { Fragment, useRef, useMemo, useState, useEffect, useCallback } from "react"; +import { useMemoizedFn } from "ahooks"; +import { IconSend, IconPlayerStop, IconChevronDown, IconPlus } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ActionMenu } from "../ActionMenu"; +import { SlashCommandMenu } from "../SlashCommandMenu"; +import { FilePickerMenu } from "../FilePickerMenu"; +import { MediaThumbnail } from "../MediaThumbnail"; +import { MediaPreviewModal } from "../MediaPreviewModal"; +import { BottomToolbar } from "../BottomToolbar"; +import { StreamingConfirmDialog } from "../StreamingConfirmDialog"; +import { ThinkingButton } from "../ThinkingButton"; +import { PlanModeButton } from "../PlanModeButton"; +import { + getModelById, + getMediaFallbackModel, + getModelsForMedia, + groupModelsByProvider, + providerDisplayName, + useChatStore, + useSettingsStore, +} from "@/stores"; +import { bridge, Events } from "@/services"; +import { Content } from "@/lib/content"; +import { cn } from "@/lib/utils"; +import { useSlashMenu, findActiveToken } from "./hooks/useSlashMenu"; +import { useFilePicker } from "./hooks/useFilePicker"; +import { useMediaUpload } from "./hooks/useMediaUpload"; +import { useClickOutside } from "./hooks/useClickOutside"; +import { useInputHistory } from "./hooks/useInputHistory"; +import { computeMentionInsert } from "./utils"; + +interface InputAreaProps { + onAuthAction?: () => void; +} + +export function InputArea({ onAuthAction }: InputAreaProps) { + const textareaRef = useRef(null); + const menuRef = useRef(null); + const [text, setText] = useState(""); + const [cursorPos, setCursorPos] = useState(0); + const [previewMedia, setPreviewMedia] = useState(null); + + const { isStreaming, sendMessage, abort, draftMedia, removeDraftMedia, hasProcessingMedia, getMediaInConversation, pendingInput, planMode } = useChatStore(); + const { currentModel, thinkingEffort, updateModel, toggleThinking, selectThinkingEffort, models, extensionConfig, getCurrentThinkingMode } = useSettingsStore(); + + const isProcessing = hasProcessingMedia(); + const thinkingMode = getCurrentThinkingMode(); + + const [showPlanModeConfirm, setShowPlanModeConfirm] = useState(false); + + const handleTogglePlanMode = () => { + // Turning OFF during streaming needs confirmation — user may want next turn, not current + if (planMode && isStreaming) { + setShowPlanModeConfirm(true); + return; + } + const newState = !planMode; + useChatStore.setState({ planMode: newState }); // optimistic + void bridge.setPlanMode(newState); + }; + + const handleConfirmExitPlanMode = () => { + useChatStore.setState({ planMode: false }); + void bridge.setPlanMode(false); + setShowPlanModeConfirm(false); + }; + + const mediaReq = useMemo(() => { + const media = getMediaInConversation(); + return { image: media.hasImage, video: media.hasVideo }; + }, [getMediaInConversation, draftMedia]); + + const availableModels = useMemo(() => getModelsForMedia(models, mediaReq), [models, mediaReq]); + const currentModelConfig = getModelById(models, currentModel); + const modelGroups = useMemo(() => groupModelsByProvider(availableModels), [availableModels]); + const showProviderGroups = modelGroups.length > 1; + const currentModelLabel = currentModelConfig === undefined + ? "No models available" + : showProviderGroups + ? `${currentModelConfig.name} · ${providerDisplayName(currentModelConfig.provider)}` + : currentModelConfig.name; + + // Auto-switch model if current model doesn't support required media + useEffect(() => { + if (!mediaReq.image && !mediaReq.video) { + return; + } + const isCurrentModelValid = availableModels.some((m) => m.id === currentModel); + if (isCurrentModelValid) { + return; + } + const fallbackModel = getMediaFallbackModel(availableModels, currentModelConfig); + if (fallbackModel !== undefined) { + updateModel(fallbackModel.id); + } + }, [mediaReq.image, mediaReq.video, currentModel, currentModelConfig, availableModels, updateModel]); + + // Restore pending input + useEffect(() => { + if (!pendingInput || isStreaming) { + return; + } + + // 只在输入框为空时恢复 + if (text.trim()) { + return; + } + + const textContent = Content.getText(pendingInput.content); + if (textContent) { + setText(textContent); + setTimeout(() => { + textareaRef.current?.focus(); + adjustHeight(); + }, 0); + } + }, [pendingInput, isStreaming]); + + const activeToken = useMemo(() => findActiveToken(text, cursorPos), [text, cursorPos]); + + const { handlePaste, handlePickMedia } = useMediaUpload(); + + const adjustHeight = useMemoizedFn(() => { + const ta = textareaRef.current; + if (ta) { + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 140)}px`; + } + }); + + const { + handleKey: handleHistoryKey, + add: addToHistory, + reset: resetHistoryIndex, + } = useInputHistory({ + text, + setText, + onHeightChange: () => setTimeout(adjustHeight, 0), + }); + + const clearInput = useMemoizedFn(() => { + setText(""); + setCursorPos(0); + setTimeout(adjustHeight, 0); + }); + + const removeActiveToken = useMemoizedFn(() => { + if (!activeToken) return; + const newText = text.slice(0, activeToken.start) + text.slice(cursorPos); + const newCursorPos = activeToken.start; + setText(newText); + setCursorPos(newCursorPos); + setTimeout(() => { + textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); + adjustHeight(); + }, 0); + }); + + const handleSend = useMemoizedFn(() => { + if (isProcessing || (!text.trim() && draftMedia.length === 0)) { + return; + } + + addToHistory(text); + sendMessage(text); + clearInput(); + }); + + const handleSlashCommand = useMemoizedFn((name: string) => { + sendMessage(`/${name}`); + clearInput(); + }); + + const applyMention = useMemoizedFn((filePath: string) => { + const { newText, newCursorPos } = computeMentionInsert({ + text, + cursorPos, + filePath, + activeToken, + isAppend: false, + }); + + setText(newText); + setCursorPos(newCursorPos); + setTimeout(() => { + textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos); + textareaRef.current?.focus(); + adjustHeight(); + }, 0); + }); + + const { + showSlashMenu, + filteredCommands, + selectedIndex: slashSelectedIndex, + setSelectedIndex: setSlashSelectedIndex, + handleSlashMenuKey, + resetSlashMenu, + } = useSlashMenu(activeToken, handleSlashCommand, removeActiveToken); + + const { + showFileMenu, + filePickerMode, + folderPath, + fileItems, + selectedIndex: fileSelectedIndex, + isLoading: isFileLoading, + showMediaOption, + setSelectedIndex: setFileSelectedIndex, + setFilePickerMode, + setFolderPath, + handleFileMenuKey, + resetFilePicker, + } = useFilePicker( + activeToken, + applyMention, + () => { + void handlePickMedia(); + }, + removeActiveToken, + ); + + const closeMenus = useCallback(() => { + if (showSlashMenu || showFileMenu) { + removeActiveToken(); + } + }, [showSlashMenu, showFileMenu, removeActiveToken]); + + useClickOutside([textareaRef, menuRef], showSlashMenu || showFileMenu, closeMenus); + + useEffect(() => { + resetSlashMenu(); + }, [showSlashMenu, resetSlashMenu]); + + useEffect(() => { + if (!showFileMenu) { + resetFilePicker(); + } + }, [showFileMenu, resetFilePicker]); + + useEffect(() => { + const unsub = bridge.on<{ mention: string }>(Events.InsertMention, ({ mention }) => { + setText((prev) => prev + mention + " "); + + setTimeout(() => { + textareaRef.current?.focus(); + adjustHeight(); + }, 0); + }); + + return unsub; + }, [adjustHeight]); + + const handleKeyDown = useMemoizedFn((e: React.KeyboardEvent) => { + if (e.nativeEvent.isComposing) { + return; + } + + if (handleSlashMenuKey(e)) { + return; + } + + if (handleFileMenuKey(e)) { + return; + } + + if (handleHistoryKey(e)) { + return; + } + + if (extensionConfig.useCtrlEnterToSend) { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + handleSend(); + } + } else { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + } + }); + + const handleChange = (e: React.ChangeEvent) => { + setText(e.target.value); + setCursorPos(e.target.selectionStart); + resetHistoryIndex(); + setTimeout(adjustHeight, 0); + }; + + const handleSelect = () => { + setCursorPos(textareaRef.current?.selectionStart ?? 0); + }; + + const handleAddButtonClick = useMemoizedFn(() => { + const newText = text + "@"; + setText(newText); + setCursorPos(newText.length); + setTimeout(() => { + textareaRef.current?.focus(); + textareaRef.current?.setSelectionRange(newText.length, newText.length); + adjustHeight(); + }, 0); + }); + + const hasModels = availableModels.length > 0; + const canSend = (text.trim() || draftMedia.length > 0) && !isProcessing; + + return ( +
      + +
      + {showSlashMenu && filteredCommands.length > 0 && ( +
      + +
      + )} + + {showFileMenu && ( +
      + { + void handlePickMedia(); + }} + onSwitchToFolder={() => { + setFilePickerMode("folder"); + setFolderPath(""); + setFileSelectedIndex(0); + }} + onSwitchToSearch={() => { + setFilePickerMode("search"); + setFolderPath(""); + setFileSelectedIndex(0); + }} + onSelectItem={(item) => applyMention(item.path)} + onNavigateUp={() => { + setFolderPath(folderPath.split("/").slice(0, -1).join("/")); + setFileSelectedIndex(0); + }} + onNavigateInto={(item) => { + setFilePickerMode("folder"); + setFolderPath(item.path); + setFileSelectedIndex(0); + }} + onHover={setFileSelectedIndex} + /> +
      + )} + +
      + {draftMedia.length > 0 && ( +
      + {draftMedia.map((item) => ( + setPreviewMedia(item.dataUri!) : undefined} + onRemove={() => removeDraftMedia(item.id)} + /> + ))} +
      + )} + +