From 274b69d8d56fc06a499d4f61b32b692dbf855f49 Mon Sep 17 00:00:00 2001 From: Vai3soh Date: Fri, 13 Feb 2026 12:16:16 +1000 Subject: [PATCH 1/6] wip: Agent Control Protocol integration + Void platform migration (eslint layering) This commit begins a large refactor and platform migration of Void chat/LLM infrastructure. Key changes: - Integrated Agent Control Protocol (ACP) in electron-main: builtin agent, main service, IPC wiring, log sanitization, loop guard, and supporting utilities. - Relocated shared Void code from workbench-contrib into src/vs/platform/void to satisfy eslint module-boundary rules and eliminate layering violations. - Extracted common and electron-main components (types, helpers, services) so they can be safely reused across workbench and main-process code. - Removed the static modelCapabilities layer; model configuration is now request-driven and propagated through the LLM pipeline (provider, model, parameters, limits). - Rewrote sendLLMMessage.impl: unified streaming send/receive pipeline, robust handling of text and tool calls (including correction of invalid XML tool-call output), tool schema conversion (OpenAI / Anthropic / Gemini), output budgeting, and token usage collection. - Completely reworked the system prompt for the existing execution model; added a separate prompt for the internal ACP agent responsible for generating an execution plan. - Improved edit_file workflow: unified-diff previews for the UI and removal of brittle exact-match behavior. - Added support for disabling/skipping tools (static Void tools and dynamic/MCP tools) via UI settings and JSON configuration. - Expanded Settings UI (void-settings-tsx/Settings.tsx): tool approval controls, unified Tools section, per-tool enable/disable toggles with source attribution. - File reading and terminal tools now return chunked output and surface results in the UI. Notes: - WIP: first part of a larger change-set. --- package-lock.json | 21 + package.json | 2 + src/vs/code/electron-main/app.ts | 56 +- src/vs/platform/acp/common/acpIpc.ts | 190 + src/vs/platform/acp/common/acpLogSanitizer.ts | 15 + src/vs/platform/acp/common/iAcpService.ts | 90 + .../acp/electron-main/acpBuiltinAgent.ts | 1882 +++++++++ .../acp/electron-main/acpMainService.ts | 1597 ++++++++ .../test/acpBuiltinAgent.loopError.test.ts | 84 + .../electron-main/test/acpMainService.test.ts | 70 + .../vendor/acp-sdk.vendored.d.ts | 18 + .../electron-main/vendor/acp-sdk.vendored.js | 1 + .../acp/electron-main/vendor/ws.vendored.d.ts | 13 + .../acp/electron-main/vendor/ws.vendored.js | 7 + .../acp/test/common/acpLogSanitizer.test.ts | 33 + .../acpBuiltinAgent.refreshConfig.test.ts | 280 ++ src/vs/platform/void/common/acpArgs.ts | 40 + .../void/common/chatThreadServiceTypes.ts | 44 +- .../void/common/directoryStrService.ts | 55 +- .../void/common/dynamicModelService.ts | 389 ++ .../void/common/editCodeServiceTypes.ts | 10 +- src/vs/platform/void/common/helpers/colors.ts | 49 + .../common/helpers/extractCodeFromResult.ts | 172 + .../void/common/helpers/systemInfo.ts | 8 +- .../void/common/helpers/util.ts | 5 +- src/vs/platform/void/common/jsonTypes.ts | 24 + src/vs/platform/void/common/loopGuard.ts | 178 + .../platform/void/common/mcpServiceTypes.ts | 153 + .../void/common/metricsService.ts | 21 +- src/vs/platform/void/common/modelInference.ts | 684 ++++ .../platform/void/common/prompt/constants.ts | 20 + .../void/common/prompt/prompt_helper.ts | 29 + .../prompt/systemPromptNativeTemplate.ts | 45 + .../common/prompt/systemPromptXMLTemplate.ts | 39 + src/vs/platform/void/common/providerReg.ts | 859 +++++ .../void/common/remoteModelsService.ts | 14 + src/vs/platform/void/common/requestParams.ts | 55 + .../void/common/sendLLMMessageTypes.ts | 341 ++ src/vs/platform/void/common/storageKeys.ts | 19 + .../void/common/toolOutputFileNames.ts | 90 + .../void/common/toolOutputTruncation.ts | 60 + src/vs/platform/void/common/toolsRegistry.ts | 226 ++ .../platform/void/common/toolsServiceTypes.ts | 98 + .../void/common/voidSCMTypes.ts | 2 +- .../void/common/voidSettingsService.ts | 352 +- .../platform/void/common/voidSettingsTypes.ts | 281 ++ .../void/common/voidUpdateService.ts | 12 +- .../void/common/voidUpdateServiceTypes.ts | 0 .../llmMessage/extractGrammar.ts | 1045 +++++ .../llmMessage/sendLLMMessage.impl.ts | 2862 ++++++++++++++ .../llmMessage/sendLLMMessage.ts | 181 + .../llmMessage/toolSchemaConversion.ts | 455 +++ .../void/electron-main/mcpChannel.ts | 341 +- .../void/electron-main/metricsMainService.ts | 193 +- .../void/electron-main/remoteModelsService.ts | 36 + .../electron-main/sendLLMMessageChannel.ts | 289 ++ .../void/electron-main/voidSCMMainService.ts | 0 .../electron-main/voidUpdateMainService.ts | 134 +- .../contrib/void/browser/ChatAcpHandler.ts | 1054 +++++ .../void/browser/ChatCheckpointManager.ts | 265 ++ .../void/browser/ChatCodespanManager.ts | 151 + .../void/browser/ChatExecutionEngine.ts | 902 +++++ .../void/browser/ChatHistoryCompressor.ts | 218 ++ .../void/browser/ChatNotificationManager.ts | 57 + .../void/browser/ChatToolOutputManager.ts | 523 +++ .../void/browser/_markerCheckService.ts | 129 +- .../contrib/void/browser/aiRegexService.ts | 108 - .../void/browser/autocompleteService.ts | 29 +- .../contrib/void/browser/chatThreadService.ts | 2411 ++++-------- .../void/browser/contextGatheringService.ts | 142 +- .../browser/convertToLLMMessageService.ts | 514 ++- .../contrib/void/browser/editCodeService.ts | 3408 ++++++++++++----- .../void/browser/editCodeServiceInterface.ts | 42 +- .../void/browser/extensionTransferService.ts | 35 +- .../contrib/void/browser/fileService.ts | 3 +- .../helperServices/consistentItemService.ts | 3 - .../contrib/void/browser/helpers/findDiffs.ts | 149 +- .../void/browser/lib/diff-match-patch.d.ts | 7 + .../void/browser/lib/diff-match-patch.js | 2220 +++++++++++ .../contrib/void/browser/media/void.css | 2 + .../void/browser/metricsPollService.ts | 2 +- .../void/browser/miscWokrbenchContrib.ts | 14 +- .../contrib/void/browser/quickEditActions.ts | 5 +- .../contrib/void/browser/react/build.js | 80 +- .../src/markdown/ApplyBlockHoverButtons.tsx | 694 +++- .../react/src/markdown/ChatMarkdownRender.tsx | 298 +- .../react/src/markdown/inferSelection.ts | 381 ++ .../src/quick-edit-tsx/QuickEditChat.tsx | 10 +- .../react/src/sidebar-tsx/ErrorBoundary.tsx | 26 +- .../react/src/sidebar-tsx/ErrorDisplay.tsx | 5 +- .../browser/react/src/sidebar-tsx/Sidebar.tsx | 8 - .../react/src/sidebar-tsx/SidebarChat.tsx | 3221 ++-------------- .../src/sidebar-tsx/SidebarChatBubbles.tsx | 609 +++ .../src/sidebar-tsx/SidebarChatCommandBar.tsx | 310 ++ .../src/sidebar-tsx/SidebarChatShared.ts | 171 + .../src/sidebar-tsx/SidebarChatTools.tsx | 2053 ++++++++++ .../react/src/sidebar-tsx/SidebarChatUI.tsx | 563 +++ .../src/sidebar-tsx/SidebarThreadSelector.tsx | 46 +- .../browser/react/src/sidebar-tsx/index.tsx | 2 - .../void/browser/react/src/util/inputs.tsx | 705 ++-- .../void/browser/react/src/util/services.tsx | 221 +- .../VoidCommandBar.tsx | 2 +- .../VoidSelectionHelper.tsx | 15 +- .../src/void-onboarding/VoidOnboarding.tsx | 431 +-- .../src/void-settings-tsx/ModelDropdown.tsx | 150 +- .../react/src/void-settings-tsx/Settings.tsx | 2964 +++++++++----- .../src/void-settings-tsx/WarningBox.tsx | 2 +- .../void/browser/remoteModelsService.ts | 24 + .../contrib/void/browser/sidebarActions.ts | 31 +- .../contrib/void/browser/sidebarPane.ts | 2 +- .../void/browser/terminalToolService.ts | 344 +- .../test/ChatToolOutputManager.test.ts | 547 +++ .../contrib/void/browser/toolsService.ts | 668 +++- .../contrib/void/browser/void.contribution.ts | 16 +- .../void/browser/voidCommandBarService.ts | 8 +- .../contrib/void/browser/voidSCMService.ts | 14 +- .../void/browser/voidSelectionHelperWidget.ts | 65 +- .../contrib/void/browser/voidSettingsPane.ts | 3 - .../contrib/void/browser/voidUpdateActions.ts | 43 +- .../contrib/void/common/directoryStrTypes.ts | 2 +- .../contrib/void/common/helpers/colors.ts | 40 - .../common/helpers/extractCodeFromResult.ts | 460 --- .../void/common/helpers/languageHelpers.ts | 116 +- .../contrib/void/common/mcpService.ts | 403 +- .../contrib/void/common/mcpServiceTypes.ts | 134 +- .../contrib/void/common/modelCapabilities.ts | 1586 -------- .../contrib/void/common/prompt/prompts.ts | 1783 ++++----- .../void/common/refreshModelService.ts | 222 -- .../void/common/sendLLMMessageService.ts | 316 +- .../void/common/sendLLMMessageTypes.ts | 215 -- .../contrib/void/common/storageKeys.ts | 4 - .../contrib/void/common/toolsService.ts | 80 + .../contrib/void/common/toolsServiceTypes.ts | 97 - .../contrib/void/common/voidModelService.ts | 7 +- .../contrib/void/common/voidSettingsTypes.ts | 524 --- .../llmMessage/extractGrammar.ts | 380 -- .../llmMessage/sendLLMMessage.impl.ts | 967 ----- .../llmMessage/sendLLMMessage.ts | 136 - .../electron-main/sendLLMMessageChannel.ts | 156 - 139 files changed, 33510 insertions(+), 15512 deletions(-) create mode 100644 src/vs/platform/acp/common/acpIpc.ts create mode 100644 src/vs/platform/acp/common/acpLogSanitizer.ts create mode 100644 src/vs/platform/acp/common/iAcpService.ts create mode 100644 src/vs/platform/acp/electron-main/acpBuiltinAgent.ts create mode 100644 src/vs/platform/acp/electron-main/acpMainService.ts create mode 100644 src/vs/platform/acp/electron-main/test/acpBuiltinAgent.loopError.test.ts create mode 100644 src/vs/platform/acp/electron-main/test/acpMainService.test.ts create mode 100644 src/vs/platform/acp/electron-main/vendor/acp-sdk.vendored.d.ts create mode 100644 src/vs/platform/acp/electron-main/vendor/acp-sdk.vendored.js create mode 100644 src/vs/platform/acp/electron-main/vendor/ws.vendored.d.ts create mode 100644 src/vs/platform/acp/electron-main/vendor/ws.vendored.js create mode 100644 src/vs/platform/acp/test/common/acpLogSanitizer.test.ts create mode 100644 src/vs/platform/acp/test/node/acpBuiltinAgent.refreshConfig.test.ts create mode 100644 src/vs/platform/void/common/acpArgs.ts rename src/vs/{workbench/contrib => platform}/void/common/chatThreadServiceTypes.ts (53%) rename src/vs/{workbench/contrib => platform}/void/common/directoryStrService.ts (91%) create mode 100644 src/vs/platform/void/common/dynamicModelService.ts rename src/vs/{workbench/contrib => platform}/void/common/editCodeServiceTypes.ts (92%) create mode 100644 src/vs/platform/void/common/helpers/colors.ts create mode 100644 src/vs/platform/void/common/helpers/extractCodeFromResult.ts rename src/vs/{workbench/contrib => platform}/void/common/helpers/systemInfo.ts (63%) rename src/vs/{workbench/contrib => platform}/void/common/helpers/util.ts (60%) create mode 100644 src/vs/platform/void/common/jsonTypes.ts create mode 100644 src/vs/platform/void/common/loopGuard.ts create mode 100644 src/vs/platform/void/common/mcpServiceTypes.ts rename src/vs/{workbench/contrib => platform}/void/common/metricsService.ts (72%) create mode 100644 src/vs/platform/void/common/modelInference.ts create mode 100644 src/vs/platform/void/common/prompt/constants.ts create mode 100644 src/vs/platform/void/common/prompt/prompt_helper.ts create mode 100644 src/vs/platform/void/common/prompt/systemPromptNativeTemplate.ts create mode 100644 src/vs/platform/void/common/prompt/systemPromptXMLTemplate.ts create mode 100644 src/vs/platform/void/common/providerReg.ts create mode 100644 src/vs/platform/void/common/remoteModelsService.ts create mode 100644 src/vs/platform/void/common/requestParams.ts create mode 100644 src/vs/platform/void/common/sendLLMMessageTypes.ts create mode 100644 src/vs/platform/void/common/storageKeys.ts create mode 100644 src/vs/platform/void/common/toolOutputFileNames.ts create mode 100644 src/vs/platform/void/common/toolOutputTruncation.ts create mode 100644 src/vs/platform/void/common/toolsRegistry.ts create mode 100644 src/vs/platform/void/common/toolsServiceTypes.ts rename src/vs/{workbench/contrib => platform}/void/common/voidSCMTypes.ts (92%) rename src/vs/{workbench/contrib => platform}/void/common/voidSettingsService.ts (64%) create mode 100644 src/vs/platform/void/common/voidSettingsTypes.ts rename src/vs/{workbench/contrib => platform}/void/common/voidUpdateService.ts (79%) rename src/vs/{workbench/contrib => platform}/void/common/voidUpdateServiceTypes.ts (100%) create mode 100644 src/vs/platform/void/electron-main/llmMessage/extractGrammar.ts create mode 100644 src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.impl.ts create mode 100644 src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts create mode 100644 src/vs/platform/void/electron-main/llmMessage/toolSchemaConversion.ts rename src/vs/{workbench/contrib => platform}/void/electron-main/mcpChannel.ts (54%) rename src/vs/{workbench/contrib => platform}/void/electron-main/metricsMainService.ts (52%) create mode 100644 src/vs/platform/void/electron-main/remoteModelsService.ts create mode 100644 src/vs/platform/void/electron-main/sendLLMMessageChannel.ts rename src/vs/{workbench/contrib => platform}/void/electron-main/voidSCMMainService.ts (100%) rename src/vs/{workbench/contrib => platform}/void/electron-main/voidUpdateMainService.ts (56%) create mode 100644 src/vs/workbench/contrib/void/browser/ChatAcpHandler.ts create mode 100644 src/vs/workbench/contrib/void/browser/ChatCheckpointManager.ts create mode 100644 src/vs/workbench/contrib/void/browser/ChatCodespanManager.ts create mode 100644 src/vs/workbench/contrib/void/browser/ChatExecutionEngine.ts create mode 100644 src/vs/workbench/contrib/void/browser/ChatHistoryCompressor.ts create mode 100644 src/vs/workbench/contrib/void/browser/ChatNotificationManager.ts create mode 100644 src/vs/workbench/contrib/void/browser/ChatToolOutputManager.ts delete mode 100644 src/vs/workbench/contrib/void/browser/aiRegexService.ts create mode 100644 src/vs/workbench/contrib/void/browser/lib/diff-match-patch.d.ts create mode 100644 src/vs/workbench/contrib/void/browser/lib/diff-match-patch.js create mode 100644 src/vs/workbench/contrib/void/browser/react/src/markdown/inferSelection.ts create mode 100644 src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatBubbles.tsx create mode 100644 src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatCommandBar.tsx create mode 100644 src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatShared.ts create mode 100644 src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatTools.tsx create mode 100644 src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatUI.tsx create mode 100644 src/vs/workbench/contrib/void/browser/remoteModelsService.ts create mode 100644 src/vs/workbench/contrib/void/browser/test/ChatToolOutputManager.test.ts delete mode 100644 src/vs/workbench/contrib/void/common/helpers/colors.ts delete mode 100644 src/vs/workbench/contrib/void/common/helpers/extractCodeFromResult.ts delete mode 100644 src/vs/workbench/contrib/void/common/modelCapabilities.ts delete mode 100644 src/vs/workbench/contrib/void/common/refreshModelService.ts delete mode 100644 src/vs/workbench/contrib/void/common/sendLLMMessageTypes.ts create mode 100644 src/vs/workbench/contrib/void/common/toolsService.ts delete mode 100644 src/vs/workbench/contrib/void/common/toolsServiceTypes.ts delete mode 100644 src/vs/workbench/contrib/void/common/voidSettingsTypes.ts delete mode 100644 src/vs/workbench/contrib/void/electron-main/llmMessage/extractGrammar.ts delete mode 100644 src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.impl.ts delete mode 100644 src/vs/workbench/contrib/void/electron-main/llmMessage/sendLLMMessage.ts delete mode 100644 src/vs/workbench/contrib/void/electron-main/sendLLMMessageChannel.ts diff --git a/package-lock.json b/package-lock.json index 9fc60e62c84..eccb0a6188f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@anthropic-ai/sdk": "^0.40.0", "@c4312/eventsource-umd": "^3.0.5", "@floating-ui/react": "^0.27.8", @@ -99,6 +100,7 @@ "@types/wicg-file-system-access": "^2020.9.6", "@types/windows-foreground-love": "^0.3.0", "@types/winreg": "^1.2.30", + "@types/ws": "^8.18.1", "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.8.0", @@ -197,6 +199,15 @@ "windows-foreground-love": "0.5.0" } }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.14.1.tgz", + "integrity": "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -4121,6 +4132,16 @@ "integrity": "sha1-kdZxDlNtNFucmwF8V0z2qNpkxRg= sha512-c4m/hnOI1j34i8hXlkZzelE6SXfOqaTWhBp0UgBuwmpiafh22OpsE261Rlg//agZtQHIY5cMgbkX8bnthUFrmA==", "dev": true }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", diff --git a/package.json b/package.json index e6341c0903b..75fc08a3e3a 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "update-build-ts-version": "npm install typescript@next && tsc -p ./build/tsconfig.build.json" }, "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1", "@anthropic-ai/sdk": "^0.40.0", "@c4312/eventsource-umd": "^3.0.5", "@floating-ui/react": "^0.27.8", @@ -161,6 +162,7 @@ "@types/wicg-file-system-access": "^2020.9.6", "@types/windows-foreground-love": "^0.3.0", "@types/winreg": "^1.2.30", + "@types/ws": "^8.18.1", "@types/yauzl": "^2.10.0", "@types/yazl": "^2.4.2", "@typescript-eslint/utils": "^8.8.0", diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index c3d2dfe5461..f70e4dd3e4d 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -55,7 +55,7 @@ import { ProcessMainService } from '../../platform/process/electron-main/process import { IKeyboardLayoutMainService, KeyboardLayoutMainService } from '../../platform/keyboardLayout/electron-main/keyboardLayoutMainService.js'; import { ILaunchMainService, LaunchMainService } from '../../platform/launch/electron-main/launchMainService.js'; import { ILifecycleMainService, LifecycleMainPhase, ShutdownReason } from '../../platform/lifecycle/electron-main/lifecycleMainService.js'; -import { ILoggerService, ILogService } from '../../platform/log/common/log.js'; +import { ILoggerService, ILogService, LogLevel } from '../../platform/log/common/log.js'; import { IMenubarMainService, MenubarMainService } from '../../platform/menubar/electron-main/menubarMainService.js'; import { INativeHostMainService, NativeHostMainService } from '../../platform/native/electron-main/nativeHostMainService.js'; import { IProductService } from '../../platform/product/common/productService.js'; @@ -122,17 +122,21 @@ import { NativeMcpDiscoveryHelperService } from '../../platform/mcp/node/nativeM import { IWebContentExtractorService } from '../../platform/webContentExtractor/common/webContentExtractor.js'; import { NativeWebContentExtractorService } from '../../platform/webContentExtractor/electron-main/webContentExtractorService.js'; import ErrorTelemetry from '../../platform/telemetry/electron-main/errorTelemetry.js'; +import { startBuiltinAcpAgent } from '../../platform/acp/electron-main/acpBuiltinAgent.js'; +import { AcpChannel, AcpChannelName } from '../../platform/acp/common/acpIpc.js'; +import { AcpMainService } from '../../platform/acp/electron-main/acpMainService.js'; +import { installDebugFetchLogging } from '../../platform/void/electron-main/llmMessage/sendLLMMessage.impl.js'; +import { IMetricsService } from '../../platform/void/common/metricsService.js'; +import { IVoidUpdateService } from '../../platform/void/common/voidUpdateService.js'; +import { MetricsMainService } from '../../platform/void/electron-main/metricsMainService.js'; +import { VoidMainUpdateService } from '../../platform/void/electron-main/voidUpdateMainService.js'; +import { LLMMessageChannel } from '../../platform/void/electron-main/sendLLMMessageChannel.js'; +import { IRemoteModelsService } from '../../platform/void/common/remoteModelsService.js'; +import { RemoteModelsService } from '../../platform/void/electron-main/remoteModelsService.js'; +import { VoidSCMService } from '../../platform/void/electron-main/voidSCMMainService.js'; +import { IVoidSCMService } from '../../platform/void/common/voidSCMTypes.js'; +import { MCPChannel } from '../../platform/void/electron-main/mcpChannel.js'; -// in theory this is not allowed -// ignore the eslint errors below -import { IMetricsService } from '../../workbench/contrib/void/common/metricsService.js'; -import { IVoidUpdateService } from '../../workbench/contrib/void/common/voidUpdateService.js'; -import { MetricsMainService } from '../../workbench/contrib/void/electron-main/metricsMainService.js'; -import { VoidMainUpdateService } from '../../workbench/contrib/void/electron-main/voidUpdateMainService.js'; -import { LLMMessageChannel } from '../../workbench/contrib/void/electron-main/sendLLMMessageChannel.js'; -import { VoidSCMService } from '../../workbench/contrib/void/electron-main/voidSCMMainService.js'; -import { IVoidSCMService } from '../../workbench/contrib/void/common/voidSCMTypes.js'; -import { MCPChannel } from '../../workbench/contrib/void/electron-main/mcpChannel.js'; /** * The main VS Code application. There will only ever be one instance, * even if the user starts many instances (e.g. from the command line). @@ -608,7 +612,11 @@ export class CodeApplication extends Disposable { // Open Windows await appInstantiationService.invokeFunction(accessor => this.openFirstWindow(accessor, initialProtocolUrls)); - + try { + startBuiltinAcpAgent(this.logService, undefined, appInstantiationService); + } catch (e) { + this.logService.warn('Failed to start built-in ACP Agent', e); + } // Signal phase: after window open this.lifecycleMainService.phase = LifecycleMainPhase.AfterWindowOpen; @@ -1104,6 +1112,7 @@ export class CodeApplication extends Disposable { // Void main process services (required for services with a channel for comm between browser and electron-main (node)) services.set(IMetricsService, new SyncDescriptor(MetricsMainService, undefined, false)); services.set(IVoidUpdateService, new SyncDescriptor(VoidMainUpdateService, undefined, false)); + services.set(IRemoteModelsService, new SyncDescriptor(RemoteModelsService, undefined, false)); services.set(IVoidSCMService, new SyncDescriptor(VoidSCMService, undefined, false)); // Default Extensions Profile Init @@ -1227,6 +1236,14 @@ export class CodeApplication extends Disposable { const externalTerminalChannel = ProxyChannel.fromService(accessor.get(IExternalTerminalMainService), disposables); mainProcessElectronServer.registerChannel('externalTerminal', externalTerminalChannel); + //ACP + const instantiationService = accessor.get(IInstantiationService); + const acpMainService = instantiationService.createInstance(AcpMainService); + mainProcessElectronServer.registerChannel(AcpChannelName, new AcpChannel(acpMainService)); + Event.once(this.lifecycleMainService.onWillShutdown)(() => { + void acpMainService.disconnect().catch(() => undefined); + }); + // MCP const mcpDiscoveryChannel = ProxyChannel.fromService(accessor.get(INativeMcpDiscoveryHelperService), disposables); mainProcessElectronServer.registerChannel(NativeMcpDiscoveryHelperChannelName, mcpDiscoveryChannel); @@ -1243,15 +1260,26 @@ export class CodeApplication extends Disposable { const voidUpdatesChannel = ProxyChannel.fromService(accessor.get(IVoidUpdateService), disposables); mainProcessElectronServer.registerChannel('void-channel-update', voidUpdatesChannel); - const sendLLMMessageChannel = new LLMMessageChannel(accessor.get(IMetricsService)); + const logService = accessor.get(ILogService); + const lvl = logService.getLevel?.(); + if (lvl === LogLevel.Debug || lvl === LogLevel.Trace) { + installDebugFetchLogging(logService); + } + const sendLLMMessageChannel = new LLMMessageChannel( + accessor.get(IMetricsService), + logService, + ); mainProcessElectronServer.registerChannel('void-channel-llmMessage', sendLLMMessageChannel); + const remoteModelsChannel = ProxyChannel.fromService(accessor.get(IRemoteModelsService), disposables); + mainProcessElectronServer.registerChannel('void-channel-remoteModels', remoteModelsChannel); + // Void added this const voidSCMChannel = ProxyChannel.fromService(accessor.get(IVoidSCMService), disposables); mainProcessElectronServer.registerChannel('void-channel-scm', voidSCMChannel); // Void added this - const mcpChannel = new MCPChannel(); + const mcpChannel = new MCPChannel(logService); mainProcessElectronServer.registerChannel('void-channel-mcp', mcpChannel); // Extension Host Debug Broadcasting diff --git a/src/vs/platform/acp/common/acpIpc.ts b/src/vs/platform/acp/common/acpIpc.ts new file mode 100644 index 00000000000..d43574f7aaa --- /dev/null +++ b/src/vs/platform/acp/common/acpIpc.ts @@ -0,0 +1,190 @@ + +import { Event } from '../../../base/common/event.js'; +import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { + IAcpService, + IAcpStream, + IAcpUserMessage, + IAcpMessageChunk, + IAcpSendOptions, + IAcpChatMessage +} from './iAcpService.js'; + +export const AcpChannelName = 'acp'; +export type AcpRequestId = string; + + +export type AcpHostCallbackKind = + | 'requestPermission' + | 'createTerminal' + | 'terminalOutput' + | 'waitForTerminalExit' + | 'killTerminal' + | 'releaseTerminal' + | 'readTextFile' + | 'writeTextFile' + | 'extMethod'; + +export interface AcpHostCallbackRequest { + requestId: string; + kind: AcpHostCallbackKind; + + sessionId?: string; + threadId?: string; + + params?: any; +} + +export interface AcpHostCallbackResponse { + requestId: string; + result?: any; + error?: string; +} + + +export interface IAcpMainServiceForChannel { + + connect(opts?: IAcpSendOptions): Promise; + disconnect(): Promise; + isConnected(): boolean; + + + sendChatMessage(args: { + threadId: string; + history: IAcpChatMessage[]; + message: IAcpChatMessage; + opts?: IAcpSendOptions; + }): Promise; + + cancel(args: { requestId: AcpRequestId }): Promise; + + + onData(requestId: AcpRequestId): Event; + + + onHostCallback: Event; + hostCallbackResult(resp: AcpHostCallbackResponse): Promise; +} + + +function unwrapWindowHandshake(name: string, arg: any, extraArg?: any): { name: string; arg: any } { + const extractPayload = (container: any): any => { + if (!container || typeof container !== 'object') + return undefined; + if ('arg' in container) return (container as any).arg; + + if ('args' in container) { + const a = (container as any).args; + return Array.isArray(a) ? a[0] : a; + } + + if ('payload' in container) return (container as any).payload; + if ('data' in container) return (container as any).data; + + return undefined; + }; + + if (typeof name === 'string' && name.startsWith('window:')) { + if (arg && typeof arg === 'object') { + if (typeof (arg as any).event === 'string') { + return { name: String((arg as any).event), arg: extractPayload(arg) }; + } + if (typeof (arg as any).command === 'string') { + return { name: String((arg as any).command), arg: extractPayload(arg) }; + } + if (Array.isArray(arg) && arg.length >= 1) { + return { name: String(arg[0]), arg: arg.length > 1 ? arg[1] : undefined }; + } + } + + if (typeof arg === 'string') { + return { name: arg, arg: typeof extraArg === 'undefined' ? undefined : extraArg }; + } + + return { name: '', arg: undefined }; + } + return { name, arg }; +} + + +export class AcpChannel implements IChannel { + constructor(private readonly service: IAcpMainServiceForChannel) { } + + listen(event: string, arg?: any): Event { + const extra = (arguments as any)[2]; + const { name, arg: realArg } = unwrapWindowHandshake(event, arg, extra); + + switch (name) { + case 'onData': + return this.service.onData(realArg.requestId) as Event; + case 'onHostCallback': + return this.service.onHostCallback as Event; + } + throw new Error(`AcpChannel: unknown event ${name}`); + } + + + call(command: string, arg?: any): Promise { + const extra = (arguments as any)[2]; + const { name, arg: realArg } = unwrapWindowHandshake(command, arg, extra); + + switch (name) { + case 'connect': + return this.service.connect(realArg) as any; + case 'disconnect': + return this.service.disconnect() as any; + case 'isConnected': + return Promise.resolve(this.service.isConnected()) as any; + case 'sendChatMessage': + return this.service.sendChatMessage(realArg) as any; + case 'cancel': + return this.service.cancel(realArg) as any; + case 'hostCallbackResult': + return this.service.hostCallbackResult(realArg) as any; + } + return Promise.reject(new Error(`AcpChannel: unknown command ${name}`)); + } +} + +export class AcpChannelClient implements IAcpService { + declare readonly _serviceBrand: undefined; + + private _connected = false; + + constructor(private readonly channel: IChannel) { } + + isConnected(): boolean { + + return this._connected; + } + + async connect(opts?: IAcpSendOptions): Promise { + await this.channel.call('connect', opts); + this._connected = true; + } + + async disconnect(): Promise { + try { + await this.channel.call('disconnect'); + } finally { + this._connected = false; + } + } + + async sendChatMessage( + threadId: string, + history: IAcpChatMessage[], + message: IAcpUserMessage, + opts?: IAcpSendOptions + ): Promise { + + const requestId = await this.channel.call('sendChatMessage', { threadId, history, message, opts }); + const onData = this.channel.listen('onData', { requestId }); + + return { + onData, + cancel: () => { void this.channel.call('cancel', { requestId }); } + }; + } +} + diff --git a/src/vs/platform/acp/common/acpLogSanitizer.ts b/src/vs/platform/acp/common/acpLogSanitizer.ts new file mode 100644 index 00000000000..48dbc12181a --- /dev/null +++ b/src/vs/platform/acp/common/acpLogSanitizer.ts @@ -0,0 +1,15 @@ +export const SENSITIVE_KEY_RE = /(^|_)(key|token|secret|password|passwd|pwd|authorization|bearer|cookie|session)(_|$)/i; + +export function redactEnvForLog(env: any): any { + if (!env || typeof env !== 'object') return env; + const out: Record = {}; + for (const [k, v] of Object.entries(env)) { + out[k] = SENSITIVE_KEY_RE.test(k) ? '' : v; + } + return out; +} + +export function sanitizeAcpSendOptionsForLog(opts?: T): T | undefined { + if (!opts) return opts; + return { ...(opts as any), env: redactEnvForLog((opts as any).env) }; +} diff --git a/src/vs/platform/acp/common/iAcpService.ts b/src/vs/platform/acp/common/iAcpService.ts new file mode 100644 index 00000000000..0668b83eb93 --- /dev/null +++ b/src/vs/platform/acp/common/iAcpService.ts @@ -0,0 +1,90 @@ +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { Event } from '../../../base/common/event.js'; +import type { LLMTokenUsage } from '../../void/common/sendLLMMessageTypes.js'; + +export type IAcpMessageChunk = + | { type: 'text'; text: string } + | { type: 'reasoning'; reasoning: string } + | { type: 'plan'; plan: { title?: string; items: Array<{ id?: string; text: string; state?: string }> } } + | { + type: 'tool_call'; + toolCall: { + id: string; + name: string; + args: Record; + }; + } + | { + type: 'tool_result'; + toolResult: { + id: string; + name: string; + result: any; + error?: string; + }; + } + | { + type: 'tool_progress'; + toolProgress: { + id: string; + name: string; + terminalId?: string; + output: string; + truncated?: boolean; + exitStatus?: { exitCode: number | null; signal: string | null }; + }; + } + | { type: 'error'; error: string } + | { type: 'done'; tokenUsageSnapshot?: LLMTokenUsage }; + +export interface IAcpSendOptions { + mode?: 'builtin' | 'websocket' | 'process'; + // For websocket mode + agentUrl?: string; + + // For process mode + command?: string; + args?: string[]; + env?: Record; + + // Common + model?: string | null; + system?: string | null; + featureName?: 'Chat' | 'Ctrl+K'; + maxToolOutputLength?: number; + readFileChunkLines?: number; +} + +export interface IAcpUserMessage { + role: 'user'; + content: string; +} + +export interface IAcpAssistantMessage { + role: 'assistant'; + content: string; +} + +export type IAcpChatMessage = IAcpUserMessage | IAcpAssistantMessage; + +export interface IAcpStream { + onData: Event; + cancel(): void; +} + +export const IAcpService = createDecorator('acpService'); + +export interface IAcpService { + readonly _serviceBrand: undefined; + + isConnected(): boolean; + connect(opts?: IAcpSendOptions): Promise; + disconnect(): Promise; + + sendChatMessage( + threadId: string, + history: IAcpChatMessage[], + message: IAcpUserMessage, + opts?: IAcpSendOptions + ): Promise; +} diff --git a/src/vs/platform/acp/electron-main/acpBuiltinAgent.ts b/src/vs/platform/acp/electron-main/acpBuiltinAgent.ts new file mode 100644 index 00000000000..3ba80cd2be6 --- /dev/null +++ b/src/vs/platform/acp/electron-main/acpBuiltinAgent.ts @@ -0,0 +1,1882 @@ +import { WebSocketServer } from 'ws'; +import { + AgentSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent, + type InitializeRequest, + type InitializeResponse, + type AuthenticateRequest, + type AuthenticateResponse, + type NewSessionRequest, + type NewSessionResponse, + type CancelNotification, + type PromptRequest, + type PromptResponse, +} from '@agentclientprotocol/sdk'; +import type { ILogService } from '../../log/common/log.js'; +import type { INotificationService } from '../../notification/common/notification.js'; +import type { IInstantiationService, ServicesAccessor } from '../../instantiation/common/instantiation.js'; +import { IVoidSettingsService } from '../../void/common/voidSettingsService.js'; +import { sendChatRouter as sendChatRouterOriginal } from '../../void/electron-main/llmMessage/sendLLMMessage.impl.js'; +import { ProviderName, SettingsOfProvider, ModelSelectionOptions, OverridesOfModel, ChatMode, defaultGlobalSettings } from '../../void/common/voidSettingsTypes.js'; +import { LLMChatMessage, type DynamicRequestConfig, type RequestParamsConfig, type ProviderRouting, type AdditionalToolInfo, LLMPlan, LLMTokenUsage } from '../../void/common/sendLLMMessageTypes.js'; +import { getModelApiConfiguration } from '../../void/common/modelInference.js'; +import { LLMLoopDetector, LOOP_DETECTED_MESSAGE } from '../../void/common/loopGuard.js'; +import { computeTruncatedToolOutput } from '../../void/common/toolOutputTruncation.js'; +import { stableToolOutputsRelPath } from '../../void/common/toolOutputFileNames.js'; + +type Stream = ConstructorParameters[1]; + +// Allow tests to override sendChatRouter while keeping the default implementation for runtime. +let sendChatRouterImpl = sendChatRouterOriginal; + +let started = false; + +function wsNdjsonStream(ws: any): Stream { + const readable = new ReadableStream({ + start(controller) { + ws.on('message', (data: any) => { + try { + if (typeof data === 'string') { + controller.enqueue(new TextEncoder().encode(data)); + } else if (data instanceof Buffer) { + controller.enqueue(new Uint8Array(data)); + } else if (data instanceof ArrayBuffer) { + controller.enqueue(new Uint8Array(data)); + + } else if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView; + controller.enqueue(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); + } + } catch (e) { + controller.error(e); + } + }); + ws.on('close', () => controller.close()); + ws.on('error', (e: any) => controller.error(e)); + } + }); + const writable = new WritableStream({ + write(chunk) { ws.send(Buffer.from(chunk)); }, + close() { try { ws.close(); } catch { } }, + abort() { try { ws.close(); } catch { } } + }); + return ndJsonStream(writable, readable); +} + +export function startBuiltinAcpAgent(log?: ILogService, notificationService?: INotificationService, instantiationService?: IInstantiationService): void { + if (started) return; + started = true; + + const PORT = Number(process.env.VOID_ACP_AGENT_PORT || 8719); + const HOST = process.env.VOID_ACP_AGENT_HOST || '127.0.0.1'; + + let wss: WebSocketServer | null = null; + try { + wss = new WebSocketServer({ host: HOST, port: PORT }); + const HEARTBEAT_MS = 30_000; + const heartbeatTimer = setInterval(() => { + if (!wss) return; + for (const ws of wss.clients as any) { + if (ws.isAlive === false) { + try { ws.terminate(); } catch { /* noop */ } + continue; + } + ws.isAlive = false; + try { ws.ping(); } catch { /* noop */ } + } + }, HEARTBEAT_MS); + wss.on('close', () => clearInterval(heartbeatTimer)); + } catch (e) { + log?.warn?.('[ACP Agent] failed to start ws server', e); + started = false; + return; + } + + wss.on('connection', (ws) => { + (ws as any).isAlive = true; + ws.on('pong', () => { (ws as any).isAlive = true; }); + const stream = wsNdjsonStream(ws); + new AgentSideConnection((conn) => new VoidPipelineAcpAgent(conn, log, notificationService, instantiationService), stream); + log?.trace?.('[ACP Agent] client connected'); + }); + + wss.on('listening', () => log?.info?.(`[ACP Agent] listening on ws://${HOST}:${PORT}`)); + wss.on('error', (e) => log?.warn?.('[ACP Agent] error', e)); +} + +// ---- Local types to reduce any ---- + +type ToolCall = { + id: string; + name: string; + args?: Record; +}; + +type ToolCallUpdate = { + toolCallId: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed'; + title: string; + kind?: string; + content?: string | Record; +}; + +type ProviderNameStr = string; +type SettingsOfProviderLike = unknown; +type ModelSelectionOptionsLike = unknown; +type OverridesOfModelLike = unknown; +type ChatModeLike = string | null; + +interface LoopGuardConfig { + maxTurnsPerPrompt?: number; + maxSameAssistantPrefix?: number; + maxSameToolCall?: number; +} + +interface GetLLMConfigResponse { + providerName: ProviderNameStr | null; + modelName: string | null; + settingsOfProvider: SettingsOfProviderLike; + modelSelectionOptions: ModelSelectionOptionsLike | null; + overridesOfModel: OverridesOfModelLike | null; + separateSystemMessage: string | null; + chatMode: ChatModeLike; + loopGuard?: LoopGuardConfig | null; + requestParams: RequestParamsConfig | null; + providerRouting?: ProviderRouting | null; + dynamicRequestConfig?: DynamicRequestConfig | null; + additionalTools?: AdditionalToolInfo[] | null; + disabledStaticTools?: string[] | null; + disabledDynamicTools?: string[] | null; +} + +interface ExecuteWithTextResponse { + ok: boolean; + result: unknown; + text: string; +} + +const ACP_PLAN_TOOL: AdditionalToolInfo = { + name: 'acp_plan', + description: 'Report/update the execution plan to the client UI via ACP. Use instead of printing a plan in text.', + params: { + entries: { + description: 'Complete list of plan entries (client replaces the plan on each update).', + type: 'array', + items: { + type: 'object', + description: 'Plan entry', + properties: { + content: { type: 'string', description: 'Human-readable task description' }, + priority: { type: 'string', enum: ['high', 'medium', 'low'] }, + status: { type: 'string', enum: ['pending', 'in_progress', 'completed', 'failed'] }, + }, + required: ['content', 'priority', 'status'], + }, + }, + }, +}; + +interface ToolCallLike { + id?: string; + name?: string; + rawParams?: Record; + isDone?: boolean; +} + +interface OnTextChunk { + fullText?: string; + fullReasoning?: string; + toolCall?: ToolCallLike; + plan?: LLMPlan; +} + +interface OnFinalMessagePayload { + fullText?: string; + fullReasoning?: string; + toolCall?: ToolCallLike; + plan?: LLMPlan; + tokenUsage?: LLMTokenUsage; +} + +type OAIFunctionCall = { id: string; name: string; args: Record }; + +type LLMMessage = { + role: 'system' | 'user' | 'assistant' | 'tool'; + content: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; + }>; +}; + +type SessionState = { + + cancelled?: boolean; + aborter?: (() => void) | null; + // Track the most recent tool call that is awaiting a tool result. + // This lets us handle UI "skip" that arrives as a separate user message + // without scanning message history. + pendingToolCall?: { id: string; name: string } | null; + messages: LLMMessage[]; + // Last LLM token usage snapshot for the most recent sendChatRouter turn in this session. + // Used to aggregate per‑prompt usage and send it back to the host via PromptResponse._meta. + llmTokenUsageLast?: LLMTokenUsage | undefined; + threadId?: string; + llmCfg: { + providerName: ProviderNameStr; + settingsOfProvider: SettingsOfProviderLike; + modelSelectionOptions?: ModelSelectionOptionsLike; + overridesOfModel?: OverridesOfModelLike; + modelName: string; + separateSystemMessage?: string | null; + chatMode: ChatModeLike; + requestParams?: RequestParamsConfig | null; + dynamicRequestConfig?: DynamicRequestConfig | null; + providerRouting?: ProviderRouting | null; + loopGuard?: LoopGuardConfig | null; + additionalTools?: AdditionalToolInfo[] | null; + disabledStaticTools?: string[] | null; + disabledDynamicTools?: string[] | null; + }; +}; + +type StreamDeltaState = { + totalLength: number; + prefix: string; +}; + +const STREAM_PREFIX_PROBE_LEN = 96; +const emptyStreamDeltaState = (): StreamDeltaState => ({ totalLength: 0, prefix: '' }); +const makePrefixProbe = (s: string): string => s.slice(0, STREAM_PREFIX_PROBE_LEN); + +const toDeltaChunk = ( + incomingRaw: unknown, + prev: StreamDeltaState +): { chunk: string; next: StreamDeltaState } => { + const incoming = typeof incomingRaw === 'string' ? incomingRaw : ''; + if (!incoming) return { chunk: '', next: prev }; + + if (prev.totalLength <= 0) { + return { + chunk: incoming, + next: { totalLength: incoming.length, prefix: makePrefixProbe(incoming) }, + }; + } + + const probeLen = Math.min(prev.prefix.length, incoming.length); + const prevProbe = probeLen > 0 ? prev.prefix.slice(0, probeLen) : ''; + const incomingProbe = probeLen > 0 ? incoming.slice(0, probeLen) : ''; + const hasSamePrefix = probeLen > 0 && prevProbe === incomingProbe; + + if (incoming.length > prev.totalLength && hasSamePrefix) { + return { + chunk: incoming.slice(prev.totalLength), + next: { totalLength: incoming.length, prefix: makePrefixProbe(incoming) }, + }; + } + + if (incoming.length === prev.totalLength && hasSamePrefix) { + return { + chunk: '', + next: { totalLength: incoming.length, prefix: makePrefixProbe(incoming) }, + }; + } + + if (incoming.length < prev.totalLength && hasSamePrefix) { + // Ignore regressive snapshots to keep stream monotonic for UI. + return { + chunk: '', + next: prev, + }; + } + + // Fallback: treat incoming as plain delta chunk. + return { + chunk: incoming, + next: { + totalLength: prev.totalLength + incoming.length, + prefix: prev.prefix || makePrefixProbe(incoming), + }, + }; +}; + +class VoidPipelineAcpAgent implements Agent { + private sessions = new Map(); + private _updateChainBySession = new Map>(); + private _textStreamStateBySession = new Map(); + private _reasoningStreamStateBySession = new Map(); + private _lastPlanSigBySession = new Map(); + + constructor( + private readonly conn: AgentSideConnection, + private readonly log?: ILogService, + private readonly notificationService?: INotificationService, + private readonly instantiationService?: IInstantiationService + ) { } + + private _getReadFileChunkLines(): number { + try { + const vss = this.instantiationService?.invokeFunction((a: ServicesAccessor) => a.get(IVoidSettingsService)); + const raw = (vss?.state as any)?.globalSettings?.readFileChunkLines; + const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? Number(raw) : NaN); + if (Number.isFinite(n) && n > 0) return n; + } catch { /* ignore */ } + return defaultGlobalSettings.readFileChunkLines; + } + + async initialize(_params: InitializeRequest): Promise { + return { + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + promptCapabilities: { image: false, audio: false, embeddedContext: false } + }, + authMethods: [] + }; + } + + async authenticate(_params: AuthenticateRequest): Promise { + + return {}; + } + + async newSession(_params: NewSessionRequest): Promise { + const sessionId = 'sess_' + Math.random().toString(36).slice(2); + + const meta = (_params as any)._meta; + const threadIdFromMeta = + (typeof meta?.threadId === 'string' && meta.threadId.trim()) + ? String(meta.threadId).trim() + : undefined; + + // IMPORTANT: include routing hints so renderer window routing is correct even during newSession + const rawCfg = await this.conn.extMethod('void/settings/getLLMConfig', { + featureName: 'Chat', + sessionId, + ...(threadIdFromMeta ? { threadId: threadIdFromMeta } : {}) + }) as unknown; + + const cfg = rawCfg as GetLLMConfigResponse; + + + const providerName: string = + (typeof cfg?.providerName === 'string' && cfg.providerName) ? cfg.providerName : 'openAI'; + const modelName: string = + (typeof cfg?.modelName === 'string' && cfg.modelName) ? cfg.modelName : (process.env.VOID_DEFAULT_MODEL || 'gpt-4o-mini'); + + const messages: LLMMessage[] = []; + // Restore history if provided in _meta (from AcpMainService) + if (meta?.history && Array.isArray(meta.history)) { + for (const m of meta.history) { + // Filter only user/assistant messages to avoid clutter or duplicates + if ((m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string') { + messages.push({ role: m.role, content: m.content }); + } + } + } + + this.sessions.set(sessionId, { + cancelled: false, + pendingToolCall: null, + messages, + threadId: threadIdFromMeta, + llmCfg: { + providerName, + settingsOfProvider: cfg?.settingsOfProvider, + modelSelectionOptions: cfg?.modelSelectionOptions ?? undefined, + overridesOfModel: cfg?.overridesOfModel ?? undefined, + modelName, + separateSystemMessage: (typeof cfg?.separateSystemMessage === 'string' || cfg?.separateSystemMessage === null) ? cfg.separateSystemMessage : null, + chatMode: cfg?.chatMode ?? null, + requestParams: cfg?.requestParams ?? null, + dynamicRequestConfig: cfg?.dynamicRequestConfig ?? null, + providerRouting: cfg?.providerRouting ?? null, + loopGuard: cfg?.loopGuard ?? null, + additionalTools: cfg?.additionalTools ?? null, + disabledStaticTools: Array.isArray(cfg?.disabledStaticTools) + ? cfg.disabledStaticTools.map(v => String(v ?? '').trim()).filter(Boolean) + : null, + disabledDynamicTools: Array.isArray(cfg?.disabledDynamicTools) + ? cfg.disabledDynamicTools.map(v => String(v ?? '').trim()).filter(Boolean) + : null, + } + }); + + return { sessionId }; + } + + async cancel(params: CancelNotification): Promise { + const sid = params?.sessionId; + const s = sid ? this.sessions.get(sid) : undefined; + + if (s) { + s.cancelled = true; + + try { s.aborter?.(); } catch { /* noop */ } + s.aborter = null; + + this.log?.debug?.('[ACP Agent][cancel] session cancelled', { + sessionId: sid, + threadId: s.threadId, + messagesInHistory: s.messages.length, + pendingToolCall: s.pendingToolCall, + }); + } else { + this.log?.debug?.('[ACP Agent][cancel] unknown session', { sessionId: sid }); + } + } + + + async prompt(params: PromptRequest): Promise { + const sid: string | undefined = params?.sessionId as any; + const state = sid ? this.sessions.get(sid) : undefined; + if (!sid || !state) throw new Error('No session'); + + // IMPORTANT: + + + state.cancelled = false; + + this.log?.debug?.('[ACP Agent][prompt] START', { + sessionId: sid, + threadId: state.threadId, + messageCount: state.messages.length, + provider: state.llmCfg.providerName, + model: state.llmCfg.modelName, + chatMode: state.llmCfg.chatMode, + }); + + // Aggregate token usage for this ACP prompt across all underlying LLM turns + // (including tool-induced follow-up calls). This is sent back via PromptResponse._meta + // and later forwarded to the renderer as IAcpMessageChunk.tokenUsageSnapshot. + const accumulateUsage = (a: LLMTokenUsage | undefined, b: LLMTokenUsage | undefined): LLMTokenUsage | undefined => { + if (!b) return a; + if (!a) return { ...b }; + return { + input: a.input + b.input, + cacheCreation: a.cacheCreation + b.cacheCreation, + cacheRead: a.cacheRead + b.cacheRead, + output: a.output + b.output, + }; + }; + + const rollbackDanglingToolCall = (toolCallId: string, assistantText?: string) => { + if (!toolCallId) return; + const last = state.messages[state.messages.length - 1] as any; + const toolCalls = last?.role === 'assistant' ? last?.tool_calls : undefined; + if (!Array.isArray(toolCalls)) return; + const hasThisId = toolCalls.some((tc: any) => String(tc?.id ?? '') === String(toolCallId)); + if (!hasThisId) return; + const existingText = typeof last.content === 'string' ? last.content : ''; + const t = (assistantText ?? existingText ?? '').trim(); + delete last.tool_calls; + if (t) last.content = t; + }; + + //const skipUiText = (toolName: string) => `Skip ${toolName}. Continue with next steps.`; + const skipModelText = (toolName: string) => + `Tool execution was skipped by the user.\n` + + `Skip ${toolName}. Continue with next steps.\n` + + `Do NOT call the same tool again in this prompt with the same arguments.\n` + + `If you require the output, ask the user to run it manually and paste the result.`; + + + let usageForThisPrompt: LLMTokenUsage | undefined = undefined; + + // refresh cfg + try { + // Update threadId from prompt meta (best-effort) + const metaWrapper = params as PromptRequest & { _meta?: any }; + const tidFromPrompt = + (typeof metaWrapper._meta?.threadId === 'string' && metaWrapper._meta.threadId.trim()) + ? String(metaWrapper._meta.threadId).trim() + : undefined; + if (tidFromPrompt) state.threadId = tidFromPrompt; + + const rawCfg = await this.conn.extMethod('void/settings/getLLMConfig', { + featureName: 'Chat', + sessionId: sid, + ...(state.threadId ? { threadId: state.threadId } : {}) + }) as unknown; + + const cfg = rawCfg as GetLLMConfigResponse; + + if (cfg && typeof cfg.providerName === 'string' && typeof cfg.modelName === 'string' + && cfg.providerName && cfg.modelName) { + const old = state.llmCfg; + state.llmCfg = { + providerName: cfg.providerName, + modelName: cfg.modelName, + settingsOfProvider: cfg.settingsOfProvider ?? old.settingsOfProvider, + modelSelectionOptions: cfg.modelSelectionOptions ?? old.modelSelectionOptions, + overridesOfModel: cfg.overridesOfModel ?? old.overridesOfModel, + separateSystemMessage: (typeof cfg.separateSystemMessage === 'string' || cfg.separateSystemMessage === null) + ? cfg.separateSystemMessage + : old.separateSystemMessage ?? null, + chatMode: cfg.chatMode ?? old.chatMode ?? null, + requestParams: cfg.requestParams ?? old.requestParams ?? null, + dynamicRequestConfig: cfg.dynamicRequestConfig ?? old.dynamicRequestConfig ?? null, + providerRouting: cfg.providerRouting ?? old.providerRouting ?? null, + loopGuard: cfg.loopGuard ?? old.loopGuard ?? null, + additionalTools: cfg.additionalTools ?? old.additionalTools ?? null, + disabledStaticTools: Array.isArray(cfg.disabledStaticTools) + ? cfg.disabledStaticTools.map(v => String(v ?? '').trim()).filter(Boolean) + : old.disabledStaticTools ?? null, + disabledDynamicTools: Array.isArray(cfg.disabledDynamicTools) + ? cfg.disabledDynamicTools.map(v => String(v ?? '').trim()).filter(Boolean) + : old.disabledDynamicTools ?? null, + }; + this.log?.debug?.(`[ACP Agent] refreshed llmCfg from settings`, JSON.stringify({ + oldProvider: old.providerName, + oldModel: old.modelName, + newProvider: state.llmCfg.providerName, + newModel: state.llmCfg.modelName, + })); + } + } catch (e) { + this.log?.warn?.('[ACP Agent] failed to refresh llmCfg from settings, keeping previous config', e); + } + + // Resolve maxToolOutputLength from global defaults for ACP truncation. + let maxToolOutputLength = defaultGlobalSettings.maxToolOutputLength; + const metaWrapper = params as PromptRequest & { _meta?: unknown }; + const meta = metaWrapper._meta; + if (meta && typeof meta === 'object') { + const maybeLen = (meta as { maxToolOutputLength?: unknown }).maxToolOutputLength; + if (typeof maybeLen === 'number' && maybeLen > 0) { + maxToolOutputLength = maybeLen; + } + } + + // Resolve readFileChunkLines (prefer prompt _meta; fallback to settings service; then defaults). + const parsePositiveInt = (v: unknown): number | undefined => { + const n = typeof v === 'number' ? v : (typeof v === 'string' ? Number(v) : NaN); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined; + }; + + const readFileChunkLinesFromMeta = (meta && typeof meta === 'object') + ? (() => { + const m = meta as { readFileChunkLines?: unknown; globalSettings?: { readFileChunkLines?: unknown } }; + return parsePositiveInt(m.readFileChunkLines ?? m.globalSettings?.readFileChunkLines); + })() + : undefined; + + let readFileChunkLines = readFileChunkLinesFromMeta ?? this._getReadFileChunkLines(); + if (!Number.isFinite(readFileChunkLines) || readFileChunkLines <= 0) { + readFileChunkLines = defaultGlobalSettings.readFileChunkLines; + } + + const lg = state.llmCfg.loopGuard; + const loopDetector = new LLMLoopDetector(lg ? { + maxTurnsPerPrompt: lg.maxTurnsPerPrompt, + maxSameAssistantPrefix: lg.maxSameAssistantPrefix, + maxSameToolCall: lg.maxSameToolCall, + } : undefined); + + const promptBlocks = params?.prompt as any[] | undefined; + const userText = extractTextFromPrompt(promptBlocks as any); + + if (!userText && !(promptBlocks && promptBlocks.length)) { + this.log?.debug?.('[ACP Agent][prompt] EMPTY PROMPT - returning early', { + sessionId: sid, + promptBlocksLength: promptBlocks?.length + }); + await this.emitText(sid, 'Empty prompt.'); + return { stopReason: 'end_turn' }; + } + + // If UI "Skip" comes as a separate user message "skip", + // convert it into a tool-result for the currently pending tool call, + // then continue normally (do not break the thread). + let consumedAsSkip = false; + const normalizedUserText = (userText ?? '').trim().toLowerCase(); + + if (normalizedUserText === 'skip' && state.pendingToolCall?.id) { + consumedAsSkip = true; + const { id: pendingId, name: pendingName } = state.pendingToolCall; + + // Mark tool call as finished in ACP UI (best effort) + try { + await this.conn.sessionUpdate({ + sessionId: sid, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: pendingId, + status: 'completed', + title: pendingName || 'tool', + content: [{ type: 'content', content: { type: 'text', text: '' } }], + rawOutput: { _skipped: true } + } + } as any); + } catch (e) { + this.log?.warn?.('[ACP Agent] failed to mark tool_call as skipped', e); + } + + // Provide tool result to the model so the loop can continue + state.messages.push({ + role: 'tool', + tool_call_id: pendingId, + content: skipModelText(pendingName || 'tool') + }); + state.pendingToolCall = null; + } + + // Normal path: push user message into model history + if (!consumedAsSkip) { + const userMsg: any = { role: 'user', content: userText }; + if (Array.isArray(promptBlocks) && promptBlocks.length) { + userMsg.contentBlocks = promptBlocks; + } + state.messages.push(userMsg); + } + + const maxTurns = state.llmCfg.loopGuard?.maxTurnsPerPrompt; + let safeguard = Math.max(25, typeof maxTurns === 'number' ? maxTurns : 0); + this.log?.debug?.('[ACP Agent] safeguard', safeguard); + + let turnCount = 0; + while (safeguard-- > 0) { + turnCount++; + this.log?.debug?.('[ACP Agent][prompt] loop iteration', { + sessionId: sid, + turn: turnCount, + safeguardRemaining: safeguard, + messagesInHistory: state.messages.length, + cancelled: state.cancelled, + }); + + if (state.cancelled) { + this.log?.debug?.('[ACP Agent][prompt] CANCELLED - returning', { + sessionId: sid, + turn: turnCount, + }); + return { stopReason: 'cancelled' }; + } + + let toolCall: OAIFunctionCall | null = null; + let assistantText = ''; + + try { + this.log?.debug?.('[ACP Agent][prompt] calling runOneTurnWithSendLLM', { + sessionId: sid, + turn: turnCount, + messagesCount: state.messages.length, + }); + + const turn = await this.runOneTurnWithSendLLM(state, sid); + toolCall = turn.toolCall; + assistantText = turn.assistantText; + + this.log?.debug?.('[ACP Agent][prompt] runOneTurnWithSendLLM completed', { + sessionId: sid, + turn: turnCount, + hasToolCall: !!toolCall, + toolName: toolCall?.name, + assistantTextLength: assistantText?.length, + }); + + const loopAfterAssistant = loopDetector.registerAssistantTurn(assistantText); + if (loopAfterAssistant.isLoop) { + this.log?.debug?.('[ACP Agent][prompt] LOOP DETECTED after assistant turn', { + sessionId: sid, + turn: turnCount, + reason: loopAfterAssistant.reason, + }); + if (toolCall?.id) { + rollbackDanglingToolCall(String(toolCall.id), assistantText); + } + this.emitError(LOOP_DETECTED_MESSAGE); + } + + if (state.llmTokenUsageLast) { + usageForThisPrompt = accumulateUsage(usageForThisPrompt, state.llmTokenUsageLast); + state.llmTokenUsageLast = undefined; + } + } catch (e: any) { + // Preserve rich error info produced by emitError (e.data.details / e.details) + // so the renderer can show the real details. + this.log?.debug?.('[ACP Agent][prompt] runOneTurnWithSendLLM threw error', { + sessionId: sid, + turn: turnCount, + error: e instanceof Error ? e.message : String(e), + }); + if (e instanceof Error) { + throw e; + } + const msg = typeof e?.message === 'string' ? e.message : String(e); + throw new Error(msg); + } + + if (!toolCall) { + this.log?.debug?.('[ACP Agent][prompt] NO TOOL CALL - ending turn', { + sessionId: sid, + turn: turnCount, + stopReason: 'end_turn', + }); + const resp: any = { stopReason: 'end_turn' as const }; + if (usageForThisPrompt) resp._meta = { ...(resp._meta || {}), llmTokenUsage: usageForThisPrompt }; + return resp as PromptResponse; + } + + if (toolCall.name === 'acp_plan') { + const rawEntries = (toolCall.args as any)?.entries; + const entries = + Array.isArray(rawEntries) + ? rawEntries.map((e: any) => { + const content = String(e?.content ?? '').trim(); + const priority = + (e?.priority === 'high' || e?.priority === 'low' || e?.priority === 'medium') + ? e.priority + : 'medium'; + const status = + (e?.status === 'pending' || e?.status === 'in_progress' || e?.status === 'completed' || e?.status === 'failed') + ? e.status + : 'pending'; + return { content, priority, status }; + }).filter((e: any) => e.content.length > 0) + : []; + + if (entries.length) { + await this.conn.sessionUpdate({ + sessionId: sid, + update: { sessionUpdate: 'plan', entries } as any + } as any); + } + + state.messages.push({ + role: 'tool', + tool_call_id: String(toolCall.id || 'acp_plan'), + content: 'ok' + }); + continue; + } + + const loopAfterTool = loopDetector.registerToolCall(toolCall.name, toolCall.args); + if (loopAfterTool.isLoop) { + if (toolCall?.id) { + rollbackDanglingToolCall(String(toolCall.id), assistantText); + } + + this.emitError(LOOP_DETECTED_MESSAGE); + } + + this.log?.debug?.('[ACP Agent][prompt] tool_call detected', { + sessionId: sid, + turn: turnCount, + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.args, + }); + + // Track pending tool call BEFORE awaiting any UI action + state.pendingToolCall = { id: String(toolCall.id), name: String(toolCall.name) }; + + // ACP tool_call + await this.conn.sessionUpdate({ + sessionId: sid, + update: { + sessionUpdate: 'tool_call', + toolCallId: toolCall.id, + title: toolCall.name, + kind: 'other', + status: 'pending', + rawInput: { name: toolCall.name, args: toolCall.args } + } + } as any); + + // Request permission + this.log?.debug?.('[ACP Agent][prompt] requesting permission', { + sessionId: sid, + turn: turnCount, + toolCallId: toolCall.id, + toolName: toolCall.name, + }); + + const perm = await this.conn.requestPermission({ + sessionId: sid, + toolCall: { + toolCallId: toolCall.id, + rawInput: { name: toolCall.name, args: toolCall.args ?? {} }, + title: toolCall.name + }, + options: [ + { optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject_once', name: 'Reject', kind: 'reject_once' } + ] + } as any); + + if (state.cancelled) { + this.log?.debug?.('[ACP Agent][prompt] CANCELLED after permission request', { + sessionId: sid, + turn: turnCount, + }); + return { stopReason: 'cancelled' }; + } + + const outcome = (perm as any)?.outcome; + const selected = outcome?.outcome === 'selected'; + const optionId = selected ? String(outcome?.optionId ?? '') : ''; + const isAllow = optionId === 'allow_once' || optionId === 'allow_always'; + + this.log?.debug?.('[ACP Agent][prompt] permission result', { + sessionId: sid, + turn: turnCount, + optionId, + isAllow, + outcome: outcome, + }); + + if (!isAllow) { + // Treat non-allow as "skipped" (this is how ACP Skip is implemented via rejectLatestToolRequest) + const toolName = String(toolCall.name || 'tool'); + await this.conn.sessionUpdate({ + sessionId: sid, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: toolCall.id, + status: 'completed', + title: toolName, + content: [{ type: 'content', content: { type: 'text', text: '' } }], + rawOutput: { _skipped: true } + } + } as any); + + state.messages.push({ + role: 'tool', + tool_call_id: String(toolCall.id), + content: skipModelText(toolName) + }); + state.pendingToolCall = null; + continue; + } + + // in_progress + await this.conn.sessionUpdate({ + sessionId: sid, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: toolCall.id, + status: 'in_progress', + title: toolCall.name, + content: [{ type: 'content', content: { type: 'text', text: 'Running...' } }] + } + } as any); + + this.log?.debug?.('[ACP Agent][prompt] executing tool', { + sessionId: sid, + turn: turnCount, + toolCallId: toolCall.id, + toolName: toolCall.name, + }); + + // Execute tool on host + let textOut = ''; + let rawOut: any = undefined; + let status: 'completed' | 'failed' | 'pending' | 'in_progress' = 'completed'; + + try { + // Special handling for terminal commands with streaming + if (toolCall.name === 'run_command') { + this.log?.debug?.('[ACP Agent][prompt] executing terminal command', { + sessionId: sid, + turn: turnCount, + toolCallId: toolCall.id, + }); + const terminalResult = await this.executeTerminalCommandWithStreaming(toolCall); + + textOut = typeof terminalResult.content === 'string' + ? terminalResult.content + : JSON.stringify(terminalResult.content || ''); + + status = terminalResult.status || 'completed'; + + const terminalId = + typeof (terminalResult as any)?.terminalId === 'string' + ? (terminalResult as any).terminalId + : undefined; + + // IMPORTANT: + // Put final text into rawOut.output (not _output), so your truncation code + // can overwrite rawOut.output with the *truncated-from-start* textOut. + rawOut = { + _type: 'terminal', + _status: status, + ...(terminalId ? { terminalId } : {}), + output: textOut, + }; + } else { + const rawExec = await this.conn.extMethod('void/tools/execute_with_text', { + name: toolCall.name, + params: toolCall.args ?? {}, + // IMPORTANT: routing hints so extMethod is handled by the correct window (workspace) + sessionId: sid, + ...(state.threadId ? { threadId: state.threadId } : {}) + }) as unknown; + + const out = rawExec as ExecuteWithTextResponse; + const originalResult = (out as any)?.result; + + // normalize + rawOut = (() => { + if (originalResult === undefined || originalResult === null) return {}; + if (typeof originalResult === 'object') return originalResult; + if (typeof originalResult === 'string') { + try { return JSON.parse(originalResult); } catch { + return { _type: 'text', content: originalResult, _originalLength: originalResult.length }; + } + } + return { _type: typeof originalResult, value: originalResult }; + })(); + + textOut = typeof out?.text === 'string' + ? out.text + : (typeof originalResult === 'string' ? originalResult : JSON.stringify(rawOut)); + } + } catch (e: any) { + textOut = `Tool error: ${String(e?.message ?? e)}`; + status = 'failed'; + rawOut = { _error: true, _message: e?.message ?? String(e), _stack: e?.stack ? e.stack.substring(0, 500) : undefined }; + this.log?.debug?.('[ACP Agent][prompt] tool execution error', { + sessionId: sid, + turn: turnCount, + toolCallId: toolCall.id, + toolName: toolCall.name, + error: e?.message ?? String(e), + }); + } + + // Truncate tool output + const originalTextOut = textOut; + if (typeof textOut === 'string' && textOut.length > maxToolOutputLength) { + const originalLength = textOut.length; + const { truncatedBody, lineAfterTruncation } = computeTruncatedToolOutput(textOut, maxToolOutputLength); + const startLineExclusive = lineAfterTruncation > 0 ? lineAfterTruncation : 0; + + const headerLines = [ + `[VOID] TOOL OUTPUT TRUNCATED, SEE TRUNCATION_META BELOW.`, + `Only the first ${maxToolOutputLength} characters are included in this message.`, + `Display limit: maxToolOutputLength = ${maxToolOutputLength} characters.`, + ]; + + const args = toolCall.args ?? {}; + const isReadFileTool = String(toolCall.name) === 'read_file'; + + + const uriArg = (args as any).uri; + const filePathFromArgs = + typeof uriArg === 'string' ? uriArg.trim() : + (uriArg && typeof uriArg === 'object' && !Array.isArray(uriArg) && typeof (uriArg as any).fsPath === 'string') + ? String((uriArg as any).fsPath).trim() + : ''; + + const requestedStartLine = (() => { + const v = (args as any).startLine; + const n = Number(v); + return Number.isFinite(n) && n > 0 ? n : 1; + })(); + + let metaObj: any; + let instructionsLines: string[]; + + if (isReadFileTool && filePathFromArgs) { + + const nextStartLine = requestedStartLine + startLineExclusive; + const fileTotalLines = parsePositiveInt( + (rawOut && typeof rawOut === 'object') ? (rawOut as any).totalNumLines : undefined + ); + + const CHUNK = readFileChunkLines; + const suggestedEndLine = nextStartLine + CHUNK - 1; + + metaObj = { + tool: 'read_file', + uri: filePathFromArgs, + requestedStartLine, + nextStartLine, + suggested: { + startLine: nextStartLine, + endLine: suggestedEndLine, + chunkLines: CHUNK, + endLineIsFileEnd: false, + }, + ...(fileTotalLines !== undefined ? { fileTotalLines } : {}), + maxChars: maxToolOutputLength, + originalLength, + }; + + instructionsLines = [ + `IMPORTANT FOR THE MODEL:`, + ` 1. Do NOT guess based only on this truncated output.`, + ` 2. Continue by calling read_file on the ORIGINAL uri (NOT on a tool-output log):`, + ` read_file({ uri: ${JSON.stringify(filePathFromArgs)}, startLine: ${nextStartLine}, endLine: ${suggestedEndLine} })`, + ` 3. IMPORTANT: endLine above is a chunk boundary, NOT the end of file.`, + ` 4. Recommended next chunk size: readFileChunkLines = ${CHUNK}.`, + ...(fileTotalLines !== undefined + ? [` Known total file lines (from tool): ${fileTotalLines}.`] + : []), + ` 5. If still truncated, increase startLine by about ${CHUNK} and repeat.`, + ]; + } else { + const logFilePathForLLM = stableToolOutputsRelPath({ + toolName: toolCall.name, + toolCallId: toolCall.id, + fullText: originalTextOut + }); + + metaObj = { logFilePath: logFilePathForLLM, startLineExclusive, maxChars: maxToolOutputLength, originalLength }; + instructionsLines = [ + `IMPORTANT FOR THE MODEL:`, + ` 1. Do NOT guess based only on this truncated output.`, + ` 2. To see the rest of this tool output, call your file-reading tool (e.g. read_file)`, + ` on logFilePath, starting from line startLineExclusive + 1.`, + ]; + } + + const metaLine = `TRUNCATION_META: ${JSON.stringify(metaObj)}`; + textOut = `${truncatedBody}...\n\n${headerLines.join('\n')}\n${instructionsLines.join('\n')}\n${metaLine}`; + + const base = (rawOut && typeof rawOut === 'object') ? rawOut : {}; + + rawOut = { + ...base, + output: (typeof (base as any).output === 'string') ? textOut : (base as any).output, + content: (typeof (base as any).content === 'string') ? textOut : (base as any).content, + text: textOut, + ...(isReadFileTool ? {} : { fileContents: originalTextOut }), + _voidTruncationMeta: metaObj, + }; + } + + if (state.cancelled) return { stopReason: 'cancelled' }; + + await this.conn.sessionUpdate({ + sessionId: sid, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: toolCall.id, + status, + title: toolCall.name, + content: [{ type: 'content', content: { type: 'text', text: textOut } }], + rawOutput: rawOut + } + } as any); + + this.log?.debug?.('[ACP Agent][prompt] tool execution completed', { + sessionId: sid, + turn: turnCount, + toolCallId: toolCall.id, + toolName: toolCall.name, + status, + outputLength: textOut.length, + }); + + // Append tool result into LLM history + state.messages.push({ + role: 'tool', + tool_call_id: String(toolCall.id), + content: textOut + }); + state.pendingToolCall = null; + + this.log?.debug?.('[ACP Agent][prompt] continuing loop after tool result', { + sessionId: sid, + turn: turnCount, + totalMessages: state.messages.length, + }); + } + + // safeguard exhausted + this.log?.debug?.('[ACP Agent][prompt] SAFEGUARD EXHAUSTED - stopping', { + sessionId: sid, + totalTurns: turnCount, + messagesInHistory: state.messages.length, + }); + const safeguardMsg = 'Reached ACP safeguard limit; stopping tool loop to avoid infinite run.'; + this.emitError(safeguardMsg); + } + + private async executeTerminalCommandWithStreaming(toolCall: ToolCall): Promise { + const argsObj = (toolCall.args ?? {}) as Record; + + const rawCommand = typeof argsObj.command === 'string' ? argsObj.command.trim() : ''; + if (!rawCommand) { + this.log?.debug?.('[ACP Agent][terminal] missing command', { + toolCallId: toolCall.id, + toolName: toolCall.name, + args: argsObj, + }); + throw new Error('Command is required for terminal execution'); + } + + this.log?.debug?.('[ACP Agent][terminal] starting', { + toolCallId: toolCall.id, + toolName: toolCall.name, + command: rawCommand, + argsCount: Object.keys(argsObj).length, + }); + + const rawArgs: string[] = Array.isArray(argsObj.args) ? argsObj.args.map((a: any) => String(a ?? '')) : []; + const rawCwd = typeof argsObj.cwd === 'string' ? argsObj.cwd.trim() : ''; + const env = (argsObj.env && typeof argsObj.env === 'object') ? argsObj.env : undefined; + + const getTitle = () => { + const t = argsObj.title; + return typeof t === 'string' && t.trim() ? t : `Running: ${rawCommand}`; + }; + + // Resolve ACP sessionId (best effort) + let sessionId = + Array.from(this.sessions.entries()) + .find(([, s]) => String(s?.pendingToolCall?.id ?? '') === String(toolCall.id))?.[0] + ?? Array.from(this.sessions.keys())[0]; + if (!sessionId) sessionId = 'unknown_session'; + + const terminalId = 'void_agent_' + Math.random().toString(36).slice(2) + Date.now().toString(36); + + const commandLine = + `$ ${rawCommand}${rawArgs.length ? ' ' + rawArgs.join(' ') : ''}` + + (rawCwd ? `\n(cwd=${rawCwd})` : '') + + `\n`; + + // Stream only tail while running (UI responsiveness). + const PROGRESS_TAIL_LIMIT = Math.max(4000, defaultGlobalSettings.maxToolOutputLength || 16000); + let lastSentTail = ''; + let progressSeq = 0; + + const logProgress = (tag: string, obj: any) => { + try { + this.log?.debug?.( + `[ACP Agent][terminal_stream][${tag}]`, + JSON.stringify({ + sessionId, + toolCallId: toolCall.id, + terminalId, + ...obj + }) + ); + } catch { /* noop */ } + }; + + const postProgressTail = async (fullDisplayOutput: string, meta?: { truncated?: boolean; exitStatus?: any }) => { + const tail = + typeof fullDisplayOutput === 'string' && fullDisplayOutput.length > PROGRESS_TAIL_LIMIT + ? fullDisplayOutput.slice(fullDisplayOutput.length - PROGRESS_TAIL_LIMIT) + : (fullDisplayOutput ?? ''); + + if (tail === lastSentTail) { + logProgress('skip_same_tail', { seq: progressSeq, tailLen: tail.length }); + return; + } + lastSentTail = tail; + progressSeq++; + + logProgress('send_tail', { + seq: progressSeq, + tailLen: tail.length, + meta: meta ? { hasExitStatus: !!meta.exitStatus, truncated: !!meta.truncated } : null, + tailPreview: tail.slice(0, 120) + }); + + await this._enqueue(sessionId, async () => { + await this.conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: toolCall.id, + status: 'in_progress', + title: getTitle(), + kind: 'execute', + content: [{ type: 'content', content: { type: 'text', text: tail } }], + rawOutput: { + _type: 'terminal', + _phase: 'progress', + terminalId, + output: tail, + text: tail, + ...(typeof meta?.truncated === 'boolean' ? { truncated: meta.truncated } : {}), + ...(meta?.exitStatus ? { exitStatus: meta.exitStatus } : {}), + _voidAcpDebug: { seq: progressSeq, ts: Date.now(), tailLen: tail.length } + } + } + } as any); + }); + }; + + const makeProgressText = (snapshotOutput: string): string => { + const out = typeof snapshotOutput === 'string' ? snapshotOutput : ''; + if (commandLine.length + out.length <= PROGRESS_TAIL_LIMIT) return commandLine + out; + + const room = Math.max(0, PROGRESS_TAIL_LIMIT - commandLine.length); + if (room > 0) return commandLine + out.slice(Math.max(0, out.length - room)); + + return out.slice(Math.max(0, out.length - PROGRESS_TAIL_LIMIT)); + }; + + const fetchOutput = async (opts?: { + full?: boolean; + }): Promise<{ + output: string; + truncated: boolean; + exitStatus?: { exitCode: number | null; signal: string | null }; + }> => { + const wantFull = !!opts?.full; + const res = await this.conn.extMethod('terminal/output', { sessionId, terminalId, full: wantFull }) as any; + + const output = + typeof res === 'string' + ? res + : (res && typeof res.output === 'string' ? res.output : ''); + + const truncated = !!(res && typeof res.truncated === 'boolean' ? res.truncated : false); + + const es = res?.exitStatus; + if (es) { + return { + output, + truncated, + exitStatus: { + exitCode: (typeof es.exitCode === 'number' || es.exitCode === null) ? es.exitCode : null, + signal: (typeof es.signal === 'string' || es.signal === null) ? es.signal : null, + } + }; + } + + return { output, truncated }; + }; + + const OUTPUT_BYTE_LIMIT = 16 * 1024 * 1024; // 16MB host buffer (terminal infra) + let exitStatus: { exitCode: number | null; signal: string | null } | undefined; + + logProgress('start', { + command: rawCommand, + argsCount: rawArgs.length, + cwd: rawCwd || null, + hasEnv: !!env + }); + + try { + const createParams: any = { + sessionId, + command: rawCommand, + type: 'ephemeral', + terminalId, + outputByteLimit: OUTPUT_BYTE_LIMIT, + }; + if (rawArgs.length) createParams.args = rawArgs; + if (env) createParams.env = env; + if (rawCwd) createParams.cwd = rawCwd; + + await this.conn.extMethod('terminal/create', createParams); + + // Make spoiler non-empty immediately + await postProgressTail(commandLine); + + // Poll terminal/output until it reports exitStatus + while (true) { + const s = this.sessions.get(sessionId); + if (s?.cancelled) { + this.log?.debug?.('[ACP Agent][terminal] cancelled', { + sessionId, + toolCallId: toolCall.id, + terminalId, + }); + logProgress('cancelled', {}); + + // Best effort: capture FULL output BEFORE killing (kill deletes the run state in renderer) + let outputSoFar = ''; + try { + const o = await fetchOutput({ full: true }); + outputSoFar = o.output ?? ''; + } catch { + try { + const o2 = await fetchOutput({ full: false }); + outputSoFar = o2.output ?? ''; + } catch { /* noop */ } + } + + try { await this.conn.extMethod('terminal/kill', { sessionId, terminalId }); } catch { /* noop */ } + try { await this.conn.extMethod('terminal/release', { sessionId, terminalId }); } catch { /* noop */ } + + return { + toolCallId: toolCall.id, + status: 'completed', + title: getTitle(), + kind: 'execute', + content: `${commandLine}${outputSoFar}(Cancelled)\n`, + terminalId + } as any; + } + + const out = await fetchOutput({ full: false }).catch((e) => { + logProgress('fetch_output_error', { message: String((e as any)?.message ?? e) }); + return ({ output: '', truncated: false } as any); + }); + + // Progress UI: only tail (bounded) + await postProgressTail( + makeProgressText(out.output), + { truncated: out.truncated, exitStatus: out.exitStatus } + ); + + if (out.exitStatus) { + exitStatus = out.exitStatus; + this.log?.debug?.('[ACP Agent][terminal] exit detected', { + sessionId, + toolCallId: toolCall.id, + terminalId, + exitStatus, + }); + logProgress('exit_detected', { exitStatus }); + break; + } + + await new Promise(r => setTimeout(r, 250)); + } + + // Final FULL read (single source of truth for "full output from start") + let fullOutput = ''; + try { + await new Promise(r => setTimeout(r, 100)); + const finFull = await fetchOutput({ full: true }); + fullOutput = finFull.output ?? ''; + if (finFull.exitStatus) exitStatus = finFull.exitStatus; + logProgress('final_full_read', { fullLen: fullOutput.length, exitStatus, fullTruncated: finFull.truncated }); + } catch (e: any) { + logProgress('final_full_read_error', { message: String(e?.message ?? e) }); + // Fallback: last tail + try { + const finTail = await fetchOutput({ full: false }); + fullOutput = finTail.output ?? ''; + } catch { /* noop */ } + } + + try { await this.conn.extMethod('terminal/release', { sessionId, terminalId }); } catch { /* noop */ } + + const suffix = exitStatus + ? `\n(exitCode=${exitStatus.exitCode ?? 0}${exitStatus.signal ? `, signal=${exitStatus.signal}` : ''})` + : ''; + + // IMPORTANT: finalText is FULL from start (commandLine + full output) + const finalText = `${commandLine}${fullOutput}${suffix}`; + logProgress('done', { finalLen: finalText.length }); + + this.log?.debug?.('[ACP Agent][terminal] completed', { + sessionId, + toolCallId: toolCall.id, + terminalId, + exitStatus, + finalLength: finalText.length, + }); + + return { + toolCallId: toolCall.id, + status: 'completed', + title: getTitle(), + kind: 'execute', + content: finalText, + terminalId + } as any; + } catch (e: any) { + try { await this.conn.extMethod('terminal/release', { sessionId, terminalId }); } catch { /* noop */ } + const msg = typeof e?.message === 'string' ? e.message : String(e); + logProgress('failed', { message: msg }); + + this.log?.debug?.('[ACP Agent][terminal] failed', { + sessionId, + toolCallId: toolCall.id, + terminalId, + error: msg, + }); + + return { + toolCallId: toolCall.id, + status: 'failed', + title: getTitle(), + kind: 'execute', + content: `Terminal tool infrastructure error: ${msg}`, + terminalId + } as any; + } + } + + private async runOneTurnWithSendLLM(state: SessionState, sid: string): Promise<{ toolCall: OAIFunctionCall | null; assistantText: string }> { + const { + providerName, + settingsOfProvider, + modelSelectionOptions, + overridesOfModel, + modelName, + separateSystemMessage, + chatMode, + requestParams, + } = state.llmCfg; + + // [{ type: 'text' }, { type: 'image_url', image_url: { url: 'data:...' } }]. + const toLLMChatMessages = (arr: any[], apiStyle: DynamicRequestConfig['apiStyle']): LLMChatMessage[] => { + return (arr || []).map((m: any) => { + if (m?.role === 'tool') { + const tool_call_id = String(m.tool_call_id ?? m.id ?? ''); + const content = typeof m.content === 'string' + ? m.content + : JSON.stringify(m.args ?? m.rawParams ?? m.content ?? {}); + return { role: 'tool', tool_call_id, content }; + } + if (m?.role === 'user' && Array.isArray(m.contentBlocks) && apiStyle === 'openai-compatible') { + const parts: any[] = []; + for (const b of m.contentBlocks) { + if (b && typeof b === 'object') { + if (b.type === 'text' && typeof b.text === 'string') { + parts.push({ type: 'text', text: b.text }); + } else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') { + const url = `data:${b.mimeType};base64,${b.data}`; + parts.push({ type: 'image_url', image_url: { url } }); + } + } + } + if (parts.length) { + return { role: 'user', content: parts } as LLMChatMessage; + } + } + + return m as LLMChatMessage; + }); + }; + + const providerNameForSend = providerName as ProviderName; + const settingsForSend = settingsOfProvider as SettingsOfProvider; + const selOptsForSend = modelSelectionOptions as ModelSelectionOptions | undefined; + const overridesForSend = overridesOfModel as OverridesOfModel | undefined; + const chatModeForSend: ChatMode | null = (chatMode as unknown as ChatMode) ?? null; + const requestParamsForSend: RequestParamsConfig | undefined = (requestParams ?? undefined) as RequestParamsConfig | undefined; + const providerRoutingForSend: ProviderRouting | undefined = (state.llmCfg.providerRouting ?? undefined) as ProviderRouting | undefined; + const disabledStaticToolsForSend: string[] | undefined = Array.isArray(state.llmCfg.disabledStaticTools) + ? state.llmCfg.disabledStaticTools.map(v => String(v ?? '').trim()).filter(Boolean) + : undefined; + const disabledDynamicToolsForSend: string[] | undefined = Array.isArray(state.llmCfg.disabledDynamicTools) + ? state.llmCfg.disabledDynamicTools.map(v => String(v ?? '').trim()).filter(Boolean) + : undefined; + const disabledDynamicToolSet = new Set((disabledDynamicToolsForSend ?? []).map(name => String(name ?? '').trim()).filter(Boolean)); + + const baseAdditionalTools: AdditionalToolInfo[] = Array.isArray(state.llmCfg.additionalTools) + ? (state.llmCfg.additionalTools as AdditionalToolInfo[]) + : []; + const additionalToolsBeforeDisable: AdditionalToolInfo[] = + (chatModeForSend === 'agent') + ? [...baseAdditionalTools, ACP_PLAN_TOOL] + : baseAdditionalTools; + const additionalToolsForSend: AdditionalToolInfo[] = + disabledDynamicToolSet.size === 0 + ? additionalToolsBeforeDisable + : additionalToolsBeforeDisable.filter(tool => { + const name = String(tool?.name ?? '').trim(); + return !!name && !disabledDynamicToolSet.has(name); + }); + + this._textStreamStateBySession.set(sid, emptyStreamDeltaState()); + this._reasoningStreamStateBySession.set(sid, emptyStreamDeltaState()); + + return new Promise<{ toolCall: OAIFunctionCall | null; assistantText: string }>((resolve, reject) => { + state.aborter = null; + let finalTool: OAIFunctionCall | null = null; + let lastAssistantText = ''; + + const originalOnText = (chunk: OnTextChunk) => { + const fullText = typeof chunk?.fullText === 'string' ? chunk.fullText : ''; + const fullReasoning = typeof chunk?.fullReasoning === 'string' ? chunk.fullReasoning : ''; + const plan: LLMPlan | undefined = chunk.plan; + + this.log?.debug?.('[ACP Agent][runOneTurn] onText', { + sessionId: sid, + fullTextLength: fullText.length, + fullReasoningLength: fullReasoning.length, + hasPlan: !!plan, + }); + + // Optional: if sendChatRouter provides structured plan, forward it. + if (plan) { + this.emitPlan(sid, plan); + } + if (fullReasoning) { + this.emitThought(sid, fullReasoning); + } + this.emitText(sid, fullText); + }; + + const originalOnFinalMessage = async (res: OnFinalMessagePayload) => { + const fullText = typeof res?.fullText === 'string' ? res.fullText : ''; + const fullReasoning = typeof res?.fullReasoning === 'string' ? res.fullReasoning : ''; + const tool = res?.toolCall; + const plan: LLMPlan | undefined = res.plan; + const tokenUsage = res.tokenUsage; + + this.log?.debug?.('[ACP Agent][runOneTurn] onFinalMessage', { + sessionId: sid, + fullTextLength: fullText.length, + fullReasoningLength: fullReasoning.length, + hasToolCall: !!tool, + toolName: tool?.name, + hasPlan: !!plan, + hasTokenUsage: !!tokenUsage, + }); + + if (plan) this.emitPlan(sid, plan); + if (fullReasoning) this.emitThought(sid, fullReasoning); + this.emitText(sid, fullText); + + if (tokenUsage) { + state.llmTokenUsageLast = tokenUsage; + try { await this.emitTokenUsage(sid, tokenUsage); } catch (e) { + this.log?.warn?.('[ACP Agent] Failed to emit token usage snapshot', e); + } + } + + if (tool && typeof tool?.name === 'string' && tool.name.trim() !== '') { + try { + const id = String(tool.id || ''); + const name = String(tool.name || ''); + const args = + tool.isDone && tool.rawParams && typeof tool.rawParams === 'object' + ? (tool.rawParams as Record) + : {}; + + finalTool = { id, name, args }; + state.messages.push({ + role: 'assistant', + content: fullText || '', + tool_calls: [{ + id, + type: 'function', + function: { name, arguments: JSON.stringify(args) } + }] + }); + } catch { + finalTool = null; + if (fullText) state.messages.push({ role: 'assistant', content: fullText }); + } + } else if (fullText) { + state.messages.push({ role: 'assistant', content: fullText }); + } + state.aborter = null; + lastAssistantText = fullText; + await this._drainSessionUpdates(sid); + resolve({ toolCall: finalTool, assistantText: lastAssistantText }); + }; + + const originalOnError = (err: unknown) => { + state.aborter = null; + const message = + (typeof (err as any)?.message === 'string' && (err as any).message) + ? String((err as any).message) + : String(err); + this.log?.debug?.('[ACP Agent][runOneTurn] onError', { + sessionId: sid, + error: message, + hasStack: !!(err as any)?.stack, + }); + // Use emitError so we preserve details/stack for the host/UI. + try { + this.emitError(message, err); + } catch (e: any) { + reject(e); + } + }; + + // Compute dynamicRequestConfig for ACP. + + const thisConfig = (settingsOfProvider as SettingsOfProvider)[providerNameForSend] as any; + const apiKey = typeof thisConfig?.apiKey === 'string' ? thisConfig.apiKey.trim() : ''; + const isCustomProvider = !!thisConfig && thisConfig._didFillInProviderSettings === true; + + let dynamicRequestConfig: DynamicRequestConfig; + + try { + // Prefer dynamicRequestConfig precomputed in renderer (DynamicProviderRegistryService) + // so ACP uses the same endpoint/headers/capabilities as the main chat pipeline. + const precomputed = state.llmCfg.dynamicRequestConfig as DynamicRequestConfig | null | undefined; + if (precomputed) { + dynamicRequestConfig = precomputed; + this.log?.debug?.('[ACP Agent] dynamicRequestConfig (from settings) OK', { + providerName: providerNameForSend, + endpoint: dynamicRequestConfig.endpoint, + hasApiKey: !!(dynamicRequestConfig.headers?.Authorization || dynamicRequestConfig.headers?.authorization), + }); + } else if (isCustomProvider) { + const apiStyle = (thisConfig.apiStyle || 'openai-compatible') as DynamicRequestConfig['apiStyle']; + const supportsSystemMessage = (thisConfig.supportsSystemMessage + || (apiStyle === 'anthropic-style' || apiStyle === 'gemini-style' ? 'separated' : 'system-role')) as DynamicRequestConfig['supportsSystemMessage']; + const inferredToolFormat = apiStyle === 'anthropic-style' + ? 'anthropic-style' + : apiStyle === 'gemini-style' + ? 'gemini-style' + : 'openai-style'; + const specialToolFormat = (thisConfig.specialToolFormat || inferredToolFormat) as DynamicRequestConfig['specialToolFormat']; + const endpoint = (thisConfig.endpoint || '').toString().trim(); + + const headers: Record = { ...(thisConfig.additionalHeaders || {}) }; + if (apiKey) { + const authHeader = (thisConfig.auth?.header || 'Authorization') as string; + const authFormat = (thisConfig.auth?.format || 'Bearer') as 'Bearer' | 'direct'; + headers[authHeader] = authFormat === 'Bearer' ? `Bearer ${apiKey}` : apiKey; + } + + dynamicRequestConfig = { + apiStyle, + endpoint: endpoint || 'https://openrouter.ai/api/v1', + headers, + specialToolFormat, + supportsSystemMessage, + }; + this.log?.debug?.('[ACP Agent] dynamicRequestConfig (custom provider fallback) OK', { + providerName: providerNameForSend, + endpoint: dynamicRequestConfig.endpoint, + hasApiKey: !!apiKey, + }); + } else { + + const modelIdForConfig = modelName.includes('/') + ? modelName + : `${providerNameForSend}/${modelName}`; + const apiCfg = getModelApiConfiguration(modelIdForConfig); + const headers: Record = {}; + if (apiKey) { + const authHeader = thisConfig?.auth?.header || apiCfg.auth?.header || 'Authorization'; + const authFormat = (thisConfig?.auth?.format || apiCfg.auth?.format || 'Bearer') as 'Bearer' | 'direct'; + headers[authHeader] = authFormat === 'Bearer' ? `Bearer ${apiKey}` : apiKey; + } + dynamicRequestConfig = { + apiStyle: apiCfg.apiStyle, + endpoint: apiCfg.endpoint, + headers, + specialToolFormat: apiCfg.specialToolFormat, + supportsSystemMessage: apiCfg.supportsSystemMessage, + }; + this.log?.debug?.('[ACP Agent] dynamicRequestConfig (builtin fallback) OK', { + providerName: providerNameForSend, + endpoint: apiCfg.endpoint, + hasApiKey: !!apiKey, + }); + } + } catch (e) { + this.log?.warn?.('[ACP Agent] Failed dynamicRequestConfig, using safe defaults:', e); + dynamicRequestConfig = { + apiStyle: 'openai-compatible', + endpoint: '', + headers: {}, + specialToolFormat: 'openai-style', + supportsSystemMessage: 'system-role', + }; + } + + const messagesForSend: LLMChatMessage[] = toLLMChatMessages(state.messages || [], dynamicRequestConfig.apiStyle); + + this.log?.debug?.('[ACP Agent][runOneTurn] calling sendChatRouter', { + sessionId: sid, + providerName: providerNameForSend, + modelName, + messagesCount: messagesForSend.length, + additionalToolsCount: additionalToolsForSend.length, + chatMode: chatModeForSend, + disabledStaticToolsCount: disabledStaticToolsForSend?.length ?? 0, + disabledDynamicToolsCount: disabledDynamicToolsForSend?.length ?? 0, + }); + + try { + const ret = void sendChatRouterImpl({ + logService: this.log, + messages: messagesForSend, + separateSystemMessage: separateSystemMessage ?? undefined, + providerName: providerNameForSend, + settingsOfProvider: settingsForSend, + modelSelectionOptions: selOptsForSend, + overridesOfModel: overridesForSend, + modelName, + dynamicRequestConfig, + _setAborter: (fn: any) => { + state.aborter = (typeof fn === 'function') ? fn : null; + }, + onText: originalOnText, + onFinalMessage: originalOnFinalMessage, + onError: originalOnError, + chatMode: chatModeForSend, + tool_choice: 'auto', + additionalTools: additionalToolsForSend, + disabledStaticTools: disabledStaticToolsForSend, + disabledDynamicTools: disabledDynamicToolsForSend, + requestParams: requestParamsForSend, + providerRouting: providerRoutingForSend, + notificationService: this.notificationService, + }); + + if (ret && typeof (ret as any).catch === 'function') { + (ret as Promise).catch(originalOnError); + } + } catch (e) { + originalOnError(e); + } + + }).finally(() => { + this._textStreamStateBySession.delete(sid); + this._reasoningStreamStateBySession.delete(sid); + }); + } + + private _enqueue(sessionId: string, op: () => Promise): Promise { + const prev = this._updateChainBySession.get(sessionId) ?? Promise.resolve(); + + + const next = prev + .then(op, op) + .catch((e) => { + this.log?.warn?.('[ACP Agent] sessionUpdate failed (swallowed)', e); + }); + this._updateChainBySession.set(sessionId, next); + return next; + } + + private _drainSessionUpdates(sessionId: string): Promise { + return this._updateChainBySession.get(sessionId) ?? Promise.resolve(); + } + + private emitPlan(sessionId: string, plan: LLMPlan) { + if (!plan?.items?.length) return Promise.resolve(); + const mapStateToAcp = (s: LLMPlan['items'][number]['state'] | undefined): 'pending' | 'in_progress' | 'completed' | 'failed' => { + switch (s) { + case 'running': return 'in_progress'; + case 'done': return 'completed'; + case 'error': return 'failed'; + case 'pending': + default: return 'pending'; + } + }; + + const cleaned = plan.items + .map(it => ({ + text: (typeof it.text === 'string' ? it.text.trim() : ''), + state: it.state ?? 'pending' + })) + .filter(it => it.text.length > 0); + + if (!cleaned.length) return Promise.resolve(); + + const sig = cleaned.map(it => `${it.state}::${it.text}`).join('\n'); + const prevSig = this._lastPlanSigBySession.get(sessionId); + if (prevSig === sig) { + this.log?.debug?.('[ACP Agent][emitPlan] skipped (unchanged)', { + sessionId, + itemsCount: cleaned.length, + }); + return Promise.resolve(); + } + + this._lastPlanSigBySession.set(sessionId, sig); + const entries = cleaned.map(it => ({ + content: it.text, + status: mapStateToAcp(it.state), + priority: 'medium' as const, + })); + + this.log?.debug?.('[ACP Agent][emitPlan] sending plan', { + sessionId, + itemsCount: entries.length, + entries: entries.map(e => ({ content: e.content.substring(0, 50), status: e.status })), + }); + + return this._enqueue(sessionId, async () => { + await this.conn.sessionUpdate({ + sessionId, + update: { sessionUpdate: 'plan', entries } as any + }); + }); + } + + private _formatErrorDetails(err?: unknown): string { + if (err instanceof Error) { + return (typeof err.stack === 'string' && err.stack) ? err.stack : err.message; + } + return err ? String(err) : ''; + } + + private emitError(message: string, err?: unknown): never { + const msg = (message ?? '').toString().trim(); + if (!msg) throw new Error('ACP Agent error'); + + this.log?.warn?.('[ACP Agent][prompt error]', msg, err); + + const details = this._formatErrorDetails(err) || msg; + const e: any = new Error(msg); + + e.data = { details }; + e.details = details; + + throw e; + } + + private emitText(sessionId: string, fullText: string) { + const prev = this._textStreamStateBySession.get(sessionId) ?? emptyStreamDeltaState(); + const { chunk, next } = toDeltaChunk(fullText, prev); + this._textStreamStateBySession.set(sessionId, next); + + if (!chunk) return Promise.resolve(); + + return this._enqueue(sessionId, async () => { + await this.conn.sessionUpdate({ + sessionId, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chunk } } + } as any); + }); + } + + private emitThought(sessionId: string, reasoning: string) { + const prev = this._reasoningStreamStateBySession.get(sessionId) ?? emptyStreamDeltaState(); + const { chunk, next } = toDeltaChunk(reasoning, prev); + this._reasoningStreamStateBySession.set(sessionId, next); + + if (!chunk) return Promise.resolve(); + + return this._enqueue(sessionId, async () => { + await this.conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: chunk } + } + } as any); + }); + } + + private async emitTokenUsage(_sessionId: string, _usage: LLMTokenUsage) { + // ACP schema on the client side does not accept sessionUpdate: 'llm_usage_snapshot' + // (Invalid params). Usage is still aggregated and returned via PromptResponse._meta, + // and then passed as IAcpMessageChunk.tokenUsageSnapshot on done. + return; + } +} + +function extractTextFromPrompt(prompt: Array<{ type: string; text?: string }> | undefined): string { + if (!Array.isArray(prompt)) return ''; + let out = ''; + for (const b of prompt) { + if (b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string') { + out += (out ? ' ' : '') + b.text; + } + } + return out.trim(); +} + +export const __test = { + setSendChatRouter(fn: typeof sendChatRouterOriginal) { + // Allow tests to stub the chat router while keeping runtime default intact. + sendChatRouterImpl = fn; + }, + reset() { + sendChatRouterImpl = sendChatRouterOriginal; + }, + VoidPipelineAcpAgent, +}; diff --git a/src/vs/platform/acp/electron-main/acpMainService.ts b/src/vs/platform/acp/electron-main/acpMainService.ts new file mode 100644 index 00000000000..d60f3e9b090 --- /dev/null +++ b/src/vs/platform/acp/electron-main/acpMainService.ts @@ -0,0 +1,1597 @@ + +import { Emitter, Event } from '../../../base/common/event.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { IAcpMessageChunk, IAcpSendOptions, IAcpChatMessage } from '../common/iAcpService.js'; +import type { LLMTokenUsage } from '../../void/common/sendLLMMessageTypes.js'; +import { IAcpMainServiceForChannel, AcpHostCallbackRequest, AcpHostCallbackResponse } from '../common/acpIpc.js'; +import { ILogService } from '../../log/common/log.js'; +import { sanitizeAcpSendOptionsForLog } from '../common/acpLogSanitizer.js' +import { homedir } from 'os'; +import * as sdk from './vendor/acp-sdk.vendored.js'; +import { WebSocket } from './vendor/ws.vendored.js'; +import { spawn, ChildProcess } from 'child_process'; +import type { + SessionNotification, + ContentBlock, + ToolCallContent, + PlanEntry, + AvailableCommand, + SessionModeId +} from '@agentclientprotocol/sdk'; +import * as path from 'path'; + +type AcpSessionUpdateNotification = SessionNotification['update'] | { + sessionUpdate: string; + content?: ContentBlock | ToolCallContent[] | PlanEntry[] | AvailableCommand[] | SessionModeId; +}; + +type Stream = ConstructorParameters[1]; + +export class AcpMainService implements IAcpMainServiceForChannel { + + constructor( + @ILogService private readonly logService: ILogService + ) { } + + private conn: sdk.ClientSideConnection | null = null; + private connected = false; + private childProcess: ChildProcess | null = null; + + private lastConnectParams: { + mode?: 'builtin' | 'websocket' | 'process'; + url?: string; + command?: string; + args?: string[]; + env?: Record; + } | undefined; + + private readonly onDataEmitterByRequest = new Map>(); + + // thread<->session + private readonly threadBySession = new Map(); + private readonly sessionByThread = new Map(); + private readonly requestSessionByRequestId = new Map(); + private readonly sessionCwdBySession = new Map(); + + // callbacks to renderer + private readonly _onHostCallback = new Emitter(); + readonly onHostCallback = this._onHostCallback.event; + + // awaiting host callback result + private readonly pendingHostCallbacks = new Map void; reject: (e: any) => void }>(); + + // per-session streaming + private readonly emitterBySession = new Map>(); + private readonly tokenUsageBySession = new Map(); + + // tool normalization caches (UI-friendly, does NOT change ACP wire protocol) + private readonly toolNameBySessionToolCallId = new Map(); + private readonly toolKindBySessionToolCallId = new Map(); + private readonly toolArgsBySessionToolCallId = new Map>(); + + // terminal caches (UI-friendly) + private readonly activeToolCallIdBySession = new Map(); + private readonly lastTerminalIdBySession = new Map(); + private readonly terminalInfoById = new Map(); + private readonly terminalIdsBySessionToolCall = new Map(); + + private readonly terminalPollBySessionToolCall = new Map(); + + private _pollKey(sessionId: string, toolCallId: string): string { + return `${sessionId}:${toolCallId}`; + } + + private _stopTerminalPoll(sessionId: string, toolCallId: string): void { + const key = this._pollKey(sessionId, toolCallId); + const st = this.terminalPollBySessionToolCall.get(key); + if (!st) return; + try { clearInterval(st.timer); } catch { /* ignore */ } + this.terminalPollBySessionToolCall.delete(key); + } + + private _stopAllTerminalPollsForSession(sessionId: string): void { + for (const k of Array.from(this.terminalPollBySessionToolCall.keys())) { + if (k.startsWith(`${sessionId}:`)) { + const st = this.terminalPollBySessionToolCall.get(k); + if (st) { + try { clearInterval(st.timer); } catch { /* ignore */ } + this.terminalPollBySessionToolCall.delete(k); + } + } + } + } + + private _startTerminalPoll(sessionId: string, toolCallId: string, terminalId: string): void { + if (!sessionId || !toolCallId || !terminalId) return; + + // IMPORTANT: + // In builtin mode the builtin ACP agent already streams terminal output via tool_call_update. + // Polling terminalOutput here is redundant and can cause duplicate/empty progress updates that + // overwrite UI with "(waiting for output...)". + if (this.lastConnectParams?.mode === 'builtin') { + this._logJson('terminalPoll SKIP (builtin mode)', { sessionId, toolCallId, terminalId }); + return; + } + + const emitter = this.emitterBySession.get(sessionId); + if (!emitter) return; + + const key = this._pollKey(sessionId, toolCallId); + const existing = this.terminalPollBySessionToolCall.get(key); + + // already polling same terminal -> no-op + if (existing && existing.terminalId === terminalId) return; + + // terminal changed -> restart + if (existing) this._stopTerminalPoll(sessionId, toolCallId); + + this._logJson('terminalPoll START', { sessionId, toolCallId, terminalId }); + + const pollState = { + terminalId, + runningTick: false, + lastLen: -1, + lastTail: '', + timer: setInterval(async () => { + const st = this.terminalPollBySessionToolCall.get(key); + if (!st) return; + if (st.runningTick) return; + st.runningTick = true; + + try { + const resp = await this._hostRequest('terminalOutput', { sessionId, terminalId }); + + const output = typeof resp?.output === 'string' ? resp.output : ''; + const truncated = !!resp?.truncated; + const exitStatus = resp?.exitStatus; + + const isDone = + !!exitStatus && ( + typeof exitStatus.exitCode === 'number' || exitStatus.exitCode === null || + typeof exitStatus.signal === 'string' || exitStatus.signal === null + ); + + // CRITICAL: never emit empty output while still running. + // This prevents UI from being overwritten with empty content. + if (!isDone && output.length === 0) { + this._logJson('terminalPoll TICK skip empty', { sessionId, toolCallId, terminalId }); + return; + } + + const tail = output.slice(-256); + + // de-spam identical output + if (output.length === st.lastLen && tail === st.lastTail) { + if (isDone) { + this._logJson('terminalPoll STOP (done + no changes)', { sessionId, toolCallId, terminalId }); + this._stopTerminalPoll(sessionId, toolCallId); + } + return; + } + + st.lastLen = output.length; + st.lastTail = tail; + + this._logJson('terminalPoll EMIT tool_progress', { + sessionId, + toolCallId, + terminalId, + outputLen: output.length, + truncated, + isDone + }); + + emitter.fire({ + type: 'tool_progress', + toolProgress: { + id: toolCallId, + name: 'run_command', + terminalId, + output, + truncated, + ...(exitStatus ? { exitStatus } : {}) + } + }); + + if (isDone) { + this._logJson('terminalPoll STOP (done)', { sessionId, toolCallId, terminalId }); + this._stopTerminalPoll(sessionId, toolCallId); + } + } catch (e: any) { + // ignore transient errors; keep polling + this._logJson('terminalPoll TICK error (ignored)', { + sessionId, + toolCallId, + terminalId, + error: e?.message ?? String(e) + }); + } finally { + const st2 = this.terminalPollBySessionToolCall.get(key); + if (st2) st2.runningTick = false; + } + }, 350) + }; + + this.terminalPollBySessionToolCall.set(key, pollState); + } + + private _safeStringify(v: any, maxLen = 50_000): string { + try { + const s = JSON.stringify(v, null, 2); + return s.length > maxLen ? (s.slice(0, maxLen) + '\n…(truncated)') : s; + } catch { + try { + const s = String(v); + return s.length > maxLen ? (s.slice(0, maxLen) + '\n…(truncated)') : s; + } catch { + return ''; + } + } + } + + private _logJson(label: string, payload: any) { + this.logService.debug(`[ACP Main] ${label}: ${this._safeStringify(payload)}`); + } + + private _toSessionId(v: any): string | undefined { + if (v === undefined || v === null) return undefined; + const s = String(v); + return s.length ? s : undefined; + } + + private getDefaultCwd(): string { + this.logService.debug(`[ACP Main] getDefaultCwd: ${process.cwd()}`); + try { return process.cwd(); } catch { } + try { return homedir(); } catch { } + return '/'; + } + + private _hasActiveRequestForSession(sessionId: string): boolean { + for (const [, sid] of this.requestSessionByRequestId) { + if (sid === sessionId) return true; + } + return false; + } + + private _toolKey(sessionId: string, toolCallId: string): string { + return `${sessionId}:${toolCallId}`; + } + + private _buildCommandLine(command: unknown, args: unknown): string | undefined { + const cmd = typeof command === 'string' ? command.trim() : ''; + if (!cmd) return undefined; + const arr = Array.isArray(args) ? args.map(a => String(a ?? '')).filter(Boolean) : []; + return arr.length ? `${cmd} ${arr.join(' ')}` : cmd; + } + + private _asObject(v: any): Record | null { + return (v && typeof v === 'object' && !Array.isArray(v)) ? v as any : null; + } + + private _resolvePathForSession(sessionId: string, p: unknown): string { + const s = String(p ?? '').trim(); + if (!s) return ''; + + const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s) && !/^[A-Za-z]:[\\/]/.test(s); + if (hasScheme) return s; + + if (path.isAbsolute(s)) return s; + + const base = this.sessionCwdBySession.get(sessionId) || this.getDefaultCwd(); + return path.resolve(base, s); + } + + private _addTerminalIdForToolCall(sessionId: string, toolCallId: string, terminalId: string) { + if (!sessionId || !toolCallId || !terminalId) return; + const key = this._toolKey(sessionId, toolCallId); + const prev = this.terminalIdsBySessionToolCall.get(key) ?? []; + if (!prev.includes(terminalId)) { + this.terminalIdsBySessionToolCall.set(key, [...prev, terminalId]); + } + } + + private _getTerminalIdForToolCall(sessionId: string, toolCallId: string): string | undefined { + const key = this._toolKey(sessionId, toolCallId); + const arr = this.terminalIdsBySessionToolCall.get(key); + return arr && arr.length ? arr[arr.length - 1] : undefined; + } + + private _isValidDiffItem(item: any): item is { path: string; oldText?: string; newText: string } { + return !!item + && typeof item.path === 'string' + && item.path.trim().length > 0 + && typeof item.newText === 'string'; + } + + private _canonicalToolName(args: { + kind?: string; + hasDiff?: boolean; + hasTerminal?: boolean; + rawName?: string; + title?: string; + }): string { + const kind = (args.kind ?? '').toLowerCase(); + if (kind === 'edit' || args.hasDiff) return 'edit_file'; + if (kind === 'execute' || args.hasTerminal) return 'run_command'; + if (kind === 'read') return 'read_file'; + + const raw = String(args.rawName ?? '').trim(); + if (raw) return raw; + + const t = String(args.title ?? '').trim(); + if (t) return t; + + return 'tool'; + } + + private _hostRequest(kind: AcpHostCallbackRequest['kind'], params: any): Promise { + const requestId = generateUuid(); + + const promise = new Promise((resolve, reject) => { + this.pendingHostCallbacks.set(requestId, { resolve, reject }); + }); + + const sid = this._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const threadId = sid ? this.threadBySession.get(sid) : params?.threadId; + + this._onHostCallback.fire({ requestId, kind, params, sessionId: sid, threadId }); + return promise; + } + + private async _captureTerminalOutputIntoCache(sessionId: string | undefined, terminalId: string | undefined): Promise { + if (!sessionId || !terminalId) return; + + try { + this._logJson('capture terminal/output -> hostRequest', { sessionId, terminalId }); + + const resp = await this._hostRequest('terminalOutput', { sessionId, terminalId }); + + const nextOutput = typeof resp?.output === 'string' ? resp.output : ''; + const nextTruncated = !!resp?.truncated; + + const exitStatus = resp?.exitStatus; + const exitCode = (typeof exitStatus?.exitCode === 'number' || exitStatus?.exitCode === null) ? exitStatus.exitCode : undefined; + const signal = (typeof exitStatus?.signal === 'string' || exitStatus?.signal === null) ? exitStatus.signal : undefined; + + const prev = this.terminalInfoById.get(terminalId) ?? {}; + const prevOutput = typeof prev.output === 'string' ? prev.output : ''; + + // IMPORTANT: keep the longest output we have ever seen for this terminalId + const mergedOutput = nextOutput.length >= prevOutput.length ? nextOutput : prevOutput; + + this.terminalInfoById.set(terminalId, { + ...prev, + sessionId, + output: mergedOutput, + truncated: (typeof prev.truncated === 'boolean' ? prev.truncated : false) || nextTruncated, + ...(exitCode !== undefined ? { exitCode } : {}), + ...(signal !== undefined ? { signal } : {}) + }); + + this._logJson('capture terminal/output -> cached', { + sessionId, + terminalId, + outputLen: mergedOutput.length, + truncated: ((typeof prev.truncated === 'boolean' ? prev.truncated : false) || nextTruncated), + exitCode: exitCode ?? null, + signal: signal ?? null + }); + } catch (e: any) { + this._logJson('capture terminal/output FAILED', { + sessionId, + terminalId, + error: e?.message ?? String(e) + }); + } + } + + // ------------------------- + // public + // ------------------------- + isConnected(): boolean { return this.connected; } + + async connect(opts?: IAcpSendOptions): Promise { + this.logService.debug('[ACP Main] connect(opts):', sanitizeAcpSendOptionsForLog(opts)); + const mode = opts?.mode || 'builtin'; + + if (this.connected && this.conn && this.lastConnectParams && this.lastConnectParams.mode === mode) { + if (mode === 'websocket' || mode === 'builtin') { + const targetUrl = mode === 'builtin' ? 'ws://127.0.0.1:8719' : (opts?.agentUrl || ''); + const url = targetUrl.trim().replace(/^http(s?):/, 'ws$1:'); + if (this.lastConnectParams.url === url) return; + } else { + const cmd = (opts?.command || '').trim(); + const args = opts?.args || []; + const env = opts?.env; + const isSame = cmd === this.lastConnectParams.command && + JSON.stringify(args) === JSON.stringify(this.lastConnectParams.args) && + JSON.stringify(env) === JSON.stringify(this.lastConnectParams.env); + + if (isSame && this.childProcess && !this.childProcess.killed) return; + } + } + + if (this.connected) await this.disconnect(); + + let stream: Stream; + + if (mode === 'process') { + const cmd = (opts?.command || '').trim(); + if (!cmd) throw new Error('ACP: command is required for process mode'); + const args = opts?.args || []; + const env = { ...process.env, ...opts?.env }; + + this.logService.debug(` Spawning process: ${cmd} ${args.join(' ')}`); + + const cp = spawn(cmd, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); + this.childProcess = cp; + + const disconnectIfStillCurrent = () => { + if (this.childProcess !== cp) return; + this.disconnect().catch(() => { }); + }; + + cp.on('error', (err) => { + this.logService.error('[ACP Main] Process error:', err); + disconnectIfStillCurrent(); + }); + + cp.on('exit', (code, signal) => { + this.logService.debug(`[ACP Main] Process exited with code ${code} signal ${signal}`); + disconnectIfStillCurrent(); + }); + + if (!cp.stdin || !cp.stdout) { + throw new Error('Failed to open stdio pipes for ACP process'); + } + + cp.stderr?.on('data', (data) => { + const str = data.toString(); + this.logService.warn('[ACP Agent Stderr]:', str); + }); + + stream = this._stdioStream(cp.stdin, cp.stdout); + this.lastConnectParams = { mode: 'process', command: cmd, args, env: opts?.env }; + } else { + let urlFromOpts = ''; + if (mode === 'builtin') urlFromOpts = 'ws://127.0.0.1:8719'; + else { + urlFromOpts = (opts?.agentUrl || '').trim(); + if (!urlFromOpts) throw new Error('ACP: agentUrl is required'); + } + const wsUrl = urlFromOpts.replace(/^http(s?):/, 'ws$1:'); + + stream = await this._wsNdjsonStream(wsUrl); + this.lastConnectParams = { mode: mode as 'builtin' | 'websocket', url: wsUrl }; + } + + this.conn = new sdk.ClientSideConnection((_agent) => { + const service = this; + + const client: sdk.Client = { + + async requestPermission(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const threadId = sid ? service.threadBySession.get(sid) : undefined; + return service._hostRequest('requestPermission', { ...params, sessionId: sid, threadId }); + }, + + async createTerminal(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const threadId = sid ? service.threadBySession.get(sid) : undefined; + const acpMode = service.lastConnectParams?.mode ?? 'builtin'; + + if (sid && typeof params?.cwd === 'string' && params.cwd) { + service.sessionCwdBySession.set(sid, String(params.cwd)); + service._logJson('createTerminal updated session cwd', { sessionId: sid, cwd: String(params.cwd) }); + } + + service._logJson('client.createTerminal called', { + sessionId: sid, + threadId, + command: params?.command, + args: params?.args, + cwd: params?.cwd, + outputByteLimit: params?.outputByteLimit + }); + + const cmdLine = service._buildCommandLine(params?.command, params?.args); + + // ------------------------- + // DEDUP across retries: + // If agent retries tool with a new toolCallId, but is actually trying to run the same command, + // reuse the last running terminalId for this session. + // ------------------------- + if (sid && !((typeof params?.terminalId === 'string') && params.terminalId.trim().length > 0) && cmdLine) { + const lastTid = service.lastTerminalIdBySession.get(sid); + if (lastTid) { + const info = service.terminalInfoById.get(lastTid); + const isDone = !!info && (info.exitCode !== undefined || info.signal !== undefined); + if (!isDone && info?.command === cmdLine) { + const activeToolCallId = service.activeToolCallIdBySession.get(sid); + if (activeToolCallId) { + service._addTerminalIdForToolCall(sid, activeToolCallId, lastTid); + service._startTerminalPoll(sid, activeToolCallId, lastTid); + } + service._logJson('client.createTerminal DEDUP (session-level): reuse last running terminal', { + sessionId: sid, + terminalId: lastTid, + commandLine: cmdLine + }); + return { terminalId: lastTid }; + } + } + } + + // Existing per-toolCall dedup (fix isDone logic) + if (sid) { + const activeToolCallId = service.activeToolCallIdBySession.get(sid); + const explicitTerminalId = (typeof params?.terminalId === 'string' && params.terminalId.trim().length > 0); + + if (!explicitTerminalId && activeToolCallId) { + const existing = service._getTerminalIdForToolCall(sid, activeToolCallId); + if (existing) { + const info = service.terminalInfoById.get(existing); + const isDone = !!info && (info.exitCode !== undefined || info.signal !== undefined); + if (!isDone) { + service._logJson('client.createTerminal DEDUP: reuse existing terminal', { + sessionId: sid, + toolCallId: activeToolCallId, + terminalId: existing + }); + return { terminalId: existing }; + } + } + } + } + + const resp = await service._hostRequest('createTerminal', { ...params, sessionId: sid, threadId, acpMode }); + const terminalId = String(resp?.terminalId ?? ''); + + if (sid && terminalId) { + service.lastTerminalIdBySession.set(sid, terminalId); + + const argv = Array.isArray(params?.args) + ? params.args.map((a: any) => String(a ?? '')) + : undefined; + + const cwd = (typeof params?.cwd === 'string' && params.cwd.trim().length) + ? String(params.cwd) + : undefined; + + const prev = service.terminalInfoById.get(terminalId) ?? {}; + service.terminalInfoById.set(terminalId, { + ...prev, + sessionId: sid, + command: cmdLine ?? prev.command, + argv: argv ?? prev.argv, + cwd: cwd ?? prev.cwd + }); + + const activeToolCallId = service.activeToolCallIdBySession.get(sid); + if (activeToolCallId) { + service._addTerminalIdForToolCall(sid, activeToolCallId, terminalId); + + const k = service._toolKey(sid, activeToolCallId); + const prevName = service.toolNameBySessionToolCallId.get(k); + if (!prevName || prevName === 'tool') service.toolNameBySessionToolCallId.set(k, 'run_command'); + + const prevArgs = service.toolArgsBySessionToolCallId.get(k) ?? {}; + service.toolArgsBySessionToolCallId.set(k, { + ...prevArgs, + commandLine: (cmdLine ?? prevArgs.commandLine), + command: (typeof prevArgs.command === 'string' ? prevArgs.command : (cmdLine ?? prevArgs.command)), + args: (Array.isArray(prevArgs.args) ? prevArgs.args : (argv ?? prevArgs.args)), + cwd: (typeof prevArgs.cwd === 'string' ? prevArgs.cwd : (cwd ?? prevArgs.cwd)) + }); + + service._logJson('createTerminal bound to active toolCallId', { + sessionId: sid, + toolCallId: activeToolCallId, + terminalId + }); + service._startTerminalPoll(sid, activeToolCallId, terminalId); + } + } + + return { terminalId }; + }, + + // terminal/output (SPEC): returns { output, truncated, exitStatus? } + async terminalOutput(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const threadId = sid ? service.threadBySession.get(sid) : undefined; + const acpMode = service.lastConnectParams?.mode ?? 'builtin'; + + const terminalId = String(params?.terminalId ?? ''); + const resp = await service._hostRequest('terminalOutput', { ...params, sessionId: sid, threadId, acpMode }); + + if (sid && terminalId) { + service.lastTerminalIdBySession.set(sid, terminalId); + const prev = service.terminalInfoById.get(terminalId) ?? {}; + service.terminalInfoById.set(terminalId, { + ...prev, + sessionId: sid, + output: typeof resp?.output === 'string' ? resp.output : prev.output, + truncated: typeof resp?.truncated === 'boolean' ? resp.truncated : prev.truncated, + exitCode: (typeof resp?.exitStatus?.exitCode === 'number' || resp?.exitStatus?.exitCode === null) ? resp.exitStatus.exitCode : prev.exitCode, + signal: (typeof resp?.exitStatus?.signal === 'string' || resp?.exitStatus?.signal === null) ? resp.exitStatus.signal : prev.signal, + }); + } + + return { + output: typeof resp?.output === 'string' ? resp.output : '', + truncated: !!resp?.truncated, + ...(resp?.exitStatus ? { exitStatus: resp.exitStatus } : {}) + }; + }, + + // terminal/wait_for_exit (SPEC): returns { exitCode, signal } + async waitForTerminalExit(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const threadId = sid ? service.threadBySession.get(sid) : undefined; + const acpMode = service.lastConnectParams?.mode ?? 'builtin'; + const terminalId = String(params?.terminalId ?? ''); + + const resp = await service._hostRequest('waitForTerminalExit', { ...params, sessionId: sid, threadId, acpMode }); + + // capture output best-effort (does not create files now; renderer decides finalization) + await service._captureTerminalOutputIntoCache(sid, terminalId); + + if (sid && terminalId) { + service.lastTerminalIdBySession.set(sid, terminalId); + const prev = service.terminalInfoById.get(terminalId) ?? {}; + service.terminalInfoById.set(terminalId, { + ...prev, + sessionId: sid, + exitCode: (typeof resp?.exitCode === 'number' || resp?.exitCode === null) ? resp.exitCode : prev.exitCode, + signal: (typeof resp?.signal === 'string' || resp?.signal === null) ? resp.signal : prev.signal, + }); + } + + return { + exitCode: (typeof resp?.exitCode === 'number' || resp?.exitCode === null) ? resp.exitCode : null, + signal: (typeof resp?.signal === 'string' || resp?.signal === null) ? resp.signal : null, + // extras harmless + ...(typeof resp?.isRunning === 'boolean' ? { isRunning: resp.isRunning } : {}) + }; + }, + + async killTerminal(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const threadId = sid ? service.threadBySession.get(sid) : undefined; + const acpMode = service.lastConnectParams?.mode ?? 'builtin'; + return service._hostRequest('killTerminal', { ...params, sessionId: sid, threadId, acpMode }); + }, + + async releaseTerminal(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const threadId = sid ? service.threadBySession.get(sid) : undefined; + const acpMode = service.lastConnectParams?.mode ?? 'builtin'; + const terminalId = String(params?.terminalId ?? ''); + + await service._captureTerminalOutputIntoCache(sid, terminalId); + + await service._hostRequest('releaseTerminal', { ...params, sessionId: sid, threadId, acpMode }); + return null; + }, + + async readTextFile(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const p = params?.path ?? params?.uri; + if (sid && typeof p === 'string' && path.isAbsolute(p)) { + service._maybeUpdateSessionCwdFromAbsolutePath(sid, p); + } + return service._hostRequest('readTextFile', params); + }, + + async writeTextFile(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id); + const p = params?.path ?? params?.uri; + if (sid && typeof p === 'string' && path.isAbsolute(p)) { + service._maybeUpdateSessionCwdFromAbsolutePath(sid, p); + } + return service._hostRequest('writeTextFile', params); + }, + + async extMethod(method: string, params: Record): Promise> { + // extMethod may be called during newSession BEFORE thread<->session mapping exists, + // so we must forward routing hints to renderer. + const pAny: any = params as any; + + const threadId = + (typeof pAny?.threadId === 'string' && pAny.threadId.trim()) + ? String(pAny.threadId).trim() + : undefined; + + const sessionId = + (typeof pAny?.sessionId === 'string' && pAny.sessionId.trim()) + ? String(pAny.sessionId).trim() + : undefined; + + return service._hostRequest('extMethod', { + method, + params, + ...(threadId ? { threadId } : {}), + ...(sessionId ? { sessionId } : {}), + }); + }, + + async sessionUpdate(params: any): Promise { + const sid = service._toSessionId(params?.sessionId ?? params?.session_id ?? params?.session?.id ?? params?.session?.sessionId); + if (!sid) return; + + const emitter = service.emitterBySession.get(sid); + if (!emitter) return; + + const update = (params && typeof params === 'object' && 'update' in params) ? (params as any).update : params; + + try { + service._emitChunksFromSessionUpdate(emitter, update, sid); + } catch (err: any) { + emitter.fire({ type: 'error', error: err?.message ?? String(err) }); + } + } + }; + + return client; + }, stream); + + this.connected = true; + + try { + await this.conn.initialize({ + protocolVersion: 1, + clientInfo: { name: 'Void', version: 'dev' }, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true + } + } as any); + } catch (e) { + this.logService.error('ACP initialize failed', e); + try { await this.disconnect(); } catch { /* ignore */ } + throw e; + } + } + + async disconnect(): Promise { + this.conn = null; + this.connected = false; + this.lastConnectParams = undefined; + + if (this.childProcess) { + if (!this.childProcess.killed) { + try { this.childProcess.kill(); } catch { } + } + this.childProcess = null; + } + + for (const [, em] of this.emitterBySession) em.dispose(); + this.emitterBySession.clear(); + + for (const [, em] of this.onDataEmitterByRequest) em.dispose?.(); + this.onDataEmitterByRequest.clear(); + + for (const [, p] of this.pendingHostCallbacks) p.reject(new Error('Disconnected')); + this.pendingHostCallbacks.clear(); + + this.requestSessionByRequestId.clear(); + this.sessionByThread.clear(); + this.threadBySession.clear(); + this.sessionCwdBySession.clear(); + + this.tokenUsageBySession.clear(); + + this.toolNameBySessionToolCallId.clear(); + this.toolKindBySessionToolCallId.clear(); + this.toolArgsBySessionToolCallId.clear(); + + this.activeToolCallIdBySession.clear(); + this.lastTerminalIdBySession.clear(); + this.terminalInfoById.clear(); + this.terminalIdsBySessionToolCall.clear(); + for (const [, st] of this.terminalPollBySessionToolCall) { + try { clearInterval(st.timer); } catch { /* ignore */ } + } + this.terminalPollBySessionToolCall.clear(); + } + + async sendChatMessage(args: { threadId: string; history: IAcpChatMessage[]; message: IAcpChatMessage; opts?: IAcpSendOptions; }): Promise { + this.logService.debug('[ACP Main] sendChatMessage called with args:', JSON.stringify({ + threadId: args.threadId, + hasHistory: args.history.length > 0, + message: args.message, + opts: sanitizeAcpSendOptionsForLog(args.opts), + }, null, 2)); + + await this.connect(args.opts); + if (!this.conn) throw new Error('ACP not connected'); + + let sessionId = this.sessionByThread.get(args.threadId); + + if (!sessionId) { + const sessionParams: any = { + cwd: this.getDefaultCwd(), + mcpServers: [], + // IMPORTANT: threadId must be present so builtin agent can route extMethod during newSession. + _meta: { history: args.history, threadId: args.threadId } + }; + + if (args?.opts?.system) { + sessionParams.systemPrompt = args.opts.system; + } + + const resp = await this.conn.newSession(sessionParams as any); + + const sidRaw = (resp as any)?.sessionId ?? (resp as any)?.session_id; + const sid = this._toSessionId(sidRaw); + if (!sid) throw new Error('ACP: agent did not return sessionId'); + + sessionId = sid; + this.sessionByThread.set(args.threadId, sessionId); + this.threadBySession.set(sessionId, args.threadId); + + if (typeof sessionParams?.cwd === 'string' && sessionParams.cwd) { + this.sessionCwdBySession.set(sessionId, sessionParams.cwd); + this._logJson('newSession stored cwd', { sessionId, cwd: sessionParams.cwd }); + } + } + + let emitter = this.emitterBySession.get(sessionId); + if (!emitter) { + emitter = new Emitter(); + this.emitterBySession.set(sessionId, emitter); + } + + const requestId = generateUuid(); + this.onDataEmitterByRequest.set(requestId, emitter); + this.requestSessionByRequestId.set(requestId, sessionId); + + let prompt: any[]; + const msgAny: any = args.message as any; + if (Array.isArray(msgAny.contentBlocks) && msgAny.contentBlocks.length) { + prompt = msgAny.contentBlocks; + } else { + prompt = [{ type: 'text', text: args.message.content }]; + } + + if (args?.opts?.model && this.conn.setSessionModel) { + try { + await this.conn.setSessionModel({ sessionId, modelId: args.opts.model } as any); + } catch { + try { + await this.conn.setSessionModel({ sessionId, model: args.opts.model } as any); + } catch { /* ignore */ } + } + } + + const parsePositiveInt = (v: unknown): number | undefined => { + const n = typeof v === 'number' ? v : (typeof v === 'string' ? Number(v) : NaN); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined; + }; + + const promptMaxToolOutputLength = parsePositiveInt(args?.opts?.maxToolOutputLength); + const promptReadFileChunkLines = parsePositiveInt(args?.opts?.readFileChunkLines); + + // IMPORTANT: attach threadId and truncation knobs to prompt _meta for builtin ACP agent. + const promptMeta: Record = { threadId: args.threadId }; + if (promptMaxToolOutputLength !== undefined) promptMeta.maxToolOutputLength = promptMaxToolOutputLength; + if (promptReadFileChunkLines !== undefined) promptMeta.readFileChunkLines = promptReadFileChunkLines; + if (promptMaxToolOutputLength !== undefined || promptReadFileChunkLines !== undefined) { + promptMeta.globalSettings = { + ...(promptMaxToolOutputLength !== undefined ? { maxToolOutputLength: promptMaxToolOutputLength } : {}), + ...(promptReadFileChunkLines !== undefined ? { readFileChunkLines: promptReadFileChunkLines } : {}), + }; + } + + this.conn.prompt({ sessionId, prompt, _meta: promptMeta } as any) + .then((resp: any) => { + const usageFromMeta: LLMTokenUsage | undefined = resp?._meta?.llmTokenUsage; + const usageFromSession = this.tokenUsageBySession.get(sessionId); + const usage = usageFromMeta ?? usageFromSession; + this.tokenUsageBySession.delete(sessionId); + emitter?.fire({ type: 'done', ...(usage ? { tokenUsageSnapshot: usage } : {}) }); + }) + .catch((err: any) => { + const baseMsg = typeof err?.message === 'string' ? err.message : String(err); + const details = typeof err?.data?.details === 'string' ? err.data.details + : (typeof err?.details === 'string' ? err.details : ''); + const combined = details && !baseMsg.includes(details) + ? `${baseMsg}: ${details}` + : baseMsg; + + const usage = this.tokenUsageBySession.get(sessionId); + this.tokenUsageBySession.delete(sessionId); + + emitter?.fire({ type: 'error', error: combined }); + emitter?.fire({ type: 'done', ...(usage ? { tokenUsageSnapshot: usage } : {}) }); + }) + .finally(() => { + this.onDataEmitterByRequest.delete(requestId); + this.requestSessionByRequestId.delete(requestId); + }); + + return requestId; + } + + async cancel({ requestId }: { requestId: string }): Promise { + this.logService.debug('[ACP Main] cancel(requestId):', requestId); + + const sid = this._toSessionId(this.requestSessionByRequestId.get(requestId)); + + try { + if (sid) { + await this.conn?.cancel({ sessionId: sid } as any); + } + } catch { /* ignore */ } + + // This makes "Skip/Abort" actually stop long-running commands even if the agent doesn't. + if (sid) { + const terminalIdsToKill = new Set(); + + const activeToolCallId = this.activeToolCallIdBySession.get(sid); + if (activeToolCallId) { + const tid = this._getTerminalIdForToolCall(sid, activeToolCallId); + if (tid) terminalIdsToKill.add(tid); + } + + const lastTid = this.lastTerminalIdBySession.get(sid); + if (lastTid) terminalIdsToKill.add(lastTid); + + for (const terminalId of terminalIdsToKill) { + try { await this._hostRequest('killTerminal', { sessionId: sid, terminalId }); } catch { /* ignore */ } + try { await this._hostRequest('releaseTerminal', { sessionId: sid, terminalId }); } catch { /* ignore */ } + + // update cache for UI (best-effort) + const prev = this.terminalInfoById.get(terminalId) ?? {}; + this.terminalInfoById.set(terminalId, { + ...prev, + exitCode: (typeof prev.exitCode === 'number' || prev.exitCode === null) ? prev.exitCode : null, + signal: (typeof prev.signal === 'string' || prev.signal === null) ? prev.signal : 'SIGTERM' + }); + } + this._stopAllTerminalPollsForSession(sid); + } + + const em = this.onDataEmitterByRequest.get(requestId); + if (em) { + const usage = sid ? this.tokenUsageBySession.get(sid) : undefined; + if (sid) this.tokenUsageBySession.delete(sid); + em.fire({ type: 'done', ...(usage ? { tokenUsageSnapshot: usage } : {}) }); + } + + this.requestSessionByRequestId.delete(requestId); + this.onDataEmitterByRequest.delete(requestId); + + if (sid && !this._hasActiveRequestForSession(sid)) { + const threadId = this.threadBySession.get(sid); + this.emitterBySession.get(sid)?.dispose(); + this.emitterBySession.delete(sid); + if (threadId) { + this.sessionByThread.delete(threadId); + this.threadBySession.delete(sid); + } + } + } + + onData(requestId: string): Event { + let em = this.onDataEmitterByRequest.get(requestId); + if (!em) { + em = new Emitter(); + this.onDataEmitterByRequest.set(requestId, em); + } + return em.event; + } + + private async _wsNdjsonStream(url: string): Promise { + const ws = new WebSocket(url); + + await new Promise((resolve, reject) => { + ws.once('open', () => resolve()); + ws.once('error', (e) => reject(e)); + }); + + const readable = new ReadableStream({ + start(controller) { + ws.on('message', (data) => { + try { + if (typeof data === 'string') controller.enqueue(new TextEncoder().encode(data)); + else if (data instanceof Buffer) controller.enqueue(new Uint8Array(data)); + else if (data instanceof ArrayBuffer) controller.enqueue(new Uint8Array(data)); + else if (Array.isArray(data)) controller.enqueue(new Uint8Array(Buffer.concat(data))); + } catch (e) { + controller.error(e); + } + }); + ws.on('close', () => controller.close()); + ws.on('error', (e) => controller.error(e)); + } + }); + + const writable = new WritableStream({ + write(chunk) { ws.send(Buffer.from(chunk)); }, + close() { try { ws.close(); } catch { } }, + abort() { try { ws.close(); } catch { } }, + }); + + return sdk.ndJsonStream(writable, readable); + } + + private _stdioStream(stdin: import('stream').Writable, stdout: import('stream').Readable): Stream { + const readable = new ReadableStream({ + start(controller) { + stdout.on('data', (chunk: Buffer) => { + try { + controller.enqueue(new Uint8Array(chunk)); + } catch (e) { + controller.error(e); + } + }); + stdout.on('error', (err) => controller.error(err)); + stdout.on('end', () => controller.close()); + } + }); + + const writable = new WritableStream({ + write(chunk) { + if (!stdin.writable) return; + stdin.write(chunk); + }, + close() { try { stdin.end(); } catch { } }, + abort() { try { stdin.destroy(); } catch { } }, + }); + + return sdk.ndJsonStream(writable, readable); + } + + private _maybeUpdateSessionCwdFromAbsolutePath(sessionId: string, absPath: string) { + const p = String(absPath ?? ''); + if (!sessionId || !p || !path.isAbsolute(p)) return; + + // heuristic: infer workspace root by cutting before "/src/" or "/.void/" + const norm = p.replace(/\\/g, '/'); + let root: string | undefined; + + const idxSrc = norm.indexOf('/src/'); + if (idxSrc > 0) root = norm.slice(0, idxSrc); + + const idxVoid = norm.indexOf('/.void/'); + if (!root && idxVoid > 0) root = norm.slice(0, idxVoid); + + if (root && root.trim()) { + this.sessionCwdBySession.set(sessionId, root); + this._logJson('inferred session cwd from fs path', { sessionId, cwd: root, from: absPath }); + } + } + + private _emitChunksFromSessionUpdate(emitter: Emitter, notif: AcpSessionUpdateNotification, sessionId?: string) { + if (sessionId && !this._hasActiveRequestForSession(sessionId)) return; + + const u: any = notif as any; + if (!u) return; + + const emitText = (text?: string) => { + if (typeof text === 'string') emitter.fire({ type: 'text', text }); + }; + + if (u.sessionUpdate === 'agent_message_chunk' && typeof u.content === 'object' && u.content && 'type' in u.content) { + if ((u.content as any).type === 'text') emitText((u.content as any).text); + return; + } + + if (u.sessionUpdate === 'llm_usage_snapshot' && sessionId) { + const usage: LLMTokenUsage | undefined = (u as any).usage; + if (usage) this.tokenUsageBySession.set(sessionId, usage); + return; + } + + if (u.sessionUpdate === 'agent_thought_chunk') { + if (typeof u.content === 'object' && u.content && 'type' in u.content && (u.content as any).type === 'text') { + emitter.fire({ type: 'reasoning', reasoning: (u.content as any).text }); + } + return; + } + + if ((u.sessionUpdate === 'plan' || u.sessionUpdate === 'todo' || u.sessionUpdate === 'todos') + && Array.isArray((u as any).entries)) { + + const items = (u as any).entries.map((entry: PlanEntry, i: number) => { + const rawStatus = (entry as any)?.status; + const st = typeof rawStatus === 'string' ? rawStatus : ''; + + const state: 'pending' | 'running' | 'done' | 'error' = + st === 'in_progress' ? 'running' + : st === 'completed' ? 'done' + : st === 'failed' ? 'error' + : 'pending'; + + return { + id: String(i), + text: String((entry as any)?.content ?? ''), + state, + }; + }); + + emitter.fire({ type: 'plan', plan: { items } }); + return; + } + + // ------------------------- + // tool_call + // ------------------------- + if (u.sessionUpdate === 'tool_call') { + const sid = sessionId; + const toolCallId = String(u?.toolCallId ?? ''); + const kind = typeof u?.kind === 'string' ? String(u.kind) : undefined; + const title = typeof u?.title === 'string' ? String(u.title) : undefined; + + const contentAny = Array.isArray(u?.content) ? u.content : undefined; + const hasTerminal = !!contentAny?.some((it: any) => it?.type === 'terminal' && typeof it?.terminalId === 'string' && it.terminalId); + const hasDiff = !!contentAny?.some((it: any) => it?.type === 'diff' && typeof it?.path === 'string' && it.path && typeof it?.newText === 'string'); + + const rawInAny = u?.rawInput; + const rawInObj: Record | undefined = + (rawInAny && typeof rawInAny === 'object' && !Array.isArray(rawInAny)) ? rawInAny : undefined; + + const rawName = typeof (rawInObj as any)?.name === 'string' ? String((rawInObj as any).name) : undefined; + + const canonicalName = this._canonicalToolName({ kind, hasDiff, hasTerminal, rawName, title }); + + // args: prefer rawInput.args, fallback rawInput object + let argsCandidate: unknown = undefined; + if (rawInObj) argsCandidate = ('args' in rawInObj) ? (rawInObj as any).args : rawInObj; + const args: Record = + (argsCandidate && typeof argsCandidate === 'object' && !Array.isArray(argsCandidate)) ? { ...(argsCandidate as any) } : {}; + + // Normalize args for Void UI: + try { + if (sid && canonicalName === 'read_file') { + let p: string | undefined = + (typeof (rawInObj as any)?.path === 'string' ? String((rawInObj as any).path) : undefined) + ?? (typeof (rawInObj as any)?.uri === 'string' ? String((rawInObj as any).uri) : undefined); + + if (!p && title) { + const m = title.match(/read_file:\s*(.+)$/i); + if (m?.[1]) p = m[1].trim(); + } + + if (p) { + const abs = this._resolvePathForSession(sid, p); + args.uri = abs; + } + + const line = (rawInObj as any)?.line; + const limit = (rawInObj as any)?.limit; + if (typeof line === 'number' && Number.isFinite(line)) args.startLine = line; + if (typeof limit === 'number' && Number.isFinite(limit)) args.linesCount = limit; + } + + if (sid && canonicalName === 'edit_file') { + let diffPath: string | undefined; + + if (Array.isArray(contentAny)) { + for (const it of contentAny) { + if (it?.type === 'diff' && typeof it?.path === 'string' && it.path.trim()) { + diffPath = String(it.path).trim(); + break; + } + } + } + + if (!diffPath && typeof title === 'string' && title.trim()) { + const m = title.match(/Patching\s+(.+?)(?:\s*\(|\s*$)/i); + if (m?.[1]) diffPath = m[1].trim(); + } + + const p = + diffPath + ?? (typeof (rawInObj as any)?.path === 'string' ? String((rawInObj as any).path) : undefined) + ?? (typeof (rawInObj as any)?.uri === 'string' ? String((rawInObj as any).uri) : undefined); + + if (p) { + const abs = this._resolvePathForSession(sid, p); + args.uri = abs; + args.path = abs; + } + } + + if (canonicalName === 'run_command') { + try { + if (Array.isArray(contentAny)) { + for (const it of contentAny) { + if (it?.type === 'terminal' && typeof it?.terminalId === 'string' && it.terminalId) { + args.terminalId = String(it.terminalId); + break; + } + } + } + } catch { /* ignore */ } + + const cmd = (typeof (rawInObj as any)?.command === 'string') + ? String((rawInObj as any).command).trim() + : ''; + + const argv = Array.isArray((rawInObj as any)?.args) + ? (rawInObj as any).args.map((a: any) => String(a ?? '')).filter((s: string) => s.length > 0) + : []; + + const fromPieces = cmd ? (argv.length ? `${cmd} ${argv.join(' ')}` : cmd) : ''; + const fromTitle = (typeof title === 'string') ? title.trim() : ''; + const cmdLine = (fromPieces || fromTitle || '').trim(); + + if (cmdLine) { + args.command = cmdLine; + } + } + } catch (e: any) { + this._logJson('tool_call args normalization FAILED', { + sessionId: sid, + toolCallId, + canonicalName, + error: e?.message ?? String(e) + }); + } + + if (sid && toolCallId) { + const k = this._toolKey(sid, toolCallId); + this.toolNameBySessionToolCallId.set(k, canonicalName); + this.toolKindBySessionToolCallId.set(k, kind); + this.toolArgsBySessionToolCallId.set(k, args); + + this.activeToolCallIdBySession.set(sid, toolCallId); + + if (Array.isArray(contentAny)) { + for (const item of contentAny) { + if (item?.type === 'terminal' && typeof item?.terminalId === 'string' && item.terminalId) { + this._addTerminalIdForToolCall(sid, toolCallId, String(item.terminalId)); + } + } + } + } + + this._logJson('session/update tool_call', { + sessionId: sid, + toolCallId, + kind, + title, + canonicalName, + args, + contentSummary: Array.isArray(contentAny) ? contentAny.map((x: any) => ({ type: x?.type, terminalId: x?.terminalId, path: x?.path })) : undefined + }); + + emitter.fire({ type: 'tool_call', toolCall: { id: toolCallId, name: canonicalName, args } }); + return; + } + + // ------------------------- + // tool_call_update + // ------------------------- + if (u.sessionUpdate === 'tool_call_update') { + const sid = sessionId; + if (!sid) return; + + const status = u?.status ?? null; + const toolCallId = String(u?.toolCallId ?? ''); + const key = toolCallId ? this._toolKey(sid, toolCallId) : ''; + + const updKind = typeof u?.kind === 'string' ? String(u.kind) : undefined; + if (key && updKind) this.toolKindBySessionToolCallId.set(key, updKind); + + const kind = updKind ?? (key ? this.toolKindBySessionToolCallId.get(key) : undefined); + const cachedName = key ? this.toolNameBySessionToolCallId.get(key) : undefined; + + const contentAny = Array.isArray(u?.content) ? u.content : undefined; + + const derivedDiffs: Array<{ path: string; oldText?: string; newText: string }> = []; + const derivedTerminals: Array<{ terminalId: string }> = []; + const derivedTexts: string[] = []; + + if (Array.isArray(contentAny) && contentAny.length) { + for (const item of contentAny) { + if (item?.type === 'content' && item?.content?.type === 'text' && typeof item?.content?.text === 'string') { + derivedTexts.push(item.content.text); + } else if (item?.type === 'diff') { + const cand = { + path: item?.path, + oldText: (typeof item?.oldText === 'string') ? item.oldText : '', + newText: item?.newText + }; + if (this._isValidDiffItem(cand)) { + derivedDiffs.push({ path: String(cand.path), oldText: cand.oldText, newText: String(cand.newText) }); + } + } else if (item?.type === 'terminal') { + if (typeof item?.terminalId === 'string' && item.terminalId) { + derivedTerminals.push({ terminalId: String(item.terminalId) }); + } + } + } + } + + if (toolCallId && derivedTerminals.length) { + for (const t of derivedTerminals) this._addTerminalIdForToolCall(sid, toolCallId, t.terminalId); + } + + const hasTerminal = !!(derivedTerminals.length || (toolCallId && this._getTerminalIdForToolCall(sid, toolCallId))); + const hasDiff = derivedDiffs.length > 0; + + // rawOutput from agent + let result: any = u?.rawOutput; + if (typeof result === 'string') { + const t = result.trim(); + if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { + try { result = JSON.parse(t); } catch { result = { rawOutput: result }; } + } else { + result = { rawOutput: result }; + } + } else if (result !== undefined && result !== null) { + if (typeof result !== 'object' || Array.isArray(result)) result = { value: result }; + } + if (result === undefined || result === null) result = {}; + const rObj = this._asObject(result) ?? {}; + + const canonicalName = this._canonicalToolName({ + kind, + hasDiff, + hasTerminal, + rawName: cachedName, + title: typeof u?.title === 'string' ? String(u.title) : undefined + }); + + if (key) this.toolNameBySessionToolCallId.set(key, canonicalName); + + // --- NEW: emit tool_progress for in-flight tool_call_update --- + if (status !== 'completed' && status !== 'failed') { + const acpMode = this.lastConnectParams?.mode ?? 'builtin'; + const shouldEmitProgress = !(canonicalName === 'run_command' && acpMode !== 'builtin'); + const terminalIdForProgress = + (derivedTerminals.length ? String(derivedTerminals[0].terminalId) : '') + || (typeof (rObj as any).terminalId === 'string' ? String((rObj as any).terminalId) : '') + || (toolCallId ? (this._getTerminalIdForToolCall(sid, toolCallId) ?? '') : ''); + + const rawOutStr = + (typeof (rObj as any).output === 'string' && (rObj as any).output.length > 0) ? String((rObj as any).output) + : (typeof (rObj as any).text === 'string' && (rObj as any).text.length > 0) ? String((rObj as any).text) + : (typeof (rObj as any).content === 'string' && (rObj as any).content.length > 0) ? String((rObj as any).content) + : ''; + + const textFromContent = derivedTexts.length ? derivedTexts.join('\n') : ''; + const progressText = (rawOutStr || textFromContent || '').toString(); + + const truncated = + (typeof (rObj as any).truncated === 'boolean') ? (rObj as any).truncated : undefined; + + const exitStatus = (rObj as any)?.exitStatus; + + if (!shouldEmitProgress) { + this._logJson('SKIP tool_progress (run_command via terminal poll)', { + sessionId: sid, + toolCallId, + canonicalName, + status, + acpMode, + terminalId: terminalIdForProgress || null, + derivedTextLen: textFromContent.length, + rawOutLen: rawOutStr.length, + rObjKeys: Object.keys(rObj) + }); + } else if (toolCallId && progressText) { + this._logJson('EMIT tool_progress (from tool_call_update)', { + sessionId: sid, + toolCallId, + canonicalName, + status, + terminalId: terminalIdForProgress || null, + progressLen: progressText.length, + hasDerivedTexts: derivedTexts.length, + rObjKeys: Object.keys(rObj), + hasExitStatus: !!exitStatus, + truncated: truncated ?? null, + preview: progressText.slice(0, 160) + }); + + emitter.fire({ + type: 'tool_progress', + toolProgress: { + id: toolCallId, + name: canonicalName, + ...(terminalIdForProgress ? { terminalId: terminalIdForProgress } : {}), + output: progressText, + ...(typeof truncated === 'boolean' ? { truncated } : {}), + ...(exitStatus ? { exitStatus } : {}) + } as any + }); + } else { + this._logJson('SKIP tool_progress (empty)', { + sessionId: sid, + toolCallId, + canonicalName, + status, + terminalId: terminalIdForProgress || null, + derivedTextLen: textFromContent.length, + rawOutLen: rawOutStr.length, + rObjKeys: Object.keys(rObj) + }); + } + } + + this._logJson('session/update tool_call_update', { + sessionId: sid, + toolCallId, + status, + kind, + cachedName, + canonicalName, + derivedSummary: { textLen: derivedTexts.join('\n').length, diffs: derivedDiffs.length, terminals: derivedTerminals.length }, + resultKeys: Object.keys(rObj), + contentSummary: Array.isArray(contentAny) ? contentAny.map((x: any) => ({ type: x?.type, terminalId: x?.terminalId, path: x?.path })) : undefined + }); + + if (sid && toolCallId) { + const terminalIdForPoll = + (derivedTerminals.length ? String(derivedTerminals[0].terminalId) : '') + || (typeof (rObj as any).terminalId === 'string' ? String((rObj as any).terminalId) : '') + || (this._getTerminalIdForToolCall(sid, toolCallId) ?? ''); + + // NOTE: this poll is for "SPEC terminal/* callbacks". + // Builtin agent uses extMethod('terminal/output') and does NOT need this poll, + // but keep it for websocket/process agents. + if (terminalIdForPoll && canonicalName === 'run_command' && status !== 'completed' && status !== 'failed') { + this._startTerminalPoll(sid, toolCallId, terminalIdForPoll); + } + } + + if (status === 'completed' || status === 'failed') { + if (sid && toolCallId) { + this._stopTerminalPoll(sid, toolCallId); + } + const errorText = status === 'failed' ? 'Tool failed' : undefined; + + // clear active tool call + { + const active = this.activeToolCallIdBySession.get(sid); + if (active && active === toolCallId) this.activeToolCallIdBySession.delete(sid); + } + + if (canonicalName === 'edit_file') { + const safeDiffs = derivedDiffs.filter(d => this._isValidDiffItem(d)); + emitter.fire({ + type: 'tool_result', + toolResult: { + id: toolCallId, + name: 'edit_file', + result: { ...rObj, diffs: safeDiffs }, + error: errorText + } + }); + return; + } + + if (canonicalName === 'read_file') { + const contentStr = + (typeof (rObj as any).content === 'string') ? String((rObj as any).content) + : (typeof (rObj as any).text === 'string' ? String((rObj as any).text) : ''); + + emitter.fire({ + type: 'tool_result', + toolResult: { + id: toolCallId, + name: 'read_file', + result: { ...rObj, content: String(contentStr ?? '') }, + error: errorText + } + }); + return; + } + + // run_command + if (canonicalName === 'run_command') { + const terminalId = + (derivedTerminals.length ? String(derivedTerminals[0].terminalId) : '') + || (typeof (rObj as any).terminalId === 'string' ? String((rObj as any).terminalId) : '') + || (toolCallId ? (this._getTerminalIdForToolCall(sid, toolCallId) ?? '') : ''); + + const emitRunCommandResult = () => { + const info = terminalId ? this.terminalInfoById.get(terminalId) : undefined; + const cachedOutput = (typeof info?.output === 'string') ? info.output : ''; + const fromAgentOutput = (typeof (rObj as any).output === 'string') ? String((rObj as any).output) : ''; + const output = cachedOutput.length >= fromAgentOutput.length ? cachedOutput : fromAgentOutput; + + const outResult: any = { + ...rObj, + toolCallId, + terminalId: terminalId || undefined, + output: String(output ?? '') + }; + + if (typeof info?.truncated === 'boolean') outResult.truncated = info.truncated; + if (typeof info?.command === 'string') outResult.command = info.command; + if (typeof info?.exitCode === 'number' || info?.exitCode === null) outResult.exitCode = info.exitCode; + if (typeof info?.signal === 'string' || info?.signal === null) outResult.signal = info.signal; + + emitter.fire({ + type: 'tool_result', + toolResult: { + id: toolCallId, + name: 'run_command', + result: outResult, + error: errorText + } + }); + }; + + if (terminalId) { + this._captureTerminalOutputIntoCache(sid, terminalId).then( + () => emitRunCommandResult(), + () => emitRunCommandResult() + ); + return; + } + + emitRunCommandResult(); + return; + } + + emitter.fire({ + type: 'tool_result', + toolResult: { + id: toolCallId, + name: canonicalName, + result: rObj ?? {}, + error: errorText + } + }); + return; + } + + return; + } + + if (u.sessionUpdate === 'available_commands_update' && Array.isArray((u as any).availableCommands)) { + const items = (u as any).availableCommands.map((cmd: AvailableCommand, i: number) => ({ + id: String(i), + text: `${cmd?.name ?? ''}${cmd?.description ? ` — ${cmd.description}` : ''}`.trim(), + state: 'pending' as const + })); + if (items.length) emitter.fire({ type: 'plan', plan: { items } }); + return; + } + + if (u.sessionUpdate === 'current_mode_update' && typeof (u as any).currentModeId === 'string') { + emitText(`Mode: ${(u as any).currentModeId}`); + return; + } + } + + async hostCallbackResult(resp: AcpHostCallbackResponse): Promise { + const p = this.pendingHostCallbacks.get(resp.requestId); + if (!p) return; + this.pendingHostCallbacks.delete(resp.requestId); + if (resp.error) p.reject(new Error(resp.error)); + else p.resolve(resp.result); + } +} diff --git a/src/vs/platform/acp/electron-main/test/acpBuiltinAgent.loopError.test.ts b/src/vs/platform/acp/electron-main/test/acpBuiltinAgent.loopError.test.ts new file mode 100644 index 00000000000..22c5bf7f93f --- /dev/null +++ b/src/vs/platform/acp/electron-main/test/acpBuiltinAgent.loopError.test.ts @@ -0,0 +1,84 @@ +import assert from 'assert'; +// eslint-disable-next-line local/code-import-patterns +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../platform/log/common/log.js'; +import { LOOP_DETECTED_MESSAGE } from '../../../../platform/void/common/loopGuard.js'; +import { __test } from '../acpBuiltinAgent.js'; + +suite('acpBuiltinAgent loop error', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + teardown(() => { + __test.reset(); + }); + + test('Loop detected surfaces as error.message (not sessionId) and details', async () => { + let callNo = 0; + + __test.setSendChatRouter(async (opts: any) => { + callNo++; + if (callNo === 1) { + await opts.onFinalMessage({ + fullText: 'same', + toolCall: { id: 'tc1', name: 'fake_tool', rawParams: {}, isDone: true } + }); + return; + } + + await opts.onFinalMessage({ fullText: 'same' }); + }); + + const conn: any = { + async extMethod(method: string) { + if (method === 'void/settings/getLLMConfig') { + return { + providerName: 'openAI', + modelName: 'gpt-4o-mini', + settingsOfProvider: {}, + modelSelectionOptions: null, + overridesOfModel: null, + separateSystemMessage: null, + chatMode: null, + loopGuard: { maxTurnsPerPrompt: 1, maxSameAssistantPrefix: 1 }, + requestParams: null, + providerRouting: null, + dynamicRequestConfig: { + apiStyle: 'openai-compatible', + endpoint: 'https://example.invalid', + headers: {}, + specialToolFormat: 'openai-style', + supportsSystemMessage: 'system-role', + }, + additionalTools: null, + }; + } + throw new Error(`Unexpected extMethod: ${method}`); + }, + + async sessionUpdate() { /* noop */ }, + + async requestPermission() { + // Reject -> treated as skip -> get 2nd LLM turn + return { outcome: { outcome: 'selected', optionId: 'reject_once' } }; + }, + }; + + const agent = new __test.VoidPipelineAcpAgent(conn, new NullLogService()); + const { sessionId } = await agent.newSession({} as any); + + await assert.rejects( + () => agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] } as any), + (e: any) => { + assert.ok(e instanceof Error); + assert.strictEqual(e.message, LOOP_DETECTED_MESSAGE); + + const anyErr = e as any; + const details = anyErr?.data?.details ?? anyErr?.details ?? ''; + assert.ok(typeof details === 'string'); + assert.ok(details.includes(LOOP_DETECTED_MESSAGE)); + + return true; + } + ); + }); +}); diff --git a/src/vs/platform/acp/electron-main/test/acpMainService.test.ts b/src/vs/platform/acp/electron-main/test/acpMainService.test.ts new file mode 100644 index 00000000000..ceb5ad6e175 --- /dev/null +++ b/src/vs/platform/acp/electron-main/test/acpMainService.test.ts @@ -0,0 +1,70 @@ +import assert from 'assert'; +import { AcpMainService } from '../acpMainService.js'; +import { ILogService } from '../../../log/common/log.js'; +// eslint-disable-next-line local/code-import-patterns +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; + +suite('AcpMainService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let service: AcpMainService; + let logService: ILogService; + + setup(() => { + logService = { + debug: () => { }, + info: () => { }, + warn: () => { }, + error: () => { }, + trace: () => { }, + } as any; + service = new AcpMainService(logService); + }); + + teardown(async () => { + await service.disconnect(); + }); + + test('connect defaults to websocket mode', async () => { + try { + // Expect failure due to invalid URL, but verify mode logic + await service.connect({ mode: 'websocket', agentUrl: 'ws://invalid' }); + } catch { } + // We can't inspect private state directly, but we verified the call flow doesn't crash + }); + + test('connect builtin defaults URL', async () => { + try { + await service.connect({ mode: 'builtin' }); + } catch { } + }); + + test('connect in process mode requires command', async () => { + await assert.rejects(async () => { + await service.connect({ mode: 'process', args: [] }); + }, /command is required/); + }); + + test('disconnect clears connection state', async () => { + // Manually set state to simulate connection (since we can't easily connect to real things) + (service as any).connected = true; + (service as any).lastConnectParams = { mode: 'builtin' }; + + await service.disconnect(); + + assert.strictEqual((service as any).connected, false); + assert.strictEqual((service as any).lastConnectParams, undefined); + }); + + test('sendChatMessage throws if connection fails', async () => { + await assert.rejects(async () => { + await service.sendChatMessage({ + threadId: 't1', + history: [], + message: { role: 'user', content: 'hi' }, + // Use a local non-existent port to ensure fast failure (ECONNREFUSED) instead of DNS timeout + opts: { mode: 'websocket', agentUrl: 'ws://127.0.0.1:54321' } + }); + }); + }); +}); diff --git a/src/vs/platform/acp/electron-main/vendor/acp-sdk.vendored.d.ts b/src/vs/platform/acp/electron-main/vendor/acp-sdk.vendored.d.ts new file mode 100644 index 00000000000..a9f4d5fc05a --- /dev/null +++ b/src/vs/platform/acp/electron-main/vendor/acp-sdk.vendored.d.ts @@ -0,0 +1,18 @@ +export type Stream = any; +export function ndJsonStream(writable: any, readable: any): Stream; + +export type Client = any; + +export class ClientSideConnection { + constructor(createClient: (agent: any) => any, stream: Stream); + initialize(opts: any): Promise; + newSession(params: any): Promise; + prompt(params: any): Promise; + cancel(params: any): Promise; + setSessionModel?(params: any): Promise; +} + +export class AgentSideConnection { + constructor(createAgent: (conn: any) => any, stream: Stream); +} + diff --git a/src/vs/platform/acp/electron-main/vendor/acp-sdk.vendored.js b/src/vs/platform/acp/electron-main/vendor/acp-sdk.vendored.js new file mode 100644 index 00000000000..144c530f18f --- /dev/null +++ b/src/vs/platform/acp/electron-main/vendor/acp-sdk.vendored.js @@ -0,0 +1 @@ +export * from '@agentclientprotocol/sdk'; diff --git a/src/vs/platform/acp/electron-main/vendor/ws.vendored.d.ts b/src/vs/platform/acp/electron-main/vendor/ws.vendored.d.ts new file mode 100644 index 00000000000..6bd856f9e3e --- /dev/null +++ b/src/vs/platform/acp/electron-main/vendor/ws.vendored.d.ts @@ -0,0 +1,13 @@ +export class WebSocket { + constructor(url: string, protocols?: string | string[]); + send(data: any): void; + close(): void; + on(event: 'open' | 'message' | 'close' | 'error', listener: (...args: any[]) => void): void; + once(event: 'open' | 'message' | 'close' | 'error', listener: (...args: any[]) => void): void; +} + +export class WebSocketServer { + constructor(opts: any); + on(event: 'connection' | 'listening' | 'error' | 'close', listener: (...args: any[]) => void): void; + close(cb?: (err?: Error) => void): void; +} diff --git a/src/vs/platform/acp/electron-main/vendor/ws.vendored.js b/src/vs/platform/acp/electron-main/vendor/ws.vendored.js new file mode 100644 index 00000000000..b533ca7f5ea --- /dev/null +++ b/src/vs/platform/acp/electron-main/vendor/ws.vendored.js @@ -0,0 +1,7 @@ +import { createRequire as __createRequire } from 'node:module'; + +const require = __createRequire(import.meta.url); +const ws = require('ws'); + +export const WebSocket = ws.WebSocket || ws; +export const WebSocketServer = ws.WebSocketServer || ws.Server; diff --git a/src/vs/platform/acp/test/common/acpLogSanitizer.test.ts b/src/vs/platform/acp/test/common/acpLogSanitizer.test.ts new file mode 100644 index 00000000000..fd37e7ae292 --- /dev/null +++ b/src/vs/platform/acp/test/common/acpLogSanitizer.test.ts @@ -0,0 +1,33 @@ +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { redactEnvForLog } from '../../common/acpLogSanitizer.js'; + +suite('acpLogSanitizer.redactEnvForLog', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('redacts *_KEY / *_TOKEN / password-like fields', () => { + const env = { + MISTRAL_API_KEY: 'secret1', + OPENAI_TOKEN: 'secret2', + password: 'secret3', + PATH: '/bin', + NORMAL: 'ok', + }; + + const out = redactEnvForLog(env); + + assert.deepStrictEqual(out, { + MISTRAL_API_KEY: '', + OPENAI_TOKEN: '', + password: '', + PATH: '/bin', + NORMAL: 'ok', + }); + }); + + test('returns non-object as-is', () => { + assert.strictEqual(redactEnvForLog(null), null); + assert.strictEqual(redactEnvForLog(undefined), undefined); + assert.strictEqual(redactEnvForLog('x' as any), 'x'); + }); +}); diff --git a/src/vs/platform/acp/test/node/acpBuiltinAgent.refreshConfig.test.ts b/src/vs/platform/acp/test/node/acpBuiltinAgent.refreshConfig.test.ts new file mode 100644 index 00000000000..97b246bd092 --- /dev/null +++ b/src/vs/platform/acp/test/node/acpBuiltinAgent.refreshConfig.test.ts @@ -0,0 +1,280 @@ +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +// eslint-disable-next-line local/code-layering, local/code-import-patterns +import { __test as acpTest } from '../../electron-main/acpBuiltinAgent.js'; +// eslint-disable-next-line local/code-layering, local/code-import-patterns +import { __test as llmImplTest } from '../../../void/electron-main/llmMessage/sendLLMMessage.impl.js'; + +suite('ACP builtin agent - refresh config on prompt', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('prompt() refreshes void/settings/getLLMConfig and uses new dynamicRequestConfig', async () => { + const log = new NullLogService(); + + const captured: any[] = []; + + acpTest.setSendChatRouter(async (params: any) => { + captured.push(JSON.parse(JSON.stringify(params))); + + params.onFinalMessage?.({ + fullText: 'ok', + fullReasoning: '', + anthropicReasoning: null, + toolCall: undefined, + tokenUsage: { input: 1, output: 1, cacheCreation: 0, cacheRead: 0 }, + }); + }); + + + + + let getCfgCall = 0; + + const cfgA = { + providerName: 'provA', + modelName: 'provA/modelA', + settingsOfProvider: { provA: { endpoint: 'https://provider-a.example/v1', apiKey: 'provKeyA' } }, + modelSelectionOptions: {}, + overridesOfModel: {}, + separateSystemMessage: 'SYS', + chatMode: 'normal', + requestParams: null, + providerRouting: null, + dynamicRequestConfig: { + apiStyle: 'openai-compatible', + endpoint: 'https://api-a.example/v1', + headers: { Authorization: 'Bearer keyA' }, + specialToolFormat: 'openai-style', + supportsSystemMessage: 'developer-role', + }, + additionalTools: null, + loopGuard: { maxTurnsPerPrompt: 25, maxSameAssistantPrefix: 10, maxSameToolCall: 10 }, + }; + + const cfgB = { + ...cfgA, + providerName: 'provB', + modelName: 'provB/modelB', + chatMode: 'agent', + separateSystemMessage: 'SYS2', + settingsOfProvider: { provB: { endpoint: 'https://provider-b.example/v1', apiKey: 'provKeyB' } }, + dynamicRequestConfig: { + apiStyle: 'openai-compatible', + endpoint: 'https://api-b.example/v1', + headers: { Authorization: 'Bearer keyB' }, + specialToolFormat: 'openai-style', + supportsSystemMessage: 'developer-role', + }, + additionalTools: [ + { name: 'mcp__allowed', description: 'allowed', params: {} }, + { name: 'mcp__disabled', description: 'disabled', params: {} }, + ], + disabledStaticTools: ['read_file', 'edit_file'], + disabledDynamicTools: ['mcp__disabled'], + }; + + const conn: any = { + extMethod: async (method: string, _params: any) => { + if (method === 'void/settings/getLLMConfig') { + getCfgCall++; + return (getCfgCall === 1) ? cfgA : cfgB; + } + throw new Error('Unexpected extMethod: ' + method); + }, + sessionUpdate: async (_u: any) => { /* ignore */ }, + requestPermission: async () => { throw new Error('requestPermission should not be called (no tool)'); }, + }; + + try { + const agent = new acpTest.VoidPipelineAcpAgent(conn, log); + + const { sessionId } = await agent.newSession({} as any); + + const resp = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any); + + assert.strictEqual(resp.stopReason, 'end_turn'); + assert.ok(getCfgCall >= 2, 'expected getLLMConfig called at least twice (newSession + prompt refresh)'); + + assert.strictEqual(captured.length, 1); + assert.strictEqual(captured[0].dynamicRequestConfig.endpoint, 'https://api-b.example/v1'); + assert.strictEqual(captured[0].dynamicRequestConfig.headers.Authorization, 'Bearer keyB'); + assert.strictEqual(captured[0].providerName, 'provB'); + assert.strictEqual(captured[0].modelName, 'provB/modelB'); + assert.strictEqual(captured[0].chatMode, 'agent'); + assert.deepStrictEqual(captured[0].disabledStaticTools, ['read_file', 'edit_file']); + assert.deepStrictEqual(captured[0].disabledDynamicTools, ['mcp__disabled']); + + const toolNames = Array.isArray(captured[0].additionalTools) + ? captured[0].additionalTools.map((t: any) => String(t?.name ?? '')) + : []; + assert.ok(toolNames.includes('mcp__allowed'), 'enabled dynamic tool must be passed'); + assert.ok(!toolNames.includes('mcp__disabled'), 'disabled dynamic tool must not be passed'); + assert.ok(toolNames.includes('acp_plan'), 'ACP plan tool should be present in agent mode'); + } finally { + acpTest.reset(); + } + }); + + test('builtin ACP: disabled static tools are excluded from provider payload tools', async () => { + const log = new NullLogService(); + acpTest.reset(); + + let capturedOpenAIOptions: any = null; + + class FakeOpenAI { + chat = { + completions: { + create: async (opts: any) => { + capturedOpenAIOptions = opts; + return { + choices: [{ message: { content: 'ok', tool_calls: undefined } }], + }; + }, + }, + }; + } + + llmImplTest.setOpenAIModule?.({ + default: FakeOpenAI, + APIError: class extends Error { }, + } as any); + + const cfg = { + providerName: 'openAI', + modelName: 'gpt-4o-mini', + settingsOfProvider: { openAI: { apiKey: 'k' } }, + modelSelectionOptions: {}, + overridesOfModel: {}, + separateSystemMessage: 'SYS', + chatMode: 'agent', + requestParams: null, + providerRouting: null, + dynamicRequestConfig: { + apiStyle: 'openai-compatible', + endpoint: 'https://api.openai.com/v1', + headers: { Authorization: 'Bearer k' }, + specialToolFormat: 'openai-style', + supportsSystemMessage: 'developer-role', + }, + additionalTools: null, + disabledStaticTools: ['read_file', 'edit_file'], + disabledDynamicTools: [], + loopGuard: { maxTurnsPerPrompt: 25, maxSameAssistantPrefix: 10, maxSameToolCall: 10 }, + }; + + const conn: any = { + extMethod: async (method: string, _params: any) => { + if (method === 'void/settings/getLLMConfig') return cfg; + throw new Error('Unexpected extMethod: ' + method); + }, + sessionUpdate: async (_u: any) => { /* ignore */ }, + requestPermission: async () => { throw new Error('requestPermission should not be called (no tool)'); }, + }; + + try { + const agent = new acpTest.VoidPipelineAcpAgent(conn, log); + const { sessionId } = await agent.newSession({} as any); + const resp = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any); + + assert.strictEqual(resp.stopReason, 'end_turn'); + assert.ok(capturedOpenAIOptions, 'OpenAI payload should be captured'); + assert.ok(Array.isArray(capturedOpenAIOptions.tools), 'tools must be present in agent mode'); + + const toolNames = capturedOpenAIOptions.tools + .map((t: any) => String(t?.function?.name ?? '')) + .filter(Boolean); + + assert.ok(toolNames.length > 0, 'there should be at least one tool in payload'); + assert.ok(!toolNames.includes('read_file'), 'disabled static tool read_file must be excluded'); + assert.ok(!toolNames.includes('edit_file'), 'disabled static tool edit_file must be excluded'); + assert.ok(toolNames.includes('run_command'), 'enabled static tools should remain available'); + } finally { + llmImplTest.reset?.(); + acpTest.reset(); + } + }); + + test('builtin ACP: when specialToolFormat=disabled, provider payload must not include tools', async () => { + const log = new NullLogService(); + acpTest.reset(); + + let capturedOpenAIOptions: any = null; + + class FakeOpenAI { + chat = { + completions: { + create: async (opts: any) => { + capturedOpenAIOptions = opts; + return { + choices: [{ message: { content: 'ok', tool_calls: undefined } }], + }; + }, + }, + }; + } + + llmImplTest.setOpenAIModule?.({ + default: FakeOpenAI, + APIError: class extends Error { }, + } as any); + + const cfg = { + providerName: 'openAI', + modelName: 'gpt-4o-mini', + settingsOfProvider: { openAI: { apiKey: 'k' } }, + modelSelectionOptions: {}, + overridesOfModel: {}, + separateSystemMessage: 'SYS', + chatMode: 'agent', + requestParams: null, + providerRouting: null, + dynamicRequestConfig: { + apiStyle: 'openai-compatible', + endpoint: 'https://api.openai.com/v1', + headers: { Authorization: 'Bearer k' }, + specialToolFormat: 'disabled', + supportsSystemMessage: 'developer-role', + }, + additionalTools: null, + disabledStaticTools: [], + disabledDynamicTools: [], + loopGuard: { maxTurnsPerPrompt: 25, maxSameAssistantPrefix: 10, maxSameToolCall: 10 }, + }; + + const conn: any = { + extMethod: async (method: string, _params: any) => { + if (method === 'void/settings/getLLMConfig') return cfg; + throw new Error('Unexpected extMethod: ' + method); + }, + sessionUpdate: async (_u: any) => { /* ignore */ }, + requestPermission: async () => { throw new Error('requestPermission should not be called (no tool)'); }, + }; + + try { + const agent = new acpTest.VoidPipelineAcpAgent(conn, log); + const { sessionId } = await agent.newSession({} as any); + const resp = await agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'hello' }], + } as any); + + assert.strictEqual(resp.stopReason, 'end_turn'); + assert.ok(capturedOpenAIOptions, 'OpenAI payload should be captured'); + assert.strictEqual( + capturedOpenAIOptions.tools, + undefined, + 'tools must be omitted when specialToolFormat is disabled in ACP mode' + ); + } finally { + llmImplTest.reset?.(); + acpTest.reset(); + } + }); +}); diff --git a/src/vs/platform/void/common/acpArgs.ts b/src/vs/platform/void/common/acpArgs.ts new file mode 100644 index 00000000000..43233fae073 --- /dev/null +++ b/src/vs/platform/void/common/acpArgs.ts @@ -0,0 +1,40 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +// Parse a shell-like arguments string into an array suitable for process spawn. +// Behaviour is intentionally close to typical POSIX shell splitting: +// - Whitespace outside quotes splits arguments +// - Double quotes are removed and everything inside is kept verbatim +// (so "my cfg.json" -> "my cfg.json") +// - Flags with equals are preserved as-is, including combined forms like +// --config=my_cfg_json or --config="my_cfg_json" (quotes stripped) +export const parseAcpProcessArgs = (raw: string): string[] => { + const out: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (ch === '"') { + // Toggle quote state, but do not include the quote character itself. + inQuotes = !inQuotes; + continue; + } + if (!inQuotes && /\s/.test(ch)) { + if (current.length > 0) { + out.push(current); + current = ''; + } + continue; + } + current += ch; + } + + if (current.length > 0) { + out.push(current); + } + + return out; +}; diff --git a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts b/src/vs/platform/void/common/chatThreadServiceTypes.ts similarity index 53% rename from src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts rename to src/vs/platform/void/common/chatThreadServiceTypes.ts index 44dc307e790..777fc2abd25 100644 --- a/src/vs/workbench/contrib/void/common/chatThreadServiceTypes.ts +++ b/src/vs/platform/void/common/chatThreadServiceTypes.ts @@ -3,34 +3,48 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { URI } from '../../../../base/common/uri.js'; +import { URI } from '../../../base/common/uri.js'; import { VoidFileSnapshot } from './editCodeServiceTypes.js'; +// Allow dynamic tool names (MCP/runtime tools) +export type AnyToolName = ToolName | string; import { AnthropicReasoning, RawToolParamsObj } from './sendLLMMessageTypes.js'; -import { ToolCallParams, ToolName, ToolResult } from './toolsServiceTypes.js'; +import { ToolCallParams, ToolName, ToolResultType } from './toolsServiceTypes.js'; -export type ToolMessage = { +// Attachments that can be associated with a user chat message +export type ChatImageAttachment = { + kind: 'image'; + uri: URI; + mimeType: string; + name: string; +}; + +export type ChatAttachment = ChatImageAttachment; + +// ToolMessage supports both known static tools (with typed params/results) +// and dynamic/unknown tools where params/results are untyped. +export type ToolMessage = { role: 'tool'; content: string; // give this result to LLM (string of value) + displayContent?: string; // for UI (cleaned content without path and tags) id: string; rawParams: RawToolParamsObj; - mcpServerName: string | undefined; // the server name at the time of the call } & ( - // in order of events: - | { type: 'invalid_params', result: null, name: T, } + // in order of events: for known static tools we keep strong typing + | (T extends ToolName ? { type: 'invalid_params', result: null, name: T, } : { type: 'invalid_params', result: null, name: AnyToolName }) - | { type: 'tool_request', result: null, name: T, params: ToolCallParams, } // params were validated, awaiting user + | (T extends ToolName ? { type: 'tool_request', result: null, name: T, params: ToolCallParams[T], } : { type: 'tool_request', result: null, name: AnyToolName, params: Record }) // params were validated, awaiting user - | { type: 'running_now', result: null, name: T, params: ToolCallParams, } + | (T extends ToolName ? { type: 'running_now', result: null, name: T, params: ToolCallParams[T], } : { type: 'running_now', result: null, name: AnyToolName, params: Record }) - | { type: 'tool_error', result: string, name: T, params: ToolCallParams, } // error when tool was running - | { type: 'success', result: Awaited>, name: T, params: ToolCallParams, } - | { type: 'rejected', result: null, name: T, params: ToolCallParams } + | (T extends ToolName ? { type: 'tool_error', result: string, name: T, params: ToolCallParams[T], } : { type: 'tool_error', result: string, name: AnyToolName, params: Record }) // error when tool was running + | (T extends ToolName ? { type: 'success', result: Awaited, name: T, params: ToolCallParams[T], } : { type: 'success', result: any, name: AnyToolName, params: Record }) + | (T extends ToolName ? { type: 'rejected', result: null, name: T, params: ToolCallParams[T] } : { type: 'rejected', result: null, name: AnyToolName, params: Record }) + | (T extends ToolName ? { type: 'skipped', result: null, name: T, params: ToolCallParams[T] } : { type: 'skipped', result: null, name: AnyToolName, params: Record }) ) // user rejected export type DecorativeCanceledTool = { role: 'interrupted_streaming_tool'; - name: ToolName; - mcpServerName: string | undefined; // the server name at the time of the call + name: AnyToolName; } @@ -53,10 +67,12 @@ export type ChatMessage = content: string; // content displayed to the LLM on future calls - allowed to be '', will be replaced with (empty) displayContent: string; // content displayed to user - allowed to be '', will be ignored selections: StagingSelectionItem[] | null; // the user's selection + attachments?: ChatAttachment[] | null; // optional explicit attachments (e.g. images) state: { stagingSelections: StagingSelectionItem[]; isBeingEdited: boolean; } + hidden?: boolean; // whether the message should be hidden from UI } | { role: 'assistant'; displayContent: string; // content received from LLM - allowed to be '', will be replaced with (empty) @@ -64,7 +80,7 @@ export type ChatMessage = anthropicReasoning: AnthropicReasoning[] | null; // anthropic reasoning } - | ToolMessage + | ToolMessage | DecorativeCanceledTool | CheckpointEntry diff --git a/src/vs/workbench/contrib/void/common/directoryStrService.ts b/src/vs/platform/void/common/directoryStrService.ts similarity index 91% rename from src/vs/workbench/contrib/void/common/directoryStrService.ts rename to src/vs/platform/void/common/directoryStrService.ts index d9d0a319cec..857e89b94a7 100644 --- a/src/vs/workbench/contrib/void/common/directoryStrService.ts +++ b/src/vs/platform/void/common/directoryStrService.ts @@ -3,22 +3,18 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { URI } from '../../../../base/common/uri.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IFileService, IFileStat } from '../../../../platform/files/common/files.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { ShallowDirectoryItem, BuiltinToolCallParams, BuiltinToolResultType } from './toolsServiceTypes.js'; -import { MAX_CHILDREN_URIs_PAGE, MAX_DIRSTR_CHARS_TOTAL_BEGINNING, MAX_DIRSTR_CHARS_TOTAL_TOOL } from './prompt/prompts.js'; - - -const MAX_FILES_TOTAL = 1000; - - +import { URI } from '../../../base/common/uri.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { registerSingleton, InstantiationType } from '../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { IFileService, IFileStat } from '../../../platform/files/common/files.js'; +import { IWorkspaceContextService } from '../../../platform/workspace/common/workspace.js'; +import { ShallowDirectoryItem, ToolCallParams, ToolResultType } from './toolsServiceTypes.js'; +import { MAX_CHILDREN_URIs_PAGE, MAX_DIRSTR_CHARS_TOTAL_BEGINNING, MAX_DIRSTR_CHARS_TOTAL_TOOL } from './prompt/constants.js'; + +const MAX_FILES_TOTAL = 1_000; const START_MAX_DEPTH = Infinity; -const START_MAX_ITEMS_PER_DIR = Infinity; // Add start value as Infinity - +const START_MAX_ITEMS_PER_DIR = Infinity; const DEFAULT_MAX_DEPTH = 3; const DEFAULT_MAX_ITEMS_PER_DIR = 3; @@ -33,9 +29,6 @@ export interface IDirectoryStrService { } export const IDirectoryStrService = createDecorator('voidDirectoryStrService'); - - - // Check if it's a known filtered type like .git const shouldExcludeDirectory = (name: string) => { if (name === '.git' || @@ -70,13 +63,11 @@ const shouldExcludeDirectory = (name: string) => { return false; } -// ---------- ONE LAYER DEEP ---------- - export const computeDirectoryTree1Deep = async ( fileService: IFileService, rootURI: URI, pageNumber: number = 1, -): Promise => { +): Promise => { const stat = await fileService.resolve(rootURI, { resolveMetadata: false }); if (!stat.isDirectory) { return { children: null, hasNextPage: false, hasPrevPage: false, itemsRemaining: 0 }; @@ -107,7 +98,7 @@ export const computeDirectoryTree1Deep = async ( }; }; -export const stringifyDirectoryTree1Deep = (params: BuiltinToolCallParams['ls_dir'], result: BuiltinToolResultType['ls_dir']): string => { +export const stringifyDirectoryTree1Deep = (params: ToolCallParams['ls_dir'], result: ToolResultType['ls_dir']): string => { if (!result.children) { return `Error: ${params.uri} is not a directory`; } @@ -130,13 +121,9 @@ export const stringifyDirectoryTree1Deep = (params: BuiltinToolCallParams['ls_di if (result.hasNextPage) { output += `└── (${result.itemsRemaining} results remaining...)\n`; } - return output; }; - -// ---------- IN GENERAL ---------- - const resolveChildren = async (children: undefined | IFileStat[], fileService: IFileService): Promise => { const res = await fileService.resolveAll(children ?? []) const stats = res.map(s => s.success ? s.stat : null).filter(s => !!s) @@ -183,6 +170,7 @@ const computeAndStringifyDirectoryTree = async ( let content = nodeLine; let wasCutOff = false; + void wasCutOff; let remainingChars = MAX_CHARS - nodeLine.length; // Check if it's a directory we should skip @@ -316,9 +304,6 @@ const renderChildrenCombined = async ( return { childrenContent, childrenCutOff }; }; - -// ------------------------- FOLDERS ------------------------- - export async function getAllUrisInDirectory( directoryUri: URI, maxResults: number, @@ -376,11 +361,6 @@ export async function getAllUrisInDirectory( return result; } - - -// -------------------------------------------------- - - class DirectoryStrService extends Disposable implements IDirectoryStrService { _serviceBrand: undefined; @@ -411,7 +391,7 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService { ); // If cut off, try again with DEFAULT_MAX_DEPTH and DEFAULT_MAX_ITEMS_PER_DIR - let content, wasCutOff; + let content; if (initialCutOff) { const result = await computeAndStringifyDirectoryTree( eRoot, @@ -421,15 +401,12 @@ class DirectoryStrService extends Disposable implements IDirectoryStrService { { maxDepth: DEFAULT_MAX_DEPTH, currentDepth: 0, maxItemsPerDir: DEFAULT_MAX_ITEMS_PER_DIR } ); content = result.content; - wasCutOff = result.wasCutOff; } else { content = initialContent; - wasCutOff = initialCutOff; } - let c = content.substring(0, MAX_DIRSTR_CHARS_TOTAL_TOOL) + let c = content // return full content (truncation happens in chatThreadService) c = `Directory of ${uri.fsPath}:\n${content}` - if (wasCutOff) c = `${c}\n...Result was truncated...` return c } diff --git a/src/vs/platform/void/common/dynamicModelService.ts b/src/vs/platform/void/common/dynamicModelService.ts new file mode 100644 index 00000000000..3e0aba9e602 --- /dev/null +++ b/src/vs/platform/void/common/dynamicModelService.ts @@ -0,0 +1,389 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ +import { ProviderName } from './voidSettingsTypes.js'; +import { ILogService } from '../../log/common/log.js'; +import { + VoidStaticModelInfo, ModelOverrides, + inferCapabilitiesFromOpenRouterModel, + OpenRouterModel, + ModelApiConfig +} from './modelInference.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { IRemoteModelsService } from './remoteModelsService.js'; + +export interface IDynamicModelService { + readonly _serviceBrand: undefined; + initialize(): Promise; + getDynamicCapabilities(modelName: string): VoidStaticModelInfo | null; + getAllDynamicCapabilities(): Record; + getSupportedParameters(modelId: string): string[] | null; + getDefaultParameters(modelId: string): Record | null; + getModelCapabilitiesWithFallback( + providerName: ProviderName, + modelName: string, + overridesOfModel?: ModelOverrides | undefined + ): Promise; +} + +export const IDynamicModelService = createDecorator('dynamicModelService'); + +type ModelsCache = { ts: number; data: OpenRouterModel[] }; +const MODELS_CACHE_KEY = 'void.openrouter.models.cache.v1'; +const MODELS_TTL_MS = 24 * 60 * 60 * 1000; + +function isModelsResponse(x: unknown): x is { data: OpenRouterModel[] } { + return !!x && typeof x === 'object' && Array.isArray((x as any).data); +} + +export class DynamicModelService implements IDynamicModelService { + declare readonly _serviceBrand: undefined; + + private dynamicCapabilities: Map = new Map(); + private supportedParams: Map = new Map(); + private defaultParams: Map> = new Map(); + // Alias indices to resolve models not referenced by OpenRouter id + private aliasByCanonical: Map = new Map(); // canonical_slug -> id + private aliasByHF: Map = new Map(); // hugging_face_id -> id + private isInitialized = false; + + constructor( + @IRemoteModelsService private readonly remoteModelsService: IRemoteModelsService, + @ILogService private readonly logService: ILogService + ) { } + + async initialize(): Promise { + if (this.isInitialized) return; + + this.safeDebug('[DynamicModelService] initialize() start'); + + try { + const cache = this.readCache(); + const now = Date.now(); + const cacheFresh = cache && (now - cache.ts) < MODELS_TTL_MS; + + if (cacheFresh) { + this.safeDebug( + '[DynamicModelService] cache hit (age=%dms, models=%d)', + now - cache!.ts, + cache!.data.length + ); + + this.setFromModels(cache!.data); + this.isInitialized = true; + return; + } + + if (cache) { + this.safeDebug( + '[DynamicModelService] cache stale (age=%dms, models=%d) -> refreshing', + now - cache.ts, + cache.data.length + ); + } else { + this.safeDebug('[DynamicModelService] cache miss -> fetching OpenRouter models'); + } + + const models = await this.fetchOpenRouterModels(); + + this.safeDebug( + '[DynamicModelService] fetched OpenRouter models: %d; sample: %s', + models.data.length, + this.summarizeModelIds(models.data, 30) + ); + + this.setFromModels(models.data); + this.writeCache({ ts: now, data: models.data }); + this.isInitialized = true; + + this.safeDebug('[DynamicModelService] initialize() done (models=%d)', models.data.length); + } catch (error) { + // Fallback to any cached data + const cache = this.readCache(); + if (cache) { + this.safeWarn( + '[DynamicModelService] initialize() failed, falling back to cache (models=%d)', + cache.data.length, + error + ); + this.setFromModels(cache.data); + this.isInitialized = true; + return; + } + + this.safeError('[DynamicModelService] Failed to initialize (no cache fallback)', error); + } + } + + + private safeDebug(message: string, ...args: any[]) { + try { this.logService.debug(message, ...args); } catch { /* ignore */ } + } + + private safeWarn(message: string, ...args: any[]) { + try { this.logService.warn(message, ...args); } catch { /* ignore */ } + } + + private safeError(message: string, ...args: any[]) { + try { this.logService.error(message, ...args); } catch { /* ignore */ } + } + + private summarizeModelIds(models: OpenRouterModel[], limit = 50): string { + const ids = models.map(m => m?.id).filter(Boolean) as string[]; + const head = ids.slice(0, limit).join(', '); + return ids.length > limit ? `${head} …(+${ids.length - limit})` : head; + } + + private setFromModels(models: OpenRouterModel[]) { + this.dynamicCapabilities.clear(); + this.supportedParams.clear(); + this.defaultParams.clear(); + this.aliasByCanonical.clear(); + this.aliasByHF.clear(); + for (const model of models) { + const capabilities = inferCapabilitiesFromOpenRouterModel(model); + const modelInfo: VoidStaticModelInfo = { + ...capabilities, + modelName: model.id, + recognizedModelName: model.canonical_slug, + isUnrecognizedModel: false, + _apiConfig: this.getApiConfigForModel(model) + } as VoidStaticModelInfo & { + modelName: string, + recognizedModelName: string, + isUnrecognizedModel: false, + _apiConfig: ModelApiConfig + }; + this.dynamicCapabilities.set(model.id, modelInfo); + this.supportedParams.set(model.id, Array.isArray(model.supported_parameters) ? model.supported_parameters.slice() : []); + this.defaultParams.set(model.id, model.default_parameters && typeof model.default_parameters === 'object' ? { ...model.default_parameters } : {}); + + // Build alias indices + const norm = (s: string) => s.toLowerCase(); + if (model.canonical_slug) { + this.aliasByCanonical.set(norm(model.canonical_slug), model.id); + } + if (model.hugging_face_id) { + this.aliasByHF.set(norm(model.hugging_face_id), model.id); + } + } + } + + private async fetchOpenRouterModels(): Promise<{ data: OpenRouterModel[] }> { + const headers = { + 'HTTP-Referer': 'https://voideditor.com', + 'X-Title': 'Void', + 'Accept': 'application/json' + }; + + this.safeDebug('[DynamicModelService] GET https://openrouter.ai/api/v1/models'); + + // Prefer fetchModels; some tests/mocks rely on request() counting + let json: any; + + if (typeof (this.remoteModelsService as any).fetchModels === 'function') { + this.safeDebug('[DynamicModelService] using remoteModelsService.fetchModels()'); + json = await (this.remoteModelsService as any).fetchModels('https://openrouter.ai/api/v1/models', headers); + } else if (typeof (this.remoteModelsService as any).request === 'function') { + this.safeDebug('[DynamicModelService] using remoteModelsService.request() fallback path'); + + const res = await (this.remoteModelsService as any).request( + { url: 'https://openrouter.ai/api/v1/models', headers }, + undefined + ); + + if (res?.res?.statusCode && res.res.statusCode >= 400) { + throw new Error(`OpenRouter /models HTTP ${res.res.statusCode}`); + } + + const chunks: string[] = []; + await new Promise((resolve) => { + res.stream.on('data', (d: any) => chunks.push(String(d))); + res.stream.on('end', () => resolve()); + }); + + try { json = JSON.parse(chunks.join('')); } catch { json = null; } + } else { + throw new Error('IRemoteModelsService has neither fetchModels() nor request()'); + } + + if (!isModelsResponse(json)) { + this.safeWarn('[DynamicModelService] invalid response format from OpenRouter /models'); + throw new Error('Invalid response format from OpenRouter API'); + } + + this.safeDebug( + '[DynamicModelService] /models ok: %d models; sample: %s', + json.data.length, + this.summarizeModelIds(json.data, 25) + ); + + return json; + } + + private getApiConfigForModel(_model: OpenRouterModel): ModelApiConfig { + return { + apiStyle: 'openai-compatible', + supportsSystemMessage: 'developer-role', + specialToolFormat: 'openai-style', + endpoint: 'https://openrouter.ai/api/v1', + auth: { header: 'Authorization', format: 'Bearer' } + }; + } + + private resolveModelId(query: string): string | null { + if (!query) return null; + // 1) exact id + if (this.dynamicCapabilities.has(query)) return query; + const q = query.toLowerCase(); + // 2) alias by canonical_slug + const byCanon = this.aliasByCanonical.get(q); + if (byCanon && this.dynamicCapabilities.has(byCanon)) return byCanon; + // 3) alias by hugging_face_id + const byHF = this.aliasByHF.get(q); + if (byHF && this.dynamicCapabilities.has(byHF)) return byHF; + // 4) try short-id resolution: match any known id whose suffix after '/' equals query + // (common for local/custom providers storing short model names) + for (const id of this.dynamicCapabilities.keys()) { + const i = id.indexOf('/'); + if (i > 0 && id.slice(i + 1).toLowerCase() === q) return id; + } + // 5) try normalized exact id + const normalized = q; + if (this.dynamicCapabilities.has(normalized)) return normalized; + return null; + } + + getDynamicCapabilities(modelName: string): VoidStaticModelInfo | null { + const resolved = this.resolveModelId(modelName); + if (resolved) return this.dynamicCapabilities.get(resolved) || null; + return null; + } + + getAllDynamicCapabilities(): Record { + const result: Record = {}; + for (const [name, capabilities] of this.dynamicCapabilities) { + result[name] = capabilities; + } + return result; + } + + getSupportedParameters(modelId: string): string[] | null { + const resolved = this.resolveModelId(modelId) || modelId; + return this.supportedParams.get(resolved) || null; + } + + getDefaultParameters(modelId: string): Record | null { + const resolved = this.resolveModelId(modelId) || modelId; + return this.defaultParams.get(resolved) || null; + } + + async getModelCapabilitiesWithFallback( + providerName: ProviderName, + modelName: string, + overridesOfModel?: ModelOverrides | undefined + ): Promise< + VoidStaticModelInfo & + ( + | { modelName: string; recognizedModelName: string; isUnrecognizedModel: false } + | { modelName: string; recognizedModelName?: undefined; isUnrecognizedModel: true } + ) + > { + + const dynamicCapabilities = this.getDynamicCapabilities(modelName); + if (dynamicCapabilities) { + // providerName — case-insensitive + let providerOverridesAny: any | undefined; + if (overridesOfModel) { + const providerLower = String(providerName).toLowerCase(); + for (const key of Object.keys(overridesOfModel as any)) { + if (key.toLowerCase() === providerLower) { + providerOverridesAny = (overridesOfModel as any)[key]; + break; + } + } + } + const modelOverrides = providerOverridesAny ? providerOverridesAny[modelName] : undefined; + + return { + ...dynamicCapabilities, + ...(modelOverrides || {}), + modelName, + recognizedModelName: modelName, + isUnrecognizedModel: false + }; + } + + // 2. TODO: fallback static capabillity + + + return { + modelName, + contextWindow: 4096, + reservedOutputTokenSpace: 4096, + cost: { input: 0, output: 0 }, + supportsSystemMessage: 'system-role', + specialToolFormat: 'openai-style', + supportsFIM: false, + supportCacheControl: false, + reasoningCapabilities: false, + isUnrecognizedModel: true + }; + } + + + private getStorage(): { getItem(key: string): string | null; setItem(key: string, value: string): void } | null { + try { + const g: any = globalThis as any; + + if (g.__voidDynamicModelStorage__) { + return g.__voidDynamicModelStorage__; + } + if (typeof g.localStorage === 'undefined') { + return null; + } + const storage = g.localStorage; + if (!storage || typeof storage.getItem !== 'function' || typeof storage.setItem !== 'function') { + return null; + } + return storage; + } catch { + return null; + } + } + + private readCache(): ModelsCache | null { + try { + const storage = this.getStorage(); + if (!storage) { + return null; + } + const raw = storage.getItem(MODELS_CACHE_KEY); + if (!raw) return null; + const obj = JSON.parse(raw) as ModelsCache; + if (!obj || typeof obj.ts !== 'number' || !Array.isArray(obj.data)) return null; + return obj; + } catch { + return null; + } + } + + private writeCache(v: ModelsCache) { + try { + const storage = this.getStorage(); + if (!storage) { + return; + } + storage.setItem(MODELS_CACHE_KEY, JSON.stringify(v)); + } catch { + // ignore + } + } +} + +registerSingleton(IDynamicModelService, DynamicModelService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/void/common/editCodeServiceTypes.ts b/src/vs/platform/void/common/editCodeServiceTypes.ts similarity index 92% rename from src/vs/workbench/contrib/void/common/editCodeServiceTypes.ts rename to src/vs/platform/void/common/editCodeServiceTypes.ts index 4aa09de332e..1dd993bc898 100644 --- a/src/vs/workbench/contrib/void/common/editCodeServiceTypes.ts +++ b/src/vs/platform/void/common/editCodeServiceTypes.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { URI } from '../../../../base/common/uri.js'; +import { URI } from '../../../base/common/uri.js'; export type ComputedDiff = { type: 'edit'; @@ -37,12 +37,9 @@ export type CommonZoneProps = { diffareaid: number; startLine: number; endLine: number; - _URI: URI; // typically we get the URI from model - } - export type CtrlKZone = { type: 'CtrlKZone'; originalCode?: undefined; @@ -94,7 +91,8 @@ export type DiffZone = { }; editorId?: undefined; linkedStreamingDiffZone?: undefined; - _removeStylesFns: Set // these don't remove diffs or this diffArea, only their styles + _removeStylesFns: Set; // these don't remove diffs or this diffArea, only their styles + applyBoxId?: string; // Optional applyBoxId to associate with the diff zone } & CommonZoneProps @@ -108,8 +106,6 @@ export const diffAreaSnapshotKeys = [ ] as const satisfies (keyof DiffArea)[] - - export type DiffAreaSnapshotEntry = Pick export type VoidFileSnapshot = { diff --git a/src/vs/platform/void/common/helpers/colors.ts b/src/vs/platform/void/common/helpers/colors.ts new file mode 100644 index 00000000000..8e25a98b4c8 --- /dev/null +++ b/src/vs/platform/void/common/helpers/colors.ts @@ -0,0 +1,49 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { Color, RGBA } from '../../../../base/common/color.js'; +import { registerColor } from '../../../../platform/theme/common/colorUtils.js'; + +// editCodeService colors +const sweepBG = new Color(new RGBA(100, 100, 100, .2)); +const highlightBG = new Color(new RGBA(100, 100, 100, .1)); +const sweepIdxBG = new Color(new RGBA(100, 100, 100, .5)); + +const acceptBGDark = new Color(new RGBA(155, 185, 85, .14)); +const acceptBGLight = new Color(new RGBA(46, 160, 67, .2)); +const acceptBorderDark = new Color(new RGBA(155, 185, 85, .8)); +const acceptBorderLight = new Color(new RGBA(46, 160, 67, .9)); + +const rejectBGDark = new Color(new RGBA(255, 70, 70, .14)); +const rejectBGLight = new Color(new RGBA(220, 38, 38, .2)); +const rejectBorderDark = new Color(new RGBA(255, 120, 120, .8)); +const rejectBorderLight = new Color(new RGBA(220, 38, 38, .9)); + +// Widget colors +export const acceptAllBg = 'rgb(30, 133, 56)' +export const acceptBg = 'rgb(26, 116, 48)' +export const acceptBorder = '1px solid rgb(20, 86, 38)' + +export const rejectAllBg = 'rgb(207, 40, 56)' +export const rejectBg = 'rgb(180, 35, 49)' +export const rejectBorder = '1px solid rgb(142, 28, 39)' + +export const buttonFontSize = '11px' +export const buttonTextColor = 'white' + + + +const configOfTheme = ({ dark, light }: { dark: Color; light: Color }) => { + return { dark, light, hcDark: dark, hcLight: light, } +} + +// gets converted to --vscode-void-greenBG, see void.css, asCssVariable +registerColor('void.greenBG', configOfTheme({ dark: acceptBGDark, light: acceptBGLight }), '', true); +registerColor('void.redBG', configOfTheme({ dark: rejectBGDark, light: rejectBGLight }), '', true); +registerColor('void.greenBorder', configOfTheme({ dark: acceptBorderDark, light: acceptBorderLight }), '', true); +registerColor('void.redBorder', configOfTheme({ dark: rejectBorderDark, light: rejectBorderLight }), '', true); +registerColor('void.sweepBG', configOfTheme({ dark: sweepBG, light: sweepBG }), '', true); +registerColor('void.highlightBG', configOfTheme({ dark: highlightBG, light: highlightBG }), '', true); +registerColor('void.sweepIdxBG', configOfTheme({ dark: sweepIdxBG, light: sweepIdxBG }), '', true); diff --git a/src/vs/platform/void/common/helpers/extractCodeFromResult.ts b/src/vs/platform/void/common/helpers/extractCodeFromResult.ts new file mode 100644 index 00000000000..9552d61869c --- /dev/null +++ b/src/vs/platform/void/common/helpers/extractCodeFromResult.ts @@ -0,0 +1,172 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +export class SurroundingsRemover { + readonly originalS: string + i: number + j: number + + // string is s[i...j] + + constructor(s: string) { + this.originalS = s + this.i = 0 + this.j = s.length - 1 + } + value() { + return this.originalS.substring(this.i, this.j + 1) + } + + // returns whether it removed the whole prefix + removePrefix = (prefix: string): boolean => { + let offset = 0 + // console.log('prefix', prefix, Math.min(this.j, prefix.length - 1)) + while (this.i <= this.j && offset <= prefix.length - 1) { + if (this.originalS.charAt(this.i) !== prefix.charAt(offset)) + break + offset += 1 + this.i += 1 + } + return offset === prefix.length + } + + // // removes suffix from right to left + removeSuffix = (suffix: string): boolean => { + // e.g. suffix =
, the string is 
hi

= 1; len -= 1) { + if (s.endsWith(suffix.substring(0, len))) { // the end of the string equals a prefix + this.j -= len + return len === suffix.length + } + } + return false + } + // removeSuffix = (suffix: string): boolean => { + // let offset = 0 + + // while (this.j >= Math.max(this.i, 0)) { + // if (this.originalS.charAt(this.j) !== suffix.charAt(suffix.length - 1 - offset)) + // break + // offset += 1 + // this.j -= 1 + // } + // return offset === suffix.length + // } + + // either removes all or nothing + removeFromStartUntilFullMatch = (until: string, alsoRemoveUntilStr: boolean) => { + const index = this.originalS.indexOf(until, this.i) + + if (index === -1) { + // this.i = this.j + 1 + return false + } + // console.log('index', index, until.length) + + if (alsoRemoveUntilStr) + this.i = index + until.length + else + this.i = index + + return true + } + + + removeCodeBlock = () => { + // Match either: + // 1. ```language\n\n```\n? + // 2. ```\n```\n? + + const pm = this + const foundCodeBlock = pm.removePrefix('```') + if (!foundCodeBlock) return false + + pm.removeFromStartUntilFullMatch('\n', true) // language + + const j = pm.j + let foundCodeBlockEnd = pm.removeSuffix('```') + + if (pm.j === j) foundCodeBlockEnd = pm.removeSuffix('```\n') // if no change, try again with \n after ``` + + if (!foundCodeBlockEnd) return false + + pm.removeSuffix('\n') // remove the newline before ``` + return true + } + + + deltaInfo = (recentlyAddedTextLen: number) => { + // aaaaaatextaaaaaa{recentlyAdded} + // ^ i j len + // | + // recentyAddedIdx + const recentlyAddedIdx = this.originalS.length - recentlyAddedTextLen + const actualDelta = this.originalS.substring(Math.max(this.i, recentlyAddedIdx), this.j + 1) + const ignoredSuffix = this.originalS.substring(Math.max(this.j + 1, recentlyAddedIdx), Infinity) + return [actualDelta, ignoredSuffix] as const + } +} + + + +export const extractCodeFromRegular = ({ text, recentlyAddedTextLen }: { text: string, recentlyAddedTextLen: number }): [string, string, string] => { + + const pm = new SurroundingsRemover(text) + + pm.removeCodeBlock() + + const s = pm.value() + const [delta, ignoredSuffix] = pm.deltaInfo(recentlyAddedTextLen) + + return [s, delta, ignoredSuffix] +} + + +// Ollama has its own FIM, we should not use this if we use that +export const extractCodeFromFIM = ({ text, recentlyAddedTextLen, midTag, }: { text: string, recentlyAddedTextLen: number, midTag: string }): [string, string, string] => { + + /* ------------- summary of the regex ------------- + [optional ` | `` | ```] + (match optional_language_name) + [optional strings here] + [required tag] + (match the stuff between mid tags) + [optional tag] + [optional ` | `` | ```] + */ + + const pm = new SurroundingsRemover(text) + + pm.removeCodeBlock() + + const foundMid = pm.removePrefix(`<${midTag}>`) + + if (foundMid) { + pm.removeSuffix(`\n`) // sometimes outputs \n + pm.removeSuffix(``) + } + const s = pm.value() + const [delta, ignoredSuffix] = pm.deltaInfo(recentlyAddedTextLen) + + return [s, delta, ignoredSuffix] +} + +export type ExtractedSearchReplaceBlock = { + state: 'writingOriginal' | 'writingFinal' | 'done', + orig: string, + final: string, +} + + +export const endsWithAnyPrefixOf = (str: string, anyPrefix: string) => { + // for each prefix + for (let i = anyPrefix.length; i >= 1; i--) { // i >= 1 because must not be empty string + const prefix = anyPrefix.slice(0, i) + if (str.endsWith(prefix)) return prefix + } + return null +} diff --git a/src/vs/workbench/contrib/void/common/helpers/systemInfo.ts b/src/vs/platform/void/common/helpers/systemInfo.ts similarity index 63% rename from src/vs/workbench/contrib/void/common/helpers/systemInfo.ts rename to src/vs/platform/void/common/helpers/systemInfo.ts index 85b909b23b3..9b92f5b43c9 100644 --- a/src/vs/workbench/contrib/void/common/helpers/systemInfo.ts +++ b/src/vs/platform/void/common/helpers/systemInfo.ts @@ -3,12 +3,6 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { isLinux, isMacintosh, isWindows } from '../../../../../base/common/platform.js'; - -// import { OS, OperatingSystem } from '../../../../../base/common/platform.js'; -// alternatively could use ^ and OS === OperatingSystem.Windows ? ... - - - +import { isLinux, isMacintosh, isWindows } from '../../../../base/common/platform.js'; export const os = isWindows ? 'windows' : isMacintosh ? 'mac' : isLinux ? 'linux' : null diff --git a/src/vs/workbench/contrib/void/common/helpers/util.ts b/src/vs/platform/void/common/helpers/util.ts similarity index 60% rename from src/vs/workbench/contrib/void/common/helpers/util.ts rename to src/vs/platform/void/common/helpers/util.ts index b2309a3ff7f..f46f81e9d59 100644 --- a/src/vs/workbench/contrib/void/common/helpers/util.ts +++ b/src/vs/platform/void/common/helpers/util.ts @@ -1,4 +1,7 @@ - +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ export const separateOutFirstLine = (content: string): [string, string] | [string, undefined] => { const newLineIdx = content.indexOf('\r\n') if (newLineIdx !== -1) { diff --git a/src/vs/platform/void/common/jsonTypes.ts b/src/vs/platform/void/common/jsonTypes.ts new file mode 100644 index 00000000000..b7325642566 --- /dev/null +++ b/src/vs/platform/void/common/jsonTypes.ts @@ -0,0 +1,24 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; +export type JsonObject = { [key: string]: JsonValue | undefined }; + +export type ToolOutputInput = string | JsonValue; + +export function isJsonObject(v: unknown): v is JsonObject { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +export function toJsonObject(v: unknown): JsonObject { + return isJsonObject(v) ? v : {}; +} + +export function getStringField(o: JsonObject | null | undefined, key: string): string | undefined { + if (!o) return undefined; + const v = o[key]; + return typeof v === 'string' ? v : undefined; +} + +export function stringifyUnknown(e: unknown): string { + if (e instanceof Error) return e.message; + return String(e); +} diff --git a/src/vs/platform/void/common/loopGuard.ts b/src/vs/platform/void/common/loopGuard.ts new file mode 100644 index 00000000000..1a4378dc93a --- /dev/null +++ b/src/vs/platform/void/common/loopGuard.ts @@ -0,0 +1,178 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +// Shared lightweight heuristics to detect potential infinite loops in agent-style +// LLM orchestrations (Void chat + ACP). The goals are: +// - keep behaviour deterministic and cheap (no heavy NLP) +// - be conservative to avoid false positives +// - provide a single place to tune thresholds for both ACP / non-ACP flows. + +export const LOOP_DETECTED_MESSAGE = 'Loop detected, stop stream'; + +export type LoopDetectionReason = 'max_turns' | 'assistant_repeat' | 'tool_repeat'; + +export type LoopDetectionResult = + | { isLoop: false } + | { isLoop: true; reason: LoopDetectionReason; details?: string }; + +export interface LoopDetectorOptions { + /** Maximum number of assistant turns per single user prompt (LLM calls). */ + maxTurnsPerPrompt: number; + /** How many times the same assistant first-line prefix may repeat. */ + maxSameAssistantPrefix: number; + /** How many times the same tool(name+args) may be invoked in one prompt. */ + maxSameToolCall: number; + /** Prefix length (in chars) used for assistant repetition fingerprinting. */ + assistantPrefixLength: number; +} + +const DEFAULT_OPTIONS: LoopDetectorOptions = { + maxTurnsPerPrompt: 12, + maxSameAssistantPrefix: 3, + maxSameToolCall: 3, + assistantPrefixLength: 120, +}; + +export class LLMLoopDetector { + private readonly opts: LoopDetectorOptions; + private assistantTurns = 0; + private readonly assistantPrefixCounts = new Map(); + private readonly toolSignatureCounts = new Map(); + + constructor(options?: Partial) { + this.opts = { ...DEFAULT_OPTIONS, ...(options ?? {}) }; + } + + /** + * Register a completed assistant turn (one LLM response). Returns a loop + * signal if any of the assistant-based heuristics trigger. + */ + registerAssistantTurn(text: string | undefined | null): LoopDetectionResult { + this.assistantTurns++; + + // Hard cap on number of assistant responses per prompt. + if (this.assistantTurns > this.opts.maxTurnsPerPrompt) { + return { + isLoop: true, + reason: 'max_turns', + details: `assistantTurns=${this.assistantTurns} > maxTurnsPerPrompt=${this.opts.maxTurnsPerPrompt}`, + }; + } + + if (!text) { + return { isLoop: false }; + } + + const prefix = this._normalizedAssistantPrefix(text); + if (!prefix) { + return { isLoop: false }; + } + + const prev = this.assistantPrefixCounts.get(prefix) ?? 0; + const next = prev + 1; + this.assistantPrefixCounts.set(prefix, next); + + if (next > this.opts.maxSameAssistantPrefix) { + return { + isLoop: true, + reason: 'assistant_repeat', + details: `assistant first-line prefix repeated ${next} times`, + }; + } + + return { isLoop: false }; + } + + /** + * Register a tool call candidate (name+args). Called before actually + * executing the tool so we can short-circuit potentially useless loops. + */ + registerToolCall(name: string | undefined | null, args: unknown): LoopDetectionResult { + const n = (name ?? '').trim(); + if (!n) { + return { isLoop: false }; + } + + const sig = this._signatureForTool(n, args); + const prev = this.toolSignatureCounts.get(sig) ?? 0; + const next = prev + 1; + this.toolSignatureCounts.set(sig, next); + + if (next > this.opts.maxSameToolCall) { + return { + isLoop: true, + reason: 'tool_repeat', + details: `tool ${n} with same arguments called ${next} times`, + }; + } + + return { isLoop: false }; + } + + private _normalizedAssistantPrefix(text: string): string | null { + const trimmed = text.trim(); + if (!trimmed) return null; + + const firstLine = trimmed.split(/\r?\n/, 1)[0] ?? ''; + let normalized = firstLine + .toLowerCase() + .replace(/\s+/g, ' ') // collapse whitespace + .trim(); + + if (!normalized) return null; + + // Use only the first couple of words as the canonical "prefix" so that + // small trailing variations like "Repeat me again" vs "Repeat me" still + // map to the same fingerprint. + const words = normalized.split(' '); + const maxWords = 2; + normalized = words.slice(0, maxWords).join(' '); + + // Still cap by assistantPrefixLength to avoid overly long keys. + normalized = normalized.slice(0, this.opts.assistantPrefixLength).trim(); + + return normalized || null; + } + + private _signatureForTool(name: string, args: unknown): string { + const n = name.trim().toLowerCase(); + const argsJson = this._stableStringify(args); + return `${n}::${argsJson}`; + } + + private _stableStringify(value: any): string { + const seen = new Set(); + + const helper = (v: any): any => { + if (v === null || typeof v !== 'object') { + return v; + } + if (seen.has(v)) { + return '[Circular]'; + } + seen.add(v); + + if (Array.isArray(v)) { + return v.map(helper); + } + + const out: any = {}; + for (const key of Object.keys(v).sort()) { + out[key] = helper(v[key]); + } + return out; + }; + + try { + return JSON.stringify(helper(value)); + } catch { + try { + return JSON.stringify(String(value)); + } catch { + return '"[Unserializable]"'; + } + } + } +} diff --git a/src/vs/platform/void/common/mcpServiceTypes.ts b/src/vs/platform/void/common/mcpServiceTypes.ts new file mode 100644 index 00000000000..28ab8a2991d --- /dev/null +++ b/src/vs/platform/void/common/mcpServiceTypes.ts @@ -0,0 +1,153 @@ + +export interface MCPTool { + /** Unique tool identifier */ + name: string; + /** Human‑readable description */ + description?: string; + /** JSON schema describing expected arguments */ + inputSchema?: Record; + /** Free‑form annotations describing behaviour, security, etc. */ + annotations?: Record; +} + +export interface MCPConfigFileEntryJSON { + // Command-based server properties + command?: string; + args?: string[]; + env?: Record; + /** Optional list of MCP tool names to exclude for this server. */ + excludeTools?: string[]; + + // URL-based server properties + url?: URL; + headers?: Record; +} + +export interface MCPConfigFileJSON { + mcpServers: Record; +} + + +export type MCPServer = { + // Command-based server properties + tools: MCPTool[], + status: 'loading' | 'success' | 'offline', + command?: string, + error?: string, +} | { + tools?: undefined, + status: 'error', + command?: string, + error: string, +} + +export interface MCPServerOfName { + [serverName: string]: MCPServer; +} + +export type MCPServerEvent = { + name: string; + prevServer?: MCPServer; + newServer?: MCPServer; +} +export type MCPServerEventResponse = { response: MCPServerEvent } + +export interface MCPConfigFileParseErrorResponse { + response: { + type: 'config-file-error'; + error: string | null; + } +} + + +type MCPToolResponseType = 'text' | 'image' | 'audio' | 'resource' | 'error'; + +type ResponseImageTypes = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp' | 'image/svg+xml' | 'image/bmp' | 'image/tiff' | 'image/vnd.microsoft.icon'; + +interface ImageData { + data: string; + mimeType: ResponseImageTypes; +} + +interface MCPToolResponseBase { + toolName: string; + serverName?: string; + event: MCPToolResponseType; + text?: string; + image?: ImageData; +} + +type MCPToolResponseConstraints = { + 'text': { + image?: never; + text: string; + }; + 'error': { + image?: never; + text: string; + }; + 'image': { + text?: never; + image: ImageData; + }; + 'audio': { + text?: never; + image?: never; + }; + 'resource': { + text?: never; + image?: never; + } +} + +type MCPToolEventResponse = Omit & MCPToolResponseConstraints[T] & { event: T }; + +// Response types +export type MCPToolTextResponse = MCPToolEventResponse<'text'>; +export type MCPToolErrorResponse = MCPToolEventResponse<'error'>; +export type MCPToolImageResponse = MCPToolEventResponse<'image'>; +export type MCPToolAudioResponse = MCPToolEventResponse<'audio'>; +export type MCPToolResourceResponse = MCPToolEventResponse<'resource'>; +export type RawMCPToolCall = MCPToolTextResponse | MCPToolErrorResponse | MCPToolImageResponse | MCPToolAudioResponse | MCPToolResourceResponse; + +export interface MCPToolCallParams { + serverName: string; + toolName: string; + params: Record; +} + + + +const _sanitizeMcpToolPrefix = (serverName: string): string => { + // Avoid delimiter collisions and invalid chars in tool names + let s = (serverName ?? '').trim(); + + // Prevent "__" inside prefix (since it's our delimiter) + s = s.replace(/__+/g, '_'); + + // OpenAI-compatible function names are typically limited to [a-zA-Z0-9_-] + s = s.replace(/[^a-zA-Z0-9_-]/g, '_'); + + // Ensure it starts with a letter or underscore (more broadly compatible) + if (!/^[a-zA-Z_]/.test(s)) { + s = `mcp_${s}`; + } + + // Never return empty + return s || 'mcp'; +}; + +export const addMCPToolNamePrefix = (serverName: string, toolName: string) => { + // Format: "server_name__tool_name" + return `${_sanitizeMcpToolPrefix(serverName)}__${toolName}`; +}; + +export const removeMCPToolNamePrefix = (name: string) => { + // Remove server name prefix with __ separator + // Format: "server_name__tool_name" -> "tool_name" + const parts = name.split('__'); + if (parts.length > 1) { + return parts.slice(1).join('__'); + } + return name; +}; diff --git a/src/vs/workbench/contrib/void/common/metricsService.ts b/src/vs/platform/void/common/metricsService.ts similarity index 72% rename from src/vs/workbench/contrib/void/common/metricsService.ts rename to src/vs/platform/void/common/metricsService.ts index 853ca95534d..74248402d61 100644 --- a/src/vs/workbench/contrib/void/common/metricsService.ts +++ b/src/vs/platform/void/common/metricsService.ts @@ -3,24 +3,22 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { createDecorator, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; -import { localize2 } from '../../../../nls.js'; -import { registerAction2, Action2 } from '../../../../platform/actions/common/actions.js'; -import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { createDecorator, ServicesAccessor } from '../../instantiation/common/instantiation.js'; +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { registerSingleton, InstantiationType } from '../../instantiation/common/extensions.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; +import { localize2 } from '../../../nls.js'; +import { registerAction2, Action2 } from '../../actions/common/actions.js'; +import { INotificationService } from '../../notification/common/notification.js'; export interface IMetricsService { readonly _serviceBrand: undefined; capture(event: string, params: Record): void; - setOptOut(val: boolean): void; getDebuggingProperties(): Promise; } export const IMetricsService = createDecorator('metricsService'); - // implemented by calling channel export class MetricsService implements IMetricsService { @@ -39,11 +37,6 @@ export class MetricsService implements IMetricsService { this.metricsService.capture(...params); } - setOptOut(...params: Parameters) { - this.metricsService.setOptOut(...params); - } - - // anything transmitted over a channel must be async even if it looks like it doesn't have to be async getDebuggingProperties(): Promise { return this.metricsService.getDebuggingProperties() diff --git a/src/vs/platform/void/common/modelInference.ts b/src/vs/platform/void/common/modelInference.ts new file mode 100644 index 00000000000..b20a6c399a6 --- /dev/null +++ b/src/vs/platform/void/common/modelInference.ts @@ -0,0 +1,684 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { IDynamicModelService } from './dynamicModelService.js'; +import { specialToolFormat, supportsSystemMessage, ProviderName, OverridesOfModel, FeatureName, ModelSelectionOptions } from './voidSettingsTypes.js'; + +export type VoidStaticModelInfo = { + contextWindow: number; + reservedOutputTokenSpace: number | null + + supportsSystemMessage: supportsSystemMessage; + specialToolFormat?: specialToolFormat; + supportsFIM: boolean; + fimTransport?: 'openai-compatible' | 'mistral-native' | 'ollama-native' | 'emulated'; + // Whether this model is allowed to use provider-specific prompt caching via + // cache_control breakpoints (Anthropic / OpenRouter / Gemini-on-OpenRouter, etc.). + // Default is false and can be opted into per model via overrides. + supportCacheControl?: boolean; + // Input modalities supported by the model (e.g. ["text", "image", "audio"]) as reported by OpenRouter/underlying provider + inputModalities?: string[]; + reasoningCapabilities: false | { + readonly supportsReasoning: true; + readonly canTurnOffReasoning: boolean; + readonly canIOReasoning: boolean; + readonly reasoningReservedOutputTokenSpace?: number; + readonly reasoningSlider?: + | undefined + | { type: 'budget_slider'; min: number; max: number; default: number } + | { type: 'effort_slider'; values: string[]; default: string } + readonly openSourceThinkTags?: [string, string]; + readonly hideEncryptedReasoning?: boolean; + }; + + cost: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + } +} + +export type ModelOverrides = Pick + +let __dynamicModelService: IDynamicModelService | null = null; + +export const setDynamicModelService = (svc: IDynamicModelService) => { + __dynamicModelService = svc; +}; + +const defaultModelOptions = { + contextWindow: 1000_000, + reservedOutputTokenSpace: 4_096, + cost: { input: 0, output: 0 }, + supportsSystemMessage: 'system-role', + supportsFIM: false, + supportCacheControl: false, + reasoningCapabilities: false, +} as const satisfies VoidStaticModelInfo; + +export const getModelCapabilities = ( + providerName: ProviderName, + modelName: string, + overridesOfModel: OverridesOfModel | undefined +): VoidStaticModelInfo & ( + | { modelName: string; recognizedModelName: string; isUnrecognizedModel: false } + | { modelName: string; recognizedModelName?: undefined; isUnrecognizedModel: true } +) => { + + const findOverrides = (overrides: OverridesOfModel | undefined, prov: ProviderName, model: string): Partial | undefined => { + if (!overrides) return undefined; + const provKey = Object.keys(overrides).find(k => k.toLowerCase() === String(prov).toLowerCase()); + if (!provKey) return undefined; + const byModel = (overrides as any)[provKey] as Record | undefined>; + let o = byModel?.[model]; + if (o === undefined && model.includes('/')) { + const afterSlash = model.slice(model.indexOf('/') + 1); + o = byModel?.[afterSlash]; + } + return o; + }; + + try { + const dynamicCaps = __dynamicModelService?.getDynamicCapabilities(modelName); + if (dynamicCaps) { + const overrides = findOverrides(overridesOfModel, providerName, modelName); + const merged: any = { + ...dynamicCaps, + ...(overrides || {}), + modelName, + recognizedModelName: modelName, + isUnrecognizedModel: false, + }; + + + + const rc = merged.reasoningCapabilities; + if (rc && typeof rc === 'object' && (rc as any).hideEncryptedReasoning === undefined) { + merged.reasoningCapabilities = { ...(rc as any), hideEncryptedReasoning: true }; + } + + return merged as VoidStaticModelInfo & { modelName: string; recognizedModelName: string; isUnrecognizedModel: false }; + } + } catch (error) { + console.warn('[getModelCapabilities] Dynamic lookup failed:', error); + } + + + const overrides = findOverrides(overridesOfModel, providerName, modelName); + const base: any = { + ...defaultModelOptions, + ...(overrides || {}), + modelName, + isUnrecognizedModel: true, + }; + + const rc = base.reasoningCapabilities; + if (rc && typeof rc === 'object' && (rc as any).hideEncryptedReasoning === undefined) { + base.reasoningCapabilities = { ...(rc as any), hideEncryptedReasoning: true }; + } + + return base as VoidStaticModelInfo & { modelName: string; isUnrecognizedModel: true }; +}; + +export const getReservedOutputTokenSpace = ( + providerName: ProviderName, + modelName: string, + opts: { isReasoningEnabled: boolean, overridesOfModel: OverridesOfModel | undefined } +) => { + const capabilities = getModelCapabilities(providerName, modelName, opts.overridesOfModel); + const { + reasoningCapabilities, + reservedOutputTokenSpace, + } = capabilities; + return opts.isReasoningEnabled && reasoningCapabilities ? reasoningCapabilities.reasoningReservedOutputTokenSpace : reservedOutputTokenSpace; +}; + +export const getIsReasoningEnabledState = ( + featureName: FeatureName, + providerName: string, + modelName: string, + modelSelectionOptions: ModelSelectionOptions | undefined, + overridesOfModel: OverridesOfModel | undefined, +) => { + const capabilities = getModelCapabilities(providerName as ProviderName, modelName, overridesOfModel); + const rc = capabilities.reasoningCapabilities as (false | { + supportsReasoning?: boolean; + canTurnOffReasoning?: boolean; + }); + + // No reasoning support at all + if (!rc || !rc || (typeof rc === 'object' && rc.supportsReasoning === false)) { + return false; + } + + // If the model cannot turn off reasoning, it must always be enabled, + // regardless of any previously persisted user option. + if (typeof rc === 'object' && rc.canTurnOffReasoning === false) { + return true; + } + + // Otherwise (toggle allowed), respect the stored value if present, + // falling back to feature defaults (Chat => enabled by default). + const defaultEnabledVal = featureName === 'Chat'; + return modelSelectionOptions?.reasoningEnabled ?? defaultEnabledVal; +}; + +// Reasoning IO wiring per provider/api style +export type SendableReasoningInfo = + | { type: 'budget_slider_value'; isReasoningEnabled: true; reasoningBudget: number } + | { type: 'effort_slider_value'; isReasoningEnabled: true; reasoningEffort: string } + | { type: 'enabled_only'; isReasoningEnabled: true } + | null; + +type ProviderReasoningIOSettings = { + input?: { + includeInPayload?: (reasoningState: SendableReasoningInfo) => null | { [key: string]: any }; + }; + output?: + | { nameOfFieldInDelta?: string; needsManualParse?: undefined } + | { nameOfFieldInDelta?: undefined; needsManualParse?: true }; +}; + +export const getSendableReasoningInfo = ( + featureName: FeatureName, + providerName: ProviderName, + modelName: string, + modelSelectionOptions: ModelSelectionOptions | undefined, + overridesOfModel: OverridesOfModel | undefined +): SendableReasoningInfo => { + const capabilities = getModelCapabilities(providerName, modelName, overridesOfModel); + const reasoning = capabilities.reasoningCapabilities; + const isEnabled = getIsReasoningEnabledState( + featureName, + providerName, + modelName, + modelSelectionOptions, + overridesOfModel + ); + if (!isEnabled) return null; + + const budget = + typeof reasoning === 'object' && reasoning?.reasoningSlider?.type === 'budget_slider' + ? modelSelectionOptions?.reasoningBudget ?? reasoning.reasoningSlider.default + : undefined; + if (budget !== undefined) { + return { type: 'budget_slider_value', isReasoningEnabled: true, reasoningBudget: budget }; + } + + const effort = + typeof reasoning === 'object' && reasoning?.reasoningSlider?.type === 'effort_slider' + ? modelSelectionOptions?.reasoningEffort ?? reasoning.reasoningSlider.default + : undefined; + if (effort !== undefined) { + return { type: 'effort_slider_value', isReasoningEnabled: true, reasoningEffort: effort }; + } + + return { type: 'enabled_only', isReasoningEnabled: true }; +}; + +function toSlugFromProviderName(p: string): string { + const s = String(p).toLowerCase(); + if (s === 'openai') return 'openai'; + if (s === 'anthropic') return 'anthropic'; + if (s === 'gemini' || s === 'google') return 'google'; + if (s === 'google-vertex' || s === 'vertex' || s === 'googlevertex') return 'google-vertex'; + if (s === 'groq') return 'groq'; + if (s === 'mistral') return 'mistral'; + if (s === 'cohere') return 'cohere'; + if (s === 'zhipuai' || s === 'zhipu' || s === 'glm') return 'zhipuai'; + if (s === 'ollama') return 'ollama'; + if (s === 'lmstudio' || s === 'lm-studio') return 'lmstudio'; + if (s === 'vllm' || s === 'vllm-server') return 'vllm'; + if (s === 'openrouter' || s === 'open-router') return 'openrouter'; + return s; +} + +export function getProviderCapabilities( + providerName: ProviderName, + modelName?: string, + _overridesOfModel?: OverridesOfModel +): { providerReasoningIOSettings: ProviderReasoningIOSettings } { + // Prefer explicit provider slug from providerName; do not override with model prefix + const slug = toSlugFromProviderName(providerName); + + // Infer via API style where possible + let apiStyle: ModelApiConfig['apiStyle'] = 'openai-compatible'; + try { + apiStyle = getModelApiConfiguration(modelName || '')?.apiStyle ?? 'openai-compatible'; + } catch { /* ignore */ } + + // Anthropic-style + if (apiStyle === 'anthropic-style' || slug === 'anthropic') { + return { + providerReasoningIOSettings: { + input: { + includeInPayload: (reasoning) => { + if (!reasoning) return null; + if (reasoning.type === 'budget_slider_value') { + return { thinking: { type: 'enabled', budget_tokens: reasoning.reasoningBudget } }; + } + return null; + } + } + } + }; + } + + // Gemini-style handled natively in sendGeminiChat; no special IO hints + if (apiStyle === 'gemini-style' || slug === 'google' || slug === 'google-vertex') { + return { providerReasoningIOSettings: {} }; + } + + // OpenRouter specifics (OpenAI-compatible transport with reasoning field) + if (slug === 'openrouter') { + return { + providerReasoningIOSettings: { + input: { + includeInPayload: (reasoning) => { + if (!reasoning) return null; + if (reasoning.type === 'budget_slider_value') { + return { reasoning: { max_tokens: reasoning.reasoningBudget } }; + } + if (reasoning.type === 'effort_slider_value') { + return { reasoning: { effort: reasoning.reasoningEffort } }; + } + return { reasoning: { enabled: true } }; + } + }, + output: { nameOfFieldInDelta: 'reasoning' } + } + }; + } + + // Groq reasoning + if (slug === 'groq') { + return { + providerReasoningIOSettings: { + input: { + includeInPayload: (reasoning) => (reasoning ? { reasoning_format: 'parsed' } : null) + }, + output: { nameOfFieldInDelta: 'reasoning' } + } + }; + } + + // vLLM server exposes reasoning_content + if (slug === 'vllm') { + return { providerReasoningIOSettings: { output: { nameOfFieldInDelta: 'reasoning_content' } } }; + } + + // Zhipu GLM-4.5 style + if (slug === 'zhipuai') { + return { + providerReasoningIOSettings: { + input: { + includeInPayload: (reasoning) => { + if (reasoning && reasoning.type === 'effort_slider_value') { + return { reasoning: { effort: reasoning.reasoningEffort } }; + } + return null; + } + }, + output: { nameOfFieldInDelta: 'reasoning_content' } + } + }; + } + + // Local runtimes (ollama, lmstudio) may render reasoning in text; ask wrapper to manually parse think tags + if (slug === 'ollama' || slug === 'lmstudio') { + return { providerReasoningIOSettings: { output: { needsManualParse: true } } }; + } + + // Default: no special handling + return { providerReasoningIOSettings: {} }; +} + + +export interface OpenRouterProvider { + name: string; + slug: string; + privacy_policy_url?: string; + terms_of_service_url?: string; + status_page_url?: string; +} + +export interface OpenRouterModel { + id: string; + canonical_slug: string; + hugging_face_id?: string; + name: string; + created: number; + description?: string; + context_length?: number; + architecture: { + modality: string; + input_modalities: string[]; + output_modalities: string[]; + tokenizer: string; + instruct_type?: string; + }; + pricing: { + prompt: string; + completion: string; + request?: string; + image?: string; + audio?: string; + web_search?: string; + internal_reasoning?: string; + input_cache_read?: string; + input_cache_write?: string; + }; + top_provider: { + context_length?: number; + max_completion_tokens?: number; + is_moderated: boolean; + }; + supported_parameters: string[]; + default_parameters?: Record; +} + +export interface ModelApiConfig { + apiStyle: 'openai-compatible' | 'anthropic-style' | 'gemini-style' | 'disabled'; + supportsSystemMessage: supportsSystemMessage; + specialToolFormat: specialToolFormat; + endpoint: string; + auth: { + header: string; + format: 'Bearer' | 'direct'; + }; +} + + +type ProviderDefaults = { + baseEndpoint?: string; + apiStyle: 'openai-compatible' | 'anthropic-style' | 'gemini-style'; + supportsSystemMessage: supportsSystemMessage; +}; + +export const WELL_KNOWN_PROVIDER_DEFAULTS: Record = { + openai: { + baseEndpoint: 'https://api.openai.com/v1', + apiStyle: 'openai-compatible', + supportsSystemMessage: 'developer-role' + }, + anthropic: { + baseEndpoint: 'https://api.anthropic.com/v1', + apiStyle: 'anthropic-style', + supportsSystemMessage: 'separated' + }, + google: { + baseEndpoint: 'https://generativelanguage.googleapis.com/v1', + apiStyle: 'gemini-style', + supportsSystemMessage: 'separated' + }, + 'google-vertex': { + baseEndpoint: 'https://generativelanguage.googleapis.com/v1', + apiStyle: 'gemini-style', + supportsSystemMessage: 'separated' + }, + mistral: { + baseEndpoint: 'https://api.mistral.ai/v1', + apiStyle: 'openai-compatible', + supportsSystemMessage: 'system-role' + }, + groq: { + baseEndpoint: 'https://api.groq.com/openai/v1', + apiStyle: 'openai-compatible', + supportsSystemMessage: 'system-role' + }, + cohere: { + baseEndpoint: 'https://api.cohere.ai/v1', + apiStyle: 'openai-compatible', + supportsSystemMessage: 'system-role' + }, + deepseek: { + baseEndpoint: 'https://api.deepseek.com/v1', + apiStyle: 'openai-compatible', + supportsSystemMessage: 'system-role' + }, + minimax: { + baseEndpoint: 'https://api.minimax.io/v1', + apiStyle: 'openai-compatible', + supportsSystemMessage: 'system-role' + }, + _default: { + apiStyle: 'openai-compatible', + supportsSystemMessage: 'system-role' + } +}; + + +export type ProviderConfigResolver = (providerSlug: string, modelId?: string) => Partial | null; +export type UserModelApiConfigGetter = (modelId: string) => ModelApiConfig | null; + +let _providerResolver: ProviderConfigResolver | null = null; +let _userModelGetter: UserModelApiConfigGetter | null = null; + +export function registerProviderConfigResolver(resolver: ProviderConfigResolver | null) { + _providerResolver = resolver; +} + +export function registerUserModelApiConfigGetter(getter: UserModelApiConfigGetter | null) { + _userModelGetter = getter; +} + + +export function __dangerouslyResetApiResolversForTests() { + _providerResolver = null; + _userModelGetter = null; +} + + +export function getProviderSlug(modelId: string): string { + const parts = modelId.split('/'); + const result = parts.length > 1 ? parts[0] : '_unknown'; + return result; +} + +function apiStyleToToolFormat(style: ModelApiConfig['apiStyle']): ModelApiConfig['specialToolFormat'] { + if (style === 'anthropic-style') return 'anthropic-style'; + if (style === 'gemini-style') return 'gemini-style'; + return 'openai-style'; +} + + +export function getModelApiConfiguration(modelId: string): ModelApiConfig { + + + if (_userModelGetter) { + const userCfg = _userModelGetter(modelId); + if (userCfg) { + return userCfg; + } + } + + const providerSlug = getProviderSlug(modelId); + + + if (_providerResolver) { + const p = _providerResolver(providerSlug, modelId); + if (p) { + const apiStyle = p.apiStyle ?? 'openai-compatible'; + const supportsSystemMessage = + p.supportsSystemMessage ?? + (apiStyle === 'anthropic-style' || apiStyle === 'gemini-style' ? 'separated' : 'system-role'); + + const result = { + apiStyle, + supportsSystemMessage, + specialToolFormat: p.specialToolFormat ?? apiStyleToToolFormat(apiStyle), + endpoint: p.endpoint ?? 'https://openrouter.ai/api/v1', + auth: p.auth ?? { header: 'Authorization', format: 'Bearer' } + }; + return result; + } + } + + + const known = WELL_KNOWN_PROVIDER_DEFAULTS[providerSlug] || WELL_KNOWN_PROVIDER_DEFAULTS._default; + const apiStyle = known.apiStyle; + const result: ModelApiConfig = { + apiStyle, + supportsSystemMessage: known.supportsSystemMessage, + specialToolFormat: apiStyleToToolFormat(apiStyle), + endpoint: known.baseEndpoint || 'https://openrouter.ai/api/v1', + auth: { header: 'Authorization', format: 'Bearer' } + }; + return result; +} + + +export function inferCapabilitiesFromOpenRouterModel(model: OpenRouterModel): Partial { + const params = model.supported_parameters || []; + + const capabilities: Partial = { + contextWindow: model.context_length || 4096, + reservedOutputTokenSpace: model.top_provider?.max_completion_tokens || 4096, + cost: { + input: parseFloat(model.pricing?.prompt) || 0, + output: parseFloat(model.pricing?.completion) || 0 + } + }; + + // System message support depends on tool support; when tools unsupported, set false + const apiConfig = getModelApiConfiguration(model.id); + const hasTools = params.includes('tools') && params.includes('tool_choice'); + capabilities.supportsSystemMessage = hasTools ? apiConfig.supportsSystemMessage : false; + + + if (hasTools) { + capabilities.specialToolFormat = apiConfig.specialToolFormat; + } else { + capabilities.specialToolFormat = 'disabled'; + } + + // Inference reasoning capabilities + capabilities.reasoningCapabilities = inferReasoningCapabilities(params, model); + + // Input modalities (text, image, audio, etc.) + if (Array.isArray(model.architecture?.input_modalities) && model.architecture.input_modalities.length > 0) { + capabilities.inputModalities = model.architecture.input_modalities.slice(); + } + + + const description = model.description?.toLowerCase() || ''; + if (description.includes('fill-in-middle') || + description.includes('autocomplete') || + model.architecture?.instruct_type === 'fim') { + capabilities.supportsFIM = true; + } else { + capabilities.supportsFIM = false; + } + + + if (capabilities.supportsFIM) { + if (model.id.includes('codellama') || model.id.includes('ollama')) { + capabilities.fimTransport = 'ollama-native'; + } else if (model.id.includes('mistral')) { + capabilities.fimTransport = 'mistral-native'; + } else { + capabilities.fimTransport = 'openai-compatible'; + } + } + + return capabilities; +} + + +export function inferReasoningCapabilities(params: string[], model: OpenRouterModel): false | any { + const hasParams = params.includes('reasoning') && params.includes('include_reasoning'); + if (!hasParams) return false; + + const modelName = model.name.toLowerCase(); + + // Anthropic/Claude patterns + if (modelName.includes('anthropic') || modelName.includes('claude')) { + return { + supportsReasoning: true, + canTurnOffReasoning: false, + canIOReasoning: true, + reasoningReservedOutputTokenSpace: model.top_provider?.max_completion_tokens || 8192, + reasoningSlider: { + type: 'budget_slider', + min: 1024, + max: 8192, + default: 1024 + } + }; + } + + + if (isThinkingOnlyModel(model)) { + return { + supportsReasoning: true, + canTurnOffReasoning: false, + canIOReasoning: true, + openSourceThinkTags: ['', ''] + }; + } + + // OpenAI-style reasoning models + if (modelName.includes('openai') || modelName.includes('gpt')) { + return { + supportsReasoning: true, + canTurnOffReasoning: true, + canIOReasoning: true, + reasoningSlider: { + type: 'effort_slider', + values: ['low', 'medium', 'high'], + default: 'low' + } + }; + } + + // Default reasoning capabilities + return { + supportsReasoning: true, + canTurnOffReasoning: true, + canIOReasoning: true, + reasoningSlider: { + type: 'effort_slider', + values: ['low', 'medium', 'high'], + default: 'low' + } + }; +} + + +function isThinkingOnlyModel(model: OpenRouterModel): boolean { + const searchText = [model.name, model.canonical_slug, model.description] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + const patterns = { + nonThinking: ['non-thinking', 'non thinking'], + thinkingOnly: ['thinking only', 'thinking-only', 'thinking', '', 'code reasoning'] + }; + + + if (patterns.nonThinking.some(pattern => searchText.includes(pattern))) { + return false; + } + + + + return patterns.thinkingOnly.some(pattern => searchText.includes(pattern)); +} + + +export function getSystemMessageType(providerSlug: string): 'system-role' | 'developer-role' | 'separated' { + const config = WELL_KNOWN_PROVIDER_DEFAULTS[providerSlug]; + return config?.supportsSystemMessage || 'system-role'; +} + +export function inferApiStyle(providerSlug: string): 'openai-compatible' | 'anthropic-style' | 'gemini-style' { + const config = WELL_KNOWN_PROVIDER_DEFAULTS[providerSlug]; + return config?.apiStyle || 'openai-compatible'; +} + diff --git a/src/vs/platform/void/common/prompt/constants.ts b/src/vs/platform/void/common/prompt/constants.ts new file mode 100644 index 00000000000..38b216e847a --- /dev/null +++ b/src/vs/platform/void/common/prompt/constants.ts @@ -0,0 +1,20 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +export const MAX_DIRSTR_CHARS_TOTAL_BEGINNING = 20_000; +export const MAX_DIRSTR_CHARS_TOTAL_TOOL = 20_000; +export const MAX_DIRSTR_RESULTS_TOTAL_BEGINNING = 100; +export const MAX_DIRSTR_RESULTS_TOTAL_TOOL = 100; + +export const MAX_FILE_CHARS_PAGE = 500_000; +export const MAX_CHILDREN_URIs_PAGE = 500; + +export const MAX_TERMINAL_CHARS = 100_000; +export const MAX_TERMINAL_INACTIVE_TIME = 8; +export const MAX_TERMINAL_BG_COMMAND_TIME = 20; + +export const MAX_PREFIX_SUFFIX_CHARS = 20_000; + + diff --git a/src/vs/platform/void/common/prompt/prompt_helper.ts b/src/vs/platform/void/common/prompt/prompt_helper.ts new file mode 100644 index 00000000000..13f7666dee6 --- /dev/null +++ b/src/vs/platform/void/common/prompt/prompt_helper.ts @@ -0,0 +1,29 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +export function toolFormatNativeHelp(format: 'openai-style' | 'anthropic-style' | 'gemini-style' | 'disabled' | undefined) { + if (format === 'disabled' || format === undefined) { + throw new Error(`Unsupported tool format: ${String(format)}. This helper should not handle 'disabled' or undefined formats.`); + } + + switch (format) { + case 'openai-style': + return `Provider format: OpenAI function-calling. +- Call tools by returning a function call with 'name' and JSON 'arguments'. +- Use snake_case keys in arguments; omit optional args unless needed. +- Avoid free-form patches; use tools to make changes.` + case 'anthropic-style': + return `Provider format: Anthropic tool use. +- Invoke tools with the native tool invocation object (name + input JSON). +- Keep inputs minimal; avoid free-form patches; use tools to make changes.` + case 'gemini-style': + return `Provider format: Gemini function calling. +- Call tools via functionCall object (name + JSON args). +- Use snake_case keys; avoid free-form patches.` + default: + return `Tools are available via native function calling. Use snake_case keys; omit optional args unless needed. Avoid free-form patches; apply changes via tools.` + } +} + diff --git a/src/vs/platform/void/common/prompt/systemPromptNativeTemplate.ts b/src/vs/platform/void/common/prompt/systemPromptNativeTemplate.ts new file mode 100644 index 00000000000..6bc62bc1ece --- /dev/null +++ b/src/vs/platform/void/common/prompt/systemPromptNativeTemplate.ts @@ -0,0 +1,45 @@ +export const SYSTEM_PROMPT_NATIVE_TEMPLATE = ` + +{{CRITICAL_RULES}} + +GUIDELINES + +Context: +- This is a fork of the VSCode repo called Void. +- Explore the repo as needed; prefer existing services and built-in functions. +- OS: {{OS}} +{{SHELL_LINE}} +- Workspaces: {{WORKSPACES}} + +Role & Objective: +- You are an expert coding agent that helps the user develop, run, and modify their codebase with minimal, correct changes. + +Priorities (in order): +1) Tool rules & safety +2) Correctness and minimal deltas (respect repo conventions) +3) Helpfulness & brevity +4) Style consistent with the codebase + +{{CORE_EXECUTION_RULES}} + +HALLUCINATION PREVENTION RULES: +- If you're "assuming" what code looks like → STOP +- When in doubt → ALWAYS read first, edit second +- NEVER trust your "knowledge" of file contents — only trust what you read this session + +{{SELECTIONS_SECTION}} + +{{EDITS_SECTION}} + +{{STRICT_EDIT_SPEC}} + +{{SAFETY_SCOPE_SECTION}} + +Language & formatting: +- Match the user's language. Use concise Markdown; avoid tables. + +Follow the provider-specific invocation rules: +{{TOOL_FORMAT_HELP}} + +Now Date: {{NOW_DATE}} +` diff --git a/src/vs/platform/void/common/prompt/systemPromptXMLTemplate.ts b/src/vs/platform/void/common/prompt/systemPromptXMLTemplate.ts new file mode 100644 index 00000000000..b20f314e9ae --- /dev/null +++ b/src/vs/platform/void/common/prompt/systemPromptXMLTemplate.ts @@ -0,0 +1,39 @@ +export const SYSTEM_PROMPT_XML_TEMPLATE = `{{ROLE_AND_OBJECTIVE}} + +{{CRITICAL_SECTIONS}} + +{{ABSOLUTE_PRIORITY}} + +{{FORBIDDEN_SECTION}} + +{{PRIMARY_RESPONSE_FORMAT}} + +{{XML_TOOL_SHAPE}} + +{{MANDATORY_RULES}} + +{{WHEN_TO_USE_TOOLS}} + +{{WHEN_NOT_TO_USE_TOOLS}} + +{{TOKEN_OPTIMIZATION}} + +{{VERIFICATION_WORKFLOW}} + +{{STOP_CONDITION}} + +Context: +- OS: {{OS}} +{{SHELL_LINE}} +- Workspace: {{WORKSPACES}} +- This is a VSCode fork called Void + +Available tools: + +{{XML_TOOLS_LIST}} + +{{EFFICIENT_TASK_APPROACH}} + +{{REMEMBER_SECTION}} + +Now Date: {{NOW_DATE}}`; diff --git a/src/vs/platform/void/common/providerReg.ts b/src/vs/platform/void/common/providerReg.ts new file mode 100644 index 00000000000..e093373a488 --- /dev/null +++ b/src/vs/platform/void/common/providerReg.ts @@ -0,0 +1,859 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { + OpenRouterProvider, + ModelApiConfig, + registerProviderConfigResolver, + registerUserModelApiConfigGetter, + getModelApiConfiguration, + VoidStaticModelInfo, + getProviderSlug +} from './modelInference.js'; +import { IVoidSettingsService, CustomProviderSettings, ModelCapabilityOverride } from './voidSettingsService.js'; +import { IDynamicModelService } from './dynamicModelService.js'; +import { IRemoteModelsService } from './remoteModelsService.js'; +import { ILogService } from '../../log/common/log.js'; +import { specialToolFormat, supportsSystemMessage } from './voidSettingsTypes.js'; + +export type ProviderMeta = { + title: string; + subTextMd?: string; + defaultModels?: string[]; + apiKeyPlaceholder?: string; + endpointPlaceholder?: string; +} + +export interface IUserProviderSettings extends CustomProviderSettings { } + +export interface IDynamicProviderRegistryService { + readonly _serviceBrand: undefined; + initialize(): Promise; + refreshProviders(force?: boolean): Promise; + getProviders(): OpenRouterProvider[]; + + // events + onDidChangeProviders: Event; + onDidChangeProviderModels: Event<{ slug: string }>; + + + getUserProviderSettings(slug: string): IUserProviderSettings | undefined; + getConfiguredProviderSlugs(): string[]; + setUserProviderSettings(slug: string, settings: IUserProviderSettings): Promise; + deleteUserProviderSettings(slug: string): Promise; + + + refreshModelsForProvider(slug: string): Promise; + getProviderModels(slug: string): string[]; + setProviderModels(slug: string, models: string[], modelsCapabilities?: Record>): Promise; // updated signature + + + setPerModelOverride(modelId: string, cfg: ModelApiConfig | null): Promise; + getPerModelOverride(modelId: string): ModelApiConfig | null; + + getModelCapabilityOverride(slug: string, modelId: string): ModelCapabilityOverride | undefined; + setModelCapabilityOverride(slug: string, modelId: string, overrides: ModelCapabilityOverride | undefined): Promise; + getEffectiveModelCapabilities(slug: string, modelId: string): Promise>; + + getRequestConfigForModel(modelId: string, preferredProviderSlug?: string): { + endpoint: string; + apiStyle: 'openai-compatible' | 'anthropic-style' | 'gemini-style' | 'disabled'; + supportsSystemMessage: supportsSystemMessage, + specialToolFormat: specialToolFormat; + headers: Record; + }; + +} + +export const IDynamicProviderRegistryService = createDecorator('dynamicProviderRegistryService'); + +type ProvidersCache = { ts: number; data: OpenRouterProvider[] }; +const PROVIDERS_CACHE_KEY = 'void.openrouter.providers.cache.v1'; +const PROVIDERS_TTL_MS = 24 * 60 * 60 * 1000; // 24h + +type ORCandidate = { + fullId: string; + provider: string; + short: string; + shortNorm: string; + baseKey: string; + caps: Partial; +}; + +type ORIndex = { + byFull: Map; + byShort: Map; + byBase: Map; +}; + +export class DynamicProviderRegistryService implements IDynamicProviderRegistryService { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeProviders = new Emitter(); + readonly onDidChangeProviders = this._onDidChangeProviders.event; + + private readonly _onDidChangeProviderModels = new Emitter<{ slug: string }>(); + readonly onDidChangeProviderModels = this._onDidChangeProviderModels.event; + + private providers: OpenRouterProvider[] = []; + private perModelOverrides = new Map(); + private initialized = false; + + constructor( + @IRemoteModelsService private readonly remoteModelsService: IRemoteModelsService, + @IVoidSettingsService private readonly settingsService: IVoidSettingsService, + @IDynamicModelService private readonly dynamicModelService: IDynamicModelService, + @ILogService private readonly logService: ILogService + ) { + + registerProviderConfigResolver((providerSlug) => { + this.logService.debug(`[DEBUG registerProviderConfigResolver] Called with providerSlug: "${providerSlug}"`); + const cfg = this.settingsService.state.customProviders?.[providerSlug]; + this.logService.debug(`[DEBUG registerProviderConfigResolver] cfg:`, cfg); + if (!cfg) { + this.logService.debug(`[DEBUG registerProviderConfigResolver] No config found, returning null`); + return null; + } + const result = { + endpoint: cfg.endpoint, + apiStyle: cfg.apiStyle, + supportsSystemMessage: cfg.supportsSystemMessage, + auth: cfg.auth ?? { header: 'Authorization', format: 'Bearer' } + }; + this.logService.debug(`[DEBUG registerProviderConfigResolver] Returning:`, result); + return result; + }); + + registerUserModelApiConfigGetter((modelId) => { + this.logService.debug(`[DEBUG registerUserModelApiConfigGetter] Called with modelId: "${modelId}"`); + if (this.perModelOverrides.has(modelId)) { + const result = this.perModelOverrides.get(modelId)!; + this.logService.debug(`[DEBUG registerUserModelApiConfigGetter] Found in perModelOverrides:`, result); + return result; + } + const all = this.settingsService.state.customProviders || {}; + for (const slug of Object.keys(all)) { + const entry = all[slug]; + const perModel = entry?.perModel || {}; + if (perModel && perModel[modelId]) { + const p = perModel[modelId]!; + const result = { + apiStyle: p.apiStyle ?? 'openai-compatible', + supportsSystemMessage: p.supportsSystemMessage ?? (p.apiStyle === 'anthropic-style' || p.apiStyle === 'gemini-style' ? 'separated' : 'system-role'), + specialToolFormat: p.specialToolFormat ?? (p.apiStyle === 'anthropic-style' ? 'anthropic-style' : p.apiStyle === 'gemini-style' ? 'gemini-style' : 'openai-style'), + endpoint: p.endpoint ?? all[slug].endpoint ?? 'https://openrouter.ai/api/v1', + auth: p.auth ?? { header: 'Authorization', format: 'Bearer' } + }; + this.logService.debug(`[DEBUG registerUserModelApiConfigGetter] Found in perModel for slug "${slug}":`, result); + return result; + } + } + this.logService.debug(`[DEBUG registerUserModelApiConfigGetter] No config found, returning null`); + return null; + }); + } + + + async initialize(): Promise { + if (this.initialized) return; + + const cached = this.readCache(); + if (cached) { + this.providers = cached; + this._onDidChangeProviders.fire(); + this.refreshProviders(false).catch(() => { }); + this.initialized = true; + return; + } + + await this.refreshProviders(true); + this.initialized = true; + } + + async refreshProviders(force = false): Promise { + const now = Date.now(); + const meta = this.readCacheMeta(); + + if (!force && meta && (now - meta.ts) < PROVIDERS_TTL_MS) { + return; + } + + try { + const json = await this.remoteModelsService.fetchModels('https://openrouter.ai/api/v1/providers', { + 'HTTP-Referer': 'https://voideditor.com', + 'X-Title': 'Void', + 'Accept': 'application/json' + }); + + if (json && typeof json === 'object' && Array.isArray((json as any).data)) { + this.providers = (json as any).data as OpenRouterProvider[]; + this.writeCache({ ts: now, data: this.providers }); + this._onDidChangeProviders.fire(); + } + } catch { + // ignore + } + } + + getProviders(): OpenRouterProvider[] { + return this.providers.slice(); + } + + private toShortName(id: string): string { + const i = id.indexOf('/'); + return i === -1 ? id : id.slice(i + 1); + } + + private isFreeVariant(name: string): boolean { + return name.endsWith(':free') || name.includes(':free'); + } + + isLocalEndpoint(urlStr?: string): boolean { + if (!urlStr) return false; + try { + const u = new URL(urlStr); + const h = u.hostname.toLowerCase(); + if (h === 'localhost' || h === '127.0.0.1' || h === '::1') return true; + // private IPv4: 10/8, 172.16-31/12, 192.168/16 + if (/^10\.\d+\.\d+\.\d+$/.test(h)) return true; + if (/^192\.168\.\d+\.\d+$/.test(h)) return true; + const m = /^172\.(\d+)\.\d+\.\d+$/.exec(h); + if (m) { + const sec = Number(m[1]); + if (sec >= 16 && sec <= 31) return true; + } + } catch { /* ignore */ } + return false; + } + + private sanitizeModelsAndCaps( + models: string[], + caps?: Record>, + opts?: { keepFullIds?: boolean; dropFree?: boolean } + ): { models: string[]; caps?: Record> } { + const keepFull = !!opts?.keepFullIds; + const dropFree = opts?.dropFree === true; + + const out: string[] = []; + for (const m of models) { + const name = keepFull ? m : this.toShortName(m); + if (dropFree && this.isFreeVariant(name)) continue; + if (!out.includes(name)) out.push(name); + } + + let newCaps: Record> | undefined; + if (caps) { + newCaps = {}; + for (const [k, v] of Object.entries(caps)) { + const key = keepFull ? k : this.toShortName(k); + if (dropFree && this.isFreeVariant(key)) continue; + if (!(key in newCaps)) newCaps[key] = v; + } + } + + return { models: out, caps: newCaps }; + } + + private static readonly NAME_QUALIFIERS = new Set([ + 'pro', 'mini', 'search', 'lite', 'high', 'low', 'medium', 'large', 'xl', 'xlarge', 'turbo', 'fast', 'slow', + 'instruct', 'chat', 'reasoning', 'flash', 'dev', 'beta', 'latest', 'preview', 'free' + ]); + + private normalizeName(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().replace(/\s+/g, ' '); + } + + private toBaseKey(modelShortName: string): string { + const tokens = this.normalizeName(modelShortName).split(' '); + const filtered = tokens.filter(t => !DynamicProviderRegistryService.NAME_QUALIFIERS.has(t) && t.length > 0); + + const finalTokens: string[] = []; + for (const t of filtered) { + const m = /^([a-z]+)(\d+)$/.exec(t); + if (m) { + finalTokens.push(m[1], m[2]); + } else { + finalTokens.push(t); + } + } + return finalTokens.join(' '); + } + + private splitProvider(idOrShort: string): { provider?: string; short: string } { + const i = idOrShort.indexOf('/'); + if (i === -1) return { short: idOrShort }; + return { provider: idOrShort.slice(0, i), short: idOrShort.slice(i + 1) }; + } + + private buildOpenRouterIndex(): ORIndex { + const all = this.dynamicModelService.getAllDynamicCapabilities(); // fullId -> caps + const byFull = new Map(); + const byShort = new Map(); + const byBase = new Map(); + + for (const [fullId, caps] of Object.entries(all)) { + const { provider, short } = this.splitProvider(fullId); + if (!provider) continue; + const shortNorm = this.normalizeName(short); + const baseKey = this.toBaseKey(short); + const item: ORCandidate = { fullId, provider, short, shortNorm, baseKey, caps }; + + byFull.set(fullId, item); + + const arrS = byShort.get(shortNorm) ?? []; + arrS.push(item); + byShort.set(shortNorm, arrS); + + const arrB = byBase.get(baseKey) ?? []; + arrB.push(item); + byBase.set(baseKey, arrB); + } + return { byFull, byShort, byBase }; + } + + private scoreCandidate(remoteShort: string, remoteProvider: string | undefined, cand: ORCandidate): number { + + const shortNorm = this.normalizeName(remoteShort); + const baseKey = this.toBaseKey(remoteShort); + + let score = 0; + + if (cand.shortNorm === shortNorm) score += 500; + if (cand.baseKey === baseKey) score += 300; + if (remoteProvider && cand.provider === remoteProvider) score += 80; + + + const a = shortNorm, b = cand.shortNorm; + let pref = 0; + for (let i = 0; i < Math.min(a.length, b.length); i++) { + if (a[i] !== b[i]) break; + pref++; + } + score += Math.min(pref, 10) * 5; + + + const lenDiff = Math.abs(a.length - b.length); + score -= Math.min(lenDiff, 10) * 2; + + return score; + } + + private findBestOpenRouterMatch(remoteId: string, index: ORIndex): ORCandidate | null { + const { provider: rProv, short: rShort } = this.splitProvider(remoteId); + const shortNorm = this.normalizeName(rShort); + const baseKey = this.toBaseKey(rShort); + + + if (rProv) { + const fullId = `${rProv}/${rShort}`; + const exact = index.byFull.get(fullId); + if (exact) return exact; + + + const sameShort = index.byShort.get(shortNorm)?.filter(c => c.provider === rProv); + if (sameShort && sameShort.length) { + + let best = sameShort[0], bestScore = this.scoreCandidate(rShort, rProv, best); + for (let i = 1; i < sameShort.length; i++) { + const s = this.scoreCandidate(rShort, rProv, sameShort[i]); + if (s > bestScore) { best = sameShort[i]; bestScore = s; } + } + return best; + } + } + + + const sameShortAll = index.byShort.get(shortNorm); + if (sameShortAll && sameShortAll.length) { + let best = sameShortAll[0], bestScore = this.scoreCandidate(rShort, rProv, best); + for (let i = 1; i < sameShortAll.length; i++) { + const s = this.scoreCandidate(rShort, rProv, sameShortAll[i]); + if (s > bestScore) { best = sameShortAll[i]; bestScore = s; } + } + return best; + } + + + const baseCandidates = index.byBase.get(baseKey); + if (baseCandidates && baseCandidates.length) { + let best = baseCandidates[0], bestScore = this.scoreCandidate(rShort, rProv, best); + for (let i = 1; i < baseCandidates.length; i++) { + const s = this.scoreCandidate(rShort, rProv, baseCandidates[i]); + if (s > bestScore) { best = baseCandidates[i]; bestScore = s; } + } + return best; + } + + + let globalBest: ORCandidate | null = null; + let globalScore = -Infinity; + for (const cand of index.byFull.values()) { + const s = this.scoreCandidate(rShort, rProv, cand); + if (s > globalScore) { globalBest = cand; globalScore = s; } + } + + return globalBest && globalScore >= 200 ? globalBest : null; + } + + private async inferCapabilitiesForRemoteModels(remoteIds: string[]): Promise>> { + await this.dynamicModelService.initialize(); + const index = this.buildOpenRouterIndex(); + + const caps: Record> = {}; + for (const rid of remoteIds) { + const match = this.findBestOpenRouterMatch(rid, index); + + if (match) { + caps[rid] = { + contextWindow: match.caps.contextWindow, + reservedOutputTokenSpace: match.caps.reservedOutputTokenSpace, + cost: match.caps.cost, + supportsSystemMessage: match.caps.supportsSystemMessage, + specialToolFormat: match.caps.specialToolFormat, + supportsFIM: match.caps.supportsFIM ?? false, + reasoningCapabilities: match.caps.reasoningCapabilities, + fimTransport: match.caps.fimTransport, + inputModalities: match.caps.inputModalities, + }; + } + } + return caps; + } + + + private async refreshModelsViaProviderEndpoint(slug: string, endpoint: string): Promise { + const cfg = this.getUserProviderSettings(slug); + const url = endpoint.replace(/\/$/, '') + '/models'; + const headers: Record = { Accept: 'application/json', ...(cfg?.additionalHeaders || {}) }; + + if (cfg?.apiKey) { + const authHeader = cfg.auth?.header || 'Authorization'; + const format = cfg.auth?.format || 'Bearer'; + headers[authHeader] = format === 'Bearer' ? `Bearer ${cfg.apiKey}` : cfg.apiKey; + } + + this.logService.debug(`[DynamicProviderRegistryService] (${slug}) GET ${url}`); + + const json: any = await this.remoteModelsService.fetchModels(url, headers); + const arr: any[] = Array.isArray(json?.data) ? json.data : (Array.isArray(json) ? json : []); + const ids = arr.map((m: any) => m.id ?? m.model ?? m.name).filter(Boolean) as string[]; + + this.logService.debug(`[DynamicProviderRegistryService] (${slug}) /models returned ${ids.length} models`); + if (ids.length) { + const limit = 150; + const head = ids.slice(0, limit).join(', '); + this.logService.debug( + `[DynamicProviderRegistryService] (${slug}) models: ${ids.length > limit ? `${head} …(+${ids.length - limit})` : head}` + ); + } + + return ids; + } + + + private async publishAllConfiguredToChat(): Promise { + const cps = this.settingsService.state.customProviders || {}; + const aggregated = new Set(); + + for (const slug of Object.keys(cps)) { + const isOR = String(slug).toLowerCase() === 'openrouter'; + const models = cps[slug]?.models || []; + + for (const id of models) { + if (isOR) { + aggregated.add(id); + } else { + aggregated.add(`${slug}/${id}`); + } + } + } + + + await this.settingsService.setAutodetectedModels('openRouter', Array.from(aggregated), {}); + } + + + + getUserProviderSettings(slug: string): IUserProviderSettings | undefined { + return this.settingsService.state.customProviders?.[slug]; + } + + getConfiguredProviderSlugs(): string[] { + return Object.keys(this.settingsService.state.customProviders || {}); + } + + async setUserProviderSettings(slug: string, settings: IUserProviderSettings): Promise { + await this.settingsService.setCustomProviderSettings(slug, settings); + this._onDidChangeProviders.fire(); + } + + async deleteUserProviderSettings(slug: string): Promise { + await this.settingsService.setCustomProviderSettings(slug, undefined); + this._onDidChangeProviders.fire(); + } + + async refreshModelsForProvider(slug: string): Promise { + const cfg = this.getUserProviderSettings(slug); + const isOpenRouterSlug = String(slug).toLowerCase() === 'openrouter'; + + + if (this.isLocalEndpoint(cfg?.endpoint)) { + const remoteIds = await this.refreshModelsViaProviderEndpoint(slug, cfg!.endpoint!); + + const inferredCaps = await this.inferCapabilitiesForRemoteModels(remoteIds); + + + const { models, caps } = this.sanitizeModelsAndCaps(remoteIds, inferredCaps, { + keepFullIds: true, + dropFree: false + }); + + await this.setProviderModels(slug, models, caps); + await this.publishAllConfiguredToChat(); + return; + } + + + + if (isOpenRouterSlug) { + await this.dynamicModelService.initialize(); + const allCaps = this.dynamicModelService.getAllDynamicCapabilities(); + const entries = Object.entries(allCaps); + const fullIds = entries.map(([id]) => id); + const capsFull: Record> = {}; + for (const [id, info] of entries) { + capsFull[id] = { + contextWindow: info.contextWindow, + reservedOutputTokenSpace: info.reservedOutputTokenSpace, + cost: info.cost, + supportsSystemMessage: info.supportsSystemMessage, + specialToolFormat: info.specialToolFormat, + supportsFIM: info.supportsFIM ?? false, + reasoningCapabilities: info.reasoningCapabilities, + fimTransport: info.fimTransport, + inputModalities: info.inputModalities, + }; + } + const { models, caps } = this.sanitizeModelsAndCaps(fullIds, capsFull, { keepFullIds: true, dropFree: false }); + await this.setProviderModels(slug, models, caps); + await this.publishAllConfiguredToChat(); + return; + } + + + await this.dynamicModelService.initialize(); + const all = this.dynamicModelService.getAllDynamicCapabilities(); + const bySlug = Object.entries(all).filter(([id]) => id.startsWith(slug + '/')); + + + if (bySlug.length === 0 && cfg?.endpoint) { + try { + const remoteIds = await this.refreshModelsViaProviderEndpoint(slug, cfg.endpoint); + const inferredCaps = await this.inferCapabilitiesForRemoteModels(remoteIds); + + const norm = this.sanitizeModelsAndCaps(remoteIds, inferredCaps, { keepFullIds: true, dropFree: false }); + await this.setProviderModels(slug, norm.models, norm.caps); + await this.publishAllConfiguredToChat(); + return; + } catch { + + } + } + + const ids = bySlug.map(([id]) => id); + const caps: Record> = {}; + for (const [id, info] of bySlug) { + caps[id] = { + contextWindow: info.contextWindow, + reservedOutputTokenSpace: info.reservedOutputTokenSpace, + cost: info.cost, + supportsSystemMessage: info.supportsSystemMessage, + specialToolFormat: info.specialToolFormat, + supportsFIM: info.supportsFIM ?? false, + reasoningCapabilities: info.reasoningCapabilities, + fimTransport: (info as any).fimTransport, + inputModalities: (info as any).inputModalities, + }; + } + const norm = this.sanitizeModelsAndCaps(ids, caps, { keepFullIds: true, dropFree: false }); + await this.setProviderModels(slug, norm.models, norm.caps); + await this.publishAllConfiguredToChat(); + } + + getProviderModels(slug: string): string[] { + return this.settingsService.state.customProviders?.[slug]?.models ?? []; + } + + getModelCapabilityOverride(slug: string, modelId: string): ModelCapabilityOverride | undefined { + const cp = this.settingsService.state.customProviders?.[slug]; + return cp?.modelCapabilityOverrides?.[modelId]; + } + + async setModelCapabilityOverride(slug: string, modelId: string, overrides: ModelCapabilityOverride | undefined): Promise { + const cp = this.settingsService.state.customProviders?.[slug] || {}; + const cur = { ...(cp.modelCapabilityOverrides || {}) }; + if (overrides === undefined) { + delete cur[modelId]; + } else { + cur[modelId] = overrides; + } + await this.settingsService.setCustomProviderSettings(slug, { + ...cp, + modelCapabilityOverrides: cur + }); + + this._onDidChangeProviderModels.fire({ slug }); + } + + async getEffectiveModelCapabilities(slug: string, modelId: string): Promise> { + + const fullId = modelId.includes('/') ? modelId : `${slug}/${modelId}`; + + await this.dynamicModelService.initialize(); + const base = this.dynamicModelService.getDynamicCapabilities(fullId); + + const cp = this.settingsService.state.customProviders?.[slug]; + + const saved = cp?.modelsCapabilities?.[modelId] ?? cp?.modelsCapabilities?.[fullId]; + + const minimal: Partial = base ?? saved ?? { + contextWindow: 4096, + reservedOutputTokenSpace: 4096, + cost: { input: 0, output: 0 }, + supportsSystemMessage: 'system-role', + specialToolFormat: 'openai-style', + supportsFIM: false, + reasoningCapabilities: false + }; + + const ov = this.getModelCapabilityOverride(slug, modelId); + return { ...minimal, ...(ov || {}) }; + } + + async setProviderModels( + slug: string, + models: string[], + modelsCapabilities?: Record> + ): Promise { + + + + const keepFullIds = true; + + const norm = this.sanitizeModelsAndCaps(models, modelsCapabilities, { + keepFullIds, + dropFree: false + }); + + const cur = this.settingsService.state.customProviders?.[slug] || {}; + const prevCaps = cur.modelsCapabilities || {}; + + const nextCaps: Record> = {}; + + + if (norm.caps) { + for (const [k, v] of Object.entries(norm.caps)) { + nextCaps[k] = v; + } + } + + + for (const m of norm.models) { + if (!(m in nextCaps) && (m in prevCaps)) { + nextCaps[m] = prevCaps[m]; + } + } + + + + + try { + await this.dynamicModelService.initialize(); + + const missing = norm.models.filter(m => !nextCaps[m]); + if (missing.length) { + const inferred = await this.inferCapabilitiesForRemoteModels(missing); + + let got = 0; + for (const m of missing) { + const cap = inferred[m]; + if (cap) { + nextCaps[m] = cap; + got++; + } + } + + this.logService.debug( + `[DynamicProviderRegistryService] setProviderModels("${slug}"): inferred caps for ${got}/${missing.length}` + ); + } + } catch (e) { + this.logService.warn(`[DynamicProviderRegistryService] setProviderModels("${slug}"): failed to infer caps`, e); + } + + const capsToStore = norm.models.length ? nextCaps : {}; + + await this.settingsService.setCustomProviderSettings(slug, { + ...cur, + models: norm.models, + modelsCapabilities: capsToStore, + modelsLastRefreshedAt: Date.now() + }); + + this._onDidChangeProviderModels.fire({ slug }); + + await this.publishAllConfiguredToChat(); + } + + async setPerModelOverride(modelId: string, cfg: ModelApiConfig | null): Promise { + if (cfg) this.perModelOverrides.set(modelId, cfg); + else this.perModelOverrides.delete(modelId); + } + + getPerModelOverride(modelId: string): ModelApiConfig | null { + return this.perModelOverrides.get(modelId) ?? null; + } + + getRequestConfigForModel(modelId: string, preferredProviderSlug?: string): { + endpoint: string; + apiStyle: 'openai-compatible' | 'anthropic-style' | 'gemini-style' | 'disabled'; + supportsSystemMessage: supportsSystemMessage, + specialToolFormat: specialToolFormat; + headers: Record; + } { + const preferred = preferredProviderSlug?.trim().toLowerCase(); + this.logService.debug(`[DEBUG getRequestConfigForModel] Called with modelId: "${modelId}", preferredProviderSlug: "${preferred || ''}"`); + const base = getModelApiConfiguration(modelId); + const slugFromModel = getProviderSlug(modelId).toLowerCase(); + + const cpPreferred = preferred ? this.getUserProviderSettings(preferred) : undefined; + const cpByModel = this.getUserProviderSettings(slugFromModel); + + + const cpOpenRouter = preferred !== 'openrouter' && slugFromModel !== 'openrouter' + ? this.getUserProviderSettings('openrouter') + : undefined; + const cp = cpPreferred ?? cpByModel ?? cpOpenRouter; + + const usedSlug = cpPreferred ? preferred : (cpByModel ? slugFromModel : (cpOpenRouter ? 'openrouter' : slugFromModel)); + this.logService.debug(`[DEBUG getRequestConfigForModel] slugFromModel="${slugFromModel}", usedSlug="${usedSlug}", hasCp=${!!cp}`); + + this.logService.debug(`[DEBUG getRequestConfigForModel] base:`, base); + this.logService.debug(`[DEBUG getRequestConfigForModel] cp(usedSlug="${usedSlug}"):`, cp); + + const endpoint = (cp?.endpoint && cp.endpoint.trim()) || base.endpoint; + this.logService.debug(`[DEBUG getRequestConfigForModel] final endpoint: "${endpoint}"`); + + const headers: Record = { + Accept: 'application/json' + }; + + if (cp?.apiKey) { + const headerName = cp.auth?.header || 'Authorization'; + const format = cp.auth?.format || 'Bearer'; + headers[headerName] = format === 'Bearer' ? `Bearer ${cp.apiKey}` : cp.apiKey; + } + if (cp?.additionalHeaders) { + for (const [k, v] of Object.entries(cp.additionalHeaders)) { + headers[k] = String(v); + } + } + + // Pull per‑model capability overrides for this provider/model so that + // semantic flags like supportsSystemMessage and specialToolFormat are + // taken from the same source as ConvertToLLMMessageService / ACP, and + // are NOT silently overwritten by WELL_KNOWN_PROVIDER_DEFAULTS. + let effSupportsSystemMessage = base.supportsSystemMessage; + let effSpecialToolFormat: specialToolFormat = base.specialToolFormat; + try { + if (cp) { + const caps = cp.modelsCapabilities; + // modelsCapabilities are stored under the exact id used in chat + const savedCaps: Partial | undefined = caps?.[modelId]; + const ov = usedSlug ? this.getModelCapabilityOverride(usedSlug, modelId) as (ModelCapabilityOverride | undefined) : undefined; + const merged: Partial = { ...(savedCaps ?? {}), ...(ov ?? {}) }; + if (merged.supportsSystemMessage !== undefined) { + effSupportsSystemMessage = merged.supportsSystemMessage as supportsSystemMessage; + } + if (merged.specialToolFormat !== undefined) { + effSpecialToolFormat = merged.specialToolFormat as specialToolFormat; + } + } + } catch (e) { + this.logService.warn('[DEBUG getRequestConfigForModel] Failed to apply capability overrides, falling back to base:', e); + } + + const result = { + endpoint, + apiStyle: base.apiStyle, + supportsSystemMessage: effSupportsSystemMessage, + specialToolFormat: effSpecialToolFormat, + headers + }; + + // Redact sensitive headers before logging + const redactedResult = { + endpoint: result.endpoint, + apiStyle: result.apiStyle, + supportsSystemMessage: result.supportsSystemMessage, + specialToolFormat: result.specialToolFormat, + headers: { ...result.headers } + }; + + const sensitiveHeaders = new Set([ + 'authorization', + 'x-api-key', + 'api-key', + 'x-goog-api-key', + 'proxy-authorization', + ]); + + for (const [k, v] of Object.entries(redactedResult.headers)) { + if (sensitiveHeaders.has(k.toLowerCase())) { + if (typeof v === 'string' && v.startsWith('Bearer ')) { + redactedResult.headers[k] = 'Bearer ***'; + } else { + redactedResult.headers[k] = '***'; + } + } + } + + this.logService.debug(`[DEBUG getRequestConfigForModel] result:`, JSON.stringify(redactedResult, null, 2)); + return result; + } + + private readCache(): OpenRouterProvider[] | null { + try { + if (typeof localStorage === 'undefined') return null; + const raw = localStorage.getItem(PROVIDERS_CACHE_KEY); + if (!raw) return null; + const obj = JSON.parse(raw) as ProvidersCache; + if (!obj || !obj.ts || !Array.isArray(obj.data)) return null; + if ((Date.now() - obj.ts) > PROVIDERS_TTL_MS) return null; + return obj.data; + } catch { return null; } + } + + private readCacheMeta(): ProvidersCache | null { + try { + if (typeof localStorage === 'undefined') return null; + const raw = localStorage.getItem(PROVIDERS_CACHE_KEY); + if (!raw) return null; + return JSON.parse(raw) as ProvidersCache; + } catch { return null; } + } + + private writeCache(v: ProvidersCache) { + try { + if (typeof localStorage === 'undefined') return; + localStorage.setItem(PROVIDERS_CACHE_KEY, JSON.stringify(v)); + } catch { /* ignore */ } + } +} + +registerSingleton(IDynamicProviderRegistryService, DynamicProviderRegistryService, InstantiationType.Delayed); diff --git a/src/vs/platform/void/common/remoteModelsService.ts b/src/vs/platform/void/common/remoteModelsService.ts new file mode 100644 index 00000000000..169fa102375 --- /dev/null +++ b/src/vs/platform/void/common/remoteModelsService.ts @@ -0,0 +1,14 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export interface IRemoteModelsService { + readonly _serviceBrand: undefined; + + fetchModels(url: string, headers?: Record): Promise; +} + +export const IRemoteModelsService = createDecorator('remoteModelsService'); diff --git a/src/vs/platform/void/common/requestParams.ts b/src/vs/platform/void/common/requestParams.ts new file mode 100644 index 00000000000..845b8eb3ab8 --- /dev/null +++ b/src/vs/platform/void/common/requestParams.ts @@ -0,0 +1,55 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +// Request parameters that should be excluded from UI override template +export const EXCLUDED_REQUEST_PARAMS = new Set([ + 'tools', + 'tool_choice', + 'response_format', + 'structured_outputs', + 'reasoning', + 'include_reasoning', + // Provider routing is configured separately as a top-level `provider` object + 'provider' +]); + +export function isExcluded(key: string): boolean { + return EXCLUDED_REQUEST_PARAMS.has(String(key)); +} + +export function coerceFallbackForKey(key: string): any { + const k = String(key); + if (k === 'temperature') return 0.2; + if (k === 'max_tokens' || k === 'max_completion_tokens') return 6000; + if (k === 'top_p') return 1; + if (k === 'top_k') return 40; + if (k === 'presence_penalty' || k === 'frequency_penalty' || k === 'repetition_penalty') return 0; + if (k === 'logprobs' || k === 'top_logprobs') return 0; + if (k === 'logit_bias') return {}; + if (k === 'seed') return 0; + if (k === 'stop') return []; + if (k === 'min_p') return 0; + return ''; +} + +export function filterSupportedParams(supported: readonly string[] | null | undefined): string[] { + const list = Array.isArray(supported) ? supported : []; + return list.filter(k => !isExcluded(String(k))); +} + +export function computeRequestParamsTemplate( + supportedParams: readonly string[] | null | undefined, + defaultParams?: Record | null +): Record { + const defs = defaultParams && typeof defaultParams === 'object' ? defaultParams : {}; + const filtered = filterSupportedParams(supportedParams); + const entries = filtered.map(rawKey => { + const k = String(rawKey); + const hasDefault = Object.prototype.hasOwnProperty.call(defs, k) && defs[k] !== null; + return [k, hasDefault ? defs[k] : coerceFallbackForKey(k)]; + }); + return Object.fromEntries(entries); +} + diff --git a/src/vs/platform/void/common/sendLLMMessageTypes.ts b/src/vs/platform/void/common/sendLLMMessageTypes.ts new file mode 100644 index 00000000000..a120ee097e4 --- /dev/null +++ b/src/vs/platform/void/common/sendLLMMessageTypes.ts @@ -0,0 +1,341 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { ToolName, ToolParamName } from './toolsServiceTypes.js' +import { ChatMode, supportsSystemMessage, specialToolFormat, ModelSelection, ModelSelectionOptions, OverridesOfModel, ProviderName, RefreshableProviderName, SettingsOfProvider } from './voidSettingsTypes.js' + +// Parameter injection controls (renderer → main) +export type ParameterInjectionMode = 'default' | 'off' | 'override'; +export type RequestParamsConfig = { mode: ParameterInjectionMode; params?: Record }; + +// OpenRouter provider routing object; forwarded as `provider` in request body +// when using the OpenRouter endpoint. The fields correspond to +// https://openrouter.ai/docs/guides/routing/provider-selection +export type ProviderRouting = { + order?: string[]; + allow_fallbacks?: boolean; + require_parameters?: boolean; + data_collection?: 'allow' | 'deny'; + zdr?: boolean; + enforce_distillable_text?: boolean; + only?: string[]; + ignore?: string[]; + quantizations?: string[]; + sort?: string; + max_price?: Record; + // Allow forward‑compatible custom fields without breaking typing + [k: string]: any; +}; + +export type DynamicRequestConfig = { + endpoint: string; + apiStyle: 'openai-compatible' | 'anthropic-style' | 'gemini-style' | 'disabled'; + supportsSystemMessage: supportsSystemMessage, + specialToolFormat: specialToolFormat; + fimTransport?: 'openai-compatible' | 'mistral-native' | 'ollama-native' | 'emulated'; + // Optional effective capabilities passed from renderer (dynamic registry) + reasoningCapabilities?: any; + /** Whether this model should use provider-specific prompt caching (cache_control). */ + supportCacheControl?: boolean; + headers: Record; +}; + +export const errorDetails = (fullError: Error | null): string | null => { + if (fullError === null) { + return null + } + else if (typeof fullError === 'object') { + if (Object.keys(fullError).length === 0) return null + return JSON.stringify(fullError, null, 2) + } + else if (typeof fullError === 'string') { + return null + } + return null +} + +export const getErrorMessage: (error: unknown) => string = (error) => { + if (error instanceof Error) return `${error.name}: ${error.message}` + return error + '' +} + +// Aggregated token usage for a single LLM request. +// All fields are non-negative counts of tokens as reported by the provider. +export type LLMTokenUsage = { + input: number; + cacheCreation: number; + cacheRead: number; + output: number; +} + +export type AnthropicAssistantBlock = + AnthropicReasoning | + { type: 'text'; text: string } | + { type: 'tool_use'; name: string; input: Record; id: string } | + { type: 'image'; source: { type: 'base64'; media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; data: string } }; + +export type AnthropicUserBlock = + { type: 'text'; text: string } | + { type: 'tool_result'; tool_use_id: string; content: string } | + { type: 'image'; source: { type: 'base64'; media_type: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; data: string } }; + +export type AnthropicLLMChatMessage = + | { + role: 'assistant'; + content: string | AnthropicAssistantBlock[]; + } + | { + role: 'user'; + content: string | AnthropicUserBlock[]; + }; +export type OpenAITextPart = { type: 'text'; text: string }; +export type OpenAIImageURLPart = { type: 'image_url'; image_url: { url: string; detail?: 'auto' | 'low' | 'high' } }; + +export type OpenAILLMChatMessage = { + role: 'system' | 'developer'; + content: string; +} | { + role: 'user'; + content: string | (OpenAITextPart | OpenAIImageURLPart)[]; +} | { + role: 'assistant', + content: string | (AnthropicReasoning | OpenAITextPart)[]; + tool_calls?: { type: 'function'; id: string; function: { name: string; arguments: string; } }[]; +} | { + role: 'tool', + content: string; + tool_call_id: string; +} + +export type GeminiLLMChatMessage = { + role: 'model' + parts: ( + | { text: string; } + | { functionCall: { id: string; name: ToolName, args: Record } } + )[]; +} | { + role: 'user'; + parts: ( + | { text: string; } + | { functionResponse: { id: string; name: ToolName, response: { output: string } } } + | { inlineData: { mimeType: string; data: string } } + )[]; +} + +export type LLMChatMessage = AnthropicLLMChatMessage | OpenAILLMChatMessage | GeminiLLMChatMessage + + + +export type LLMFIMMessage = { + prefix: string; + suffix: string; + stopTokens: string[]; +} + + +export type RawToolParamsObj = { + [paramName in ToolParamName]?: string; +} + +export type RawToolCallObjKnown = { + name: ToolName; + rawParams: RawToolParamsObj; + doneParams: ToolParamName[]; + id: string; + isDone: boolean; +} + +export type RawToolCallObjDynamic = { + name: string; // dynamic/MCP tool name + rawParams: Record; + doneParams: string[]; + id: string; + isDone: boolean; +} + +export type RawToolCallObj = RawToolCallObjKnown | RawToolCallObjDynamic; + +export type AnthropicReasoning = ({ type: 'thinking'; thinking: any; signature: string; } | { type: 'redacted_thinking', data: any }) + +export type LLMPlan = { + title?: string; + items: Array<{ id?: string; text: string; state?: 'pending' | 'running' | 'done' | 'error' }>; +}; + +export type OnText = (p: { + fullText: string; + fullReasoning: string; + toolCall?: RawToolCallObj; + plan?: LLMPlan; + /** Optional per-request token usage snapshot when the provider reports it. */ + tokenUsage?: LLMTokenUsage; +}) => void + +export type OnFinalMessage = (p: { + fullText: string; + fullReasoning: string; + toolCall?: RawToolCallObj; + anthropicReasoning: AnthropicReasoning[] | null; + plan?: LLMPlan; + /** Final per-request token usage when the provider reports it. */ + tokenUsage?: LLMTokenUsage; +}) => void // id is tool_use_id +export type OnError = (p: { message: string; fullError: Error | null }) => void +export type OnAbort = () => void +export type AbortRef = { current: (() => void) | null } + + +// service types +type SendLLMType = { + messagesType: 'chatMessages'; + messages: LLMChatMessage[]; // the type of raw chat messages that we send to Anthropic, OAI, etc + separateSystemMessage: string | undefined; + chatMode: ChatMode | null; + tool_choice?: { type: 'function', function: { name: string } } | 'none' | 'auto' | 'required'; +} | { + messagesType: 'FIMMessage'; + messages: LLMFIMMessage; + separateSystemMessage?: undefined; + chatMode?: undefined; + tool_choice?: undefined; +} +export type ServiceSendLLMMessageParams = { + onText: OnText; + onFinalMessage: OnFinalMessage; + onError: OnError; + logging: { loggingName: string, loggingExtras?: { [k: string]: any } }; + modelSelection: ModelSelection | null; + modelSelectionOptions: ModelSelectionOptions | undefined; + overridesOfModel: OverridesOfModel | undefined; + onAbort: OnAbort; + // Optional OpenRouter provider routing object (sent as top-level `provider` field) + providerRouting?: ProviderRouting; +} & SendLLMType; + +// params to the true sendLLMMessage function +export type SendLLMMessageParams = { + onText: OnText; + onFinalMessage: OnFinalMessage; + onError: OnError; + logging: { loggingName: string, loggingExtras?: { [k: string]: any } }; + abortRef: AbortRef; + + modelSelection: ModelSelection; + modelSelectionOptions: ModelSelectionOptions | undefined; + overridesOfModel: OverridesOfModel | undefined; + + settingsOfProvider: SettingsOfProvider; + additionalTools?: AdditionalToolInfo[]; + /** Disabled static Void tool names. */ + disabledStaticTools?: string[]; + /** Disabled dynamic/MCP tool names (prefixed names). */ + disabledDynamicTools?: string[]; + dynamicRequestConfig?: DynamicRequestConfig; + // Optional per-model request parameter injection (e.g., OpenRouter supported parameters) + requestParams?: RequestParamsConfig; + // Optional OpenRouter provider routing object (sent as top-level `provider` field) + providerRouting?: ProviderRouting; + // Optional UI/global switch: emit warning notification when response is truncated. + notifyOnTruncation?: boolean; +} & SendLLMType + + +export type JsonSchemaLike = { + description?: string; + type?: string; + enum?: any[]; + items?: JsonSchemaLike; + properties?: { [propName: string]: JsonSchemaLike }; + required?: string[]; + default?: any; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; +}; + + +// can't send functions across a proxy, use listeners instead +export type BlockedMainLLMMessageParams = 'onText' | 'onFinalMessage' | 'onError' | 'abortRef' +// Additional dynamic tools from MCP and other sources +export type AdditionalToolInfo = { + name: string; + description: string; + params?: { + [paramName: string]: JsonSchemaLike; + }; +}; + +export type MainSendLLMMessageParams = + Omit + & { + requestId: string; + } + & SendLLMType +export type MainLLMMessageAbortParams = { requestId: string } + +export type EventLLMMessageOnTextParams = Parameters[0] & { + requestId: string; + /** + * Internal transport optimization flags. + * When true, corresponding field carries only delta relative to previous chunk for this request. + */ + isFullTextDelta?: boolean; + isFullReasoningDelta?: boolean; +} +export type EventLLMMessageOnFinalMessageParams = Parameters[0] & { requestId: string } +export type EventLLMMessageOnErrorParams = Parameters[0] & { requestId: string } + +// service -> main -> internal -> event (back to main) +// (browser) + + +// These are from 'ollama' SDK +interface OllamaModelDetails { + parent_model: string; + format: string; + family: string; + families: string[]; + parameter_size: string; + quantization_level: string; +} + +export type OllamaModelResponse = { + name: string; + modified_at: Date; + size: number; + digest: string; + details: OllamaModelDetails; + expires_at: Date; + size_vram: number; +} + +export type OpenaiCompatibleModelResponse = { + id: string; + created: number; + object: 'model'; + owned_by: string; +} + +// params to the true list fn +export type ModelListParams = { + providerName: ProviderName; + settingsOfProvider: SettingsOfProvider; + onSuccess: (param: { models: ModelResponse[] }) => void; + onError: (param: { error: string }) => void; +} + +// params to the service +export type ServiceModelListParams = { + providerName: RefreshableProviderName; + onSuccess: (param: { models: modelResponse[] }) => void; + onError: (param: { error: any }) => void; +} + +type BlockedMainModelListParams = 'onSuccess' | 'onError' +export type MainModelListParams = Omit, BlockedMainModelListParams> & { providerName: RefreshableProviderName, requestId: string } + +export type EventModelListOnSuccessParams = Parameters['onSuccess']>[0] & { requestId: string } +export type EventModelListOnErrorParams = Parameters['onError']>[0] & { requestId: string } diff --git a/src/vs/platform/void/common/storageKeys.ts b/src/vs/platform/void/common/storageKeys.ts new file mode 100644 index 00000000000..476c05b1307 --- /dev/null +++ b/src/vs/platform/void/common/storageKeys.ts @@ -0,0 +1,19 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +// past values: +// 'void.settingsServiceStorage' +// 'void.settingsServiceStorageI' // 1.0.2 + +// 1.0.3 +export const VOID_SETTINGS_STORAGE_KEY = 'void.settingsServiceStorageII' + + +// past values: +// 'void.chatThreadStorage' +// 'void.chatThreadStorageI' // 1.0.2 + +// 1.0.3 +export const THREAD_STORAGE_KEY = 'void.chatThreadStorageII' diff --git a/src/vs/platform/void/common/toolOutputFileNames.ts b/src/vs/platform/void/common/toolOutputFileNames.ts new file mode 100644 index 00000000000..4bb37e7719a --- /dev/null +++ b/src/vs/platform/void/common/toolOutputFileNames.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function normalizeForHash(s: unknown): string { + return String(s ?? '').replace(/\r\n/g, '\n'); +} + +// 32-bit FNV-1a => 8 hex chars +export function fnv1a32Hex(s: unknown): string { + const str = normalizeForHash(s); + let hash = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0; + } + return hash.toString(16).padStart(8, '0'); +} + +export function sanitizeForFileNamePart(s: unknown): string { + return String(s ?? '') + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .slice(0, 80); +} + +export function toolPrefixForToolName(toolName: unknown): string { + const t = String(toolName ?? '').trim(); + + if (t === 'run_command' || t === 'run_persistent_command' || t === 'open_persistent_terminal') return 'terminal'; + + if (t === 'read_file' || t === 'readTextFile' || t === 'fs/read_text_file') return 'read'; + if (t === 'rewrite_file' || t === 'writeTextFile' || t === 'fs/write_text_file') return 'write'; + + if (t === 'edit_file') return 'edit'; + + return sanitizeForFileNamePart(t) || 'output'; +} + +export function toolOutputFileName(prefix: unknown, key: unknown): string { + const p = sanitizeForFileNamePart(prefix) || 'output'; + const h = fnv1a32Hex(key); + return `${p}_${h}.log`; +} + +/** + * Accepts: + * - absolute path + * - relative ".void/tool_outputs/x.log" + * - just "x.log" + * and returns workspace-relative ".void/tool_outputs/" + */ +export function normalizeMetaLogFilePath(p: unknown): string | null { + const s = String(p ?? '').trim(); + if (!s) return null; + + const parts = s.split(/[/\\]/).filter(Boolean); + const base0 = parts[parts.length - 1]; + const base = sanitizeForFileNamePart(base0); + + if (!base) return null; + return `.void/tool_outputs/${base}`; +} + +export function looksLikeStableToolOutputsRelPath(p: unknown): boolean { + const s = String(p ?? ''); + return /^\.void\/tool_outputs\/[a-zA-Z0-9._-]+_[0-9a-f]{8}\.log$/.test(s); +} + +export function stableToolOutputsRelPath(opts: { + toolName?: unknown; + terminalId?: unknown; + toolCallId?: unknown; + keyText?: unknown; + fullText?: unknown; + prefixOverride?: unknown; +}): string { + const prefix = + (sanitizeForFileNamePart(opts.prefixOverride) || '') || + toolPrefixForToolName(opts.toolName); + + const key = + (String(opts.terminalId ?? '').trim() ? String(opts.terminalId) : + String(opts.toolCallId ?? '').trim() ? String(opts.toolCallId) : + (opts.fullText ?? opts.keyText ?? '')); + + const fileName = toolOutputFileName(prefix, key); + return `.void/tool_outputs/${fileName}`; +} diff --git a/src/vs/platform/void/common/toolOutputTruncation.ts b/src/vs/platform/void/common/toolOutputTruncation.ts new file mode 100644 index 00000000000..a57f497d3ef --- /dev/null +++ b/src/vs/platform/void/common/toolOutputTruncation.ts @@ -0,0 +1,60 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +/** + * Shared helper for truncating large tool outputs before sending them to an LLM + * or displaying them in the UI. + * + * This helper is intentionally pure and platform-agnostic so it can be used + * from both renderer (browser) and main/electron processes. + */ +export interface TruncatedToolOutput { + originalLength: number; + truncatedBody: string; + /** true if originalLength > maxLength */ + needsTruncation: boolean; + /** + * 1-based line number after the truncated prefix of the output. + * + * When {@link needsTruncation} is true, this is the line number such that + * consumers can read the remainder of the log starting from the line + * strictly after the part that was shown to the user. When not truncated, + * this is 0. + */ + lineAfterTruncation: number; +} + +/** + * Computes the truncated body of a tool output, without adding any headers + * or file-path hints. Callers are responsible for appending explanatory + * lines (e.g. [VOID] Tool output was truncated...) and any log-file paths. + */ +export function computeTruncatedToolOutput(originalText: string, maxLength: number): TruncatedToolOutput { + const originalLength = originalText.length; + const computeLineAfterTruncation = (body: string): number => { + if (!body) { + return 0; + } + // Treat all common newline sequences as line breaks. + return body.split(/\r\n|\r|\n/).length; + }; + + if (originalLength <= maxLength || maxLength <= 0) { + return { + originalLength, + truncatedBody: originalText, + needsTruncation: false, + lineAfterTruncation: 0, + }; + } + + const truncatedBody = originalText.substring(0, maxLength); + return { + originalLength, + truncatedBody, + needsTruncation: true, + lineAfterTruncation: computeLineAfterTruncation(truncatedBody), + }; +} diff --git a/src/vs/platform/void/common/toolsRegistry.ts b/src/vs/platform/void/common/toolsRegistry.ts new file mode 100644 index 00000000000..de5be46a374 --- /dev/null +++ b/src/vs/platform/void/common/toolsRegistry.ts @@ -0,0 +1,226 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { + MAX_TERMINAL_BG_COMMAND_TIME, + MAX_TERMINAL_INACTIVE_TIME, +} from './prompt/constants.js'; + +import { + ToolCallParams, ToolResultType, + approvalTypeOfToolName, + type SnakeCaseKeys, type ToolName +} from './toolsServiceTypes.js'; +import type { ChatMode } from './voidSettingsTypes.js'; + +export type InternalToolInfo = { + name: string; + description: string; + params: { + [paramName: string]: { description: string; type?: string } + }; +}; + +const uriParam = (object: string) => ({ + uri: { + description: + `The path to the ${object}. Prefer workspace-relative paths starting with ./ (e.g. ./src/...).` + + ` Absolute OS paths are also allowed when needed.`, + }, +}); + +const paginationParam = { + page_number: { description: 'Optional. The page number of the result. Default is 1.' } +} as const; + +const terminalDescHelper = `You can use this tool to run any command: sed, grep, etc. Do not edit any files with this tool; use edit_file instead. When working with git and other tools that open an editor (e.g. git diff), you should pipe to cat to get all results and not get stuck in vim.`; + +const cwdHelper = 'Optional. The directory in which to run the command. Defaults to the first workspace folder.'; + +export const voidTools + : { + [T in keyof ToolCallParams]: { + name: string; + description: string; + params: Partial<{ [paramName in keyof SnakeCaseKeys]: { description: string } }> + } + } + = { + read_file: { + name: 'read_file', + description: 'Reads file contents. Can read entire file, specific line range, or chunks of N lines.', + params: { + ...uriParam('file'), + start_line: { + description: 'Optional. 1-based line number to start reading from. Default = 1.' + }, + end_line: { + description: 'Optional. 1-based line number to stop reading at. If omitted with lines_count, reads to end of file.' + }, + lines_count: { + description: 'Optional. Number of lines to read starting from start_line. Alternative to end_line.' + }, + page_number: { + description: 'Optional. For character-based pagination of large files. Default = 1.' + }, + }, + }, + ls_dir: { + name: 'ls_dir', + description: 'Lists all files and folders in the given URI.', + params: { + uri: { + description: + `Optional. The path to the folder. Leave this as empty or "" to search all folders.` + + ` Prefer workspace-relative paths starting with ./; absolute paths are also allowed.`, + }, + ...paginationParam, + }, + }, + get_dir_tree: { + name: 'get_dir_tree', + description: 'Returns a tree diagram of files and folders in the given folder.', + params: { + ...uriParam('folder') + } + }, + edit_file: { + name: 'edit_file', + description: 'Apply a single, atomic replacement by specifying ORIGINAL and UPDATED snippets.', + params: { + ...uriParam('file'), + original_snippet: { description: 'The exact ORIGINAL snippet to locate in the file.' }, + updated_snippet: { description: 'The UPDATED snippet that should replace the ORIGINAL.' }, + occurrence: { description: 'Optional. 1-based occurrence index to replace. If null, uses replace_all flag behavior.' }, + replace_all: { description: 'Optional. If true, replace all occurrences of ORIGINAL with UPDATED.' }, + location_hint: { description: 'Optional. Opaque hint object to help locate ORIGINAL if necessary.' }, + encoding: { description: 'Optional. File encoding (e.g., utf-8).' }, + newline: { description: 'Optional. Preferred newline style (LF or CRLF).' }, + }, + }, + search_pathnames_only: { + name: 'search_pathnames_only', + description: 'Returns all pathnames that match the given query (searches ONLY file names).', + params: { + query: { description: 'Your query for the search.' }, + include_pattern: { description: 'Optional. Limit your search if there were too many results.' }, + ...paginationParam, + }, + }, + search_for_files: { + name: 'search_for_files', + description: 'Returns files whose content matches the given query (substring or regex).', + params: { + query: { description: 'Your query for the search.' }, + search_in_folder: { description: 'Optional. Fill only if the previous search was truncated. Searches descendants only.' }, + is_regex: { description: 'Optional. Default false. Whether the query is a regex.' }, + ...paginationParam, + }, + }, + search_in_file: { + name: 'search_in_file', + description: 'Returns all start line numbers where the content appears in the file.', + params: { + ...uriParam('file'), + query: { description: 'The string or regex to search for in the file.' }, + is_regex: { description: 'Optional. Default false. Whether the query is a regex.' }, + } + }, + read_lint_errors: { + name: 'read_lint_errors', + description: 'View all lint errors on a file.', + params: { + ...uriParam('file'), + }, + }, + rewrite_file: { + name: 'rewrite_file', + description: 'Replaces entire file contents with provided new contents.', + params: { + ...uriParam('file'), + new_content: { description: 'The new contents of the file. Must be a string.' } + }, + }, + create_file_or_folder: { + name: 'create_file_or_folder', + description: 'Create a file or folder at the given path. To create a folder, the path MUST end with a trailing slash.', + params: { + ...uriParam('file or folder'), + }, + }, + delete_file_or_folder: { + name: 'delete_file_or_folder', + description: 'Delete a file or folder at the given path.', + params: { + ...uriParam('file or folder'), + is_recursive: { description: 'Optional. Return true to delete recursively.' } + }, + }, + run_command: { + name: 'run_command', + description: `Runs a terminal command and waits for the result (times out after ${MAX_TERMINAL_INACTIVE_TIME}s of inactivity). ${terminalDescHelper}`, + params: { + command: { description: 'The terminal command to run.' }, + cwd: { description: cwdHelper }, + }, + }, + run_persistent_command: { + name: 'run_persistent_command', + description: `Runs a terminal command in the persistent terminal created with open_persistent_terminal (results after ${MAX_TERMINAL_BG_COMMAND_TIME}s are returned, command continues in background). ${terminalDescHelper}`, + params: { + command: { description: 'The terminal command to run.' }, + persistent_terminal_id: { description: 'The ID of the terminal created using open_persistent_terminal.' }, + }, + }, + open_persistent_terminal: { + name: 'open_persistent_terminal', + description: 'Open a new persistent terminal (e.g. for npm run dev).', + params: { + cwd: { description: cwdHelper }, + } + }, + kill_persistent_terminal: { + name: 'kill_persistent_terminal', + description: 'Interrupt and close a persistent terminal opened with open_persistent_terminal.', + params: { persistent_terminal_id: { description: 'The ID of the persistent terminal.' } } + } + } satisfies { [T in keyof ToolResultType]: InternalToolInfo }; + +export const toolNames = Object.keys(voidTools) as ToolName[]; +const toolNamesSet = new Set(toolNames); + +export const isAToolName = (toolName: string): toolName is ToolName => toolNamesSet.has(toolName); + +export const dynamicVoidTools = new Map(); + +export const availableTools = (chatMode: ChatMode) => { + if (chatMode === 'normal') { + return undefined; + } + + const toolNamesForMode: ToolName[] | undefined = + chatMode === 'gather' + ? (Object.keys(voidTools) as ToolName[]).filter( + toolName => !(toolName in approvalTypeOfToolName), + ) + : chatMode === 'agent' + ? (Object.keys(voidTools) as ToolName[]) + : undefined; + + if (!toolNamesForMode || toolNamesForMode.length === 0) { + return undefined; + } + + const dynamicByName = new Map(); + for (const dynamicTool of dynamicVoidTools.values()) { + dynamicByName.set(dynamicTool.name, dynamicTool); + } + + const allTools = toolNamesForMode.map(toolName => { + return dynamicByName.get(toolName) ?? voidTools[toolName]; + }); + + return allTools.length > 0 ? allTools : undefined; +}; diff --git a/src/vs/platform/void/common/toolsServiceTypes.ts b/src/vs/platform/void/common/toolsServiceTypes.ts new file mode 100644 index 00000000000..d661687e5ab --- /dev/null +++ b/src/vs/platform/void/common/toolsServiceTypes.ts @@ -0,0 +1,98 @@ +import { URI } from '../../../base/common/uri.js' + +export type TerminalResolveReason = { type: 'timeout' } | { type: 'done', exitCode: number } + +export type LintErrorItem = { code: string, message: string, startLineNumber: number, endLineNumber: number } + +// Partial of IFileStat +export type ShallowDirectoryItem = { + uri: URI; + name: string; + isDirectory: boolean; + isSymbolicLink: boolean; +} + +// PARAMS OF TOOL CALL +export type ToolCallParams = { + 'read_file': { uri: URI, startLine: number | null, endLine: number | null, lines_count?: number | null; pageNumber: number }, + 'ls_dir': { uri: URI, pageNumber: number }, + 'get_dir_tree': { uri: URI }, + 'search_pathnames_only': { query: string, includePattern: string | null, pageNumber: number }, + 'search_for_files': { query: string, isRegex: boolean, searchInFolder: URI | null, pageNumber: number }, + 'search_in_file': { uri: URI, query: string, isRegex: boolean }, + 'read_lint_errors': { uri: URI }, + // --- + 'rewrite_file': { uri: URI, newContent: string }, + 'edit_file': { uri: URI, originalSnippet: string, updatedSnippet: string, occurrence: number | null, replaceAll: boolean, locationHint: any | null, encoding: string | null, newline: string | null }, + 'create_file_or_folder': { uri: URI, isFolder: boolean }, + 'delete_file_or_folder': { uri: URI, isRecursive: boolean, isFolder: boolean }, + // --- + 'run_command': { command: string; cwd: string | null, terminalId: string }, + 'open_persistent_terminal': { cwd: string | null }, + 'run_persistent_command': { command: string; persistentTerminalId: string }, + 'kill_persistent_terminal': { persistentTerminalId: string }, +} + +// RESULT OF TOOL CALL +export type ToolResultType = { + 'read_file': { + fileContents: string, + totalFileLen: number, + totalNumLines: number, + hasNextPage: boolean, + readingLines?: string, + readLinesCount?: number, + }, + 'ls_dir': { children: ShallowDirectoryItem[] | null, hasNextPage: boolean, hasPrevPage: boolean, itemsRemaining: number }, + 'get_dir_tree': { str: string, }, + 'search_pathnames_only': { uris: URI[], hasNextPage: boolean }, + 'search_for_files': { uris: URI[], hasNextPage: boolean }, + 'search_in_file': { lines: number[]; }, + 'read_lint_errors': { lintErrors: LintErrorItem[] | null }, + // --- + 'rewrite_file': Promise<{ lintErrors: LintErrorItem[] | null }>, + 'edit_file': Promise<{ applied: boolean; occurrences_found?: number; occurrence_applied?: number; updated_text?: string; preview?: { before: string; after: string } }>, + 'create_file_or_folder': {}, + 'delete_file_or_folder': {}, + // --- + 'run_command': { result: string; resolveReason: TerminalResolveReason; }, + 'run_persistent_command': { result: string; resolveReason: TerminalResolveReason; }, + 'open_persistent_terminal': { persistentTerminalId: string }, + 'kill_persistent_terminal': {}, +} + +export type ToolName = keyof ToolResultType + +export const approvalTypeOfToolName: Partial<{ [T in ToolName]?: 'edits' | 'terminal' }> = { + 'create_file_or_folder': 'edits', + 'delete_file_or_folder': 'edits', + 'rewrite_file': 'edits', + 'edit_file': 'edits', + 'run_command': 'terminal', + 'run_persistent_command': 'terminal', + 'open_persistent_terminal': 'terminal', + 'kill_persistent_terminal': 'terminal', +} + +// {{add: define new type for approval types}} +export type ToolApprovalType = NonNullable<(typeof approvalTypeOfToolName)[keyof typeof approvalTypeOfToolName]>; + +export const toolApprovalTypes = new Set( + Object.values(approvalTypeOfToolName).filter((v): v is ToolApprovalType => v !== undefined) +) + +export type SnakeCase = + S extends 'URI' ? 'uri' + : S extends `${infer Prefix}URI` ? `${SnakeCase}_uri` + : S extends `${infer C}${infer Rest}` + ? `${C extends Lowercase ? C : `_${Lowercase}`}${SnakeCase}` + : S; + +export type SnakeCaseKeys> = { + [K in keyof T as SnakeCase>]: T[K] +}; + +export type ToolParamName = { + [K in ToolName]: keyof SnakeCaseKeys +}[ToolName] & string; + diff --git a/src/vs/workbench/contrib/void/common/voidSCMTypes.ts b/src/vs/platform/void/common/voidSCMTypes.ts similarity index 92% rename from src/vs/workbench/contrib/void/common/voidSCMTypes.ts rename to src/vs/platform/void/common/voidSCMTypes.ts index e9e6bbb2280..e51898a21fb 100644 --- a/src/vs/workbench/contrib/void/common/voidSCMTypes.ts +++ b/src/vs/platform/void/common/voidSCMTypes.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; export interface IVoidSCMService { readonly _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/void/common/voidSettingsService.ts b/src/vs/platform/void/common/voidSettingsService.ts similarity index 64% rename from src/vs/workbench/contrib/void/common/voidSettingsService.ts rename to src/vs/platform/void/common/voidSettingsService.ts index 3e0c229520c..17ddeacf8bf 100644 --- a/src/vs/workbench/contrib/void/common/voidSettingsService.ts +++ b/src/vs/platform/void/common/voidSettingsService.ts @@ -3,29 +3,72 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { deepClone } from '../../../../base/common/objects.js'; -import { IEncryptionService } from '../../../../platform/encryption/common/encryptionService.js'; -import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { deepClone } from '../../../base/common/objects.js'; +import { IEncryptionService } from '../../../platform/encryption/common/encryptionService.js'; +import { registerSingleton, InstantiationType } from '../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../platform/storage/common/storage.js'; import { IMetricsService } from './metricsService.js'; -import { defaultProviderSettings, getModelCapabilities, ModelOverrides } from './modelCapabilities.js'; +import { getModelCapabilities, VoidStaticModelInfo, ModelOverrides } from './modelInference.js'; import { VOID_SETTINGS_STORAGE_KEY } from './storageKeys.js'; -import { defaultSettingsOfProvider, FeatureName, ProviderName, ModelSelectionOfFeature, SettingsOfProvider, SettingName, providerNames, ModelSelection, modelSelectionsEqual, featureNames, VoidStatefulModelInfo, GlobalSettings, GlobalSettingName, defaultGlobalSettings, ModelSelectionOptions, OptionsOfModelSelection, ChatMode, OverridesOfModel, defaultOverridesOfModel, MCPUserStateOfName as MCPUserStateOfName, MCPUserState } from './voidSettingsTypes.js'; - +import { + FeatureName, ProviderName, + ModelSelectionOfFeature, + SettingsOfProvider, + SettingName, + ModelSelection, + modelSelectionsEqual, + featureNames, + specialToolFormat, + supportsSystemMessage, + VoidStatefulModelInfo, + GlobalSettings, + GlobalSettingName, + defaultGlobalSettings, + ModelSelectionOptions, + OptionsOfModelSelection, + MCPUserStateOfName, + MCPUserState, + ChatMode, + OverridesOfModel, + defaultOverridesOfModel +} from './voidSettingsTypes.js'; // name is the name in the dropdown export type ModelOption = { name: string, selection: ModelSelection } - - -type SetSettingOfProviderFn = ( - providerName: ProviderName, - settingName: S, - newVal: SettingsOfProvider[ProviderName][S extends keyof SettingsOfProvider[ProviderName] ? S : never], -) => Promise; +export type ModelCapabilityOverride = { + contextWindow?: number; + reservedOutputTokenSpace?: number; + supportsSystemMessage?: supportsSystemMessage; + specialToolFormat?: specialToolFormat; + supportsFIM?: boolean; + reasoningCapabilities?: false | any; + fimTransport?: 'openai-compatible' | 'mistral-native' | 'ollama-native' | 'emulated'; + supportCacheControl?: boolean; +}; + +export type CustomProviderSettings = { + endpoint?: string; + apiKey?: string; + apiStyle?: 'openai-compatible' | 'anthropic-style' | 'gemini-style'; + supportsSystemMessage?: supportsSystemMessage; + auth?: { header: string; format: 'Bearer' | 'direct' }; + additionalHeaders?: Record; + perModel?: Record; + models?: string[]; + modelsCapabilities?: Record>; + modelCapabilityOverrides?: Record; + modelsLastRefreshedAt?: number; +}; + +// Narrowed overloads to avoid ambiguous intersection types for `models` +type SetSettingOfProviderFn = { + (providerName: ProviderName, settingName: 'models', newVal: VoidStatefulModelInfo[]): Promise; + (providerName: ProviderName, settingName: Exclude, newVal: any): Promise; +}; type SetModelSelectionOfFeatureFn = ( featureName: K, @@ -34,7 +77,7 @@ type SetModelSelectionOfFeatureFn = ( type SetGlobalSettingFn = (settingName: T, newVal: GlobalSettings[T]) => void; -type SetOptionsOfModelSelection = (featureName: FeatureName, providerName: ProviderName, modelName: string, newVal: Partial) => void +type SetOptionsOfModelSelection = (featureName: FeatureName, providerName: string, modelName: string, newVal: Partial) => void export type VoidSettingsState = { @@ -43,15 +86,11 @@ export type VoidSettingsState = { readonly optionsOfModelSelection: OptionsOfModelSelection; readonly overridesOfModel: OverridesOfModel; readonly globalSettings: GlobalSettings; + readonly customProviders: Record; readonly mcpUserStateOfName: MCPUserStateOfName; // user-controlled state of MCP servers - readonly _modelOptions: ModelOption[] // computed based on the two above items } -// type RealVoidSettings = Exclude -// type EventProp = T extends 'globalSettings' ? [T, keyof VoidSettingsState[T]] : T | 'all' - - export interface IVoidSettingsService { readonly _serviceBrand: undefined; readonly state: VoidSettingsState; // in order to play nicely with react, you should immutably change state @@ -63,7 +102,6 @@ export interface IVoidSettingsService { setModelSelectionOfFeature: SetModelSelectionOfFeatureFn; setOptionsOfModelSelection: SetOptionsOfModelSelection; setGlobalSetting: SetGlobalSettingFn; - // setMCPServerStates: (newStates: MCPServerStates) => Promise; // setting to undefined CLEARS it, unlike others: setOverridesOfModel(providerName: ProviderName, modelName: string, overrides: Partial | undefined): Promise; @@ -75,15 +113,14 @@ export interface IVoidSettingsService { toggleModelHidden(providerName: ProviderName, modelName: string): void; addModel(providerName: ProviderName, modelName: string): void; deleteModel(providerName: ProviderName, modelName: string): boolean; + setCustomProviderSettings(slug: string, settings: CustomProviderSettings | undefined): Promise; addMCPUserStateOfNames(userStateOfName: MCPUserStateOfName): Promise; removeMCPUserStateOfNames(serverNames: string[]): Promise; setMCPServerState(serverName: string, state: MCPUserState): Promise; + setToolDisabled(toolName: string, disabled: boolean): Promise; } - - - const _modelsWithSwappedInNewModels = (options: { existingModels: VoidStatefulModelInfo[], models: string[], type: 'autodetected' | 'default' }) => { const { existingModels, models, type } = options @@ -92,7 +129,7 @@ const _modelsWithSwappedInNewModels = (options: { existingModels: VoidStatefulMo existingModelsMap[existingModel.modelName] = existingModel } - const newDefaultModels = models.map((modelName, i) => ({ modelName, type, isHidden: !!existingModelsMap[modelName]?.isHidden, })) + const newDefaultModels = models.map((modelName) => ({ modelName, type, isHidden: !!existingModelsMap[modelName]?.isHidden, })) return [ ...newDefaultModels, // swap out all the models of this type for the new models of this type @@ -103,7 +140,6 @@ const _modelsWithSwappedInNewModels = (options: { existingModels: VoidStatefulMo ] } - export const modelFilterOfFeatureName: { [featureName in FeatureName]: { filter: ( @@ -112,49 +148,30 @@ export const modelFilterOfFeatureName: { ) => boolean; emptyMessage: null | { message: string, priority: 'always' | 'fallback' } } } = { - 'Autocomplete': { filter: (o, opts) => getModelCapabilities(o.providerName, o.modelName, opts.overridesOfModel).supportsFIM, emptyMessage: { message: 'No models support FIM', priority: 'always' } }, - 'Chat': { filter: o => true, emptyMessage: null, }, - 'Ctrl+K': { filter: o => true, emptyMessage: null, }, - 'Apply': { filter: o => true, emptyMessage: null, }, - 'SCM': { filter: o => true, emptyMessage: null, }, -} - - -const _stateWithMergedDefaultModels = (state: VoidSettingsState): VoidSettingsState => { - let newSettingsOfProvider = state.settingsOfProvider - - // recompute default models - for (const providerName of providerNames) { - const defaultModels = defaultSettingsOfProvider[providerName]?.models ?? [] - const currentModels = newSettingsOfProvider[providerName]?.models ?? [] - const defaultModelNames = defaultModels.map(m => m.modelName) - const newModels = _modelsWithSwappedInNewModels({ existingModels: currentModels, models: defaultModelNames, type: 'default' }) - newSettingsOfProvider = { - ...newSettingsOfProvider, - [providerName]: { - ...newSettingsOfProvider[providerName], - models: newModels, - }, + 'Autocomplete': { + filter: (o, opts) => getModelCapabilities( + o.providerName as ProviderName, + o.modelName, opts.overridesOfModel + ).supportsFIM, emptyMessage: { + message: 'No models support FIM', priority: 'always' } - } - return { - ...state, - settingsOfProvider: newSettingsOfProvider, - } + }, + 'Chat': { filter: () => true, emptyMessage: null, }, + 'Ctrl+K': { filter: () => true, emptyMessage: null, }, + 'Apply': { filter: () => true, emptyMessage: null, }, + 'SCM': { filter: () => true, emptyMessage: null, }, } const _validatedModelState = (state: Omit): VoidSettingsState => { let newSettingsOfProvider = state.settingsOfProvider - // recompute _didFillInProviderSettings - for (const providerName of providerNames) { + // recompute _didFillInProviderSettings for any existing entries + for (const providerName of Object.keys(newSettingsOfProvider)) { const settingsAtProvider = newSettingsOfProvider[providerName] - - const didFillInProviderSettings = Object.keys(defaultProviderSettings[providerName]).every(key => !!settingsAtProvider[key as keyof typeof settingsAtProvider]) - + if (!settingsAtProvider) continue; + const didFillInProviderSettings = !!(settingsAtProvider as any).endpoint || !!(settingsAtProvider as any).apiKey; if (didFillInProviderSettings === settingsAtProvider._didFillInProviderSettings) continue - newSettingsOfProvider = { ...newSettingsOfProvider, [providerName]: { @@ -164,14 +181,20 @@ const _validatedModelState = (state: Omit): } } - // update model options + // update model options from dynamic custom providers only let newModelOptions: ModelOption[] = [] - for (const providerName of providerNames) { - const providerTitle = providerName // displayInfoOfProviderName(providerName).title.toLowerCase() // looks better lowercase, best practice to not use raw providerName - if (!newSettingsOfProvider[providerName]._didFillInProviderSettings) continue // if disabled, don't display model options - for (const { modelName, isHidden } of newSettingsOfProvider[providerName].models) { - if (isHidden) continue - newModelOptions.push({ name: `${modelName} (${providerTitle})`, selection: { providerName, modelName } }) + { + const seen = new Set(); + const customProviders = state.customProviders || {}; + for (const [slug, cp] of Object.entries(customProviders)) { + const models = Array.isArray(cp?.models) ? cp!.models! : []; + if (models.length === 0) continue; + for (const m of models) { + const key = `${slug}::${m}`; + if (seen.has(key)) continue; + seen.add(key); + newModelOptions.push({ name: m, selection: { providerName: slug, modelName: m } }); + } } } @@ -207,17 +230,15 @@ const _validatedModelState = (state: Omit): return newState } - - - - const defaultState = () => { const d: VoidSettingsState = { - settingsOfProvider: deepClone(defaultSettingsOfProvider), + // start empty; dynamic providers populate via customProviders + settingsOfProvider: {}, modelSelectionOfFeature: { 'Chat': null, 'Ctrl+K': null, 'Autocomplete': null, 'Apply': null, 'SCM': null }, globalSettings: deepClone(defaultGlobalSettings), optionsOfModelSelection: { 'Chat': {}, 'Ctrl+K': {}, 'Autocomplete': {}, 'Apply': {}, 'SCM': {} }, overridesOfModel: deepClone(defaultOverridesOfModel), + customProviders: {}, _modelOptions: [], // computed later mcpUserStateOfName: {}, } @@ -230,12 +251,12 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { _serviceBrand: undefined; private readonly _onDidChangeState = new Emitter(); - readonly onDidChangeState: Event = this._onDidChangeState.event; // this is primarily for use in react, so react can listen + update on state changes + readonly onDidChangeState: Event = this._onDidChangeState.event; state: VoidSettingsState; private readonly _resolver: () => void - waitForInitState: Promise // await this if you need a valid state initially + waitForInitState: Promise constructor( @IStorageService private readonly _storageService: IStorageService, @@ -249,13 +270,31 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { // at the start, we haven't read the partial config yet, but we need to set state to something this.state = defaultState() let resolver: () => void = () => { } - this.waitForInitState = new Promise((res, rej) => resolver = res) + this.waitForInitState = new Promise((res) => { resolver = res }) this._resolver = resolver this.readAndInitializeState() } + setCustomProviderSettings = async (slug: string, settings: CustomProviderSettings | undefined) => { + const newMap = { ...this.state.customProviders }; + if (settings === undefined) { + delete newMap[slug]; + } else { + newMap[slug] = settings; + } + + const newState: VoidSettingsState = { + ...this.state, + customProviders: newMap + }; + this.state = _validatedModelState(newState); + await this._storeState(); + this._onDidChangeState.fire(); + + this._metricsService.capture('Update Custom Provider', { slug, hasSettings: settings !== undefined }); + }; dangerousSetState = async (newState: VoidSettingsState) => { @@ -263,83 +302,77 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { await this._storeState() this._onDidChangeState.fire() this._onUpdate_syncApplyToChat() - this._onUpdate_syncSCMToChat() } async resetState() { await this.dangerousSetState(defaultState()) } - - - async readAndInitializeState() { let readS: VoidSettingsState try { readS = await this._readState(); + const gs = readS.globalSettings as any; // 1.0.3 addition, remove when enough users have had this code run - if (readS.globalSettings.includeToolLintErrors === undefined) readS.globalSettings.includeToolLintErrors = true + if (gs.includeToolLintErrors === undefined) gs.includeToolLintErrors = true; + if (gs.applyAstInference === undefined) gs.applyAstInference = defaultGlobalSettings.applyAstInference; // autoapprove is now an obj not a boolean (1.2.5) - if (typeof readS.globalSettings.autoApprove === 'boolean') readS.globalSettings.autoApprove = {} + if (typeof gs.autoApprove === 'boolean') gs.autoApprove = {}; // 1.3.5 add source control feature if (readS.modelSelectionOfFeature && !readS.modelSelectionOfFeature['SCM']) { readS.modelSelectionOfFeature['SCM'] = deepClone(readS.modelSelectionOfFeature['Chat']) readS.optionsOfModelSelection['SCM'] = deepClone(readS.optionsOfModelSelection['Chat']) } - // add disableSystemMessage feature - if (readS.globalSettings.disableSystemMessage === undefined) readS.globalSettings.disableSystemMessage = false; - - // add autoAcceptLLMChanges feature - if (readS.globalSettings.autoAcceptLLMChanges === undefined) readS.globalSettings.autoAcceptLLMChanges = false; + + // Loop guard thresholds (added later): backfill from defaults if missing. + if (gs.loopGuardMaxTurnsPerPrompt === undefined) { + gs.loopGuardMaxTurnsPerPrompt = defaultGlobalSettings.loopGuardMaxTurnsPerPrompt; + } + if (gs.loopGuardMaxSameAssistantPrefix === undefined) { + gs.loopGuardMaxSameAssistantPrefix = defaultGlobalSettings.loopGuardMaxSameAssistantPrefix; + } + if (gs.loopGuardMaxSameToolCall === undefined) { + gs.loopGuardMaxSameToolCall = defaultGlobalSettings.loopGuardMaxSameToolCall; + } + + // Chat retries and tool output limits (added later) + if (gs.chatRetries === undefined) { + gs.chatRetries = defaultGlobalSettings.chatRetries; + } + if (gs.retryDelay === undefined) { + gs.retryDelay = defaultGlobalSettings.retryDelay; + } + if (gs.maxToolOutputLength === undefined) { + gs.maxToolOutputLength = defaultGlobalSettings.maxToolOutputLength; + } + if (gs.notifyOnTruncation === undefined) { + gs.notifyOnTruncation = defaultGlobalSettings.notifyOnTruncation; + } + if (!Array.isArray(gs.disabledToolNames)) { + gs.disabledToolNames = []; + } } catch (e) { readS = defaultState() } + if (!readS.customProviders) { + (readS as any).customProviders = {}; + } + // the stored data structure might be outdated, so we need to update it here try { - readS = { - ...defaultState(), - ...readS, - // no idea why this was here, seems like a bug - // ...defaultSettingsOfProvider, - // ...readS.settingsOfProvider, - } - - for (const providerName of providerNames) { - readS.settingsOfProvider[providerName] = { - ...defaultSettingsOfProvider[providerName], - ...readS.settingsOfProvider[providerName], - } as any - - // conversion from 1.0.3 to 1.2.5 (can remove this when enough people update) - for (const m of readS.settingsOfProvider[providerName].models) { - if (!m.type) { - const old = (m as { isAutodetected?: boolean; isDefault?: boolean }) - if (old.isAutodetected) - m.type = 'autodetected' - else if (old.isDefault) - m.type = 'default' - else m.type = 'custom' - } - } - - // remove when enough people have had it run (default is now {}) - if (providerName === 'openAICompatible' && !readS.settingsOfProvider[providerName].headersJSON) { - readS.settingsOfProvider[providerName].headersJSON = '{}' - } - } + readS = { ...defaultState(), ...readS }; } catch (e) { readS = defaultState() } - this.state = readS - this.state = _stateWithMergedDefaultModels(this.state) - this.state = _validatedModelState(this.state); + this.state = _validatedModelState(readS); + //await initializeOpenRouterWithDynamicModels(this.state.settingsOfProvider.openRouter); this._resolver(); this._onDidChangeState.fire(); @@ -365,22 +398,25 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { this._storageService.store(VOID_SETTINGS_STORAGE_KEY, encryptedState, StorageScope.APPLICATION, StorageTarget.USER); } - setSettingOfProvider: SetSettingOfProviderFn = async (providerName, settingName, newVal) => { + // Implementation compatible with the overloads above + setSettingOfProvider: SetSettingOfProviderFn = async (providerName: ProviderName, settingName: any, newVal: any) => { const newModelSelectionOfFeature = this.state.modelSelectionOfFeature const newOptionsOfModelSelection = this.state.optionsOfModelSelection + const existing = this.state.settingsOfProvider[providerName] || { _didFillInProviderSettings: undefined, models: [] as VoidStatefulModelInfo[] }; const newSettingsOfProvider: SettingsOfProvider = { ...this.state.settingsOfProvider, [providerName]: { - ...this.state.settingsOfProvider[providerName], + ...existing, [settingName]: newVal, } } const newGlobalSettings = this.state.globalSettings const newOverridesOfModel = this.state.overridesOfModel + const newcustomProviders = this.state.customProviders const newMCPUserStateOfName = this.state.mcpUserStateOfName const newState = { @@ -389,26 +425,24 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { settingsOfProvider: newSettingsOfProvider, globalSettings: newGlobalSettings, overridesOfModel: newOverridesOfModel, + customProviders: newcustomProviders, mcpUserStateOfName: newMCPUserStateOfName, } this.state = _validatedModelState(newState) + //await initializeOpenRouterWithDynamicModels(this.state.settingsOfProvider.openRouter); + await this._storeState() this._onDidChangeState.fire() } - private _onUpdate_syncApplyToChat() { // if sync is turned on, sync (call this whenever Chat model or !!sync changes) this.setModelSelectionOfFeature('Apply', deepClone(this.state.modelSelectionOfFeature['Chat'])) } - private _onUpdate_syncSCMToChat() { - this.setModelSelectionOfFeature('SCM', deepClone(this.state.modelSelectionOfFeature['Chat'])) - } - setGlobalSetting: SetGlobalSettingFn = async (settingName, newVal) => { const newState: VoidSettingsState = { ...this.state, @@ -423,11 +457,8 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { // hooks if (this.state.globalSettings.syncApplyToChat) this._onUpdate_syncApplyToChat() - if (this.state.globalSettings.syncSCMToChat) this._onUpdate_syncSCMToChat() - } - setModelSelectionOfFeature: SetModelSelectionOfFeatureFn = async (featureName, newVal) => { const newState: VoidSettingsState = { ...this.state, @@ -444,14 +475,11 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { // hooks if (featureName === 'Chat') { - // When Chat model changes, update synced features - this._onUpdate_syncApplyToChat() - this._onUpdate_syncSCMToChat() + if (this.state.globalSettings.syncApplyToChat) this._onUpdate_syncApplyToChat() } } - - setOptionsOfModelSelection = async (featureName: FeatureName, providerName: ProviderName, modelName: string, newVal: Partial) => { + setOptionsOfModelSelection = async (featureName: FeatureName, providerName: string, modelName: string, newVal: Partial) => { const newState: VoidSettingsState = { ...this.state, optionsOfModelSelection: { @@ -461,7 +489,7 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { [providerName]: { ...this.state.optionsOfModelSelection[featureName][providerName], [modelName]: { - ...this.state.optionsOfModelSelection[featureName][providerName]?.[modelName], + ...(this.state.optionsOfModelSelection[featureName][providerName]?.[modelName] ?? {}), ...newVal } } @@ -496,12 +524,10 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { this._metricsService.capture('Update Model Overrides', { providerName, modelName, overrides }); } - - - setAutodetectedModels(providerName: ProviderName, autodetectedModelNames: string[], logging: object) { - const { models } = this.state.settingsOfProvider[providerName] + const current = this.state.settingsOfProvider[providerName] || { models: [] as VoidStatefulModelInfo[] } as any; + const models = (current.models ?? []) as VoidStatefulModelInfo[] const oldModelNames = models.map(m => m.modelName) const newModels = _modelsWithSwappedInNewModels({ existingModels: models, models: autodetectedModelNames, type: 'autodetected' }) @@ -518,7 +544,8 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { toggleModelHidden(providerName: ProviderName, modelName: string) { - const { models } = this.state.settingsOfProvider[providerName] + const current = this.state.settingsOfProvider[providerName] || { models: [] as VoidStatefulModelInfo[] } as any; + const models = (current.models ?? []) as VoidStatefulModelInfo[] const modelIdx = models.findIndex(m => m.modelName === modelName) if (modelIdx === -1) return const newIsHidden = !models[modelIdx].isHidden @@ -533,9 +560,10 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { } addModel(providerName: ProviderName, modelName: string) { - const { models } = this.state.settingsOfProvider[providerName] + const current = this.state.settingsOfProvider[providerName] || { models: [] as VoidStatefulModelInfo[] } as any; + const models = (current.models ?? []) as VoidStatefulModelInfo[] const existingIdx = models.findIndex(m => m.modelName === modelName) - if (existingIdx !== -1) return // if exists, do nothing + if (existingIdx !== -1) return const newModels = [ ...models, { modelName, type: 'custom', isHidden: false } as const @@ -546,11 +574,11 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { } deleteModel(providerName: ProviderName, modelName: string): boolean { - const { models } = this.state.settingsOfProvider[providerName] + const models = (this.state.settingsOfProvider[providerName]?.models ?? []) as VoidStatefulModelInfo[] const delIdx = models.findIndex(m => m.modelName === modelName) if (delIdx === -1) return false const newModels = [ - ...models.slice(0, delIdx), // delete the idx + ...models.slice(0, delIdx), ...models.slice(delIdx + 1, Infinity) ] this.setSettingOfProvider(providerName, 'models', newModels) @@ -560,7 +588,6 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { return true } - // MCP Server State private _setMCPUserStateOfName = async (newStates: MCPUserStateOfName) => { const newState: VoidSettingsState = { ...this.state, @@ -609,7 +636,32 @@ class VoidSettingsService extends Disposable implements IVoidSettingsService { this._metricsService.capture('Update MCP Server State', { serverName, state }); } -} + setToolDisabled = async (toolName: string, disabled: boolean) => { + const normalized = String(toolName ?? '').trim(); + if (!normalized) return; + + const current = Array.isArray(this.state.globalSettings.disabledToolNames) + ? this.state.globalSettings.disabledToolNames + : []; + const set = new Set(current.map(v => String(v).trim()).filter(Boolean)); + if (disabled) set.add(normalized); + else set.delete(normalized); + + const next = Array.from(set.values()).sort((a, b) => a.localeCompare(b)); + const newState: VoidSettingsState = { + ...this.state, + globalSettings: { + ...this.state.globalSettings, + disabledToolNames: next + } + }; + this.state = _validatedModelState(newState); + await this._storeState(); + this._onDidChangeState.fire(); + this._metricsService.capture('Set Tool Disabled', { toolName: normalized, disabled }); + } + +} registerSingleton(IVoidSettingsService, VoidSettingsService, InstantiationType.Eager); diff --git a/src/vs/platform/void/common/voidSettingsTypes.ts b/src/vs/platform/void/common/voidSettingsTypes.ts new file mode 100644 index 00000000000..92d81258ff3 --- /dev/null +++ b/src/vs/platform/void/common/voidSettingsTypes.ts @@ -0,0 +1,281 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { ModelOverrides } from './modelInference.js'; +import { ToolApprovalType } from './toolsServiceTypes.js'; +import { VoidSettingsState, CustomProviderSettings } from './voidSettingsService.js' +import { IDynamicProviderRegistryService, ProviderMeta } from './providerReg.js'; + + +let __dynamicProviderRegistry: IDynamicProviderRegistryService | undefined; +export const setDynamicProviderRegistryService = (svc: IDynamicProviderRegistryService) => { + __dynamicProviderRegistry = svc; +}; + +export type specialToolFormat = 'openai-style' | 'anthropic-style' | 'gemini-style' | 'disabled'; +export type supportsSystemMessage = false | 'system-role' | 'developer-role' | 'separated'; +export type ProviderName = string; + + +// Minimal dynamic provider metadata lookup. Falls back to simple title when not available. +export const getProviderMeta = (providerName: ProviderName): ProviderMeta | null => { + const reg = __dynamicProviderRegistry; + const slug = String(providerName).toLowerCase(); + try { + const providers = reg?.getProviders() ?? []; + const found = providers.find(p => (p.slug?.toLowerCase() === slug) || (p.name?.toLowerCase() === slug)); + if (found) return { title: found.name || found.slug }; + } catch { /* ignore */ } + return { title: String(providerName) } as ProviderMeta; +}; + +export const getLocalProviderNames = (): string[] => { + const reg = __dynamicProviderRegistry as any; + const providers = __dynamicProviderRegistry?.getProviders() ?? []; + const out: string[] = []; + for (const p of providers) { + const endpoint = (p as any).base_url || (p as any).api_base || ''; + if (p.slug && reg?.isLocalEndpoint(endpoint)) out.push(p.slug); + } + return out; +}; + +export type VoidStatefulModelInfo = { // <-- STATEFUL + modelName: string, + type: 'default' | 'autodetected' | 'custom'; + isHidden: boolean, // whether or not the user is hiding it (switched off) +} // TODO!!! eventually we'd want to let the user change supportsFIM, etc on the model themselves + +type CommonProviderSettings = { + _didFillInProviderSettings: boolean | undefined, // undefined initially, computed when user types in all fields + models: VoidStatefulModelInfo[], +} + +// Important: exclude 'models' from CustomProviderSettings to avoid conflict with stateful models list +export type SettingsAtProvider = Omit & CommonProviderSettings + +// part of state +export type SettingsOfProvider = Record + +// Legacy fields removed; dynamic config uses generic fields only +export type CustomSettingName = 'apiKey' | 'endpoint' | 'headersJSON' +export type SettingName = CustomSettingName | '_didFillInProviderSettings' | 'models' + +type DisplayInfoForProviderName = { + title: string, + desc?: string, +} + +export const displayInfoOfProviderName = (providerName: ProviderName): DisplayInfoForProviderName => { + const meta = getProviderMeta(providerName) + if (meta) return { title: meta.title } + return { title: providerName } +} + +export const subTextMdOfProviderName = (providerName: ProviderName): string => { + const meta = getProviderMeta(providerName) + return meta?.subTextMd ?? '' +} + +export const customSettingNamesOfProvider = (_providerName: ProviderName): CustomSettingName[] => { + return ['apiKey', 'endpoint', 'headersJSON']; +} + +// Dynamic approach: no static defaults. Start with an empty map and let user/dynamic registry populate. +export const defaultSettingsOfProvider: SettingsOfProvider = {} as SettingsOfProvider; + + +export type ModelSelection = { providerName: string, modelName: string } + +export const modelSelectionsEqual = (m1: ModelSelection, m2: ModelSelection) => { + return m1.modelName === m2.modelName && m1.providerName === m2.providerName +} + +// this is a state +export const featureNames = ['Chat', 'Ctrl+K', 'Autocomplete', 'Apply', 'SCM'] as const +export type ModelSelectionOfFeature = Record<(typeof featureNames)[number], ModelSelection | null> +export type FeatureName = keyof ModelSelectionOfFeature + +export const displayInfoOfFeatureName = (featureName: FeatureName) => { + // editor: + if (featureName === 'Autocomplete') + return 'Autocomplete' + else if (featureName === 'Ctrl+K') + return 'Quick Edit' + // sidebar: + else if (featureName === 'Chat') + return 'Chat' + else if (featureName === 'Apply') + return 'Apply' + else if (featureName === 'SCM') + return 'Commit Message Generator' + else + throw new Error(`Feature Name ${featureName} not allowed`) +} + +// the models of these can be refreshed (in theory all can, but not all should) +export const localProviderNames: ProviderName[] = []; +export const nonlocalProviderNames: ProviderName[] = []; +export const refreshableProviderNames: ProviderName[] = localProviderNames; +export type RefreshableProviderName = ProviderName; + +// models that come with download buttons +export const hasDownloadButtonsOnModelsProviderNames: ProviderName[] = [] + +// use this in isFeatuerNameDissbled +export const isProviderNameDisabled = (providerName: ProviderName, settingsState: VoidSettingsState) => { + const settingsAtProvider = (settingsState.settingsOfProvider[providerName] as any) || { models: [], _didFillInProviderSettings: false }; + const isAutodetected = (refreshableProviderNames as string[]).includes(providerName) + const isDisabled = (settingsAtProvider.models ?? []).length === 0 + if (isDisabled) { + return isAutodetected ? 'providerNotAutoDetected' : (!settingsAtProvider._didFillInProviderSettings ? 'notFilledIn' : 'addModel') + } + return false +} + +export const isFeatureNameDisabled = (featureName: FeatureName, settingsState: VoidSettingsState) => { + // if has a selected provider, check if it's enabled + const selectedProvider = settingsState.modelSelectionOfFeature[featureName] + + if (selectedProvider) { + const { providerName } = selectedProvider + + + const customProvider = settingsState.customProviders?.[providerName] + if (!customProvider?.endpoint) { + return 'addProvider' + } + return false + } + + // Dynamic providers: if any configured provider exists, suggest adding a model; else suggest adding provider + const anyConfigured = Object.values(settingsState.customProviders || {}).some(v => !!v?.endpoint) + if (anyConfigured) return 'addModel' + + return 'addProvider' +} + +export type ChatMode = 'agent' | 'gather' | 'normal' + +export const DISABLE_TELEMETRY_KEY = 'void.settings.disableTelemetry'; + +export type GlobalSettings = { + autoRefreshModels: boolean; + aiInstructions: string; + enableAutocomplete: boolean; + syncApplyToChat: boolean; + syncSCMToChat: boolean; + enableFastApply: boolean; + applyAstInference: boolean; + chatMode: ChatMode; + autoApprove: { [approvalType in ToolApprovalType]?: boolean }; + mcpAutoApprove: boolean; + showInlineSuggestions: boolean; + includeToolLintErrors: boolean; + // Loop guard thresholds shared between non-ACP chat and ACP agent. + // These map directly onto LLMLoopDetector options (except prefix length, which uses a fixed default). + loopGuardMaxTurnsPerPrompt: number; + loopGuardMaxSameAssistantPrefix: number; + loopGuardMaxSameToolCall: number; + isOnboardingComplete: boolean; + disableTelemetry: boolean; + + useAcp: boolean; + // Connection mode: 'builtin' | 'websocket' | 'process' + acpMode: 'builtin' | 'websocket' | 'process'; + acpAgentUrl: string; // for websocket + // for process: + acpProcessCommand: string; + acpProcessArgs: string[]; + acpProcessEnv: Record; + + acpModel: string | null; + acpSystemPrompt: string | null; + showAcpPlanInChat: boolean; + + chatRetries: number; + retryDelay: number; + maxToolOutputLength: number; + readFileChunkLines: number; + notifyOnTruncation: boolean; + /** Tool names (static and dynamic) disabled by user in settings UI. */ + disabledToolNames: string[]; +} + +export const defaultGlobalSettings: GlobalSettings = { + autoRefreshModels: true, + aiInstructions: '', + enableAutocomplete: false, + syncApplyToChat: true, + enableFastApply: true, + applyAstInference: true, + syncSCMToChat: true, + chatMode: 'agent', + autoApprove: {}, + mcpAutoApprove: false, + showInlineSuggestions: true, + includeToolLintErrors: true, + loopGuardMaxTurnsPerPrompt: 38, + loopGuardMaxSameAssistantPrefix: 16, + loopGuardMaxSameToolCall: 16, + isOnboardingComplete: false, + disableTelemetry: true, + useAcp: false, + acpMode: 'builtin', + acpAgentUrl: 'ws://127.0.0.1:8719', + acpProcessCommand: '', + acpProcessArgs: [], + acpProcessEnv: {}, + acpModel: null, + acpSystemPrompt: null, + showAcpPlanInChat: true, + + chatRetries: 0, + retryDelay: 2500, + maxToolOutputLength: 40000, + readFileChunkLines: 200, + notifyOnTruncation: true, + disabledToolNames: [], +} + +export type GlobalSettingName = keyof GlobalSettings +export const globalSettingNames = Object.keys(defaultGlobalSettings) as GlobalSettingName[] + +export type ModelSelectionOptions = { + reasoningEnabled?: boolean; + reasoningBudget?: number; + reasoningEffort?: string; + /** Custom temperature for OpenAI-compatible providers */ + temperature?: number; + /** Custom max_tokens for OpenAI-compatible providers */ + maxTokens?: number; +} + +export type OptionsOfModelSelection = { + [featureName in FeatureName]: { + [providerName: string]: { + [modelName: string]: ModelSelectionOptions | undefined + } + } +} + +export type OverridesOfModel = { + [providerName: string]: { + [modelName: string]: Partial | undefined + } +} + +export const defaultOverridesOfModel: OverridesOfModel = {} + +// Back-compat shim for older imports; dynamic list should be retrieved from registry instead. +export const providerNames: ProviderName[] = [] + +export interface MCPUserState { + isOn: boolean; +} + +export interface MCPUserStateOfName { + [serverName: string]: MCPUserState | undefined; +} diff --git a/src/vs/workbench/contrib/void/common/voidUpdateService.ts b/src/vs/platform/void/common/voidUpdateService.ts similarity index 79% rename from src/vs/workbench/contrib/void/common/voidUpdateService.ts rename to src/vs/platform/void/common/voidUpdateService.ts index fbf72b547ba..8a378895e0c 100644 --- a/src/vs/workbench/contrib/void/common/voidUpdateService.ts +++ b/src/vs/platform/void/common/voidUpdateService.ts @@ -3,23 +3,19 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; +import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { registerSingleton, InstantiationType } from '../../instantiation/common/extensions.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; import { VoidCheckUpdateRespose } from './voidUpdateServiceTypes.js'; - - export interface IVoidUpdateService { readonly _serviceBrand: undefined; check: (explicit: boolean) => Promise; } - export const IVoidUpdateService = createDecorator('VoidUpdateService'); - // implemented by calling channel export class VoidUpdateService implements IVoidUpdateService { diff --git a/src/vs/workbench/contrib/void/common/voidUpdateServiceTypes.ts b/src/vs/platform/void/common/voidUpdateServiceTypes.ts similarity index 100% rename from src/vs/workbench/contrib/void/common/voidUpdateServiceTypes.ts rename to src/vs/platform/void/common/voidUpdateServiceTypes.ts diff --git a/src/vs/platform/void/electron-main/llmMessage/extractGrammar.ts b/src/vs/platform/void/electron-main/llmMessage/extractGrammar.ts new file mode 100644 index 00000000000..f416604b90a --- /dev/null +++ b/src/vs/platform/void/electron-main/llmMessage/extractGrammar.ts @@ -0,0 +1,1045 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../../base/common/uuid.js' +import { endsWithAnyPrefixOf } from '../../common/helpers/extractCodeFromResult.js' +import { availableTools, InternalToolInfo } from '../../common/toolsRegistry.js' +import { ToolName, ToolParamName } from '../../common/toolsServiceTypes.js' +import { OnFinalMessage, OnText, RawToolCallObj, RawToolParamsObj } from '../../common/sendLLMMessageTypes.js' +import { ChatMode } from '../../common/voidSettingsTypes.js' + + +const escapeRegExp = (s: string) => + s.replace(/[\\\^\$\.\*\+\?\(\)\[\]\{\}\|\/\-]/g, '\\$&'); + +type ToolOfToolName = { [toolName: string]: InternalToolInfo | undefined } + +//this need if llm tool (edit_file) call with CDATA block +const extractParamValue = ( + str: string, + paramName: string, + toolName: string +): { value: string | undefined; endIndex: number } => { + const needsCDATA = toolName === 'edit_file' && + (paramName === 'original_snippet' || paramName === 'updated_snippet'); + + const esc = escapeRegExp(paramName); + const openRe = new RegExp(`<${esc}\\b[^>]*>`, 'i'); // + const closeRe = new RegExp(``, 'i'); // + + const openMatch = openRe.exec(str); + if (!openMatch) return { value: undefined, endIndex: -1 }; + + const contentStart = (openMatch.index ?? 0) + openMatch[0].length; + + if (needsCDATA) { + const cStart = str.indexOf('', cStart + 9); + if (cEnd !== -1) { + const tailAfterCdata = str.slice(cEnd + 3); + const closeMatchAfter = closeRe.exec(tailAfterCdata); + if (closeMatchAfter) { + const value = str.slice(cStart + 9, cEnd); + const endIndex = (cEnd + 3) + ((closeMatchAfter.index ?? 0) + closeMatchAfter[0].length); + return { value, endIndex }; + } + } + } + } + + const tail = str.slice(contentStart); + const closeMatch = closeRe.exec(tail); + if (!closeMatch) return { value: undefined, endIndex: -1 }; + + const valueEnd = contentStart + (closeMatch.index ?? 0); + const value = str.slice(contentStart, valueEnd); + return { value, endIndex: valueEnd + closeMatch[0].length }; +}; + + +const getRequiredParamNames = ( + toolName: ToolName, + toolOfToolName: ToolOfToolName +): ToolParamName[] => { + const def: any = toolOfToolName[toolName]?.params; + if (!def) return []; + const required: ToolParamName[] = []; + for (const key of Object.keys(def)) { + const meta = def[key]; + if (meta && typeof meta === 'object' && (meta.required === true || meta?.schema?.required === true)) { + required.push(key as ToolParamName); + } + } + return required; +}; + +const hasAllRequiredParams = ( + toolName: ToolName, + paramsObj: RawToolParamsObj, + toolOfToolName: ToolOfToolName +): boolean => { + const required = getRequiredParamNames(toolName, toolOfToolName); + if (required.length === 0) return false; + return required.every(p => paramsObj[p] !== undefined); +}; + + +const findLastOpenBefore = (s: string, toolName: string, beforeIdx: number): { index: number; len: number } | null => { + const esc = escapeRegExp(toolName); + const openReG = new RegExp(`<${esc}\\b[^>]*>`, 'ig'); + let lastIdx = -1; + let lastLen = 0; + let m: RegExpExecArray | null; + while ((m = openReG.exec(s))) { + if (m.index < beforeIdx) { + lastIdx = m.index; + lastLen = m[0].length; + } else { + break; + } + } + return lastIdx >= 0 ? { index: lastIdx, len: lastLen } : null; +}; + + +const findParamAnchorBefore = ( + s: string, + toolName: ToolName, + beforeIdx: number, + toolOfToolName: ToolOfToolName +): number => { + const params = Object.keys(toolOfToolName[toolName]?.params ?? {}); + if (params.length === 0) return -1; + const alt = params.map(escapeRegExp).join('|'); + const re = new RegExp(`<(?:${alt})\\b[^>]*>`, 'ig'); + + let firstIdx = -1; + let lastIdx = -1; + let m: RegExpExecArray | null; + while ((m = re.exec(s))) { + if (m.index < beforeIdx) { + if (firstIdx === -1) firstIdx = m.index; + lastIdx = m.index; + } else { + break; + } + } + return firstIdx >= 0 ? firstIdx : lastIdx; +}; + +const maskCodeBlocks = (s: string): string => { + if (!s) return s; + const len = s.length; + const mask = new Uint8Array(len); + + + const mark = (from: number, to: number) => { + if (from < 0 || to <= from) return; + const a = Math.max(0, Math.min(len, from)); + const b = Math.max(0, Math.min(len, to)); + for (let i = a; i < b; i++) mask[i] = 1; + }; + + + { + const re = /```[\s\S]*?```/g; + let m: RegExpExecArray | null; + while ((m = re.exec(s))) { + mark(m.index, m.index + m[0].length); + } + } + + + { + const re = /~~~[\s\S]*?~~~/g; + let m: RegExpExecArray | null; + while ((m = re.exec(s))) { + mark(m.index, m.index + m[0].length); + } + } + + + { + const re = /`[^`\n]+`/g; + let m: RegExpExecArray | null; + while ((m = re.exec(s))) { + mark(m.index, m.index + m[0].length); + } + } + + + if (!mask.includes(1)) return s; + const out: string[] = new Array(len); + for (let i = 0; i < len; i++) { + out[i] = mask[i] ? ' ' : s[i]; + } + return out.join(''); +}; + + +type ToolRegion = + | { kind: 'self'; toolName: ToolName; start: number; end: number } + | { kind: 'openOnly'; toolName: ToolName; start: number; end: number } + | { kind: 'openClose'; toolName: ToolName; start: number; end: number } + | { kind: 'closeOnly'; toolName: ToolName; start: number; end: number }; + +const findFirstToolRegionEnhanced = ( + text: string, + tools: InternalToolInfo[], + toolOfToolName: ToolOfToolName +): ToolRegion | null => { + if (!text || !tools.length) return null; + + const namesAlt = tools.map(t => escapeRegExp(t.name)).join('|'); + if (!namesAlt) return null; + + + const anyTagRe = new RegExp(`<\\/?(${namesAlt})\\b[^>]*?>`, 'ig'); + const first = anyTagRe.exec(text); + if (!first) return null; + + const idx = first.index; + const raw = first[0]; + const name = (first[1] || '').toLowerCase() as ToolName; + + const isClose = raw.startsWith('$/i.test(raw); + + if (isSelf) { + return { kind: 'self', toolName: name, start: idx, end: idx + raw.length }; + } + + if (!isClose) { + + const esc = escapeRegExp(name); + const openLen = raw.length; + const tail = text.slice(idx + openLen); + const closeRe = new RegExp(``, 'i'); + const mClose = closeRe.exec(tail); + if (mClose && mClose.index !== undefined) { + const end = (idx + openLen) + mClose.index + mClose[0].length; + return { kind: 'openClose', toolName: name, start: idx, end }; + } + return { kind: 'openOnly', toolName: name, start: idx, end: text.length }; + } + + + const closeLen = raw.length; + const mOpenPrev = findLastOpenBefore(text, name, idx); + if (mOpenPrev) { + return { kind: 'openClose', toolName: name, start: mOpenPrev.index, end: idx + closeLen }; + } + + + const anchor = findParamAnchorBefore(text, name, idx, toolOfToolName); + const start = (anchor >= 0 ? anchor : idx); + return { kind: 'closeOnly', toolName: name, start, end: idx + closeLen }; +}; + + +const normalizeParamName = (s: string) => { + let out = (s || '').trim(); + out = out.replace(/-/g, '_'); + // camelCase -> snake_case + out = out.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); + return out; +}; + +// If we ever wrap CDATA, avoid breaking on "]]>" +const escapeForCdata = (s: string) => { + if (!s) return s; + return s.includes(']]>') ? s.split(']]>').join(']]]]>') : s; +}; + +const findToolCallRegion = (maskedText: string): { start: number; end: number } | null => { + const openRe = /]*>/i; + const mOpen = openRe.exec(maskedText); + if (!mOpen || mOpen.index === undefined) return null; + + const start = mOpen.index; + + // try find close + const tail = maskedText.slice(start + mOpen[0].length); + const mClose = /<\/tool_call\s*>/i.exec(tail); + if (mClose && mClose.index !== undefined) { + const end = (start + mOpen[0].length) + mClose.index + mClose[0].length; + return { start, end }; + } + + // no close yet (streaming) -> treat as open until end + return { start, end: maskedText.length }; +}; + +const buildVoidXmlFromToolCallWrapper = ( + wrapperXml: string, + toolOfToolName: { [toolName: string]: InternalToolInfo | undefined } +): { toolName: ToolName; xml: string } | null => { + // function name variants: + // 1) ... + // 2) ... + const fnMatch = + //i.exec(wrapperXml) || + /]*\bname\s*=\s*["']?([a-zA-Z0-9_\-]+)["']?[^>]*>/i.exec(wrapperXml); + + if (!fnMatch) return null; + + const toolNameRaw = (fnMatch[1] || '').toLowerCase(); + const toolName = toolNameRaw as ToolName; + + // Only transform if tool is actually known/allowed in this context + if (!toolOfToolName[toolName]) return null; + + const allowedParams = new Set(Object.keys(toolOfToolName[toolName]?.params ?? {})); + + // parameter variants: + // 1) ... + // 2) ... + const params: Array<{ name: string; value: string }> = []; + const paramRe = + /([\s\S]*?)<\/parameter\s*>|]*\bname\s*=\s*["']?([a-zA-Z0-9_\-]+)["']?[^>]*>([\s\S]*?)<\/parameter\s*>/ig; + + let m: RegExpExecArray | null; + while ((m = paramRe.exec(wrapperXml))) { + const rawName = (m[1] ?? m[3] ?? '').trim(); + const rawVal = (m[2] ?? m[4] ?? ''); + + if (!rawName) continue; + + const name = normalizeParamName(rawName); + + // If tool has a known param set, keep only matching ones (optional, but safer) + if (allowedParams.size > 0 && !allowedParams.has(name)) { + continue; + } + const needsCdata = toolName === 'edit_file' && (name === 'original_snippet' || name === 'updated_snippet'); + const value = needsCdata ? rawVal : rawVal.trim(); + + params.push({ name, value }); + } + + // Build void XML + const inner = params.map(p => { + const needsCdata = toolName === 'edit_file' && (p.name === 'original_snippet' || p.name === 'updated_snippet'); + if (needsCdata) { + return `<${p.name}>`; + } + return `<${p.name}>${p.value}`; + }).join('\n'); + + // If wrapper has we can close; if not, leave open (streaming-friendly) + const hasClose = /<\/tool_call\s*>/i.test(wrapperXml); + const xml = hasClose + ? `<${toolName}>\n${inner}\n` + : `<${toolName}>\n${inner}\n`; + + return { toolName, xml }; +}; + +const parseXMLPrefixToToolCall = ( + toolName: ToolName, + toolId: string, + str: string, + toolOfToolName: ToolOfToolName +): RawToolCallObj => { + + const paramsObj: RawToolParamsObj = {}; + const doneParams: ToolParamName[] = []; + let isDone = false; + + const getAnswer = (): RawToolCallObj => { + for (const p in paramsObj) { + const paramName = p as ToolParamName; + const orig = paramsObj[paramName]; + if (orig === undefined) continue; + + const isCDATAParam = toolName === 'edit_file' && + (paramName === 'original_snippet' || paramName === 'updated_snippet'); + if (!isCDATAParam) { + paramsObj[paramName] = trimBeforeAndAfterNewLines(orig as string); + } + } + return { + name: toolName, + rawParams: paramsObj, + doneParams, + isDone, + id: toolId, + }; + }; + + const esc = escapeRegExp(toolName); + const openRe = new RegExp(`<${esc}\\b[^>]*>`, 'i'); // + const closeRe = new RegExp(``, 'i'); // + const selfRe = new RegExp(`<${esc}\\b[^>]*/>`, 'i'); // + + const openMatch = openRe.exec(str); + const selfMatch = selfRe.exec(str); + + + if (!openMatch && !selfMatch) return getAnswer(); + + + if (selfMatch && (!openMatch || (selfMatch.index ?? 0) < (openMatch.index ?? 0))) { + isDone = true; + return getAnswer(); + } + + + const start = (openMatch!.index ?? 0) + openMatch![0].length; + const tail = str.slice(start); + const closeMatch = closeRe.exec(tail); + + let inner = ''; + if (!closeMatch) { + + inner = tail; + } else { + inner = tail.slice(0, closeMatch.index ?? 0); + isDone = true; + } + + const allowedParams = Object.keys(toolOfToolName[toolName]?.params ?? {}) as ToolParamName[]; + for (const paramName of allowedParams) { + const { value } = extractParamValue(inner, paramName, toolName); + if (value !== undefined) { + paramsObj[paramName] = value; + doneParams.push(paramName); + } + } + + if (!isDone) { + const allRequiredPresent = hasAllRequiredParams(toolName, paramsObj, toolOfToolName); + const allAllowedPresent = allowedParams.length > 0 && doneParams.length === allowedParams.length; + if (allRequiredPresent || allAllowedPresent) { + isDone = true; + } + } + + return getAnswer(); +}; + +const splitProviderReasoning = (s: string, tags: [string, string] | null): { reasoning: string; after: string } => { + if (!s) return { reasoning: '', after: '' }; + if (!tags) return { reasoning: s, after: '' }; + + const [openTag, closeTag] = tags; + + + const closeIdx = s.lastIndexOf(closeTag); + if (closeIdx >= 0) { + const beforeClose = s.slice(0, closeIdx); + const after = s.slice(closeIdx + closeTag.length); + const reasoning = beforeClose.split(openTag).join('').split(closeTag).join(''); + return { reasoning, after }; + } + + + const reasoning = s.split(openTag).join('').split(closeTag).join(''); + return { reasoning, after: '' }; +}; + + +type ExtractReasoningWrapperOpts = { + toolsListOverride?: InternalToolInfo[]; + enableXmlToolParsing?: boolean; +}; + +export const extractReasoningAndXMLToolsWrapper = ( + onText: OnText, + onFinalMessage: OnFinalMessage, + thinkTagsInit: [string, string] | null, + chatMode: ChatMode | null, + opts?: ExtractReasoningWrapperOpts +): { newOnText: OnText; newOnFinalMessage: OnFinalMessage } => { + + // Tools + const enableXmlToolParsing = opts?.enableXmlToolParsing !== false; + const allowed = enableXmlToolParsing && chatMode ? availableTools(chatMode) : null; + const toolsList: InternalToolInfo[] = + !enableXmlToolParsing + ? [] + : (opts?.toolsListOverride && opts.toolsListOverride.length) + ? opts.toolsListOverride + : (Array.isArray(allowed) + ? allowed + : (allowed ? Object.values(allowed) : [])); + + type ToolOfToolName = { [toolName: string]: InternalToolInfo | undefined }; + const toolOfToolName: ToolOfToolName = {}; + for (const t of toolsList) toolOfToolName[t.name] = t; + + const toolId = generateUuid(); + + const toolTagHints = toolsList.map(t => String(t.name || '').toLowerCase()).filter(Boolean); + const incrementLookbackChars = 96; + const reasoningPrefixProbeLen = 96; + let lastReasoningParseObservedLen = 0; + let lastThinkDetectObservedLen = 0; + + + let r_foundTag1 = false; + let r_foundTag2 = false; + let r_latestAddIdx = 0; + let r_fullTextSoFar = ''; + let r_fullReasoningSoFar = ''; + let r_providerReasoningAcc = ''; + + + let lastReasoning = ''; + let latestToolCall: RawToolCallObj | undefined = undefined; + + + let activeThinkTags: [string, string] | null = thinkTagsInit; + + const likelyContainsToolMarkup = (s: string): boolean => { + if (!s || s.indexOf('<') === -1) return false; + const lower = s.toLowerCase(); + + if ( + lower.includes(' `<${h}`), + ...toolTagHints.map(h => ` { + if (!s || !enableXmlToolParsing || toolOpenMarkers.length === 0) return s; + if (s.indexOf('<') === -1) return s; + + const lower = s.toLowerCase(); + let trimLen = 0; + + for (const marker of toolOpenMarkers) { + const m = endsWithAnyPrefixOf(lower, marker); + if (!m) continue; + + // marker itself still represents an unfinished open/close sequence in streaming context, + // so trim both partial prefixes and exact marker matches. + const curr = m.length; + if (curr > trimLen) trimLen = curr; + } + + if (trimLen <= 0) return s; + return s.slice(0, Math.max(0, s.length - trimLen)).trimEnd(); + }; + + const shouldParseByIncrement = (full: string, prevLen: number): boolean => { + if (!enableXmlToolParsing || toolsList.length === 0 || !full) return false; + const safePrev = Math.max(0, Math.min(prevLen, full.length)); + const start = Math.max(0, safePrev - incrementLookbackChars); + return likelyContainsToolMarkup(full.slice(start)); + }; + + const stripThinkTagsFromText = (s: string): string => { + if (!s || !activeThinkTags) return s; + const [openTag, closeTag] = activeThinkTags; + return s.split(openTag).join('').split(closeTag).join(''); + }; + + const stripToolCallWrapperOnce = (s: string): string => { + if (!s) return s; + + + const full = s.replace(//i, ''); + if (full !== s) return full.trim(); + + + return s.replace(/ { + + const lastNl = beforeText.lastIndexOf('\n'); + const tail = lastNl >= 0 ? beforeText.slice(lastNl + 1) : beforeText; + return tail.trim() === ''; + }; + + // ============ Utils ============ + const stripToolXmlOnce = (s: string, toolName: string): string => { + if (!s) return s; + const esc = escapeRegExp(toolName); + const re = new RegExp(`<${esc}\\b[\\s\\S]*?<\\/${esc}>`, 'i'); + return s.replace(re, '').trim(); + }; + + const stripToolXmlOrOpenTailOnce = (s: string, toolName: string): string => { + if (!s) return s; + const esc = escapeRegExp(toolName); + + // Prefer removing a closed block first. + const closedRe = new RegExp(`<${esc}\\b[\\s\\S]*?<\\/${esc}>`, 'i'); + const removedClosed = s.replace(closedRe, '').trim(); + if (removedClosed !== s) return removedClosed; + + // Streaming/open-only fallback: drop everything from opening tag to end. + const openTailRe = new RegExp(`<${esc}\\b[^>]*>[\\s\\S]*$`, 'i'); + const removedOpenTail = s.replace(openTailRe, '').trimEnd(); + if (removedOpenTail !== s) return removedOpenTail; + + return s; + }; + + const stripFakeResultTail = (s: string): string => { + if (!s) return s; + const m = s.match(/<\w+_result\b[\s\S]*$/i); + if (!m || m.index === undefined || m.index < 0) return s; + return s.slice(0, m.index).trimEnd(); + }; + + const parseToolFromText = ( + text: string, + opts?: { skipLikelihoodCheck?: boolean } + ): { beforeText: string; call?: RawToolCallObj; source?: 'tool_call' | 'tool_tag' } => { + if (!text) return { beforeText: '' }; + if (!enableXmlToolParsing || !toolsList.length) return { beforeText: text }; + if (!opts?.skipLikelihoodCheck && !likelyContainsToolMarkup(text)) return { beforeText: text }; + + const masked = (text.includes('`') || text.includes('~')) ? maskCodeBlocks(text) : text; + + + const toolCallRegion = findToolCallRegion(masked); + + + const region = findFirstToolRegionEnhanced(masked, toolsList, toolOfToolName); + + const toolCallStartsFirst = + toolCallRegion && + (!region || toolCallRegion.start < region.start); + + if (toolCallStartsFirst) { + const beforeText = text.slice(0, toolCallRegion.start); + + const wrapperSlice = text.slice(toolCallRegion.start, toolCallRegion.end); + const built = buildVoidXmlFromToolCallWrapper(wrapperSlice, toolOfToolName); + if (built) { + const call = parseXMLPrefixToToolCall( + built.toolName, + toolId, + built.xml, + toolOfToolName + ); + return { beforeText, call, source: 'tool_call' }; + } + return { beforeText }; + } + + if (!region) return { beforeText: text }; + + const before = text.slice(0, region.start); + let xmlForParse = ''; + + if (region.kind === 'closeOnly') { + + const esc = escapeRegExp(region.toolName); + const slice = text.slice(region.start, region.end); + const inner = slice.replace(new RegExp(`\\s*$`, 'i'), ''); + xmlForParse = `<${region.toolName}>` + inner + ``; + } else { + + xmlForParse = text.slice(region.start); + } + + const call = parseXMLPrefixToToolCall( + region.toolName as ToolName, + toolId, + xmlForParse, + toolOfToolName + ); + + return { beforeText: before, call, source: 'tool_tag' }; + }; + + const mergeToolCall = (curr: RawToolCallObj | undefined, cand: RawToolCallObj | undefined) => { + if (!cand) return curr; + if (!curr) return cand; + + + if (curr.name === cand.name) { + const rawParams = { ...(curr.rawParams ?? {}), ...(cand.rawParams ?? {}) } as RawToolParamsObj; + const doneParams = Array.from(new Set([...(curr.doneParams ?? []), ...(cand.doneParams ?? [])])) as ToolParamName[]; + + let isDone = !!(curr.isDone || cand.isDone); + if (!isDone) { + try { + isDone = hasAllRequiredParams(curr.name as ToolName, rawParams, toolOfToolName); + } catch { } + } + + return { + ...curr, + rawParams, + doneParams, + isDone, + }; + } + + + if (curr.isDone) return curr; + if (cand.isDone) return cand; + return curr; + }; + + + const THINK_PAIRS: [string, string][] = [ + ['', ''], + ['', ''], + ['◁think▷', '◁/think▷'], + ['◁thinking▷', '◁/thinking▷'], + ['‹think›', '‹/think›'], + ['〈think〉', '〈/think〉'], + ['【think】', '【/think】'], + ['【thinking】', '【/thinking】'], + ]; + + const detectThinkTags = (s: string): [string, string] | null => { + if (!s) return null; + for (const [open, close] of THINK_PAIRS) { + if (s.includes(open)) return [open, close]; + const partial = endsWithAnyPrefixOf(s, open); + if (partial && partial !== '') return [open, close]; + } + return null; + }; + + const maxThinkOpenTagLen = THINK_PAIRS.reduce((m, [open]) => Math.max(m, open.length), 0); + const detectThinkTagsByIncrement = (full: string): [string, string] | null => { + if (!full) return null; + const safePrev = full.length < lastThinkDetectObservedLen + ? 0 + : Math.max(0, Math.min(lastThinkDetectObservedLen, full.length)); + const start = Math.max(0, safePrev - maxThinkOpenTagLen); + const maybe = detectThinkTags(full.slice(start)); + lastThinkDetectObservedLen = full.length; + return maybe; + }; + + const extractReasoningViaTags = ( + fullText_: string, + tags: [string, string] + ): { textOut: string; reasoningOut: string; earlyReturn?: boolean } => { + const [openTag, closeTag] = tags; + + + if (!r_foundTag1) { + const endsWithOpen = endsWithAnyPrefixOf(fullText_, openTag); + if (endsWithOpen && endsWithOpen !== openTag) { + return { textOut: r_fullTextSoFar, reasoningOut: r_fullReasoningSoFar, earlyReturn: true }; + } + const tag1Index = fullText_.indexOf(openTag); + if (tag1Index !== -1) { + r_foundTag1 = true; + r_fullTextSoFar += fullText_.substring(0, tag1Index); + r_latestAddIdx = tag1Index + openTag.length; + } else { + r_fullTextSoFar = fullText_; + r_latestAddIdx = fullText_.length; + return { textOut: r_fullTextSoFar, reasoningOut: r_fullReasoningSoFar }; + } + } + + + if (!r_foundTag2) { + const endsWithClose = endsWithAnyPrefixOf(fullText_, closeTag); + if (endsWithClose && endsWithClose !== closeTag) { + if (fullText_.length > r_latestAddIdx) { + r_fullReasoningSoFar += fullText_.substring(r_latestAddIdx); + r_latestAddIdx = fullText_.length; + } + return { textOut: r_fullTextSoFar, reasoningOut: r_fullReasoningSoFar, earlyReturn: true }; + } + const tag2Index = fullText_.indexOf(closeTag, r_latestAddIdx); + if (tag2Index !== -1) { + r_fullReasoningSoFar += fullText_.substring(r_latestAddIdx, tag2Index); + r_foundTag2 = true; + r_latestAddIdx = tag2Index + closeTag.length; + } else { + if (fullText_.length > r_latestAddIdx) { + r_fullReasoningSoFar += fullText_.substring(r_latestAddIdx); + r_latestAddIdx = fullText_.length; + } + return { textOut: r_fullTextSoFar, reasoningOut: r_fullReasoningSoFar }; + } + } + + + if (fullText_.length > r_latestAddIdx) { + r_fullTextSoFar += fullText_.substring(r_latestAddIdx); + r_latestAddIdx = fullText_.length; + } + return { textOut: r_fullTextSoFar, reasoningOut: r_fullReasoningSoFar }; + }; + + // ============ Handlers ============ + + const isValidToolCall = (t?: RawToolCallObj): t is RawToolCallObj => + !!(t && t.name && String(t.name).trim().length > 0); + + const newOnText: OnText = (params) => { + const rawFullText = params.fullText || ''; + const providerReasoning = params.fullReasoning ?? undefined; + const incomingPlan = params.plan; + + let textForXml = rawFullText; + let reasoningForSearch = lastReasoning; + + + if (providerReasoning !== undefined) { + if (!r_providerReasoningAcc) { + r_providerReasoningAcc = providerReasoning; + } else { + const prevLen = r_providerReasoningAcc.length; + const probeLen = Math.min(reasoningPrefixProbeLen, prevLen, providerReasoning.length); + const hasSamePrefix = + probeLen > 0 && + providerReasoning.slice(0, probeLen) === r_providerReasoningAcc.slice(0, probeLen); + + if (providerReasoning.length > prevLen && hasSamePrefix) { + + r_providerReasoningAcc = providerReasoning; + } else if (providerReasoning.length < prevLen && hasSamePrefix) { + + r_providerReasoningAcc = providerReasoning; + } else if (!(providerReasoning.length === prevLen && hasSamePrefix)) { + + r_providerReasoningAcc += providerReasoning; + } + } + + + if (!activeThinkTags) { + const maybe = detectThinkTagsByIncrement(r_providerReasoningAcc); + if (maybe) { activeThinkTags = maybe; } + } + + + if (activeThinkTags) { + const [openTag, closeTag] = activeThinkTags; + const pOpen = endsWithAnyPrefixOf(providerReasoning, openTag); + const pClose = endsWithAnyPrefixOf(providerReasoning, closeTag); + if ((pOpen && pOpen !== openTag) || (pClose && pClose !== closeTag)) { + return; + } + } + + + const { reasoning, after } = splitProviderReasoning(r_providerReasoningAcc, activeThinkTags); + reasoningForSearch = reasoning; + textForXml = (textForXml || '') + (after || ''); + } else { + + if (!activeThinkTags) { + const maybe = detectThinkTagsByIncrement(rawFullText); + if (maybe) { activeThinkTags = maybe; } + } + + + if (activeThinkTags) { + const r = extractReasoningViaTags(rawFullText, activeThinkTags); + if (r.earlyReturn) { + return; + } + textForXml = r.textOut; + reasoningForSearch = r.reasoningOut; + } + } + + lastReasoning = reasoningForSearch; + textForXml = stripThinkTagsFromText(textForXml); + + const shouldParseMainText = + enableXmlToolParsing && + toolsList.length > 0 && + likelyContainsToolMarkup(textForXml); + const { beforeText, call: callFromText } = shouldParseMainText + ? parseToolFromText(textForXml, { skipLikelihoodCheck: true }) + : { beforeText: textForXml }; + let uiText = beforeText; + if (chatMode && chatMode !== 'normal') { + const shouldParseReasoning = shouldParseByIncrement(reasoningForSearch, lastReasoningParseObservedLen); + lastReasoningParseObservedLen = reasoningForSearch.length; + const parsedR = shouldParseReasoning + ? parseToolFromText(reasoningForSearch, { skipLikelihoodCheck: true }) + : { beforeText: reasoningForSearch }; + if (parsedR.call && (parsedR.source === 'tool_call' || isBlockToolCall(parsedR.beforeText))) { + latestToolCall = mergeToolCall(latestToolCall, parsedR.call); + } + } + + + const inboundTool = params.toolCall; + latestToolCall = mergeToolCall(latestToolCall, callFromText); + if (isValidToolCall(inboundTool)) { + latestToolCall = mergeToolCall(latestToolCall, inboundTool); + } + + let uiReasoning = lastReasoning; + if (latestToolCall?.isDone) { + uiText = stripToolCallWrapperOnce(uiText); + uiText = stripToolXmlOrOpenTailOnce(uiText, latestToolCall.name); + uiReasoning = stripToolCallWrapperOnce(uiReasoning); + uiReasoning = stripToolXmlOnce(uiReasoning, latestToolCall.name); + uiText = stripFakeResultTail(uiText); + } + uiText = stripThinkTagsFromText(uiText); + uiText = stripTrailingPartialToolMarker(uiText); + uiReasoning = stripTrailingPartialToolMarker(uiReasoning); + + onText({ + fullText: uiText.trim(), + fullReasoning: uiReasoning, + toolCall: isValidToolCall(latestToolCall) + ? latestToolCall + : (isValidToolCall(inboundTool) ? inboundTool : undefined), + plan: incomingPlan, + // propagate provider token usage unchanged + tokenUsage: (params as any).tokenUsage, + }); + }; + + const newOnFinalMessage: OnFinalMessage = (params) => { + + newOnText(params); + + const providerReasoning = params.fullReasoning ?? ''; + if (!activeThinkTags) { + const maybe = detectThinkTags(providerReasoning || params.fullText || ''); + if (maybe) activeThinkTags = maybe; + } + + let extraFromReasoning = ''; + let finalReasoning = lastReasoning; + if (providerReasoning) { + const { reasoning, after } = splitProviderReasoning(providerReasoning, activeThinkTags); + finalReasoning = reasoning; + extraFromReasoning = after; + } + + const baseTextForUiRaw = (params.fullText || '') + (extraFromReasoning || ''); + let baseTextForUi = stripThinkTagsFromText(baseTextForUiRaw); + const plan = params.plan; + + if (latestToolCall && !latestToolCall.isDone && typeof latestToolCall.name === 'string') { + try { + if (hasAllRequiredParams(latestToolCall.name as ToolName, latestToolCall.rawParams as RawToolParamsObj, toolOfToolName)) { + latestToolCall = { ...latestToolCall, isDone: true }; + } + } catch { } + } + + const inboundTool = params.toolCall; + + if ((latestToolCall && !latestToolCall.isDone) && !(isValidToolCall(inboundTool) && inboundTool.isDone)) { + onFinalMessage({ + fullText: baseTextForUi.trim(), + fullReasoning: finalReasoning, + anthropicReasoning: params.anthropicReasoning, + toolCall: undefined, + plan, + // preserve original token usage info + tokenUsage: (params as any).tokenUsage, + }); + return; + } + + const { beforeText, call } = parseToolFromText(baseTextForUi); + let uiText = beforeText.trim(); + let uiReasoning = finalReasoning; + if (chatMode && chatMode !== 'normal') { + const parsedR = parseToolFromText(finalReasoning); + if (parsedR.call && (parsedR.source === 'tool_call' || isBlockToolCall(parsedR.beforeText))) { + latestToolCall = mergeToolCall(latestToolCall, parsedR.call); + } + } + + if (call?.isDone) { + uiText = stripToolXmlOrOpenTailOnce(uiText, call.name); + uiReasoning = stripToolXmlOnce(uiReasoning, call.name); + uiText = stripFakeResultTail(uiText); + } + + uiText = stripThinkTagsFromText(uiText); + + // Parse Plan again if needed? + // We already parsed from baseTextForUi which is the source for uiText (via parseToolFromText). + // parseToolFromText returns a slice. If we stripped plan from baseTextForUi, it's gone from uiText too. + // So we don't need to parse again. + + const finalTool = + (isValidToolCall(inboundTool) && inboundTool.isDone ? inboundTool : undefined) || + (isValidToolCall(latestToolCall) && latestToolCall.isDone ? latestToolCall : undefined) || + (isValidToolCall(call) && call.isDone ? call : undefined); + + if (finalTool?.isDone) { + uiText = stripToolCallWrapperOnce(uiText); + uiText = stripToolXmlOrOpenTailOnce(uiText, finalTool.name); + uiReasoning = stripToolCallWrapperOnce(uiReasoning); + uiReasoning = stripToolXmlOnce(uiReasoning, finalTool.name); + } + + onFinalMessage({ + fullText: uiText, + fullReasoning: uiReasoning, + anthropicReasoning: params.anthropicReasoning, + toolCall: finalTool, + plan, + // preserve original token usage info + tokenUsage: (params as any).tokenUsage, + }); + }; + + return { newOnText, newOnFinalMessage }; +}; + +export const extractReasoningWrapper = ( + onText: OnText, + onFinalMessage: OnFinalMessage, + thinkTagsInit: [string, string] | null, + chatMode: ChatMode | null +): { newOnText: OnText; newOnFinalMessage: OnFinalMessage } => { + return extractReasoningAndXMLToolsWrapper( + onText, + onFinalMessage, + thinkTagsInit, + chatMode, + { enableXmlToolParsing: false } + ); +}; + + +// trim all whitespace up until the first newline, and all whitespace up until the last newline +const trimBeforeAndAfterNewLines = (s: string) => { + if (!s) return s; + const firstNewLineIndex = s.indexOf('\n'); + if (firstNewLineIndex !== -1 && s.substring(0, firstNewLineIndex).trim() === '') { + s = s.substring(firstNewLineIndex + 1, Infinity) + } + const lastNewLineIndex = s.lastIndexOf('\n'); + if (lastNewLineIndex !== -1 && s.substring(lastNewLineIndex + 1, Infinity).trim() === '') { + s = s.substring(0, lastNewLineIndex) + } + return s +} diff --git a/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.impl.ts b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.impl.ts new file mode 100644 index 00000000000..31f4c426cbd --- /dev/null +++ b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.impl.ts @@ -0,0 +1,2862 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { AdditionalToolInfo, AnthropicLLMChatMessage, GeminiLLMChatMessage, LLMChatMessage, LLMFIMMessage, ModelListParams, OllamaModelResponse, OnError, OnFinalMessage, OnText, RawToolCallObj, RawToolCallObjKnown, RawToolCallObjDynamic, RawToolParamsObj, DynamicRequestConfig, RequestParamsConfig, ProviderRouting, LLMTokenUsage } from '../../common/sendLLMMessageTypes.js'; +import { ChatMode, specialToolFormat, displayInfoOfProviderName, ModelSelectionOptions, OverridesOfModel, ProviderName, SettingsOfProvider } from '../../common/voidSettingsTypes.js'; +import { getSendableReasoningInfo, getModelCapabilities, getProviderCapabilities, getReservedOutputTokenSpace } from '../../common/modelInference.js'; + +import { extractReasoningAndXMLToolsWrapper, extractReasoningWrapper } from './extractGrammar.js'; +import { availableTools, InternalToolInfo, isAToolName, voidTools } from '../../common/toolsRegistry.js'; +import { ToolName, ToolParamName } from '../../common/toolsServiceTypes.js' +import { generateUuid } from '../../../../base/common/uuid.js'; +import { toOpenAICompatibleTool, toAnthropicTool, toGeminiTool } from './toolSchemaConversion.js'; +import { ILogService } from '../../../log/common/log.js'; +import { INotificationService, Severity, NotificationPriority, NeverShowAgainScope } from '../../../notification/common/notification.js'; + +let getSendableReasoningInfoImpl = getSendableReasoningInfo; + +const XML_TOOL_FORMAT_CORRECTION_PROMPT = [ + 'Your previous response contained an invalid XML tool call that could not be parsed.', + 'Reply in English only.', + 'Respond again now and strictly follow the XML tool-call format defined in the system instructions.', + 'Use direct tool tags only: value.', + 'Do not use attributes for tool parameters.', + 'Do not wrap tool calls in JSON or wrappers unless the system instructions explicitly require it.', + 'If no tool call is needed, reply with plain text and do not include XML-like tool tags.', +].join(' '); + +type ChatCompletionCreateParamsStreaming = import('openai/resources/chat/completions').ChatCompletionCreateParamsStreaming; +//type ChatCompletionChunk = import('openai/resources/chat/completions').ChatCompletionChunk; +//type OpenAIStream = import('openai/streaming').Stream; +type OpenAIChatCompletionTool = import('openai/resources/chat/completions/completions.js').ChatCompletionTool; +type OpenAIClient = import('openai').OpenAI; +type OpenAIClientOptions = import('openai').ClientOptions; +type GoogleGeminiTool = import('@google/genai').Tool; +type GoogleThinkingConfig = import('@google/genai').ThinkingConfig; +type AnthropicToolUseBlock = import('@anthropic-ai/sdk').Anthropic.ToolUseBlock; + +let openAIModule: (typeof import('openai')) | undefined; +const getOpenAIModule = async () => openAIModule ??= await import('openai'); +const getOpenAIModuleSync = () => openAIModule; + +let anthropicModule: (typeof import('@anthropic-ai/sdk')) | undefined; +const getAnthropicModule = async () => anthropicModule ??= await import('@anthropic-ai/sdk'); + +let mistralCoreModule: (typeof import('@mistralai/mistralai/core.js')) | undefined; +const getMistralCoreModule = async () => mistralCoreModule ??= await import('@mistralai/mistralai/core.js'); + +let mistralFimModule: (typeof import('@mistralai/mistralai/funcs/fimComplete.js')) | undefined; +const getMistralFimModule = async () => mistralFimModule ??= await import('@mistralai/mistralai/funcs/fimComplete.js'); + +let googleGenAIModule: (typeof import('@google/genai')) | undefined; +const getGoogleGenAIModule = async () => googleGenAIModule ??= await import('@google/genai'); + +let ollamaModule: (typeof import('ollama')) | undefined; +const getOllamaModule = async () => ollamaModule ??= await import('ollama'); + +const normalizeHeaders = (h: any): Record => { + try { + if (!h) return {}; + // WHATWG Headers + if (typeof h.entries === 'function') return Object.fromEntries(Array.from(h.entries())); + // [ [k,v], ... ] + if (Array.isArray(h)) return Object.fromEntries(h); + // plain object + if (typeof h === 'object') return { ...h }; + return { value: String(h) }; + } catch { + return {}; + } +}; + +let _fetchDebugInstalled = false; + +const _safeJson = (v: unknown): string => { + try { return JSON.stringify(v, null, 2); } catch { return String(v); } +}; + +const _logDebug = (logService: ILogService | undefined, msg: string, data?: unknown) => { + logService?.debug?.(`[LLM][debug] ${msg}${data === undefined ? '' : `\n${_safeJson(data)}`}`); +}; + +const _logWarn = (logService: ILogService | undefined, msg: string, data?: unknown) => { + logService?.warn?.(`[LLM][warn] ${msg}${data === undefined ? '' : `\n${_safeJson(data)}`}`); +}; + +const _isPlainObject = (v: unknown): v is Record => { + return !!v && typeof v === 'object' && !Array.isArray(v); +}; + +const _redactHeaderValue = (key: string, value: string): string => { + const k = key.toLowerCase(); + if (k === 'authorization') { + // "Bearer xxx" -> "Bearer ***" + if (value.startsWith('Bearer ')) return 'Bearer ***'; + return '***'; + } + return '***'; +}; + +const _redactHeaders = (headers: Record): Record => { + const out: Record = { ...headers }; + const sensitive = new Set([ + 'authorization', + 'x-api-key', + 'api-key', + 'x-goog-api-key', + 'proxy-authorization', + ]); + + for (const [k, v] of Object.entries(out)) { + if (sensitive.has(k.toLowerCase())) { + out[k] = _redactHeaderValue(k, String(v ?? '')); + } + } + return out; +}; + +const _shouldRedactKey = (key: string): boolean => { + return /(api[-_]?key|authorization|token$|secret|password|session)/i.test(key); +}; + +const _deepRedact = (v: unknown): unknown => { + if (Array.isArray(v)) return v.map(_deepRedact); + if (!_isPlainObject(v)) return v; + + const out: Record = {}; + for (const [k, val] of Object.entries(v)) { + if (_shouldRedactKey(k)) out[k] = '***'; + else out[k] = _deepRedact(val); + } + return out; +}; + +/** + * Optional debugging helper. Call from electron-main startup when log level is Debug/Trace. + * Redacts API keys/tokens from headers and JSON bodies. + */ +export function installDebugFetchLogging(logService: ILogService): void { + if (_fetchDebugInstalled) return; + _fetchDebugInstalled = true; + + try { + const desc = Object.getOwnPropertyDescriptor(globalThis, 'fetch'); + if (!desc || desc.writable) { + const orig = globalThis.fetch; + globalThis.fetch = async (input: any, init?: any) => { + let url = ''; + let method = 'GET'; + + try { + url = typeof input === 'string' ? input : (input?.url ?? String(input)); + method = init?.method ?? 'GET'; + + const hdrsRaw = normalizeHeaders(init?.headers ?? input?.headers); + const hdrs = _redactHeaders(hdrsRaw); + + _logDebug(logService, `HTTP Request ${method} ${url}`); + _logDebug(logService, `HTTP Headers`, hdrs); + + if (init?.body) { + const bodyStr = (typeof init.body === 'string') ? init.body : ''; + if (bodyStr) { + try { + const parsed = JSON.parse(bodyStr); + _logDebug(logService, `HTTP Body (json, redacted)`, _deepRedact(parsed)); + } catch { + _logDebug(logService, `HTTP Body (non-json)`, '[omitted]'); + } + } else { + _logDebug(logService, `HTTP Body`, '[non-string body omitted]'); + } + } + } catch { + // ignore + } + + const resp = await (orig as any)(input, init); + + try { + const respHdrsRaw = normalizeHeaders(resp?.headers); + const respHdrs: Record = { ...respHdrsRaw }; + + // redact response sensitive headers too + const sensitive = new Set([ + 'authorization', + 'x-api-key', + 'api-key', + 'x-goog-api-key', + 'proxy-authorization', + 'set-cookie', + ]); + for (const [k, v] of Object.entries(respHdrs)) { + if (sensitive.has(k.toLowerCase())) respHdrs[k] = '***'; + else respHdrs[k] = String(v); + } + + _logDebug(logService, `HTTP Response ${resp?.status} ${resp?.statusText ?? ''} for ${method} ${url}`); + _logDebug(logService, `HTTP Response Headers`, respHdrs); + } catch { + // ignore + } + + return resp; + }; + } + } catch { + // ignore + } +} + +const extractBearer = (headers: Record): string => { + const auth = headers.Authorization ?? headers.authorization ?? ''; + return auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : auth; +}; + + +type InternalCommonMessageParams = { + onText: OnText; + onFinalMessage: OnFinalMessage; + onError: OnError; + providerName: ProviderName; + settingsOfProvider: SettingsOfProvider; + modelSelectionOptions: ModelSelectionOptions | undefined; + overridesOfModel: OverridesOfModel | undefined; + modelName: string; + _setAborter: (aborter: () => void) => void; + dynamicRequestConfig?: DynamicRequestConfig; + requestParams?: RequestParamsConfig; + providerRouting?: ProviderRouting; + logService?: ILogService; + + // NEW (optional): pass from caller if you have it (workbench/renderer side usually) + notificationService?: INotificationService; + notifyOnTruncation?: boolean; +} + +type SendChatParams_Internal = InternalCommonMessageParams & { + messages: LLMChatMessage[]; + separateSystemMessage: string | undefined; + tool_choice?: { type: 'function', function: { name: string } } | 'none' | 'auto' | 'required'; + chatMode: ChatMode | null; + additionalTools?: AdditionalToolInfo[]; + disabledStaticTools?: string[]; + disabledDynamicTools?: string[]; +} +type SendFIMParams_Internal = InternalCommonMessageParams & { messages: LLMFIMMessage; separateSystemMessage: string | undefined; } +export type ListParams_Internal = ModelListParams + + +const invalidApiKeyMessage = (providerName: ProviderName) => `Invalid ${displayInfoOfProviderName(providerName).title} API key.` + +const isAbortError = (e: any) => { + const msg = String(e?.message || '').toLowerCase(); + return e?.name === 'AbortError' || msg.includes('abort') || msg.includes('canceled'); +}; + +// Try to detect completeness of a streamed JSON (counts braces and handles strings/escapes) +const tryParseJsonWhenComplete = (s: string): { ok: boolean; value?: any } => { + let inStr = false, escaped = false, depth = 0, started = false; + for (let i = 0; i < s.length; i++) { + const ch = s[i]; + if (inStr) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inStr = false; + } else { + if (ch === '"') inStr = true; + else if (ch === '{') { depth++; started = true; } + else if (ch === '}') { if (depth > 0) depth--; } + } + } + if (started && depth === 0 && !inStr) { + try { + const json = JSON.parse(s.trim()); + return { ok: true, value: json }; + } catch { /* not ready */ } + } + return { ok: false }; +}; + +const getCurrentMaxTokens = (opts: any) => { + const v1 = typeof opts?.max_completion_tokens === 'number' ? opts.max_completion_tokens : undefined; + const v2 = typeof opts?.max_tokens === 'number' ? opts.max_tokens : undefined; + const result = (v1 ?? v2 ?? 1024); + return result; +}; + +const mapOpenAIUsageToLLMTokenUsage = (usage: any): LLMTokenUsage | undefined => { + if (!usage || typeof usage !== 'object') return undefined; + const promptDetails = (usage as any).prompt_tokens_details ?? (usage as any).promptTokensDetails ?? {}; + return { + input: Number((usage as any).prompt_tokens ?? (usage as any).input_tokens ?? 0) || 0, + cacheCreation: Number(promptDetails.cached_creation_tokens ?? promptDetails.cached_tokens ?? 0) || 0, + cacheRead: Number(promptDetails.cached_read_tokens ?? 0) || 0, + output: Number((usage as any).completion_tokens ?? (usage as any).output_tokens ?? 0) || 0, + }; +}; + +const mapAnthropicUsageToLLMTokenUsage = (usage: any): LLMTokenUsage | undefined => { + if (!usage || typeof usage !== 'object') return undefined; + return { + input: Number((usage as any).input_tokens ?? 0) || 0, + cacheCreation: Number((usage as any).cache_creation_input_tokens ?? 0) || 0, + cacheRead: Number((usage as any).cache_read_input_tokens ?? 0) || 0, + output: Number((usage as any).output_tokens ?? 0) || 0, + }; +}; + +const mapGeminiUsageToLLMTokenUsage = (usage: any): LLMTokenUsage | undefined => { + if (!usage || typeof usage !== 'object') return undefined; + const input = Number((usage as any).promptTokenCount ?? 0) || 0; + // Gemini reports candidatesTokenCount (output) and totalTokenCount; prefer explicit candidatesTokenCount. + let output = Number((usage as any).candidatesTokenCount ?? 0) || 0; + if (!output && (usage as any).totalTokenCount !== null) { + const total = Number((usage as any).totalTokenCount) || 0; + if (total && total >= input) output = total - input; + } + return { + input, + cacheCreation: 0, + cacheRead: 0, + output, + }; +}; + +/** + * Validates LLMTokenUsage to ensure all fields are valid numbers. + * Returns undefined if usage is invalid or all zeros. + */ +function validateLLMTokenUsage(usage: LLMTokenUsage | undefined, logService?: ILogService): LLMTokenUsage | undefined { + if (!usage || typeof usage !== 'object') { + return undefined; + } + + const validated: LLMTokenUsage = { + input: safeNumber(usage.input, 'input', logService), + output: safeNumber(usage.output, 'output', logService), + cacheCreation: safeNumber(usage.cacheCreation, 'cacheCreation', logService), + cacheRead: safeNumber(usage.cacheRead, 'cacheRead', logService) + }; + + if ( + validated.input === 0 && validated.output === 0 && + validated.cacheCreation === 0 && validated.cacheRead === 0 + ) { + _logWarn(logService, 'tokenUsage normalized to all zeros, treating as invalid', usage); + return undefined; + } + + return validated; +} + +function safeNumber(value: unknown, fieldName: string, logService?: ILogService): number { + if (typeof value === 'number' && !isNaN(value) && isFinite(value) && value >= 0) { + return value; + } + if (value !== undefined && value !== null && value !== 0) { + _logWarn(logService, `Invalid ${fieldName} value (${typeof value}); defaulting to 0`, value); + } + return 0; +} + +const _escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const stripMarkdownCodeForXmlToolDetection = (s: string): string => { + if (!s) return s; + return s + .replace(/```[\s\S]*?```/g, ' ') + .replace(/~~~[\s\S]*?~~~/g, ' ') + .replace(/`[^`\n]*`/g, ' '); +}; + +const hasLikelyUnparsedXmlToolCall = ({ + fullText, + fullReasoning, + toolNames, +}: { + fullText: string; + fullReasoning: string; + toolNames?: readonly string[]; +}): boolean => { + const merged = `${fullText || ''}\n${fullReasoning || ''}`; + if (!merged || merged.indexOf('<') === -1) return false; + + const cleaned = stripMarkdownCodeForXmlToolDetection(merged); + if (!cleaned || cleaned.indexOf('<') === -1) return false; + + const namesAlt = (toolNames || []) + .map(n => String(n || '').trim()) + .filter(Boolean) + .map(_escapeRegex) + .join('|'); + const extra = namesAlt ? `|${namesAlt}` : ''; + const re = new RegExp(`<\\s*(?:\\/\\s*)?(?:tool_call|function|parameter${extra})\\b`, 'i'); + return re.test(cleaned); +}; + +const hasLikelyToolParamAttributes = ({ + fullText, + fullReasoning, + toolNames, +}: { + fullText: string; + fullReasoning: string; + toolNames?: readonly string[]; +}): boolean => { + const names = (toolNames || []) + .map(n => String(n || '').trim()) + .filter(Boolean) + .map(_escapeRegex); + if (names.length === 0) return false; + + const merged = `${fullText || ''}\n${fullReasoning || ''}`; + if (!merged || merged.indexOf('<') === -1) return false; + const cleaned = stripMarkdownCodeForXmlToolDetection(merged); + if (!cleaned || cleaned.indexOf('<') === -1) return false; + + const re = new RegExp(`<\\s*(?:${names.join('|')})\\b[^>]*\\s[a-zA-Z_][a-zA-Z0-9_\\-]*\\s*=`, 'i'); + return re.test(cleaned); +}; + +type LengthRetryPolicy = { + enabled?: boolean; + maxAttempts?: number; + maxTokensCap?: number; + increaseStrategy?: 'add' | 'multiply'; + step?: number; + factor?: number; +}; + +const bumpMaxTokens = (mutableOptions: any, policy: LengthRetryPolicy) => { + const prev = getCurrentMaxTokens(mutableOptions); + const cap = policy.maxTokensCap ?? 8192; + const next = (policy.increaseStrategy ?? 'add') === 'add' + ? prev + (policy.step ?? 500) + : Math.ceil(prev * (policy.factor ?? 1.5)); + const newVal = Math.min(next, cap); + if (typeof mutableOptions.max_completion_tokens === 'number') mutableOptions.max_completion_tokens = newVal; + else mutableOptions.max_tokens = newVal; +}; + +const isAsyncIterable = (x: any): x is AsyncIterable => + x && typeof x[Symbol.asyncIterator] === 'function'; + +const newOpenAICompatibleSDK = async ({ settingsOfProvider, providerName, includeInPayload }: { settingsOfProvider: SettingsOfProvider, providerName: ProviderName, includeInPayload?: { [s: string]: any } }) => { + const { default: OpenAI } = await getOpenAIModule(); + const commonPayloadOpts: OpenAIClientOptions = { + dangerouslyAllowBrowser: true, + ...includeInPayload, + }; + + // Generic dynamic provider path: use endpoint/apiKey/auth/additionalHeaders from custom provider settings + const thisConfig: any = (settingsOfProvider as any)[providerName] || {}; + const endpoint = (thisConfig.endpoint ?? '').toString(); + if (!endpoint) throw new Error(`OpenAI-compatible endpoint is not configured for provider "${providerName}".`); + + const apiKey = (thisConfig.apiKey ?? '').toString(); + const additionalHeaders: Record = { ...(thisConfig.additionalHeaders || {}) }; + const authHeaderName: string = (thisConfig.auth?.header || 'Authorization'); + const authFormat: 'Bearer' | 'direct' = (thisConfig.auth?.format || 'Bearer'); + + // Decide whether to rely on OpenAI client apiKey (adds Authorization: Bearer) or custom header + let apiKeyForOpenAI = 'noop'; + if (apiKey) { + if (authHeaderName.toLowerCase() === 'authorization' && authFormat === 'Bearer') { + apiKeyForOpenAI = apiKey; + } else { + additionalHeaders[authHeaderName] = authFormat === 'Bearer' ? `Bearer ${apiKey}` : apiKey; + } + } + + return new OpenAI({ baseURL: endpoint, apiKey: apiKeyForOpenAI, defaultHeaders: additionalHeaders, ...commonPayloadOpts }); +} + +const attachCacheControlToTextMessage = (content: any): any => { + if (!content) return content; + // String -> single text part with cache_control + if (typeof content === 'string') { + return [{ type: 'text', text: content, cache_control: { type: 'ephemeral' } }]; + } + // Array of parts -> mark first text part without cache_control + if (Array.isArray(content)) { + const parts = content.slice(); + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if (p && typeof p === 'object' && p.type === 'text' && !p.cache_control) { + parts[i] = { ...p, cache_control: { type: 'ephemeral' } }; + return parts; + } + } + return parts; + } + return content; +}; + +const applyCacheControlOpenAIStyle = (messages: any[], enabled: boolean): any[] => { + if (!enabled || !Array.isArray(messages) || messages.length === 0) return messages; + // Heuristic: mark at most the first 2 textual messages (e.g. system + first user) + let remaining = 2; + const out = messages.map((m) => m); + for (let i = 0; i < out.length && remaining > 0; i++) { + const msg = out[i]; + if (!msg || typeof msg !== 'object') continue; + if (msg.role !== 'system' && msg.role !== 'developer' && msg.role !== 'user') continue; + if (!msg.content) continue; + const newContent = attachCacheControlToTextMessage(msg.content); + if (newContent === msg.content) continue; + out[i] = { ...msg, content: newContent }; + remaining--; + } + return out; +}; + +const _sendOpenAICompatibleFIM = async (params: SendFIMParams_Internal) => { + const { + messages: { prefix, suffix, stopTokens }, + onFinalMessage, + onError, + settingsOfProvider, + modelName: modelName_, + providerName, + overridesOfModel, + dynamicRequestConfig, + } = params; + + const { modelName, supportsFIM } = getModelCapabilities(providerName, modelName_, overridesOfModel); + if (!supportsFIM) { + if (modelName === modelName_) + onError({ message: `Model ${modelName} does not support FIM.`, fullError: null }); + else + onError({ message: `Model ${modelName_} (${modelName}) does not support FIM.`, fullError: null }); + return; + } + + let openai: OpenAIClient; + let modelForRequest = modelName; + + if (dynamicRequestConfig?.apiStyle === 'openai-compatible') { + const { default: OpenAI } = await getOpenAIModule(); + const token = extractBearer(dynamicRequestConfig.headers) || 'noop'; + + const headersNoAuth: Record = { ...dynamicRequestConfig.headers }; + delete (headersNoAuth as any).Authorization; + delete (headersNoAuth as any).authorization; + + openai = new OpenAI({ + baseURL: dynamicRequestConfig.endpoint, + apiKey: token, + defaultHeaders: headersNoAuth, + dangerouslyAllowBrowser: true, + maxRetries: 0, + }); + + + modelForRequest = modelName; + } else { + openai = await newOpenAICompatibleSDK({ providerName, settingsOfProvider }); + } + + + const basePayload: any = { + model: modelForRequest, + prompt: prefix, + suffix: suffix, + stop: stopTokens, + max_tokens: 300, + }; + + // Inject OpenRouter provider routing when talking to OpenRouter endpoint + if (params.providerRouting && dynamicRequestConfig?.endpoint?.includes('openrouter.ai')) { + basePayload.provider = params.providerRouting; + } + + return openai.completions + .create(basePayload) + .then(async response => { + const fullText = response.choices[0]?.text; + const usage = validateLLMTokenUsage(mapOpenAIUsageToLLMTokenUsage((response as any)?.usage), params.logService); + onFinalMessage({ + fullText, + fullReasoning: '', + anthropicReasoning: null, + ...(usage ? { tokenUsage: usage } : {}), + }); + }) + .catch(async (error: any) => { + const { APIError } = await getOpenAIModule(); + if (error instanceof APIError && error.status === 401) { + onError({ message: invalidApiKeyMessage(providerName), fullError: error }); + } else { + onError({ message: error + '', fullError: error }); + } + }); +}; + +/** + * Get static tools for a given chat mode + */ +const normalizeToolNameSet = (names?: readonly string[]): Set => { + if (!Array.isArray(names)) return new Set(); + return new Set(names.map(v => String(v ?? '').trim()).filter(Boolean)); +}; + +const filterDynamicTools = ( + dynamicTools?: AdditionalToolInfo[], + disabledDynamicTools?: readonly string[] +): AdditionalToolInfo[] | undefined => { + if (!dynamicTools?.length) return dynamicTools; + const disabledSet = normalizeToolNameSet(disabledDynamicTools); + if (disabledSet.size === 0) return dynamicTools; + const filtered = dynamicTools.filter(tool => !disabledSet.has(String(tool?.name ?? '').trim())); + return filtered.length ? filtered : undefined; +}; + +const getStaticTools = (chatMode: ChatMode, disabledStaticTools?: readonly string[]): InternalToolInfo[] => { + const allowedUnknown = availableTools(chatMode); + if (!Array.isArray(allowedUnknown) || allowedUnknown.length === 0) return []; + + const staticTools: InternalToolInfo[] = []; + for (const tool of allowedUnknown) { + if (!tool || typeof tool !== 'object') continue; + const maybeTool = tool as Partial; + if (typeof maybeTool.name !== 'string' || typeof maybeTool.description !== 'string') continue; + staticTools.push(maybeTool as InternalToolInfo); + } + if (staticTools.length === 0) return []; + + const disabledSet = normalizeToolNameSet(disabledStaticTools); + if (disabledSet.size === 0) return staticTools; + return staticTools.filter(tool => !disabledSet.has(String(tool.name ?? '').trim())); +}; + +/** + * Merge static and dynamic tools, with dynamic tools taking precedence + */ +const mergeTools = (staticTools: InternalToolInfo[], dynamicTools?: AdditionalToolInfo[]): (InternalToolInfo | AdditionalToolInfo)[] => { + const allTools: (InternalToolInfo | AdditionalToolInfo)[] = [...staticTools]; + + if (dynamicTools) { + // Add dynamic tools, overriding static ones with same name + for (const dynamicTool of dynamicTools) { + const staticIndex = allTools.findIndex(t => t.name === dynamicTool.name); + if (staticIndex >= 0) { + allTools[staticIndex] = dynamicTool; + } else { + allTools.push(dynamicTool); + } + } + } + + return allTools; +} + +const openAITools = ( + chatMode: ChatMode, + additionalTools?: AdditionalToolInfo[], + logService?: ILogService, + disabledStaticTools?: readonly string[], + disabledDynamicTools?: readonly string[], +): OpenAIChatCompletionTool[] | null => { + + const staticTools = getStaticTools(chatMode, disabledStaticTools); + const dynamicTools = filterDynamicTools(additionalTools, disabledDynamicTools); + const allTools = mergeTools(staticTools, dynamicTools); + if (allTools.length === 0) { + return null; + } + + const convertedTools = allTools.map(toolInfo => toOpenAICompatibleTool(toolInfo, logService)); + return convertedTools.length ? convertedTools : null; +}; + +function snakeToCamel(s: string): string { + return s + .split('_') + .filter(Boolean) + .map((chunk, i) => + i === 0 + ? chunk + : chunk[0].toUpperCase() + chunk.slice(1) + ) + .join(''); +} + +type ToolInfoUnion = InternalToolInfo | AdditionalToolInfo; + +function camelToSnake(s: string): string { + return s.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase()); +} + +const buildToolDefsMap = ( + staticTools: InternalToolInfo[], + dynamicTools?: AdditionalToolInfo[] +): Map => { + const all = mergeTools(staticTools, dynamicTools); + const map = new Map(); + for (const t of all) map.set(t.name, t as ToolInfoUnion); + return map; +}; + +const rawToolCallObjOf = ( + name: string, + toolParamsStr: string, + id: string, + toolDefsMap?: ReadonlyMap +): RawToolCallObj | null => { + if (!name || !String(name).trim()) return null; + + let input: unknown; + try { + input = toolParamsStr ? JSON.parse(toolParamsStr) : {}; + } catch { + return null; + } + if (!input || typeof input !== 'object') return null; + + const toolInfo = toolDefsMap?.get(name); + if (toolDefsMap && !toolInfo) { + return null; + } + + + if (!toolInfo && isAToolName(name)) { + const rawParams: RawToolParamsObj = {} as RawToolParamsObj; + for (const snakeParam in voidTools[name].params) { + const camelParam = snakeToCamel(snakeParam); + const snakeAlt = camelToSnake(snakeParam); + const val = + (input as Record)[snakeParam] ?? + (input as Record)[camelParam] ?? + (input as Record)[snakeAlt]; + (rawParams as unknown as Record)[snakeParam] = val; + } + return { + id, + name: name as ToolName, + rawParams, + doneParams: Object.keys(rawParams) as ToolParamName[], + isDone: true, + } as RawToolCallObj; + } + + + if (toolInfo && (toolInfo as ToolInfoUnion & { params?: Record }).params) { + const params = (toolInfo as ToolInfoUnion & { params?: Record }).params || {}; + const rawParams: Record = {}; + for (const paramName of Object.keys(params)) { + const camel = snakeToCamel(paramName); + const snake = camelToSnake(paramName); + const val = + (input as Record)[paramName] ?? + (input as Record)[camel] ?? + (input as Record)[snake]; + if (val !== undefined) rawParams[paramName] = val; + } + return { + id, + name: name as ToolName, + rawParams: rawParams as RawToolParamsObj, + doneParams: Object.keys(rawParams) as string[], + isDone: true, + } as RawToolCallObjKnown; + } + + + const rawParams = input as Record; + return { + id, + name: name as string, + rawParams, + doneParams: Object.keys(rawParams), + isDone: true, + } as RawToolCallObjDynamic; +}; + +// ------------ OPENAI-COMPATIBLE ------------ +export interface RunStreamParams { + openai: OpenAIClient + options: ChatCompletionCreateParamsStreaming + onText: OnText + onFinalMessage: OnFinalMessage + onError: OnError + _setAborter: (aborter: () => void) => void + nameOfReasoningFieldInDelta?: string + providerName: any + + // tool definitions map (static + dynamic) + toolDefsMap?: ReadonlyMap + logService?: ILogService + + stopOnFirstToolCall?: boolean // default: true + allowedToolNames?: string[] // default: undefined (no filter) + emitToolCallProgress?: boolean // default: true + timeoutMs?: number // default: undefined (no timeout) + lengthRetryPolicy?: LengthRetryPolicy // default: { enabled: true, maxAttempts: 2, ... } + + // NEW (optional) + notificationService?: INotificationService + notifyOnTruncation?: boolean +} + + +export async function runStream({ + openai, + options, + onText, + onFinalMessage, + onError, + _setAborter, + nameOfReasoningFieldInDelta, + providerName, + toolDefsMap, + logService, + stopOnFirstToolCall = true, + allowedToolNames, + emitToolCallProgress = true, + timeoutMs, + lengthRetryPolicy = { enabled: true, maxAttempts: 2, maxTokensCap: 16384, increaseStrategy: 'add', step: 2000, factor: 1.5 }, + notificationService, + notifyOnTruncation = true, +}: RunStreamParams): Promise { + + const policy: Required = { + enabled: lengthRetryPolicy?.enabled !== false, + maxAttempts: Math.max(1, lengthRetryPolicy?.maxAttempts ?? 2), + maxTokensCap: lengthRetryPolicy?.maxTokensCap ?? 8192, + increaseStrategy: lengthRetryPolicy?.increaseStrategy ?? 'add', + step: lengthRetryPolicy?.step ?? 500, + factor: lengthRetryPolicy?.factor ?? 1.5, + }; + + const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + + const openAIExports = getOpenAIModuleSync(); + const OpenAIApiError = openAIExports?.APIError; + + let everHadAnyData = false; + let currentOptions: any = { ...options, stream: true }; + let lastTokenUsage: LLMTokenUsage | undefined; + + // ---------------- [LLM][debug][runStream] helpers ---------------- + const __hasDebug = !!logService && typeof logService.debug === 'function'; + + const __safeJson = (v: unknown, maxLen = 6000): string => { + try { + const seen = new WeakSet(); + const s = JSON.stringify( + v, + (_k, val) => { + if (typeof val === 'bigint') return val.toString(); + if (val && typeof val === 'object') { + if (seen.has(val as any)) return '[Circular]'; + seen.add(val as any); + } + return val; + }, + 2 + ); + if (typeof s === 'string' && s.length > maxLen) { + return s.slice(0, maxLen) + `…(+${s.length - maxLen})`; + } + return s; + } catch (e) { + try { return String(v); } catch { return `[unstringifiable: ${String(e)}]`; } + } + }; + + const __dbg = (msg: string, data?: unknown) => { + if (!__hasDebug) return; + try { + logService?.debug?.(`[LLM][debug][runStream] ${msg}${data === undefined ? '' : `\n${__safeJson(data)}`}`); + } catch { + // ignore + } + }; + + const __isHeavyDebugEnabled = __hasDebug; + + const __toolNames = (() => { + try { return toolDefsMap ? Array.from(toolDefsMap.keys()) : []; } catch { return []; } + })(); + + const __escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const __TOOL_TAG_RE: RegExp = (() => { + const names = __toolNames.map(__escapeRe).filter(Boolean); + + // - / + + const alts = names.length ? `|${names.join('|')}` : ''; + return new RegExp(`<\\s*(?:\\/\\s*)?(?:tool_call\\b${alts})`, 'i'); + })(); + + const __previewAroundFirstMatch = (s: string, max = 260): string => { + try { + if (!s) return ''; + const m = s.match(__TOOL_TAG_RE); + if (!m || typeof m.index !== 'number') { + return s.length <= max ? s : (s.slice(0, max) + `…(+${s.length - max})`); + } + const i = m.index; + const from = Math.max(0, i - Math.floor(max / 2)); + const to = Math.min(s.length, from + max); + const chunk = s.slice(from, to); + return (from > 0 ? '…' : '') + chunk + (to < s.length ? '…' : ''); + } catch { + return ''; + } + }; + // ---------------------------------------------------------------- + + let didNotifyTruncation = false; + + const notifyTruncationOnce = (info: { + kind: 'length' | 'timeout'; + model: string; + provider: any; + maxTokens: number; + attempt: number; + maxAttempts: number; + textLen: number; + reasoningLen: number; + }) => { + if (!notifyOnTruncation) return; + if (didNotifyTruncation) return; + if (!notificationService) return; + + didNotifyTruncation = true; + + const isReasoningOnly = info.textLen === 0 && info.reasoningLen > 0; + const why = info.kind === 'timeout' ? 'timeout' : 'token limit (finish_reason=length)'; + + const msg = + `LLM response was truncated: ${why}.\n` + + `Provider: ${String(info.provider)}. LLM: ${String(info.model)}.\n` + + `max_tokens/max_completion_tokens: ${info.maxTokens}. Attempt: ${info.attempt}/${info.maxAttempts}.\n` + + `Received: text=${info.textLen}, reasoning=${info.reasoningLen}.\n` + + (isReasoningOnly + ? `It appears the entire budget was consumed by reasoning, and the model didn't have time to produce a final answer.\n` + : ``) + + `How to fix: increase max_tokens/max_completion_tokens (or maxTokensCap for retries), or reduce reasoning effort / disable reasoning.`; + + try { + notificationService.notify({ + id: 'void.llm.outputTruncated', + severity: Severity.Warning, + message: msg, + priority: NotificationPriority.DEFAULT, + neverShowAgain: { + id: 'void.llm.outputTruncated', + isSecondary: true, + scope: NeverShowAgainScope.PROFILE + } + }); + } catch { + // best-effort + } + }; + + const normalizeToolIndex = (idx: unknown): number => { + if (typeof idx === 'number' && isFinite(idx)) return idx; + if (typeof idx === 'string' && idx.trim() !== '' && isFinite(Number(idx))) return Number(idx); + return 0; + }; + + const coerceArgsToString = (args: unknown): string => { + if (typeof args === 'string') return args; + if (args && typeof args === 'object') { + try { return JSON.stringify(args); } catch { return ''; } + } + return ''; + }; + + const getDeltaContentValue = (delta: any): unknown => { + if (delta?.content !== undefined) return delta.content; + if (delta?.contentParts !== undefined) return delta.contentParts; + if (delta?.content_parts !== undefined) return delta.content_parts; + if (delta?.message?.content !== undefined) return delta.message.content; + return undefined; + }; + + const getDeltaToolCallsValue = (delta: any): unknown => { + return delta?.tool_calls ?? delta?.toolCalls; + }; + + const coerceContentToString = (content: unknown): string => { + if (typeof content === 'string') return content; + if (!content) return ''; + + if (Array.isArray(content)) { + let out = ''; + for (const part of content) { + if (typeof part === 'string') { out += part; continue; } + if (!part || typeof part !== 'object') continue; + const p: any = part; + + if (typeof p.text === 'string') { out += p.text; continue; } + if (p.text && typeof p.text === 'object' && typeof p.text.value === 'string') { out += p.text.value; continue; } + + if (typeof p.content === 'string') { out += p.content; continue; } + if (typeof p.value === 'string') { out += p.value; continue; } + } + return out; + } + + if (typeof content === 'object') { + const c: any = content; + if (typeof c.text === 'string') return c.text; + if (c.text && typeof c.text === 'object' && typeof c.text.value === 'string') return c.text.value; + if (typeof c.value === 'string') return c.value; + } + + return ''; + }; + + const coerceToolCallsArray = (toolCalls: unknown): any[] => { + if (Array.isArray(toolCalls)) return toolCalls; + if (toolCalls && typeof toolCalls === 'object') return [toolCalls]; + return []; + }; + + // ---- hard "prove it runs" log (even if heavy debug disabled) ---- + __dbg('entered', { + providerName, + model: (currentOptions as any)?.model, + hasToolDefsMap: !!toolDefsMap, + toolNamesCount: __toolNames.length, + nameOfReasoningFieldInDelta, + stopOnFirstToolCall, + emitToolCallProgress, + timeoutMs: timeoutMs ?? null, + policy, + heavyDebug: __isHeavyDebugEnabled, + }); + // ----------------------------------------------------------------- + + type ToolAcc = { name: string; id: string; args: string; }; + + const pickPreferredToolAcc = (m: Map): ToolAcc | undefined => { + if (m.has(0)) return m.get(0); + let bestIdx: number | null = null; + for (const k of m.keys()) { + if (bestIdx === null || k < bestIdx) bestIdx = k; + } + return bestIdx === null ? undefined : m.get(bestIdx); + }; + + for (let attempt = 1; attempt <= policy.maxAttempts; attempt++) { + let fullReasoningSoFar = ''; + let fullTextSoFar = ''; + let lastFinishReason: string | undefined; + + let abortedByUsForCompletedTool = false; + let abortedByTimeout = false; + let timeoutHandle: any | null = null; + + let chunkCount = 0; + let hasReceivedToolCall = false; + + let reasoningSource: 'details' | 'deltaField' | null = null; + let sawAnyReasoningDelta = false; + let sawAnyTextDelta = false; + + // “reasoning stopped” heuristic + let lastReasoningAppendChunk = -1; + let loggedReasoningStop = false; + + // “XML tool tags seen” + let sawXmlToolTagInText = false; + let sawXmlToolTagInReasoning = false; + let sawToolCallsStructured = false; + + const toolAccByIdx = new Map(); + const getAcc = (idx: number): ToolAcc => { + let acc = toolAccByIdx.get(idx); + if (!acc) { + acc = { name: '', id: '', args: '' }; + toolAccByIdx.set(idx, acc); + } + return acc; + }; + + const buildToolCall = (): RawToolCallObj | null => { + const pref = pickPreferredToolAcc(toolAccByIdx); + if (!pref) return null; + return rawToolCallObjOf(pref.name, pref.args, pref.id, toolDefsMap); + }; + + __dbg('attempt start', { + attempt, + maxAttempts: policy.maxAttempts, + model: (currentOptions as any)?.model, + max_tokens: (currentOptions as any)?.max_tokens ?? (currentOptions as any)?.max_completion_tokens ?? null, + }); + + try { + const resp = await openai.chat.completions.create(currentOptions) as any; + const controller: AbortController | undefined = resp?.controller; + _setAborter(() => controller?.abort()); + + const isIter = isAsyncIterable(resp); + + if (!isIter) { + // ----- non-stream path ----- + const nonStreamResp = resp; + const choice = nonStreamResp?.choices?.[0]; + const msg = choice?.message ?? {}; + + const text = coerceContentToString(msg?.content ?? msg?.contentParts ?? msg?.content_parts); + + const rawUsage = nonStreamResp?.usage; + const tokenUsage = validateLLMTokenUsage(mapOpenAIUsageToLLMTokenUsage(rawUsage), logService); + + let collectedReasoning = ''; + const details = msg?.reasoning_details ?? msg?.reasoningDetails; + if (Array.isArray(details) && details.length) { + const textParts: string[] = []; + let sawEncrypted = false; + for (const d of details) { + if (typeof (d as any)?.text === 'string') textParts.push((d as any).text); + if (d && typeof d === 'object' && (d as any).type === 'reasoning.encrypted') sawEncrypted = true; + } + collectedReasoning = textParts.join(''); + if (!collectedReasoning && sawEncrypted) { + collectedReasoning = 'Reasoning content is encrypted by the provider and cannot be displayed'; + } + } else if (nameOfReasoningFieldInDelta) { + const maybe = msg?.[nameOfReasoningFieldInDelta]; + if (typeof maybe === 'string' && maybe) collectedReasoning = maybe; + } + + if (__isHeavyDebugEnabled) { + if (__TOOL_TAG_RE.test(text)) { + sawXmlToolTagInText = true; + __dbg('XML tool tag detected (NON-STREAM main)', { + attempt, + preview: __previewAroundFirstMatch(text), + }); + } + if (__TOOL_TAG_RE.test(collectedReasoning)) { + sawXmlToolTagInReasoning = true; + __dbg('XML tool tag detected (NON-STREAM reasoning)', { + attempt, + preview: __previewAroundFirstMatch(collectedReasoning), + }); + } + } + + const toolCalls = msg?.tool_calls ?? msg?.toolCalls ?? []; + if (Array.isArray(toolCalls) && toolCalls.length > 0) { + sawToolCallsStructured = true; + const t0 = toolCalls[0]; + const acc = getAcc(0); + acc.name = t0?.function?.name ?? ''; + acc.id = t0?.id ?? ''; + acc.args = coerceArgsToString(t0?.function?.arguments ?? ''); + } + + const legacyFC = msg?.function_call; + if (legacyFC && !toolAccByIdx.size) { + sawToolCallsStructured = true; + const acc = getAcc(0); + acc.name = legacyFC?.name ?? ''; + acc.id = legacyFC?.id ?? ''; + acc.args = coerceArgsToString(legacyFC?.arguments ?? ''); + } + + const toolCall = buildToolCall(); + + __dbg('non-stream end', { + attempt, + textLen: text?.length ?? 0, + reasoningLen: collectedReasoning?.length ?? 0, + hasToolCall: !!toolCall, + toolName: (toolCall as any)?.name ?? null, + sawToolCallsStructured, + sawXmlToolTagInText, + sawXmlToolTagInReasoning, + }); + + // Check for truncation in non-stream path + const finishReason = choice?.finish_reason; + const hitLimit = (finishReason === 'length'); + + if (!toolCall && hitLimit && policy.enabled && attempt < policy.maxAttempts) { + const prev = getCurrentMaxTokens(currentOptions); + bumpMaxTokens(currentOptions, policy); + const next = getCurrentMaxTokens(currentOptions); + __dbg('retrying non-stream due to truncation', { attempt, prevMaxTokens: prev, nextMaxTokens: next }); + await sleep(150 * attempt); + continue; + } + + if (!toolCall && hitLimit) { + notifyTruncationOnce({ + kind: 'length', + model: String((currentOptions as any)?.model ?? ''), + provider: providerName, + maxTokens: getCurrentMaxTokens(currentOptions), + attempt, + maxAttempts: policy.maxAttempts, + textLen: (text ?? '').length, + reasoningLen: (collectedReasoning ?? '').length, + }); + } + + if (text || collectedReasoning || toolCall) { + onFinalMessage({ + fullText: text ?? '', + fullReasoning: collectedReasoning ?? '', + anthropicReasoning: null, + ...(toolCall ? { toolCall } : {}), + ...(tokenUsage ? { tokenUsage } : {}), + }); + return; + } + + _logWarn(logService, 'Void: Response from model was empty (non-stream path)', { + providerName, + attempt, + isIter, + nonStreamKeys: Object.keys(nonStreamResp || {}), + messageKeys: Object.keys(msg || {}), + messageContentType: Array.isArray(msg?.content) ? 'array' : typeof msg?.content, + }); + onError({ message: 'Void: Response from model was empty.', fullError: null }); + return; + } + + // ---- streaming path ---- + if (timeoutMs && controller) { + timeoutHandle = setTimeout(() => { + abortedByTimeout = true; + try { controller.abort(); } catch { } + }, timeoutMs); + } + + for await (const chunk of resp as any) { + chunkCount++; + + const choice = chunk?.choices?.[0]; + if (!choice) continue; + + const rawUsage = chunk?.usage; + const usage = validateLLMTokenUsage(mapOpenAIUsageToLLMTokenUsage(rawUsage), logService); + if (usage) lastTokenUsage = usage; + + if (choice.finish_reason) { + lastFinishReason = choice.finish_reason; + if (__isHeavyDebugEnabled) { + __dbg('finish_reason seen (stream)', { attempt, chunkCount, finish_reason: choice.finish_reason }); + } + } + + const delta = choice.delta; + + // MAIN TEXT stream + const contentVal = getDeltaContentValue(delta); + const newText = coerceContentToString(contentVal); + + if (newText) { + sawAnyTextDelta = true; + fullTextSoFar += newText; + everHadAnyData = true; + + if (!sawXmlToolTagInText && __isHeavyDebugEnabled && (__TOOL_TAG_RE.test(newText) || __TOOL_TAG_RE.test(fullTextSoFar.slice(-2000)))) { + sawXmlToolTagInText = true; + __dbg('XML tool tag detected (MAIN text stream)', { + attempt, + chunkCount, + newTextPreview: __previewAroundFirstMatch(newText), + }); + } + } + + // REASONING (details priority) + let appendedReasoningThisChunk = false; + + const details = (delta as any)?.reasoning_details ?? (delta as any)?.reasoningDetails; + if (Array.isArray(details) && details.length) { + const textParts: string[] = []; + let sawEncrypted = false; + for (const d of details) { + if (typeof (d as any)?.text === 'string') textParts.push((d as any).text); + if (d && typeof d === 'object' && (d as any).type === 'reasoning.encrypted') sawEncrypted = true; + } + const add = textParts.join(''); + if (add) { + sawAnyReasoningDelta = true; + if (reasoningSource !== 'details') { + fullReasoningSoFar = ''; + reasoningSource = 'details'; + } + fullReasoningSoFar += add; + everHadAnyData = true; + appendedReasoningThisChunk = true; + lastReasoningAppendChunk = chunkCount; + + if (!sawXmlToolTagInReasoning && __isHeavyDebugEnabled && (__TOOL_TAG_RE.test(add) || __TOOL_TAG_RE.test(fullReasoningSoFar.slice(-2000)))) { + sawXmlToolTagInReasoning = true; + __dbg('XML tool tag detected (REASONING_DETAILS stream)', { + attempt, + chunkCount, + addPreview: __previewAroundFirstMatch(add), + }); + } + } else if (sawEncrypted && !fullReasoningSoFar) { + sawAnyReasoningDelta = true; + fullReasoningSoFar = 'Reasoning content is encrypted by the provider and cannot be displayed'; + reasoningSource = 'details'; + everHadAnyData = true; + appendedReasoningThisChunk = true; + lastReasoningAppendChunk = chunkCount; + } + } + + // REASONING (field fallback) + if (!appendedReasoningThisChunk && nameOfReasoningFieldInDelta) { + const maybeField = (delta as any)?.[nameOfReasoningFieldInDelta]; + if (typeof maybeField === 'string' && maybeField) { + sawAnyReasoningDelta = true; + if (!reasoningSource) reasoningSource = 'deltaField'; + fullReasoningSoFar += maybeField; + everHadAnyData = true; + lastReasoningAppendChunk = chunkCount; + + if (!sawXmlToolTagInReasoning && __isHeavyDebugEnabled && (__TOOL_TAG_RE.test(maybeField) || __TOOL_TAG_RE.test(fullReasoningSoFar.slice(-2000)))) { + sawXmlToolTagInReasoning = true; + __dbg('XML tool tag detected (REASONING_FIELD stream)', { + attempt, + chunkCount, + field: nameOfReasoningFieldInDelta, + fieldPreview: __previewAroundFirstMatch(maybeField), + }); + } + } + } + + // Reasoning “stopped coming” heuristic (log once) + if (__isHeavyDebugEnabled && !loggedReasoningStop && sawAnyReasoningDelta && lastReasoningAppendChunk >= 0) { + if ((chunkCount - lastReasoningAppendChunk) >= 30) { + loggedReasoningStop = true; + __dbg('Reasoning appears to have stopped (no reasoning deltas for N chunks)', { + attempt, + chunkCount, + lastReasoningAppendChunk, + reasoningSource, + textLen: fullTextSoFar.length, + reasoningLen: fullReasoningSoFar.length, + }); + } + } + + // tool_calls (structured) + const toolCalls = coerceToolCallsArray(getDeltaToolCallsValue(delta)); + if (toolCalls.length) { + hasReceivedToolCall = true; + sawToolCallsStructured = true; + + if (__isHeavyDebugEnabled) { + __dbg('Structured tool_calls delta seen', { + attempt, + chunkCount, + len: toolCalls.length, + names: toolCalls.map(t => t?.function?.name ?? null).filter(Boolean).slice(0, 5), + }); + } + } + + for (const tool of toolCalls) { + const idx = normalizeToolIndex((tool as any)?.index); + const acc = getAcc(idx); + + const functionName = tool.function?.name ?? ''; + const id = tool.id ?? ''; + const functionArgs = coerceArgsToString(tool.function?.arguments); + + if (id && !acc.id) acc.id = id; + if (functionName && !acc.name) acc.name = functionName; + + if (allowedToolNames && acc.name && !allowedToolNames.includes(acc.name)) { + continue; + } + + if (functionArgs) { + acc.args += functionArgs; + everHadAnyData = true; + + if (stopOnFirstToolCall && controller && acc.name) { + const parsed = tryParseJsonWhenComplete(acc.args); + if (parsed.ok) { + abortedByUsForCompletedTool = true; + + if (__isHeavyDebugEnabled) { + __dbg('Aborting stream: tool args JSON complete (stopOnFirstToolCall)', { + attempt, + chunkCount, + toolName: acc.name, + toolId: acc.id, + argsLen: acc.args.length, + }); + } + + try { controller.abort(); } catch { } + } + } + } + } + + // legacy function_call + const legacyFC = (delta as any)?.function_call; + if (legacyFC) { + hasReceivedToolCall = true; + sawToolCallsStructured = true; + + const acc = getAcc(0); + const fcName = legacyFC?.name ?? ''; + const fcArgs = coerceArgsToString(legacyFC?.arguments); + const fcId = legacyFC?.id ?? ''; + + if (fcId && !acc.id) acc.id = fcId; + if (fcName && !acc.name) acc.name = fcName; + + if (__isHeavyDebugEnabled) { + __dbg('Legacy function_call delta seen', { attempt, chunkCount, name: fcName || null }); + } + + if (allowedToolNames && acc.name && !allowedToolNames.includes(acc.name)) { + continue; + } + + if (fcArgs) { + acc.args += fcArgs; + everHadAnyData = true; + + if (stopOnFirstToolCall && controller && acc.name) { + const parsed = tryParseJsonWhenComplete(acc.args); + if (parsed.ok) { + abortedByUsForCompletedTool = true; + + if (__isHeavyDebugEnabled) { + __dbg('Aborting stream: legacy function_call args JSON complete (stopOnFirstToolCall)', { + attempt, + chunkCount, + toolName: acc.name, + toolId: acc.id, + argsLen: acc.args.length, + }); + } + + try { controller.abort(); } catch { } + } + } + } + } + + // progress + if (emitToolCallProgress || fullTextSoFar || fullReasoningSoFar) { + const pref = pickPreferredToolAcc(toolAccByIdx); + const prefName = pref?.name ?? ''; + const prefId = pref?.id ?? ''; + + const knownTool = !!prefName && ((toolDefsMap?.has(prefName)) || isAToolName(prefName)); + const toolCallInfo = knownTool + ? { name: prefName as string, rawParams: {}, isDone: false, doneParams: [], id: prefId } as RawToolCallObj + : undefined; + + const usagePayload = lastTokenUsage ? { tokenUsage: lastTokenUsage } : {}; + onText({ + fullText: fullTextSoFar, + fullReasoning: fullReasoningSoFar, + toolCall: toolCallInfo, + ...usagePayload, + }); + } + + if (hasReceivedToolCall && (choice.finish_reason === 'tool_calls' || choice.finish_reason === 'function_call')) { + break; + } + } + + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + + const toolCall = buildToolCall(); + const usagePayload = lastTokenUsage ? { tokenUsage: lastTokenUsage } : {}; + + __dbg('attempt end', { + attempt, + chunkCount, + lastFinishReason: lastFinishReason ?? null, + abortedByUsForCompletedTool, + abortedByTimeout, + textLen: fullTextSoFar.length, + reasoningLen: fullReasoningSoFar.length, + reasoningSource, + sawAnyTextDelta, + sawAnyReasoningDelta, + sawToolCallsStructured, + sawXmlToolTagInText, + sawXmlToolTagInReasoning, + hasFinalToolCall: !!toolCall, + finalToolName: (toolCall as any)?.name ?? null, + }); + + // ✅ IMPORTANT: retry on length/timeout EVEN IF we already got partial output, + // but only when there is NO tool call (tool calls are handled differently). + const hitLimit = (lastFinishReason === 'length'); + const hitTimeout = abortedByTimeout; + + if (!toolCall && (hitLimit || hitTimeout) && policy.enabled && attempt < policy.maxAttempts) { + const prev = getCurrentMaxTokens(currentOptions); + bumpMaxTokens(currentOptions, policy); + const next = getCurrentMaxTokens(currentOptions); + + __dbg('retrying due to truncation', { + attempt, + reason: hitTimeout ? 'timeout' : 'length', + prevMaxTokens: prev, + nextMaxTokens: next, + textLen: fullTextSoFar.length, + reasoningLen: fullReasoningSoFar.length, + }); + + // give provider a tiny breather + await sleep(150 * attempt); + continue; + } + + // ✅ No more retries → notify (best-effort) if truncated + if (!toolCall && (hitLimit || hitTimeout)) { + notifyTruncationOnce({ + kind: hitTimeout ? 'timeout' : 'length', + model: String((currentOptions as any)?.model ?? ''), + provider: providerName, + maxTokens: getCurrentMaxTokens(currentOptions), + attempt, + maxAttempts: policy.maxAttempts, + textLen: fullTextSoFar.length, + reasoningLen: fullReasoningSoFar.length, + }); + } + + if (fullTextSoFar || fullReasoningSoFar || toolCall) { + onFinalMessage({ + fullText: fullTextSoFar, + fullReasoning: fullReasoningSoFar, + anthropicReasoning: null, + ...(toolCall ? { toolCall } : {}), + ...usagePayload, + }); + return; + } + + // retry on empty (unchanged) + if (policy.enabled && attempt < policy.maxAttempts) { + _logWarn(logService, 'Empty/unenriched stream from provider; retrying', { + providerName, + attempt, + chunkCount, + lastFinishReason, + abortedByTimeout, + }); + await sleep(200 * attempt); + continue; + } + + _logWarn(logService, 'Void: Response from model was empty (stream finished)', { + providerName, + attempt, + chunkCount, + lastFinishReason, + abortedByTimeout, + }); + + onError({ message: 'Void: Response from model was empty.', fullError: null }); + return; + + } catch (error: any) { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + + if (isAbortError(error) && abortedByUsForCompletedTool) { + const toolCall = buildToolCall(); + if (toolCall) { + const usagePayload = lastTokenUsage ? { tokenUsage: lastTokenUsage } : {}; + __dbg('caught AbortError after tool completion; finalizing with toolCall', { + attempt, + toolName: (toolCall as any)?.name ?? null, + }); + onFinalMessage({ + fullText: fullTextSoFar, + fullReasoning: fullReasoningSoFar, + anthropicReasoning: null, + toolCall, + ...usagePayload, + }); + return; + } + } + + if (isAbortError(error) && (fullTextSoFar || fullReasoningSoFar)) { + __dbg('caught AbortError with partial output; finalizing partial', { + attempt, + textLen: fullTextSoFar.length, + reasoningLen: fullReasoningSoFar.length, + }); + onFinalMessage({ + fullText: fullTextSoFar, + fullReasoning: fullReasoningSoFar, + anthropicReasoning: null, + ...(lastTokenUsage ? { tokenUsage: lastTokenUsage } : {}), + }); + return; + } + + _logWarn(logService, 'runStream threw error', { + providerName, + attempt, + errorName: error?.name, + errorMessage: error?.message ?? String(error), + errorCode: error?.code, + cause: { + name: error?.cause?.name, + message: error?.cause?.message, + code: error?.cause?.code, + }, + stack: error?.stack, + }); + + if (OpenAIApiError && error instanceof OpenAIApiError) { + if (error.status === 401) { + onError({ message: invalidApiKeyMessage(providerName), fullError: error }); + } else { + onError({ message: `API Error: ${error.message}`, fullError: error }); + } + } else { + onError({ message: error?.message || String(error), fullError: error }); + } + return; + } + } + + if (!everHadAnyData) { + _logWarn(logService, 'Failed to get response after retries (everHadAnyData=false)', { + providerName, + maxAttempts: policy.maxAttempts, + }); + onError({ message: 'Failed to get response after retries', fullError: null }); + } +} + +function createNativeTools( + potentialTools: any[], + tool_choice: { + type: 'function'; + function: { + name: string; + }; + } | 'none' | 'auto' | 'required' | undefined, + specialToolFormat: specialToolFormat, +) { + // Get default tool choice setting based on format + const getDefaultToolChoice = (format: typeof specialToolFormat) => { + switch (format) { + case 'anthropic-style': return { type: 'auto' } as const; + case 'openai-style': return 'auto' as const; + case 'gemini-style': return undefined; + default: return undefined; + } + }; + + // Use provided tool_choice if available and tools are enabled, otherwise use default + const effectiveToolChoice = + (tool_choice !== undefined) + ? tool_choice + : getDefaultToolChoice(specialToolFormat); + + return { + tools: potentialTools, + ...(effectiveToolChoice !== undefined ? { tool_choice: effectiveToolChoice } : {}) + }; +} + + +const _sendOpenAICompatibleChat = async (params: SendChatParams_Internal) => { + const { + messages, + separateSystemMessage, + tool_choice, + onText: onTextInput, + onFinalMessage: onFinalMessageInput, + onError, + settingsOfProvider, + modelSelectionOptions, + modelName: modelName_, + _setAborter, + providerName, + chatMode, + overridesOfModel, + additionalTools, + disabledStaticTools, + disabledDynamicTools, + dynamicRequestConfig, + requestParams, + notifyOnTruncation, + } = params; + + + const dyn = dynamicRequestConfig; + + if (!dyn && providerName === 'anthropic') { + return sendAnthropicChat(params); + } + + if (!dyn && providerName === 'gemini') { + return sendGeminiChat(params); + } + + + const baseCaps = getModelCapabilities(providerName, modelName_, overridesOfModel); + const { modelName } = baseCaps; + let supportsCacheControl = !!(baseCaps as any).supportCacheControl; + + // If renderer provided an explicit supportCacheControl via dynamic config (dynamic provider overrides), prefer it. + if (typeof (dyn as any)?.supportCacheControl === 'boolean') { + supportsCacheControl = !!(dyn as any).supportCacheControl; + } + + const baseReasoningCaps = baseCaps.reasoningCapabilities; + + const fmtForTools: specialToolFormat | undefined = + (dyn && dyn.specialToolFormat !== undefined) + ? dyn.specialToolFormat + : baseCaps.specialToolFormat; + + const { providerReasoningIOSettings } = getProviderCapabilities(providerName, modelName_, overridesOfModel); + + // reasoning + // Prefer dynamic reasoning capabilities from renderer if provided + const effectiveReasoningCaps = (dynamicRequestConfig as any)?.reasoningCapabilities ?? baseReasoningCaps; + const { canIOReasoning, openSourceThinkTags } = effectiveReasoningCaps || {}; + + // Compute sendable reasoning info locally to avoid relying on static caps in main + const computeReasoningInfo = (): ReturnType => { + const caps = effectiveReasoningCaps as any; + if (!caps || !caps.supportsReasoning) return null; + const canTurnOff = !!caps.canTurnOffReasoning; + const defaultEnabled = true || !canTurnOff; // Chat default: enabled + const enabled = (modelSelectionOptions?.reasoningEnabled ?? defaultEnabled) === true; + if (!enabled) return null; + const slider = caps?.reasoningSlider; + if (slider && slider.type === 'budget_slider') { + const v = modelSelectionOptions?.reasoningBudget ?? slider.default; + return { type: 'budget_slider_value', isReasoningEnabled: true, reasoningBudget: v } as const; + } + if (slider && slider.type === 'effort_slider') { + const v = modelSelectionOptions?.reasoningEffort ?? slider.default; + return { type: 'effort_slider_value', isReasoningEnabled: true, reasoningEffort: v } as const; + } + return { type: 'enabled_only', isReasoningEnabled: true } as const; + }; + + const reasoningInfo = computeReasoningInfo(); + let includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {}; + + if (Object.keys(includeInPayload).length === 0) { + const isReasoningEnabledState = !!reasoningInfo; + if (isReasoningEnabledState && canIOReasoning && Array.isArray(openSourceThinkTags)) { + includeInPayload = { reasoning: { enabled: true } }; + } + } + + // tools + const staticToolsForCall = chatMode !== null ? getStaticTools(chatMode, disabledStaticTools) : []; + const dynamicToolsForCall = filterDynamicTools(additionalTools, disabledDynamicTools); + const allToolsForCall = mergeTools(staticToolsForCall, dynamicToolsForCall); + const allowedToolNames = chatMode !== null ? allToolsForCall.map(tool => tool.name) : undefined; + + const potentialTools = chatMode !== null + ? openAITools(chatMode, additionalTools, params.logService, disabledStaticTools, disabledDynamicTools) + : null; + const nativeToolsObj = + potentialTools && fmtForTools && fmtForTools !== 'disabled' + ? createNativeTools(potentialTools, tool_choice, fmtForTools) + : {}; + + + let openai: OpenAIClient; + let modelForRequest = modelName; + + if (dyn?.apiStyle === 'openai-compatible') { + const { default: OpenAI } = await getOpenAIModule(); + const token = extractBearer(dyn.headers) || 'noop'; + + + const headersNoAuth: Record = { ...dyn.headers }; + delete (headersNoAuth as any).Authorization; + delete (headersNoAuth as any).authorization; + + openai = new OpenAI({ + baseURL: dyn.endpoint, + apiKey: token, + defaultHeaders: headersNoAuth, + dangerouslyAllowBrowser: true, + maxRetries: 0, + }); + + modelForRequest = modelName; + + } else { + openai = await newOpenAICompatibleSDK({ providerName, settingsOfProvider, includeInPayload }); + } + + const { needsManualParse: needsManualReasoningParse, nameOfFieldInDelta: nameOfReasoningFieldInDelta } = providerReasoningIOSettings?.output ?? {}; + const manuallyParseReasoning = !!(needsManualReasoningParse && canIOReasoning && openSourceThinkTags); + const hasReasoningFieldInDelta = !!nameOfReasoningFieldInDelta; + + const needsReasoningProcessing = hasReasoningFieldInDelta || manuallyParseReasoning; + const needsXMLToolsProcessing = !fmtForTools || fmtForTools === 'disabled'; + const needsProcessing = needsReasoningProcessing || needsXMLToolsProcessing; + + try { + params.logService?.debug?.( + `[LLM][debug] [sendLLMMessage] processing flags\n` + + JSON.stringify({ + providerName, + modelName_input: modelName_, + modelName_resolved: modelName, + fmtForTools: fmtForTools ?? null, + fmtForTools_sources: { + dyn_specialToolFormat: dyn?.specialToolFormat ?? null, + base_specialToolFormat: (baseCaps as any)?.specialToolFormat ?? null, + }, + providerReasoningIOSettings_output: { + needsManualParse: needsManualReasoningParse ?? null, + nameOfFieldInDelta: nameOfReasoningFieldInDelta ?? null, + }, + reasoningCaps: { + canIOReasoning: !!canIOReasoning, + openSourceThinkTags: Array.isArray(openSourceThinkTags) ? openSourceThinkTags : null, + }, + derived: { + manuallyParseReasoning, + hasReasoningFieldInDelta, + needsReasoningProcessing, + needsXMLToolsProcessing, + needsProcessing, + }, + }, null, 2) + ); + } catch { + + } + + let processedMessages = messages as any; + if (separateSystemMessage) { + processedMessages = [ + { role: 'system', content: separateSystemMessage }, + ...processedMessages, + ]; + } + + // Optionally inject cache_control breakpoints for providers that support it + if (supportsCacheControl) { + processedMessages = applyCacheControlOpenAIStyle(processedMessages, true); + } + + type ChatCreateParamsWithExtras = + ChatCompletionCreateParamsStreaming & + Record & + Partial<{ max_tokens: number; max_completion_tokens: number }>; + + const options: ChatCreateParamsWithExtras = { + model: modelForRequest, + messages: processedMessages, + stream: true, + ...nativeToolsObj, + }; + + // Inject user-defined request params for OpenAI-compatible payloads + if (requestParams && requestParams.mode !== 'off') { + if (requestParams.mode === 'override' && requestParams.params && typeof requestParams.params === 'object') { + for (const [k, v] of Object.entries(requestParams.params)) { + if (k === 'tools' || k === 'tool_choice' || k === 'response_format') continue; + (options as any)[k] = v as any; + } + } + // 'default' mode: nothing extra here; defaults already applied elsewhere + } + + // Add reasoning payload to options + if (Object.keys(includeInPayload).length > 0) { + Object.assign(options, includeInPayload); + } + + // Inject OpenRouter provider routing when talking to OpenRouter endpoint + if (params.providerRouting && dyn?.endpoint?.includes('openrouter.ai')) { + (options as any).provider = params.providerRouting; + } + + // Do not set max_tokens/max_completion_tokens by default. + + let onText = onTextInput; + let onFinalMessage = onFinalMessageInput; + let sawParsedDoneToolCallInCurrentRun = false; + + if (needsProcessing) { + const think = (canIOReasoning && openSourceThinkTags) ? openSourceThinkTags : null; + + const debugEnabled = !!params.logService?.debug; + + const safeJson = (v: unknown, maxLen = 2500) => { +void safeJson; + try { + const s = JSON.stringify(v, null, 2); + return s.length > maxLen ? s.slice(0, maxLen) + `…(+${s.length - maxLen})` : s; + } catch { + return String(v); + } + }; + + const preview = (s: unknown, n = 260) => { + const str = typeof s === 'string' ? s : ''; + return str.length <= n ? str : (str.slice(0, n) + `…(+${str.length - n})`); + }; + + const baseOnText = onText; + const baseOnFinal = onFinalMessage; + + let lastToolSig = ''; + + const onTextAfterParse: OnText = (p) => { + const tc = p.toolCall; + if (tc?.isDone) { + sawParsedDoneToolCallInCurrentRun = true; + } + if (debugEnabled && tc?.name) { + const sig = `${tc.id ?? ''}|${tc.name ?? ''}|${tc.isDone ? 'done' : 'progress'}|${(tc.doneParams ?? []).join(',')}`; + if (sig !== lastToolSig) { + lastToolSig = sig; + params.logService?.debug?.( + `[LLM][debug][toolParse] onText toolCall\n` + + safeJson({ + sig, + toolCall: tc, + textPreview: preview(p.fullText), + reasoningPreview: preview(p.fullReasoning), + tokenUsage: p.tokenUsage ?? null, + }) + ); + } + } + baseOnText(p); + }; + + const onFinalAfterParse: OnFinalMessage = (p) => { + const tc = p.toolCall; + if (tc?.isDone) { + sawParsedDoneToolCallInCurrentRun = true; + } + if (debugEnabled && tc?.name) { + params.logService?.debug?.( + `[LLM][debug][toolParse] onFinalMessage toolCall\n` + + safeJson({ + toolCall: tc, + finalTextLen: p.fullText.length, + finalReasoningLen: p.fullReasoning.length, + finalTextPreview: preview(p.fullText), + finalReasoningPreview: preview(p.fullReasoning), + tokenUsage: p.tokenUsage ?? null, + }) + ); + } + baseOnFinal(p); + }; + + const { newOnText, newOnFinalMessage } = needsXMLToolsProcessing + ? extractReasoningAndXMLToolsWrapper( + onTextAfterParse, + onFinalAfterParse, + think, + chatMode + ) + : extractReasoningWrapper( + onTextAfterParse, + onFinalAfterParse, + think, + chatMode + ); + + onText = newOnText; + onFinalMessage = newOnFinalMessage; + } + + { + const pickLongerString = (a: string, b: string): string => (a.length >= b.length ? a : b); + let maxSeenUiText = ''; + let maxSeenUiReasoning = ''; + const updateMaxSeenUi = (p: { fullText: string; fullReasoning: string }) => { + maxSeenUiText = pickLongerString(maxSeenUiText, p.fullText); + maxSeenUiReasoning = pickLongerString(maxSeenUiReasoning, p.fullReasoning); + }; + const toolDefsMap = chatMode !== null + ? buildToolDefsMap(staticToolsForCall, dynamicToolsForCall) + : undefined; + const xmlRepairMaxRetries = + fmtForTools === 'disabled' && Array.isArray(allowedToolNames) && allowedToolNames.length > 0 + ? 1 + : 0; + let xmlRepairRetriesUsed = 0; + let messagesForRun: any[] = Array.isArray(processedMessages) ? [...processedMessages] : []; + let xmlRepairCarryText = ''; + let xmlRepairCarryReasoning = ''; + let currentRunAborter: (() => void) | null = null; + const withXmlRepairCarry = (p: T): T => { + if (!xmlRepairCarryText && !xmlRepairCarryReasoning) return p; + const mergedText = pickLongerString(p.fullText, xmlRepairCarryText); + const mergedReasoning = pickLongerString(p.fullReasoning, xmlRepairCarryReasoning); + if (mergedText === p.fullText && mergedReasoning === p.fullReasoning) return p; + return { ...p, fullText: mergedText, fullReasoning: mergedReasoning } as T; + }; + + while (true) { + let shouldRetryForXmlRepair = false; + let hadRunError = false; + let gatedFinalText = ''; + let gatedFinalReasoning = ''; + sawParsedDoneToolCallInCurrentRun = false; + currentRunAborter = null; + let earlyAbortedForXmlRepair = false; + let earlyAbortedForParsedDoneToolCall = false; + let lastXmlRepairCheckedCombinedLen = -1; + + const maybeAbortEarlyForXmlRepair = (p: { fullText: string; fullReasoning: string }) => { + if (earlyAbortedForXmlRepair) return; + if (earlyAbortedForParsedDoneToolCall) return; + if (xmlRepairRetriesUsed >= xmlRepairMaxRetries) return; + if (sawParsedDoneToolCallInCurrentRun) return; + + const combinedLen = (p.fullText?.length ?? 0) + (p.fullReasoning?.length ?? 0); + // Avoid O(n^2)-style repeated scans on every tiny chunk. + if (combinedLen <= (lastXmlRepairCheckedCombinedLen + 256)) return; + lastXmlRepairCheckedCombinedLen = combinedLen; + + const xmlHeuristicText = (p.fullText || '').slice(-24_000); + const xmlHeuristicReasoning = (p.fullReasoning || '').slice(-24_000); + + const hasLikelyXmlMarkup = hasLikelyUnparsedXmlToolCall({ + fullText: xmlHeuristicText, + fullReasoning: xmlHeuristicReasoning, + toolNames: allowedToolNames, + }); + if (!hasLikelyXmlMarkup) return; + + // Attributes on XML tool tags are always invalid in our required format. + const hasLikelyParamAttributes = hasLikelyToolParamAttributes({ + fullText: xmlHeuristicText, + fullReasoning: xmlHeuristicReasoning, + toolNames: allowedToolNames, + }); + if (!hasLikelyParamAttributes) return; + + earlyAbortedForXmlRepair = true; + shouldRetryForXmlRepair = true; + gatedFinalText = pickLongerString(p.fullText, maxSeenUiText); + gatedFinalReasoning = pickLongerString(p.fullReasoning, maxSeenUiReasoning); + xmlRepairCarryText = pickLongerString(xmlRepairCarryText, gatedFinalText); + xmlRepairCarryReasoning = pickLongerString(xmlRepairCarryReasoning, gatedFinalReasoning); + params.logService?.warn?.( + '[LLM][warn][toolParse] Invalid XML tool parameter attributes detected; aborting stream early and requesting corrected XML tool format retry' + ); + try { + currentRunAborter?.(); + } catch { } + }; + + const onTextWithXmlRepairCarry: OnText = (p) => { + updateMaxSeenUi(p); + const sawDoneBefore = sawParsedDoneToolCallInCurrentRun; + onText(withXmlRepairCarry(p)); + const sawDoneAfter = sawParsedDoneToolCallInCurrentRun; + if ( + !earlyAbortedForParsedDoneToolCall && + !sawDoneBefore && + sawDoneAfter && + typeof currentRunAborter === 'function' + ) { + earlyAbortedForParsedDoneToolCall = true; + params.logService?.debug?.( + '[LLM][debug][toolParse] Parsed done XML toolCall in stream; aborting stream early to avoid extra reasoning tail' + ); + try { + currentRunAborter(); + } catch { } + return; + } + maybeAbortEarlyForXmlRepair(p); + }; + + const onFinalWithXmlRepairGate: OnFinalMessage = (p) => { + updateMaxSeenUi(p); + if (earlyAbortedForXmlRepair && xmlRepairRetriesUsed < xmlRepairMaxRetries) { + return; + } + const hasDoneToolCall = !!p.toolCall?.isDone; + const hasLikelyXmlMarkup = hasLikelyUnparsedXmlToolCall({ + fullText: p.fullText, + fullReasoning: p.fullReasoning, + toolNames: allowedToolNames, + }); + const hasLikelyParamAttributes = hasLikelyToolParamAttributes({ + fullText: p.fullText, + fullReasoning: p.fullReasoning, + toolNames: allowedToolNames, + }); + + const shouldRepair = + hasLikelyXmlMarkup && + ((!hasDoneToolCall && !sawParsedDoneToolCallInCurrentRun) || hasLikelyParamAttributes); + + if (shouldRepair && xmlRepairRetriesUsed < xmlRepairMaxRetries) { + shouldRetryForXmlRepair = true; + gatedFinalText = pickLongerString(p.fullText, maxSeenUiText); + gatedFinalReasoning = pickLongerString(p.fullReasoning, maxSeenUiReasoning); + xmlRepairCarryText = pickLongerString(xmlRepairCarryText, gatedFinalText); + xmlRepairCarryReasoning = pickLongerString(xmlRepairCarryReasoning, gatedFinalReasoning); + params.logService?.warn?.( + '[LLM][warn][toolParse] XML-like tool markup detected but no parsed toolCall; requesting corrected XML tool format retry' + ); + return; + } + onFinalMessage(withXmlRepairCarry(p)); + }; + + const onErrorForRun: OnError = (err) => { + hadRunError = true; + onError(err); + }; + + await runStream({ + openai, + options: { ...options, messages: messagesForRun }, + onText: onTextWithXmlRepairCarry, + onFinalMessage: onFinalWithXmlRepairGate, + onError: onErrorForRun, + _setAborter: (aborter) => { + currentRunAborter = aborter; + _setAborter(aborter); + }, + nameOfReasoningFieldInDelta, + providerName, + toolDefsMap, + allowedToolNames, + logService: params.logService, + + notificationService: params.notificationService, + notifyOnTruncation: notifyOnTruncation ?? true, + }); + + if (hadRunError || !shouldRetryForXmlRepair) { + return; + } + + xmlRepairRetriesUsed += 1; + let assistantEcho = [ + gatedFinalText, + gatedFinalReasoning, + ].filter(Boolean).join('\n\n').trim(); + const assistantEchoCap = 8_000; + if (assistantEcho.length > assistantEchoCap) { + assistantEcho = assistantEcho.slice(-assistantEchoCap); + } + + if (assistantEcho) { + messagesForRun = [ + ...messagesForRun, + { role: 'assistant', content: assistantEcho }, + ]; + } + messagesForRun = [ + ...messagesForRun, + { role: 'user', content: XML_TOOL_FORMAT_CORRECTION_PROMPT }, + ]; + params.logService?.warn?.( + `[LLM][warn][toolParse] Retrying request with XML format correction prompt (${xmlRepairRetriesUsed}/${xmlRepairMaxRetries})` + ); + } + } +}; + +const anthropicTools = ( + chatMode: ChatMode, + additionalTools?: AdditionalToolInfo[], + logService?: ILogService, + disabledStaticTools?: readonly string[], + disabledDynamicTools?: readonly string[], +) => { + const staticTools = getStaticTools(chatMode, disabledStaticTools); + const dynamicTools = filterDynamicTools(additionalTools, disabledDynamicTools); + const allTools = mergeTools(staticTools, dynamicTools); + + if (allTools.length === 0) return null; + + const convertedTools = allTools.map(toolInfo => toAnthropicTool(toolInfo, logService)); + return convertedTools.length ? convertedTools : null; +}; + +const anthropicToolToRawToolCallObj = ( + toolBlock: AnthropicToolUseBlock, + toolDefsMap?: ReadonlyMap +): RawToolCallObj | null => { + const { id, name, input } = toolBlock; + if (!name) return null; + const toolParamsStr = JSON.stringify(input ?? {}); + return rawToolCallObjOf(name, toolParamsStr, id, toolDefsMap); +} + +// ------------ ANTHROPIC ------------ +const sendAnthropicChat = async ({ + messages, + providerName, + onText, + onFinalMessage, + onError, + settingsOfProvider, + modelSelectionOptions, + overridesOfModel, + modelName: modelName_, + _setAborter, + separateSystemMessage, + chatMode, + additionalTools, + disabledStaticTools, + disabledDynamicTools, + requestParams, + dynamicRequestConfig, + logService, +}: SendChatParams_Internal) => { + const { default: Anthropic, APIError: AnthropicAPIError } = await getAnthropicModule(); + + const { + modelName, + specialToolFormat, + reasoningCapabilities, + supportCacheControl, + } = getModelCapabilities(providerName, modelName_, overridesOfModel); + + const thisConfig = settingsOfProvider.anthropic; + const { providerReasoningIOSettings } = getProviderCapabilities(providerName, modelName_, overridesOfModel); + + // reasoning + const { canIOReasoning, openSourceThinkTags } = reasoningCapabilities || {}; + const reasoningInfo = getSendableReasoningInfoImpl('Chat', providerName, modelName_, modelSelectionOptions, overridesOfModel); + const includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {}; + + // anthropic-specific - max tokens + let maxTokens = getReservedOutputTokenSpace(providerName, modelName_, { + isReasoningEnabled: !!reasoningInfo?.isReasoningEnabled, + overridesOfModel, + }); + + // tools + const staticToolsForCall = chatMode !== null ? getStaticTools(chatMode, disabledStaticTools) : []; + const dynamicToolsForCall = filterDynamicTools(additionalTools, disabledDynamicTools); + const potentialTools = chatMode !== null + ? anthropicTools(chatMode, additionalTools, logService, disabledStaticTools, disabledDynamicTools) + : null; + const toolDefsMap = chatMode !== null ? buildToolDefsMap(staticToolsForCall, dynamicToolsForCall) : undefined; + const nativeToolsObj = + potentialTools && specialToolFormat === 'anthropic-style' + ? ({ tools: potentialTools, tool_choice: { type: 'auto' } } as const) + : {}; + + // ---- dynamic headers/baseURL support ---- + const dyn = dynamicRequestConfig; + + // apiKey: prefer dyn Authorization Bearer token if present + const tokenFromDyn = dyn?.headers ? extractBearer(dyn.headers) : ''; + const apiKey = tokenFromDyn || thisConfig.apiKey; + + // Merge headers: provider saved headers + dyn headers (dyn wins) + const mergedHeaders: Record = { + ...((thisConfig as any)?.additionalHeaders || {}), + ...(dyn?.headers || {}), + }; + + // Don’t forward Authorization to Anthropic SDK; it uses x-api-key internally + delete (mergedHeaders as any).Authorization; + delete (mergedHeaders as any).authorization; + delete (mergedHeaders as any)['x-api-key']; + delete (mergedHeaders as any)['X-API-Key']; + + // baseURL: if endpoint provided as ".../v1", strip it to avoid "/v1/v1/messages" + const baseURL = + typeof dyn?.endpoint === 'string' && dyn.endpoint.trim() + ? dyn.endpoint.trim().replace(/\/v1\/?$/i, '') + : undefined; + + const anthropic = new Anthropic({ + apiKey, + dangerouslyAllowBrowser: true, + ...(baseURL ? { baseURL } : {}), + // NOTE: SDK supports defaultHeaders in modern versions; keep as any to be safe with typing drift + ...(Object.keys(mergedHeaders).length ? ({ defaultHeaders: mergedHeaders } as any) : {}), + } as any); + + // Map requestParams (override mode) to Anthropic fields + let overrideAnthropic: Record = {}; + if (requestParams && requestParams.mode === 'override' && requestParams.params && typeof requestParams.params === 'object') { + const p: any = requestParams.params; + if (typeof p.temperature === 'number') overrideAnthropic.temperature = p.temperature; + if (typeof p.top_p === 'number') overrideAnthropic.top_p = p.top_p; + if (p.stop !== undefined) overrideAnthropic.stop_sequences = Array.isArray(p.stop) ? p.stop : [p.stop]; + if (typeof p.seed === 'number') overrideAnthropic.seed = p.seed; + if (typeof p.max_tokens === 'number') maxTokens = p.max_tokens; + else if (typeof p.max_completion_tokens === 'number') maxTokens = p.max_completion_tokens; + if (p.reasoning && typeof p.reasoning === 'object') { + const bt = p.reasoning.max_tokens ?? p.reasoning.budget_tokens; + if (typeof bt === 'number') overrideAnthropic.thinking = { type: 'enabled', budget_tokens: bt }; + } + } + + let anthropicMessages = messages as AnthropicLLMChatMessage[]; + if (supportCacheControl) { + anthropicMessages = applyCacheControlOpenAIStyle(anthropicMessages as any, true) as AnthropicLLMChatMessage[]; + } + + const systemPayload: any = + separateSystemMessage && supportCacheControl + ? [{ type: 'text', text: separateSystemMessage, cache_control: { type: 'ephemeral' } }] + : separateSystemMessage ?? undefined; + + const stream = anthropic.messages.stream({ + system: systemPayload, + messages: anthropicMessages, + model: modelName, + max_tokens: maxTokens ?? 4_096, + ...overrideAnthropic, + ...includeInPayload, + ...nativeToolsObj, + }); + + const { needsManualParse: needsManualReasoningParse } = providerReasoningIOSettings?.output ?? {}; + const manuallyParseReasoning = needsManualReasoningParse && canIOReasoning && openSourceThinkTags; + const needsXMLTools = !specialToolFormat || specialToolFormat === 'disabled'; + + if (manuallyParseReasoning || needsXMLTools) { + const thinkTags = manuallyParseReasoning ? openSourceThinkTags : null; + const { newOnText, newOnFinalMessage } = needsXMLTools + ? extractReasoningAndXMLToolsWrapper( + onText, + onFinalMessage, + thinkTags, + chatMode + ) + : extractReasoningWrapper( + onText, + onFinalMessage, + thinkTags, + chatMode + ); + onText = newOnText; + onFinalMessage = newOnFinalMessage; + } + + // when receive text + let fullText = ''; + let fullReasoning = ''; + let fullToolName = ''; + let fullToolParams = ''; + let lastTokenUsage: LLMTokenUsage | undefined; + + const runOnText = () => { + const knownTool = !!fullToolName && ((toolDefsMap?.has(fullToolName)) || isAToolName(fullToolName)); + const usagePayload = lastTokenUsage ? { tokenUsage: lastTokenUsage } : {}; + onText({ + fullText, + fullReasoning, + toolCall: knownTool ? { name: fullToolName as any, rawParams: {}, isDone: false, doneParams: [], id: 'dummy' } : undefined, + ...usagePayload, + }); + }; + + stream.on('streamEvent', e => { + if (e.type === 'message_start' && (e as any)?.message?.usage) { + const usage = validateLLMTokenUsage(mapAnthropicUsageToLLMTokenUsage((e as any).message.usage), logService); + if (usage) lastTokenUsage = usage; + } + + if (e.type === 'content_block_start') { + if (e.content_block.type === 'text') { + if (fullText) fullText += '\n\n'; + fullText += e.content_block.text; + runOnText(); + } + else if (e.content_block.type === 'thinking') { + if (fullReasoning) fullReasoning += '\n\n'; + fullReasoning += e.content_block.thinking; + runOnText(); + } + else if (e.content_block.type === 'redacted_thinking') { + if (fullReasoning) fullReasoning += '\n\n'; + fullReasoning += '[redacted_thinking]'; + runOnText(); + } + else if (e.content_block.type === 'tool_use') { + fullToolName += e.content_block.name ?? ''; + runOnText(); + } + } + else if (e.type === 'content_block_delta') { + if (e.delta.type === 'text_delta') { + fullText += e.delta.text; + runOnText(); + } + else if (e.delta.type === 'thinking_delta') { + fullReasoning += e.delta.thinking; + runOnText(); + } + else if (e.delta.type === 'input_json_delta') { + fullToolParams += e.delta.partial_json ?? ''; + runOnText(); + } + } + }); + + stream.on('finalMessage', (response) => { + const anthropicReasoning = response.content.filter(c => c.type === 'thinking' || c.type === 'redacted_thinking'); + const tools = response.content.filter(c => c.type === 'tool_use'); + const toolCall = tools[0] && anthropicToolToRawToolCallObj(tools[0] as any, toolDefsMap); + const toolCallObj = toolCall ? { toolCall } : {}; + const tokenUsageFromResp = validateLLMTokenUsage(mapAnthropicUsageToLLMTokenUsage((response as any)?.usage), logService); + if (tokenUsageFromResp) lastTokenUsage = tokenUsageFromResp; + + onFinalMessage({ + fullText, + fullReasoning, + anthropicReasoning, + ...toolCallObj, + ...(lastTokenUsage ? { tokenUsage: lastTokenUsage } : {}), + }); + }); + + stream.on('error', (error) => { + if (error instanceof AnthropicAPIError && (error as any).status === 401) { + onError({ message: invalidApiKeyMessage(providerName), fullError: error }); + } else { + onError({ message: error + '', fullError: error }); + } + }); + + _setAborter(() => { + try { (stream as any).controller.abort(); } catch { } + }); +}; + +// ------------ OLLAMA ------------ +const newOllamaSDK = async ({ endpoint }: { endpoint: string | undefined }) => { + // if endpoint is empty, normally ollama will send to 11434, but we want it to fail - the user should type it in + if (!endpoint) throw new Error(`Ollama endpoint is empty. Please enter your Ollama endpoint (e.g. http://127.0.0.1:11434) in Void Settings.`) + const { Ollama } = await getOllamaModule(); + return new Ollama({ host: endpoint }) +} + +const sendOllamaFIM = async (params: SendFIMParams_Internal) => { + const { messages, onFinalMessage, onError, settingsOfProvider, modelName, _setAborter, dynamicRequestConfig } = params; + + const fallback = settingsOfProvider.ollama; + const endpoint = dynamicRequestConfig?.endpoint || fallback.endpoint; + + const ollama = await newOllamaSDK({ endpoint }); + + let fullText = ''; + try { + const stream = await ollama.generate({ + model: modelName, + prompt: messages.prefix, + suffix: messages.suffix, + options: { + stop: messages.stopTokens, + num_predict: 300, + }, + raw: true, + stream: true, + }); + _setAborter(() => stream.abort()); + for await (const chunk of stream) { + const newText = chunk.response || ''; + fullText += newText; + } + onFinalMessage({ fullText, fullReasoning: '', anthropicReasoning: null }); + } catch (error) { + onError({ message: String(error), fullError: error }); + } +}; + +// ---------------- GEMINI NATIVE IMPLEMENTATION ---------------- + +const geminiTools = ( + chatMode: ChatMode, + additionalTools?: AdditionalToolInfo[], + logService?: ILogService, + disabledStaticTools?: readonly string[], + disabledDynamicTools?: readonly string[], +): GoogleGeminiTool[] | null => { + const staticTools = getStaticTools(chatMode, disabledStaticTools); + const dynamicTools = filterDynamicTools(additionalTools, disabledDynamicTools); + const allTools = mergeTools(staticTools, dynamicTools); + + if (allTools.length === 0) return null; + + const functionDecls = allTools.map(toolInfo => toGeminiTool(toolInfo, logService)); + if (functionDecls.length === 0) return null; + + const tools: GoogleGeminiTool = { functionDeclarations: functionDecls }; + return [tools]; +}; + +const sendGeminiChat = async ({ + messages, + separateSystemMessage, + onText, + onFinalMessage, + onError, + settingsOfProvider, + overridesOfModel, + modelName: modelName_, + _setAborter, + providerName, + modelSelectionOptions, + chatMode, + additionalTools, + disabledStaticTools, + disabledDynamicTools, + requestParams, + logService, +}: SendChatParams_Internal) => { + const { GoogleGenAI } = await getGoogleGenAIModule(); + + if (providerName !== 'gemini') throw new Error(`Sending Gemini chat, but provider was ${providerName}`) + + const thisConfig = settingsOfProvider[providerName] + + const { + modelName, + specialToolFormat, + reasoningCapabilities, + } = getModelCapabilities(providerName, modelName_, overridesOfModel) + + const { providerReasoningIOSettings } = getProviderCapabilities(providerName, modelName_, overridesOfModel) + + // reasoning + const { canIOReasoning, openSourceThinkTags } = reasoningCapabilities || {} + const reasoningInfo = getSendableReasoningInfoImpl('Chat', providerName, modelName_, modelSelectionOptions, overridesOfModel) + // const includeInPayload = providerReasoningIOSettings?.input?.includeInPayload?.(reasoningInfo) || {} + + const thinkingConfig: GoogleThinkingConfig | undefined = !reasoningInfo?.isReasoningEnabled ? undefined + : reasoningInfo.type === 'budget_slider_value' ? + { thinkingBudget: reasoningInfo.reasoningBudget } + : undefined + + // tools + const staticToolsForCall = chatMode !== null ? getStaticTools(chatMode, disabledStaticTools) : []; + const dynamicToolsForCall = filterDynamicTools(additionalTools, disabledDynamicTools); + const potentialTools = chatMode !== null + ? geminiTools(chatMode, additionalTools, logService, disabledStaticTools, disabledDynamicTools) + : undefined; + const toolDefsMap = chatMode !== null ? buildToolDefsMap(staticToolsForCall, dynamicToolsForCall) : undefined; + const toolConfig = potentialTools && specialToolFormat === 'gemini-style' ? + potentialTools + : undefined + + // instance + const genAI = new GoogleGenAI({ apiKey: thisConfig.apiKey }); + + const { needsManualParse: needsManualReasoningParse } = providerReasoningIOSettings?.output ?? {}; + const manuallyParseReasoning = needsManualReasoningParse && canIOReasoning && openSourceThinkTags; + const needsXMLTools = !specialToolFormat || specialToolFormat === 'disabled'; + + if (manuallyParseReasoning || needsXMLTools) { + const thinkTags = manuallyParseReasoning ? openSourceThinkTags : null; + const { newOnText, newOnFinalMessage } = needsXMLTools + ? extractReasoningAndXMLToolsWrapper( + onText, + onFinalMessage, + thinkTags, + chatMode + ) + : extractReasoningWrapper( + onText, + onFinalMessage, + thinkTags, + chatMode + ); + onText = newOnText; + onFinalMessage = newOnFinalMessage; + } + + // when receive text + let fullReasoningSoFar = '' + let fullTextSoFar = '' + + let toolName = '' + let toolParamsStr = '' + let toolId = '' + let lastTokenUsage: LLMTokenUsage | undefined; + + + // Map requestParams (override mode) to Gemini generation config + let generationConfig: any = undefined; + if (requestParams && requestParams.mode === 'override' && requestParams.params && typeof requestParams.params === 'object') { + const p: any = requestParams.params; + generationConfig = { + ...(typeof p.temperature === 'number' ? { temperature: p.temperature } : {}), + ...(typeof p.top_p === 'number' ? { topP: p.top_p } : {}), + ...(typeof p.top_k === 'number' ? { topK: p.top_k } : {}), + ...(typeof p.max_tokens === 'number' ? { maxOutputTokens: p.max_tokens } : (typeof p.max_completion_tokens === 'number' ? { maxOutputTokens: p.max_completion_tokens } : {})), + ...(p.stop ? { stopSequences: (Array.isArray(p.stop) ? p.stop : [p.stop]) } : {}), + ...(typeof p.seed === 'number' ? { seed: p.seed } : {}), + }; + } + + genAI.models.generateContentStream({ + model: modelName, + config: { + systemInstruction: separateSystemMessage, + thinkingConfig: thinkingConfig, + tools: toolConfig, + ...(generationConfig ? { generationConfig } : {}), + }, + contents: messages as GeminiLLMChatMessage[], + }) + .then(async (stream) => { + _setAborter(() => { + try { + stream.return(fullTextSoFar); + } catch (e) { + // Ignore errors during abort + } + }); + + // Process the stream + for await (const chunk of stream) { + // message + const newText = chunk.text ?? '' + fullTextSoFar += newText + + // usage (best-effort; some chunks may not include it) + const usage = validateLLMTokenUsage(mapGeminiUsageToLLMTokenUsage((chunk as any)?.usageMetadata), logService); + if (usage) { + lastTokenUsage = usage; + } + + // tool call + const functionCalls = chunk.functionCalls + if (functionCalls && functionCalls.length > 0) { + const functionCall = functionCalls[0] // Get the first function call + toolName = functionCall.name ?? '' + toolParamsStr = JSON.stringify(functionCall.args ?? {}) + toolId = functionCall.id ?? '' + } + + // (do not handle reasoning yet) + + // call onText + const knownTool = !!toolName && ((toolDefsMap?.has(toolName)) || isAToolName(toolName)); + const usagePayload = lastTokenUsage ? { tokenUsage: lastTokenUsage } : {}; + onText({ + fullText: fullTextSoFar, + fullReasoning: fullReasoningSoFar, + toolCall: knownTool ? { name: toolName as ToolName, rawParams: {}, isDone: false, doneParams: [], id: toolId } : undefined, + ...usagePayload, + }) + } + + // on final + if (!fullTextSoFar && !fullReasoningSoFar && !toolName) { + onError({ message: 'Void: Response from model was empty.', fullError: null }) + } else { + if (!toolId) toolId = generateUuid() // ids are empty, but other providers might expect an id + const toolCall = rawToolCallObjOf(toolName, toolParamsStr, toolId, toolDefsMap) + const toolCallObj = toolCall ? { toolCall } : {} + onFinalMessage({ + fullText: fullTextSoFar, + fullReasoning: fullReasoningSoFar, + anthropicReasoning: null, + ...toolCallObj, + ...(lastTokenUsage ? { tokenUsage: lastTokenUsage } : {}), + }); + } + }) + .catch(error => { + const message = error?.message + if (typeof message === 'string') { + + if (error.message?.includes('API key')) { + onError({ message: invalidApiKeyMessage(providerName), fullError: error }); + } + else if (error?.message?.includes('429')) { + onError({ message: 'Rate limit reached. ' + error, fullError: error }); + } + else + onError({ message: error + '', fullError: error }); + } + else { + onError({ message: error + '', fullError: error }); + } + }) +}; + +const sendMistralFIMDynamic = async (params: SendFIMParams_Internal) => { + const { messages, onFinalMessage, onError, overridesOfModel, modelName: modelName_, providerName, dynamicRequestConfig } = params; + + const { modelName, supportsFIM } = getModelCapabilities(providerName, modelName_, overridesOfModel); + if (!supportsFIM) { + onError({ message: `Model ${modelName_} does not support FIM.`, fullError: null }); + return; + } + + try { + const { MistralCore } = await getMistralCoreModule(); + const { fimComplete } = await getMistralFimModule(); + + const apiKey = extractBearer(dynamicRequestConfig?.headers || {}); + const mistral = new MistralCore({ apiKey }); + + const response = await fimComplete(mistral, { + model: modelName, + prompt: messages.prefix, + suffix: messages.suffix, + stream: false, + maxTokens: 300, + stop: messages.stopTokens, + }); + + const content = response?.ok ? response.value.choices?.[0]?.message?.content ?? '' : ''; + const fullText = typeof content === 'string' + ? content + : (content || []).map((chunk: any) => (chunk.type === 'text' ? chunk.text : '')).join(''); + + onFinalMessage({ fullText, fullReasoning: '', anthropicReasoning: null }); + } catch (error) { + onError({ message: String(error), fullError: error }); + } +}; + +export const sendChatRouter = (params: SendChatParams_Internal) => { + return _sendOpenAICompatibleChat(params); +}; + +export const sendFIMRouter = async (params: SendFIMParams_Internal) => { + + if (params.dynamicRequestConfig?.fimTransport) { + switch (params.dynamicRequestConfig.fimTransport) { + case 'ollama-native': + return sendOllamaFIM({ ...params }); + case 'mistral-native': + return sendMistralFIMDynamic(params); + case 'openai-compatible': + return _sendOpenAICompatibleFIM(params); + case 'emulated': + params.onError({ message: `Emulated FIM is not yet implemented.`, fullError: null }); + return; + } + } + params.onError({ message: `FIM transport method not configured for this model.`, fullError: null }); +}; + +type OpenAIModel = { + id: string; + created: number; + object: 'model'; + owned_by: string; +}; + + +export const openaiCompatibleList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider, providerName }: ListParams_Internal) => { + const onSuccess = ({ models }: { models: OpenAIModel[] }) => onSuccess_({ models }); + const onError = ({ error }: { error: string }) => onError_({ error }); + + try { + const openai = await newOpenAICompatibleSDK({ providerName, settingsOfProvider }); + openai.models.list() + .then(async (response) => { + const models: OpenAIModel[] = []; + models.push(...response.data); + while (response.hasNextPage()) { + models.push(...(await response.getNextPage()).data); + } + onSuccess({ models }); + }) + .catch((error) => onError({ error: String(error) })); + } catch (error) { + onError({ error: String(error) }); + } +}; + + +export const ollamaList = async ({ onSuccess: onSuccess_, onError: onError_, settingsOfProvider }: ListParams_Internal) => { + const onSuccess = ({ models }: { models: OllamaModelResponse[] }) => onSuccess_({ models }); + const onError = ({ error }: { error: string }) => onError_({ error }); + + try { + const thisConfig = settingsOfProvider.ollama; + const ollama = await newOllamaSDK({ endpoint: thisConfig.endpoint }); + try { + const response = await ollama.list(); + const { models } = response; + onSuccess({ models }); + } catch (error) { + onError({ error: String(error) }); + } + } catch (error) { + onError({ error: String(error) }); + } +}; + +export const listModelsRouter = async (params: ListParams_Internal) => { + const { providerName } = params; + if (providerName === 'ollama') { + return ollamaList(params as any); + } + return openaiCompatibleList(params as any); +}; + +export const __test = { + setAnthropicModule(mod: any) { + if (mod && mod.default) { + anthropicModule = mod as any; + } else { + anthropicModule = { + default: mod, + APIError: (mod?.APIError || class extends Error { }) + } as any; + } + }, + setGoogleGenAIModule(mod: any) { + googleGenAIModule = mod as any; + }, + setOpenAIModule(mod: any) { + openAIModule = mod as any; + }, + setGetSendableReasoningInfo(fn: typeof getSendableReasoningInfo) { + getSendableReasoningInfoImpl = fn; + }, + reset() { + openAIModule = undefined as any; + anthropicModule = undefined as any; + mistralCoreModule = undefined as any; + mistralFimModule = undefined as any; + googleGenAIModule = undefined as any; + ollamaModule = undefined as any; + getSendableReasoningInfoImpl = getSendableReasoningInfo; + }, +}; diff --git a/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts new file mode 100644 index 00000000000..a130b846a63 --- /dev/null +++ b/src/vs/platform/void/electron-main/llmMessage/sendLLMMessage.ts @@ -0,0 +1,181 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { SendLLMMessageParams, OnText, OnFinalMessage, OnError } from '../../common/sendLLMMessageTypes.js'; +import { IMetricsService } from '../../common/metricsService.js'; +import { displayInfoOfProviderName } from '../../common/voidSettingsTypes.js'; +import { sendChatRouter, sendFIMRouter } from './sendLLMMessage.impl.js'; +import { ILogService } from '../../../log/common/log.js'; +import type { INotificationService } from '../../../notification/common/notification.js'; + +export const sendLLMMessage = async ( + params: SendLLMMessageParams, + metricsService: IMetricsService, + logService?: ILogService, + notificationService?: INotificationService +) => { + const { + messagesType, + messages: messages_, + onText: onText_, + onFinalMessage: onFinalMessage_, + onError: onError_, + abortRef: abortRef_, + logging: { loggingName, loggingExtras }, + settingsOfProvider, + modelSelection, + modelSelectionOptions, + overridesOfModel, + chatMode, + separateSystemMessage, + tool_choice, + additionalTools, + disabledStaticTools, + disabledDynamicTools, + dynamicRequestConfig, + requestParams, + providerRouting, + notifyOnTruncation, + } = params; + + const { providerName, modelName } = modelSelection; + + const captureLLMEvent = (eventId: string, extras?: object) => { + metricsService.capture(eventId, { + providerName, + modelName, + customEndpointURL: settingsOfProvider[providerName]?.endpoint, + numModelsAtEndpoint: settingsOfProvider[providerName]?.models?.length, + ...(messagesType === 'chatMessages' + ? { numMessages: messages_?.length } + : messagesType === 'FIMMessage' + ? { prefixLength: messages_.prefix.length, suffixLength: messages_.suffix.length } + : {}), + ...loggingExtras, + ...extras, + }); + }; + + const submitAt = Date.now(); + + let fullTextSoFar = ''; + let aborter: (() => void) | null = null; + let didAbort = false; + + const setAborter = (fn: () => void) => { + aborter = fn; + }; + + const onText: OnText = (p) => { + if (didAbort) return; + fullTextSoFar = p.fullText; + onText_(p); + }; + + const onFinalMessage: OnFinalMessage = (p) => { + if (didAbort) return; + const durationMs = Date.now() - submitAt; + captureLLMEvent(`${loggingName} - Received Full Message`, { + messageLength: p.fullText?.length ?? 0, + reasoningLength: p.fullReasoning?.length ?? 0, + durationMs, + toolCallName: p.toolCall?.name, + }); + onFinalMessage_(p); + }; + + const onError: OnError = ({ message, fullError }) => { + if (didAbort) return; + + let errorMessage = message; + if (errorMessage === 'TypeError: fetch failed') { + errorMessage = `Failed to fetch from ${displayInfoOfProviderName(providerName).title}. This likely means you specified the wrong endpoint in Void's Settings, or your local model provider like Ollama is powered off.`; + } + + captureLLMEvent(`${loggingName} - Error`, { error: errorMessage }); + onError_({ message: errorMessage, fullError }); + }; + + const onAbort = () => { + captureLLMEvent(`${loggingName} - Abort`, { messageLengthSoFar: fullTextSoFar.length }); + try { + aborter?.(); + } catch { + // ignore + } + didAbort = true; + }; + abortRef_.current = onAbort; + + if (messagesType === 'chatMessages') { + captureLLMEvent(`${loggingName} - Sending Message`); + } else if (messagesType === 'FIMMessage') { + captureLLMEvent(`${loggingName} - Sending FIM`, { + prefixLen: messages_?.prefix?.length, + suffixLen: messages_?.suffix?.length, + }); + } + + try { + if (messagesType === 'chatMessages') { + await sendChatRouter({ + messages: messages_, + onText, + onFinalMessage, + onError, + settingsOfProvider, + modelSelectionOptions, + overridesOfModel, + modelName, + _setAborter: setAborter, + providerName, + separateSystemMessage, + tool_choice, + chatMode, + additionalTools, + disabledStaticTools, + disabledDynamicTools, + dynamicRequestConfig, + requestParams, + providerRouting, + notifyOnTruncation, + logService, + notificationService, + }); + return; + } + + if (messagesType === 'FIMMessage') { + await sendFIMRouter({ + messages: messages_, + onText, + onFinalMessage, + onError, + settingsOfProvider, + modelSelectionOptions, + overridesOfModel, + modelName, + _setAborter: setAborter, + providerName, + separateSystemMessage, + dynamicRequestConfig, + requestParams, + providerRouting, + notifyOnTruncation, + logService, + notificationService, + }); + return; + } + + onError({ message: `Error: Message type "${messagesType}" not recognized.`, fullError: null }); + } catch (error) { + if (error instanceof Error) { + onError({ message: error + '', fullError: error }); + } else { + onError({ message: `Unexpected Error in sendLLMMessage: ${error}`, fullError: error as any }); + } + } +}; diff --git a/src/vs/platform/void/electron-main/llmMessage/toolSchemaConversion.ts b/src/vs/platform/void/electron-main/llmMessage/toolSchemaConversion.ts new file mode 100644 index 00000000000..2c0bb63941f --- /dev/null +++ b/src/vs/platform/void/electron-main/llmMessage/toolSchemaConversion.ts @@ -0,0 +1,455 @@ +import { createRequire } from 'node:module'; + +import type { AdditionalToolInfo } from '../../common/sendLLMMessageTypes.js'; +import type { ToolCallParams } from '../../common/toolsServiceTypes.js'; +import { voidTools, type InternalToolInfo } from '../../common/toolsRegistry.js'; +import type { ILogService } from '../../../log/common/log.js'; + +const require = createRequire(import.meta.url); + +type ZodModule = typeof import('zod'); +const { z } = require('zod') as ZodModule; + +type ZodNumber = import('zod').ZodNumber; +type ZodBoolean = import('zod').ZodBoolean; +type ZodTypeAny = import('zod').ZodTypeAny; +type AnyZodObject = import('zod').ZodObject; + +type ZodToJsonSchemaModule = typeof import('zod-to-json-schema'); +const { zodToJsonSchema } = require('zod-to-json-schema') as ZodToJsonSchemaModule; + +type GoogleGenAIModule = typeof import('@google/genai'); +const googleGenAIModule = require('@google/genai') as GoogleGenAIModule; +const { Type } = googleGenAIModule; + +type FunctionDeclaration = import('@google/genai').FunctionDeclaration; +type Schema = import('@google/genai').Schema; +type GeminiType = (typeof Type)[keyof typeof Type]; + +type OpenAIChatCompletionTool = import('openai/resources/chat/completions/completions.js').ChatCompletionTool; +type AnthropicTool = import('@anthropic-ai/sdk').Anthropic.Tool; + +export const ToolSchemas = { + read_file: z.object({ + uri: z.string().describe('URI of the file'), + start_line: z.number().int().optional().describe('1-based start line (optional)'), + end_line: z.number().int().optional().describe('1-based end line (optional)'), + lines_count: z.number().int().optional().describe('Number of lines to read from start_line (optional)'), + page_number: z.number().int().optional().describe('Page number (optional)'), + }), + ls_dir: z.object({ + uri: z.string().optional().describe('Directory URI (optional)'), + page_number: z.number().int().optional().describe('Page number (optional)'), + }), + get_dir_tree: z.object({ + uri: z.string().describe('Directory URI'), + }), + search_pathnames_only: z.object({ + query: z.string().describe('Search query'), + include_pattern: z.string().nullable().optional().describe('File pattern to include (optional)'), + page_number: z.number().int().optional().describe('Page number (optional)'), + }), + search_for_files: z.object({ + query: z.string().describe('Search query'), + is_regex: z.boolean().optional().describe('Whether the query is a regex (optional)'), + search_in_folder: z.string().nullable().optional().describe('Folder to search in (optional)'), + page_number: z.number().int().optional().describe('Page number (optional)'), + }), + search_in_file: z.object({ + uri: z.string().describe('File URI'), + query: z.string().describe('Search query'), + is_regex: z.boolean().optional().describe('Whether the query is a regex (optional)'), + }), + read_lint_errors: z.object({ + uri: z.string().describe('File URI'), + }), + rewrite_file: z.object({ + uri: z.string().describe('File URI'), + new_content: z.string().describe('New content of the file'), + }), + edit_file: z.object({ + uri: z.string().describe('File URI'), + original_snippet: z.string().describe('Exact snippet to find (copy verbatim from file)'), + updated_snippet: z.string().describe('Replacement content'), + occurrence: z.number().int().nullable().optional().describe('1-based occurrence index to replace (optional)'), + replace_all: z.boolean().optional().describe('If true, replace all occurrences'), + location_hint: z.object({ + line: z.number().int().optional().describe('Approx 1-based line number (optional)'), + anchor_before: z.string().optional().describe('Short unique line before snippet (optional)'), + anchor_after: z.string().optional().describe('Short unique line after snippet (optional)'), + }).nullable().optional().describe('Optional disambiguation hints'), + encoding: z.string().nullable().optional().describe('File encoding (default utf8)'), + newline: z.string().nullable().optional().describe('newline handling: preserve|lf|crlf'), + }), + create_file_or_folder: z.object({ + uri: z.string().describe('URI of the file or folder'), + }), + delete_file_or_folder: z.object({ + uri: z.string().describe('URI of the file or folder'), + is_recursive: z.boolean().optional().describe('Whether to delete recursively (optional)'), + }), + run_command: z.object({ + command: z.string().describe('Command to execute'), + cwd: z.string().nullable().optional().describe('Working directory (optional)'), + }), + open_persistent_terminal: z.object({ + cwd: z.string().nullable().optional().describe('Working directory (optional)'), + }), + run_persistent_command: z.object({ + command: z.string().describe('Command to run'), + persistent_terminal_id: z.string().describe('Persistent terminal ID'), + }), + kill_persistent_terminal: z.object({ + persistent_terminal_id: z.string().describe('Persistent terminal ID to kill'), + }), +} satisfies { [K in keyof ToolCallParams]: AnyZodObject }; + +type AnyToolInfo = InternalToolInfo | AdditionalToolInfo; + +const dbg = (logService: ILogService | undefined, msg: string, data?: unknown) => { + if (!logService?.debug) return; + logService.debug(`[toolSchemaConversion] ${msg}`, data); +}; + +const warn = (logService: ILogService | undefined, msg: string, data?: unknown) => { + if (!logService?.warn) return; + logService.warn(`[toolSchemaConversion] ${msg}`, data); +}; + +const safeJson = (obj: any) => { +void safeJson; + try { + return JSON.stringify(obj, null, 2); + } catch { + return String(obj); + } +}; + +const isOptionalParam = (paramInfo: any): boolean => { + if (paramInfo?.required === false) return true; + const desc = String(paramInfo?.description || '').toLowerCase(); + return /\boptional\b/.test(desc); +}; + +const normalizeType = (raw?: string): 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object' => { + const t = String(raw || 'string').toLowerCase(); + if (['int', 'integer', 'int32', 'int64'].includes(t)) return 'integer'; + if (['number', 'float', 'double'].includes(t)) return 'number'; + if (['boolean', 'bool'].includes(t)) return 'boolean'; + if (t === 'array') return 'array'; + if (t === 'object') return 'object'; + if (t === 'string') return 'string'; + return 'string'; +}; + +const tryNumber = (val: any) => { + if (typeof val === 'string' && val.trim() !== '') { + const n = Number(val); + if (!isNaN(n)) return n; + } + return val; +}; + +const preprocessedNumber = (schema: ZodNumber) => + z.preprocess(tryNumber, schema); + +const preprocessedBoolean = (schema: ZodBoolean) => + z.preprocess((val) => { + if (typeof val === 'string') { + const v = val.toLowerCase(); + if (v === 'true') return true; + if (v === 'false') return false; + } + return val; + }, schema); + +const preprocessedArray = (itemSchema: ZodTypeAny) => + z.preprocess((val) => { + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val); + return parsed; + } catch { /* noop */ } + } + return val; + }, z.array(itemSchema)); + +const preprocessedObject = (objSchema: AnyZodObject) => + z.preprocess((val) => { + if (typeof val === 'string') { + try { + const parsed = JSON.parse(val); + return parsed; + } catch { /* noop */ } + } + return val; + }, objSchema); + +const isBuiltInTool = (name: string): name is keyof ToolCallParams => { + return name in ToolSchemas; +}; + +export const paramInfoToZod = (paramInfo: any, logService?: ILogService): ZodTypeAny => { + if (!paramInfo || typeof paramInfo !== 'object') return z.string(); + + dbg(logService, 'paramInfoToZod called with', paramInfo); + + const t = normalizeType(paramInfo.type); + + + if (Array.isArray(paramInfo.enum) && paramInfo.enum.length > 0) { + const values = paramInfo.enum; + + const allNumbers = values.every( + (v: any) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '' && !isNaN(Number(v))) + ); + const allBooleans = values.every( + (v: any) => typeof v === 'boolean' || (typeof v === 'string' && ['true', 'false'].includes(v.toLowerCase())) + ); + + if (allNumbers || t === 'number' || t === 'integer') { + const nums = values.map((v: any) => Number(v)); + const base = + t === 'integer' + ? preprocessedNumber(z.number().int()) + : preprocessedNumber(z.number()); + return base + .refine((v) => nums.includes(v), paramInfo.description || 'Must be one of enum values') + .describe(paramInfo.description || ''); + } + + if (allBooleans || t === 'boolean') { + const bools = values.map((v: any) => (typeof v === 'boolean' ? v : v.toLowerCase() === 'true')); + return preprocessedBoolean(z.boolean()) + .refine((v) => bools.includes(v), paramInfo.description || 'Must be one of enum values') + .describe(paramInfo.description || ''); + } + + return z.enum(values.map(String) as [string, ...string[]]).describe(paramInfo.description || ''); + } + + switch (t) { + case 'number': { + return preprocessedNumber(z.number()).describe(paramInfo.description || ''); + } + case 'integer': { + return preprocessedNumber(z.number().int()).describe(paramInfo.description || ''); + } + case 'boolean': { + return preprocessedBoolean(z.boolean()).describe(paramInfo.description || ''); + } + case 'array': { + const items = paramInfo.items || { type: 'string' }; + return preprocessedArray(paramInfoToZod(items, logService)).describe(paramInfo.description || ''); + } + case 'object': { + const shape: Record = {}; + const props = paramInfo.properties || {}; + for (const [k, v] of Object.entries(props)) { + let child = paramInfoToZod(v, logService); + if (isOptionalParam(v)) child = child.optional(); + shape[k] = child; + } + if (Array.isArray(paramInfo.required) && paramInfo.required.length > 0) { + for (const key of Object.keys(shape)) { + if (!paramInfo.required.includes(key)) { + shape[key] = shape[key].optional(); + } + } + } + return preprocessedObject(z.object(shape)).describe(paramInfo.description || ''); + } + case 'string': + default: { + return z.string().describe(paramInfo.description || ''); + } + } +}; + +export const buildZodSchemaForTool = (toolInfo: AnyToolInfo, logService?: ILogService): AnyZodObject => { + const name = toolInfo.name as keyof ToolCallParams; + + if (name in ToolSchemas) { + return ToolSchemas[name]; + } + + const dynamicParams = (toolInfo as AdditionalToolInfo).params || {}; + dbg(logService, 'MCP tool received', { + name: toolInfo.name, + description: (toolInfo as any).description, + params: dynamicParams, + }); + + const zodProps: Record = {}; + + for (const [paramName, paramInfo] of Object.entries(dynamicParams)) { + const rawType = (paramInfo as any)?.type; + const normType = normalizeType(rawType); + + dbg(logService, 'MCP param mapping', { + tool: toolInfo.name, + param: paramName, + rawType, + normType, + enum: (paramInfo as any)?.enum, + optional: isOptionalParam(paramInfo), + description: (paramInfo as any)?.description, + }); + + let zodType = paramInfoToZod(paramInfo, logService); + if (isOptionalParam(paramInfo)) zodType = zodType.optional(); + zodProps[paramName] = zodType; + } + + const schema = z.object(zodProps); + try { + const json = zodToJsonSchema(schema, { target: 'openApi3', $refStrategy: 'none' }); + dbg(logService, 'Generated JSON schema from MCP (pre-provider)', json); + } catch (e) { + warn(logService, 'Failed to generate JSON schema for debug', e); + } + + return schema; +}; + +const applyVoidToolsDescriptionOverrides = (toolName: string, jsonSchema: any) => { + if (!jsonSchema?.properties) return; + const vt = (voidTools as any)[toolName]; + if (!vt?.params) return; + + for (const key of Object.keys(jsonSchema.properties)) { + const overrideDesc = vt.params?.[key]?.description; + if (overrideDesc) { + jsonSchema.properties[key] = jsonSchema.properties[key] || {}; + jsonSchema.properties[key].description = overrideDesc; + } + } +}; + + +const jsonSchemaToGeminiSchema = (js: any): Schema => { + const toType = (t?: string): GeminiType => { + switch ((t || '').toLowerCase()) { + case 'object': return Type.OBJECT; + case 'array': return Type.ARRAY; + case 'number': return Type.NUMBER; + case 'integer': return Type.NUMBER; + case 'boolean': return Type.BOOLEAN; + case 'string': + default: return Type.STRING; + } + }; + + const recurse = (node: any): Schema => { + if (!node || typeof node !== 'object') { + return { type: Type.STRING }; + } + + if (Array.isArray(node.enum) && node.enum.length > 0) { + return { + type: toType(node.type), + description: node.description, + enum: node.enum, + }; + } + + const t = toType(node.type); + + if (t === Type.OBJECT) { + const out: Schema = { + type: t, + description: node.description, + properties: {}, + required: Array.isArray(node.required) ? node.required : [], + }; + if (node.properties && typeof node.properties === 'object') { + for (const [k, v] of Object.entries(node.properties)) { + (out.properties as any)[k] = recurse(v); + } + } + return out; + } + + if (t === Type.ARRAY) { + return { + type: t, + description: node.description, + items: recurse(node.items || { type: 'string' }), + }; + } + + return { + type: t, + description: node.description, + }; + }; + + return recurse(js); +}; + +const getZodSchemaForTool = (toolInfo: AnyToolInfo, logService?: ILogService): AnyZodObject => { + const name = (toolInfo as any).name as string; + if (isBuiltInTool(name)) { + return ToolSchemas[name as keyof ToolCallParams]; + } + return buildZodSchemaForTool(toolInfo as AdditionalToolInfo, logService); +}; + +export const toOpenAICompatibleTool = ( + toolInfo: AnyToolInfo, + logService?: ILogService +): OpenAIChatCompletionTool => { + const { name, description } = toolInfo as { name: string; description: string }; + + const zodSchema = getZodSchemaForTool(toolInfo, logService); + const parameters = { + ...zodToJsonSchema(zodSchema, { target: 'openApi3', $refStrategy: 'none' }), + additionalProperties: false, + }; + + applyVoidToolsDescriptionOverrides(name, parameters); + + return { + type: 'function', + function: { + name, + description, + parameters, + strict: false, + }, + }; +}; + +export const toAnthropicTool = (toolInfo: AnyToolInfo, logService?: ILogService): AnthropicTool => { + const { name, description } = toolInfo as { name: string; description: string }; + + const zodSchema = getZodSchemaForTool(toolInfo, logService); + const input_schema = { + ...zodToJsonSchema(zodSchema, { $refStrategy: 'none' }), + additionalProperties: false, + }; + + dbg(logService, `Anthropic tool.input_schema for ${name}`, input_schema); + + return { + name, + description, + input_schema, + } as AnthropicTool; +}; + +export const toGeminiTool = (toolInfo: AnyToolInfo, logService?: ILogService): FunctionDeclaration => { + const { name, description } = toolInfo as { name: string; description: string }; + + const zodSchema = getZodSchemaForTool(toolInfo, logService); + const parametersJson = zodToJsonSchema(zodSchema, { $refStrategy: 'none' }); + const parameters = jsonSchemaToGeminiSchema(parametersJson); + const finalParams: Schema = + parameters.type === (Type as any).OBJECT + ? parameters + : { type: (Type as any).OBJECT, properties: {} }; + + dbg(logService, `Gemini tool.parameters for ${name}`, finalParams); + + return { name, description, parameters: finalParams }; +}; diff --git a/src/vs/workbench/contrib/void/electron-main/mcpChannel.ts b/src/vs/platform/void/electron-main/mcpChannel.ts similarity index 54% rename from src/vs/workbench/contrib/void/electron-main/mcpChannel.ts rename to src/vs/platform/void/electron-main/mcpChannel.ts index e5c4fb6e72b..7c198df5ddd 100644 --- a/src/vs/workbench/contrib/void/electron-main/mcpChannel.ts +++ b/src/vs/platform/void/electron-main/mcpChannel.ts @@ -7,16 +7,27 @@ // can't make a service responsible for this, because it needs // to be connected to the main process and node dependencies -import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; +import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { MCPConfigFileJSON, MCPConfigFileEntryJSON, MCPServer, RawMCPToolCall, MCPToolErrorResponse, MCPServerEventResponse, MCPToolCallParams, removeMCPToolNamePrefix } from '../common/mcpServiceTypes.js'; +import { + MCPConfigFileJSON, + MCPConfigFileEntryJSON, + MCPServer, + RawMCPToolCall, + MCPToolErrorResponse, + MCPServerEventResponse, + MCPToolCallParams, + addMCPToolNamePrefix, + removeMCPToolNamePrefix +} from '../common/mcpServiceTypes.js'; import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { MCPUserStateOfName } from '../common/voidSettingsTypes.js'; +import { ILogService } from '../../../platform/log/common/log.js'; const getClientConfig = (serverName: string) => { return { @@ -26,20 +37,11 @@ const getClientConfig = (serverName: string) => { } } -type MCPServerNonError = MCPServer & { status: Omit } -type MCPServerError = MCPServer & { status: 'error' } - - - type ClientInfo = { - _client: Client, // _client is the client that connects with an mcp client. We're calling mcp clients "server" everywhere except here for naming consistency. - mcpServerEntryJSON: MCPConfigFileEntryJSON, - mcpServer: MCPServerNonError, -} | { - _client?: undefined, - mcpServerEntryJSON: MCPConfigFileEntryJSON, - mcpServer: MCPServerError, -} + _client?: Client; + mcpServerEntryJSON: MCPConfigFileEntryJSON; + mcpServer: MCPServer; +}; type InfoOfClientId = { [clientId: string]: ClientInfo @@ -66,6 +68,7 @@ export class MCPChannel implements IServerChannel { } constructor( + @ILogService private readonly logService: ILogService ) { } // browser uses this to listen for changes @@ -105,97 +108,138 @@ export class MCPChannel implements IServerChannel { } } catch (e) { - console.error('mcp channel: Call Error:', e) + this.logService.error('mcp channel: Call Error:', e) } } - // server functions - + private _prefixToolNames(serverName: string, tools: { name: string;[k: string]: any }[]) { + return tools.map(({ name, ...rest }) => ({ + name: addMCPToolNamePrefix(serverName, name), + ...rest + })); + } - private async _refreshMCPServers(params: { mcpConfigFileJSON: MCPConfigFileJSON, userStateOfName: MCPUserStateOfName, addedServerNames: string[], removedServerNames: string[], updatedServerNames: string[] }) { + private _filterToolsByExclude(server: MCPConfigFileEntryJSON, tools: { name: string; [k: string]: any }[]) { + const exclude = Array.isArray(server.excludeTools) + ? new Set(server.excludeTools.map(v => String(v ?? '').trim()).filter(Boolean)) + : new Set(); + if (exclude.size === 0) return tools; + return tools.filter(t => !exclude.has(String(t?.name ?? '').trim())); + } - const { - mcpConfigFileJSON, - userStateOfName, - addedServerNames, - removedServerNames, - updatedServerNames, - } = params + private _commandString(server: MCPConfigFileEntryJSON): string { + if ((server as any).url) { + const u = (server as any).url; + return typeof u === 'string' ? u : u.toString(); + } + if (server.command) { + return `${server.command} ${server.args?.join(' ') || ''}`.trim(); + } + return ''; + } - const { mcpServers: mcpServersJSON } = mcpConfigFileJSON + private async _refreshMCPServers(params: { + mcpConfigFileJSON: MCPConfigFileJSON, + userStateOfName: MCPUserStateOfName, + addedServerNames: string[], + removedServerNames: string[], + updatedServerNames: string[] + }) { + const { mcpConfigFileJSON, userStateOfName, addedServerNames, removedServerNames, updatedServerNames } = params; + const { mcpServers: mcpServersJSON } = mcpConfigFileJSON; const allChanges: { type: 'added' | 'removed' | 'updated', serverName: string }[] = [ ...addedServerNames.map(n => ({ serverName: n, type: 'added' }) as const), ...removedServerNames.map(n => ({ serverName: n, type: 'removed' }) as const), ...updatedServerNames.map(n => ({ serverName: n, type: 'updated' }) as const), - ] - - await Promise.all( - allChanges.map(async ({ serverName, type }) => { + ]; - // check if already refreshing - if (this._refreshingServerNames.has(serverName)) return - this._refreshingServerNames.add(serverName) + await Promise.all(allChanges.map(async ({ serverName, type }) => { + if (this._refreshingServerNames.has(serverName)) return; + this._refreshingServerNames.add(serverName); + try { const prevServer = this.infoOfClientId[serverName]?.mcpServer; - // close and delete the old client + // close+delete old if (type === 'removed' || type === 'updated') { - await this._closeClient(serverName) - delete this.infoOfClientId[serverName] - this.mcpEmitters.serverEvent.onDelete.fire({ response: { prevServer, name: serverName, } }) + await this._closeClient(serverName); + delete this.infoOfClientId[serverName]; + this.mcpEmitters.serverEvent.onDelete.fire({ response: { prevServer, name: serverName } }); } - // create a new client + // create new if (type === 'added' || type === 'updated') { - const clientInfo = await this._createClient(mcpServersJSON[serverName], serverName, userStateOfName[serverName]?.isOn) - this.infoOfClientId[serverName] = clientInfo - this.mcpEmitters.serverEvent.onAdd.fire({ response: { newServer: clientInfo.mcpServer, name: serverName, } }) + const isOn = !!userStateOfName?.[serverName]?.isOn; + const clientInfo = await this._createClient(mcpServersJSON[serverName], serverName, isOn); + this.infoOfClientId[serverName] = clientInfo; + this.mcpEmitters.serverEvent.onAdd.fire({ response: { newServer: clientInfo.mcpServer, name: serverName } }); } - }) - ) - - allChanges.forEach(({ serverName, type }) => { - this._refreshingServerNames.delete(serverName) - }) - + } catch (e) { + this.logService.error('[MCP] refreshMCPServers failed for ' + serverName, e); + } finally { + this._refreshingServerNames.delete(serverName); + } + })); } - private async _createClientUnsafe(server: MCPConfigFileEntryJSON, serverName: string, isOn: boolean): Promise { - - const clientConfig = getClientConfig(serverName) - const client = new Client(clientConfig) + private async _createClientUnsafe(server: MCPConfigFileEntryJSON, serverName: string): Promise { + const clientConfig = getClientConfig(serverName); + const client = new Client(clientConfig); let transport: Transport; - let info: MCPServerNonError; - if (server.url) { + + const rawUrl: any = (server as any).url; + const url: URL | undefined = rawUrl + ? (typeof rawUrl === 'string' ? new URL(rawUrl) : rawUrl) + : undefined; + + if (url) { // first try HTTP, fall back to SSE try { - transport = new StreamableHTTPClientTransport(server.url); + transport = new StreamableHTTPClientTransport(url); await client.connect(transport); - console.log(`Connected via HTTP to ${serverName}`); - const { tools } = await client.listTools() - const toolsWithUniqueName = tools.map(({ name, ...rest }) => ({ name: this._addUniquePrefix(name), ...rest })) - info = { - status: isOn ? 'success' : 'offline', - tools: toolsWithUniqueName, - command: server.url.toString(), - } + this.logService.debug(`Connected via HTTP to ${serverName}`); + + const { tools } = await client.listTools(); + const filtered = this._filterToolsByExclude(server, tools as any); + const toolsWithStableNames = this._prefixToolNames(serverName, filtered as any); + + return { + _client: client, + mcpServerEntryJSON: server, + mcpServer: { + status: 'success', + tools: toolsWithStableNames, + command: url.toString(), + } + }; } catch (httpErr) { - console.warn(`HTTP failed for ${serverName}, trying SSE…`, httpErr); - transport = new SSEClientTransport(server.url); + this.logService.warn(`HTTP failed for ${serverName}, trying SSE…`, httpErr); + + transport = new SSEClientTransport(url); await client.connect(transport); - const { tools } = await client.listTools() - const toolsWithUniqueName = tools.map(({ name, ...rest }) => ({ name: this._addUniquePrefix(name), ...rest })) - console.log(`Connected via SSE to ${serverName}`); - info = { - status: isOn ? 'success' : 'offline', - tools: toolsWithUniqueName, - command: server.url.toString(), - } + + const { tools } = await client.listTools(); + const filtered = this._filterToolsByExclude(server, tools as any); + const toolsWithStableNames = this._prefixToolNames(serverName, filtered as any); + + this.logService.debug(`Connected via SSE to ${serverName}`); + return { + _client: client, + mcpServerEntryJSON: server, + mcpServer: { + status: 'success', + tools: toolsWithStableNames, + command: url.toString(), + } + }; } - } else if (server.command) { - // console.log('ENV DATA: ', server.env) + } + + if (server.command) { + this.logService.debug('ENV DATA: ', server.env); + transport = new StdioClientTransport({ command: server.command, args: server.args, @@ -205,43 +249,55 @@ export class MCPChannel implements IServerChannel { } as Record, }); - await client.connect(transport) - - // Get the tools from the server - const { tools } = await client.listTools() - const toolsWithUniqueName = tools.map(({ name, ...rest }) => ({ name: this._addUniquePrefix(name), ...rest })) + await client.connect(transport); - // Create a full command string for display - const fullCommand = `${server.command} ${server.args?.join(' ') || ''}` + const { tools } = await client.listTools(); + const filtered = this._filterToolsByExclude(server, tools as any); + const toolsWithStableNames = this._prefixToolNames(serverName, filtered as any); - // Format server object - info = { - status: isOn ? 'success' : 'offline', - tools: toolsWithUniqueName, - command: fullCommand, - } + const fullCommand = `${server.command} ${server.args?.join(' ') || ''}`.trim(); - } else { - throw new Error(`No url or command for server ${serverName}`); + return { + _client: client, + mcpServerEntryJSON: server, + mcpServer: { + status: 'success', + tools: toolsWithStableNames, + command: fullCommand, + } + }; } - - return { _client: client, mcpServerEntryJSON: server, mcpServer: info } - } - - private _addUniquePrefix(base: string) { - return `${Math.random().toString(36).slice(2, 8)}_${base}`; + throw new Error(`No url or command for server ${serverName}`); } private async _createClient(serverConfig: MCPConfigFileEntryJSON, serverName: string, isOn = true): Promise { + + if (!isOn) { + return { + _client: undefined, + mcpServerEntryJSON: serverConfig, + mcpServer: { + status: 'offline', + tools: [], + command: this._commandString(serverConfig), + } + }; + } + try { - const c: ClientInfo = await this._createClientUnsafe(serverConfig, serverName, isOn) - return c + return await this._createClientUnsafe(serverConfig, serverName); } catch (err) { - console.error(`❌ Failed to connect to server "${serverName}":`, err) - const fullCommand = !serverConfig.command ? '' : `${serverConfig.command} ${serverConfig.args?.join(' ') || ''}` - const c: MCPServerError = { status: 'error', error: err + '', command: fullCommand, } - return { mcpServerEntryJSON: serverConfig, mcpServer: c, } + this.logService.error(`Failed to connect to server "${serverName}":`, err); + return { + _client: undefined, + mcpServerEntryJSON: serverConfig, + mcpServer: { + status: 'error', + error: err + '', + command: this._commandString(serverConfig), + } + }; } } @@ -250,7 +306,7 @@ export class MCPChannel implements IServerChannel { await this._closeClient(serverName) delete this.infoOfClientId[serverName] } - console.log('Closed all MCP servers'); + this.logService.debug('Closed all MCP servers'); } private async _closeClient(serverName: string) { @@ -260,48 +316,48 @@ export class MCPChannel implements IServerChannel { if (client) { await client.close() } - console.log(`Closed MCP server ${serverName}`); + this.logService.debug(`Closed MCP server ${serverName}`); } private async _toggleMCPServer(serverName: string, isOn: boolean) { - const prevServer = this.infoOfClientId[serverName]?.mcpServer - // Handle turning on the server + const prevServer = this.infoOfClientId[serverName]?.mcpServer; + const existing = this.infoOfClientId[serverName]; + if (!existing) return; + if (isOn) { - // this.mcpEmitters.serverEvent.onChangeLoading.fire(getLoadingServerObject(serverName, isOn)) - const clientInfo = await this._createClientUnsafe(this.infoOfClientId[serverName].mcpServerEntryJSON, serverName, isOn) + + await this._closeClient(serverName); + + const clientInfo = await this._createClient(existing.mcpServerEntryJSON, serverName, true); + + + this.infoOfClientId[serverName] = clientInfo; + this.mcpEmitters.serverEvent.onUpdate.fire({ - response: { - name: serverName, - newServer: clientInfo.mcpServer, - prevServer: prevServer, + response: { name: serverName, newServer: clientInfo.mcpServer, prevServer } + }); + } else { + + await this._closeClient(serverName); + + + this.infoOfClientId[serverName] = { + _client: undefined, + mcpServerEntryJSON: existing.mcpServerEntryJSON, + mcpServer: { + status: 'offline', + tools: [], + command: this._commandString(existing.mcpServerEntryJSON), } - }) - } - // Handle turning off the server - else { - // this.mcpEmitters.serverEvent.onChangeLoading.fire(getLoadingServerObject(serverName, isOn)) - this._closeClient(serverName) - delete this.infoOfClientId[serverName]._client + }; this.mcpEmitters.serverEvent.onUpdate.fire({ - response: { - name: serverName, - newServer: { - status: 'offline', - tools: [], - command: '', - // Explicitly set error to undefined to reset the error state - error: undefined, - }, - prevServer: prevServer, - } - }) + response: { name: serverName, newServer: this.infoOfClientId[serverName].mcpServer, prevServer } + }); } } - // tool call functions - private async _callTool(serverName: string, toolName: string, params: any): Promise { const server = this.infoOfClientId[serverName] if (!server) throw new Error(`Server ${serverName} not found`) @@ -332,18 +388,6 @@ export class MCPChannel implements IServerChannel { } } - // if (returnValue.type === 'audio') { - // // handle audio response - // } - - // if (returnValue.type === 'image') { - // // handle image response - // } - - // if (returnValue.type === 'resource') { - // // handle resource response - // } - throw new Error(`Tool call error: We don\'t support ${returnValue.type} tool response yet for tool ${toolName} on server ${serverName}`) } @@ -380,7 +424,7 @@ export class MCPChannel implements IServerChannel { errorMessage = JSON.stringify(err, null, 2); } - const fullErrorMessage = `❌ Failed to call tool "${toolName}" on server "${serverName}": ${errorMessage}`; + const fullErrorMessage = `Failed to call tool "${toolName}" on server "${serverName}": ${errorMessage}`; const errorResponse: MCPToolErrorResponse = { event: 'error', text: fullErrorMessage, @@ -392,4 +436,3 @@ export class MCPChannel implements IServerChannel { } } - diff --git a/src/vs/workbench/contrib/void/electron-main/metricsMainService.ts b/src/vs/platform/void/electron-main/metricsMainService.ts similarity index 52% rename from src/vs/workbench/contrib/void/electron-main/metricsMainService.ts rename to src/vs/platform/void/electron-main/metricsMainService.ts index b6553c47dec..e5098d62bc5 100644 --- a/src/vs/workbench/contrib/void/electron-main/metricsMainService.ts +++ b/src/vs/platform/void/electron-main/metricsMainService.ts @@ -3,17 +3,26 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { isLinux, isMacintosh, isWindows } from '../../../../base/common/platform.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { IEnvironmentMainService } from '../../../../platform/environment/electron-main/environmentMainService.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { StorageTarget, StorageScope } from '../../../../platform/storage/common/storage.js'; -import { IApplicationStorageMainService } from '../../../../platform/storage/electron-main/storageMainService.js'; - +import { createRequire } from 'node:module'; + +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { isLinux, isMacintosh, isWindows } from '../../../base/common/platform.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { IEnvironmentMainService } from '../../../platform/environment/electron-main/environmentMainService.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; +import { StorageTarget, StorageScope } from '../../../platform/storage/common/storage.js'; +import { IApplicationStorageMainService } from '../../../platform/storage/electron-main/storageMainService.js'; import { IMetricsService } from '../common/metricsService.js'; -import { PostHog } from 'posthog-node' -import { OPT_OUT_KEY } from '../common/storageKeys.js'; +import { defaultGlobalSettings, DISABLE_TELEMETRY_KEY } from '../../void/common/voidSettingsTypes.js'; + +const POSTHOG_API_KEY = 'phc_UanIdujHiLp55BkUTjB1AuBXcasVkdqRwgnwRlWESH2'; +const POSTHOG_HOST = 'https://us.i.posthog.com'; + +const require = createRequire(import.meta.url); + +type PostHogModule = typeof import('posthog-node'); +const { PostHog } = require('posthog-node') as PostHogModule; +type PostHogClient = import('posthog-node').PostHog; const os = isWindows ? 'windows' : isMacintosh ? 'mac' : isLinux ? 'linux' : null @@ -30,13 +39,10 @@ const osInfo = _getOSInfo() // we'd like to use devDeviceId on telemetryService, but that gets sanitized by the time it gets here as 'someValue.devDeviceId' - - export class MetricsMainService extends Disposable implements IMetricsService { _serviceBrand: undefined; - private readonly client: PostHog - + private client: PostHogClient | undefined private _initProperties: object = {} @@ -69,42 +75,47 @@ export class MetricsMainService extends Disposable implements IMetricsService { // } } - - // the main id - private get distinctId() { - const oldId = this.oldId - const setValIfNotExist = oldId === 'NULL' ? undefined : oldId - return this._memoStorage('void.app.machineId', StorageTarget.MACHINE, setValIfNotExist) + private _getTelemetryDisabled(): boolean { + const val = this._appStorage.get(DISABLE_TELEMETRY_KEY, StorageScope.APPLICATION); + return typeof val === 'boolean' ? val : defaultGlobalSettings.disableTelemetry; } - // just to see if there are ever multiple machineIDs per userID (instead of this, we should just track by the user's email) - private get userId() { - return this._memoStorage('void.app.userMachineId', StorageTarget.USER) + private _ensureDefaultTelemetryFlag(): void { + const existing = this._appStorage.get(DISABLE_TELEMETRY_KEY, StorageScope.APPLICATION); + if (existing === undefined) { + this._appStorage.store( + DISABLE_TELEMETRY_KEY, + defaultGlobalSettings.disableTelemetry, + StorageScope.APPLICATION, + StorageTarget.USER + ); + } } - constructor( - @IProductService private readonly _productService: IProductService, - @IEnvironmentMainService private readonly _envMainService: IEnvironmentMainService, - @IApplicationStorageMainService private readonly _appStorage: IApplicationStorageMainService, - ) { - super() - this.client = new PostHog('phc_UanIdujHiLp55BkUTjB1AuBXcasVkdqRwgnwRlWESH2', { - host: 'https://us.i.posthog.com', - }) - - this.initialize() // async + private _ensureClient(): void { + if (!this.client) { + this.client = new PostHog(POSTHOG_API_KEY, { host: POSTHOG_HOST }); + } } - async initialize() { - // very important to await whenReady! - await this._appStorage.whenReady + private _shutdownClient(): void { + this.client?.shutdown?.(); + this.client = undefined; + } - const { commit, version, voidVersion, release, quality } = this._productService + private _updateClientStateFromFlag(): void { + if (this._getTelemetryDisabled()) { + this._shutdownClient(); + } else { + this._ensureClient(); + } + } - const isDevMode = !this._envMainService.isBuilt // found in abstractUpdateService.ts + private _buildInitProperties(): object { + const { commit, version, voidVersion, release, quality } = this._productService; + const isDevMode = !this._envMainService.isBuilt; - // custom properties we identify - this._initProperties = { + return { commit, vscodeVersion: version, voidVersion: voidVersion, @@ -116,44 +127,96 @@ export class MetricsMainService extends Disposable implements IMetricsService { oldId: this.oldId, isDevMode, ...osInfo, - } + }; + } + private _identifyIfActive(): void { + if (!this.client || this._getTelemetryDisabled()) return; const identifyMessage = { distinctId: this.distinctId, properties: this._initProperties, - } - - const didOptOut = this._appStorage.getBoolean(OPT_OUT_KEY, StorageScope.APPLICATION, false) + }; + this.client.identify(identifyMessage); + console.log('Void posthog metrics info:', JSON.stringify(identifyMessage, null, 2)); + } - console.log('User is opted out of basic Void metrics?', didOptOut) - if (didOptOut) { - this.client.optOut() - } - else { - this.client.optIn() - this.client.identify(identifyMessage) - } + private _subscribeToTelemetryChanges(): void { + const changeStore = this._register(new DisposableStore()); + + const onAppTelemetryChange = this._appStorage.onDidChangeValue( + StorageScope.APPLICATION, + DISABLE_TELEMETRY_KEY, + changeStore + ); + + this._register( + onAppTelemetryChange(() => { + const disabled = this._getTelemetryDisabled(); + if (disabled) { + this._shutdownClient(); + } else { + const hadClient = !!this.client; + this._ensureClient(); + + if (!hadClient) { + this._identifyIfActive(); + } + } + }) + ); + } - console.log('Void posthog metrics info:', JSON.stringify(identifyMessage, null, 2)) + // the main id + private get distinctId() { + const oldId = this.oldId + const setValIfNotExist = oldId === 'NULL' ? undefined : oldId + return this._memoStorage('void.app.machineId', StorageTarget.MACHINE, setValIfNotExist) } + // just to see if there are ever multiple machineIDs per userID (instead of this, we should just track by the user's email) + private get userId() { + return this._memoStorage('void.app.userMachineId', StorageTarget.USER) + } - capture: IMetricsService['capture'] = (event, params) => { - const capture = { distinctId: this.distinctId, event, properties: params } as const - // console.log('full capture:', this.distinctId) - this.client.capture(capture) + constructor( + @IProductService private readonly _productService: IProductService, + @IEnvironmentMainService private readonly _envMainService: IEnvironmentMainService, + @IApplicationStorageMainService private readonly _appStorage: IApplicationStorageMainService, + ) { + super(); + this.initialize() // async } - setOptOut: IMetricsService['setOptOut'] = (newVal: boolean) => { - if (newVal) { - this._appStorage.store(OPT_OUT_KEY, 'true', StorageScope.APPLICATION, StorageTarget.MACHINE) - } - else { - this._appStorage.remove(OPT_OUT_KEY, StorageScope.APPLICATION) - } + async initialize() { + + await this._appStorage.whenReady; + + + this._ensureDefaultTelemetryFlag(); + + + this._initProperties = this._buildInitProperties(); + + + this._updateClientStateFromFlag(); + + + this._identifyIfActive(); + + + this._subscribeToTelemetryChanges(); } + capture: IMetricsService['capture'] = (event, params) => { + + if (!this.client) return; + + const capture = { distinctId: this.distinctId, event, properties: params } as const; + this.client.capture(capture); + }; + + async getDebuggingProperties() { return this._initProperties } diff --git a/src/vs/platform/void/electron-main/remoteModelsService.ts b/src/vs/platform/void/electron-main/remoteModelsService.ts new file mode 100644 index 00000000000..6baf20bd920 --- /dev/null +++ b/src/vs/platform/void/electron-main/remoteModelsService.ts @@ -0,0 +1,36 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { IRemoteModelsService } from '../common/remoteModelsService.js'; +import { IRequestService, asJson } from '../../../platform/request/common/request.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { registerSingleton, InstantiationType } from '../../../platform/instantiation/common/extensions.js'; + +export class RemoteModelsService implements IRemoteModelsService { + declare readonly _serviceBrand: undefined; + + constructor( + @IRequestService private readonly requestService: IRequestService + ) { } + + async fetchModels(url: string, headers?: Record): Promise { + try { + const ctx = await this.requestService.request({ + type: 'GET', + url, + headers: { Accept: 'application/json', ...(headers || {}) }, + timeout: 30_000, + }, CancellationToken.None); + + const json = await asJson(ctx); + return json; + } catch (error) { + console.error('Error in RemoteModelsService:', error); + throw error; + } + } +} + +registerSingleton(IRemoteModelsService, RemoteModelsService, InstantiationType.Delayed); diff --git a/src/vs/platform/void/electron-main/sendLLMMessageChannel.ts b/src/vs/platform/void/electron-main/sendLLMMessageChannel.ts new file mode 100644 index 00000000000..97bead992c8 --- /dev/null +++ b/src/vs/platform/void/electron-main/sendLLMMessageChannel.ts @@ -0,0 +1,289 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +// registered in app.ts +// code convention is to make a service responsible for this stuff, and not a channel, but having fewer files is simpler... +import { ILogService } from '../../log/common/log.js'; +import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { + RawToolParamsObj, EventLLMMessageOnTextParams, + EventLLMMessageOnErrorParams, EventLLMMessageOnFinalMessageParams, + MainSendLLMMessageParams, AbortRef, SendLLMMessageParams, + MainLLMMessageAbortParams, ModelListParams, EventModelListOnSuccessParams, + EventModelListOnErrorParams, OllamaModelResponse, OpenaiCompatibleModelResponse, + MainModelListParams, +} from '../common/sendLLMMessageTypes.js'; +import { sendLLMMessage } from './llmMessage/sendLLMMessage.js' +import { IMetricsService } from '../common/metricsService.js'; +import type { ToolName } from '../common/toolsServiceTypes.js'; +import { listModelsRouter } from './llmMessage/sendLLMMessage.impl.js'; +import type { INotificationService } from '../../notification/common/notification.js'; + +// NODE IMPLEMENTATION - calls actual sendLLMMessage() and returns listeners to it + +type StreamDeltaState = { + totalLength: number; + prefix: string; +}; + +type RequestStreamDeltaState = { + text: StreamDeltaState; + reasoning: StreamDeltaState; +}; + +const STREAM_PREFIX_PROBE_LEN = 96; + +const emptyStreamDeltaState = (): StreamDeltaState => ({ totalLength: 0, prefix: '' }); +const emptyRequestStreamDeltaState = (): RequestStreamDeltaState => ({ + text: emptyStreamDeltaState(), + reasoning: emptyStreamDeltaState(), +}); + +const makePrefixProbe = (s: string): string => s.slice(0, STREAM_PREFIX_PROBE_LEN); + +const toDeltaPayload = ( + incomingRaw: unknown, + prev: StreamDeltaState +): { payload: string; isDelta: boolean; next: StreamDeltaState } => { + const incoming = typeof incomingRaw === 'string' ? incomingRaw : ''; + if (!incoming) return { payload: '', isDelta: true, next: prev }; + + if (prev.totalLength <= 0) { + return { + payload: incoming, + isDelta: false, + next: { totalLength: incoming.length, prefix: makePrefixProbe(incoming) }, + }; + } + + const probeLen = Math.min(prev.prefix.length, incoming.length); + const prevProbe = probeLen > 0 ? prev.prefix.slice(0, probeLen) : ''; + const incomingProbe = probeLen > 0 ? incoming.slice(0, probeLen) : ''; + const hasSamePrefix = probeLen > 0 && prevProbe === incomingProbe; + + if (incoming.length > prev.totalLength && hasSamePrefix) { + return { + payload: incoming.slice(prev.totalLength), + isDelta: true, + next: { totalLength: incoming.length, prefix: makePrefixProbe(incoming) }, + }; + } + + if (incoming.length === prev.totalLength && hasSamePrefix) { + return { + payload: '', + isDelta: true, + next: { totalLength: incoming.length, prefix: makePrefixProbe(incoming) }, + }; + } + + if (incoming.length < prev.totalLength && hasSamePrefix) { + // Ignore regressive snapshots to keep stream monotonic downstream. + return { + payload: '', + isDelta: true, + next: prev, + }; + } + + // Fallback: treat incoming as plain delta chunk. + return { + payload: incoming, + isDelta: true, + next: { + totalLength: prev.totalLength + incoming.length, + prefix: prev.prefix || makePrefixProbe(incoming), + }, + }; +}; + +export class LLMMessageChannel implements IServerChannel { + + // sendLLMMessage + private readonly llmMessageEmitters = { + onText: new Emitter(), + onFinalMessage: new Emitter(), + onError: new Emitter(), + onNotify: new Emitter<{ requestId: string; payload: any }>(), + } + + // aborters for above + private readonly _infoOfRunningRequest: Record | undefined, abortRef: AbortRef }> = {} + private readonly _streamingTextStateByRequest: Record = {}; + + // tool delegation: main -> renderer + private readonly toolRequestEmitter = new Emitter<{ requestId: string; toolCallId: string; name: ToolName; rawParams: RawToolParamsObj }>(); + private readonly toolWaiters = new Map void; reject: (e: any) => void }>(); + + + // list + private readonly listEmitters = { + ollama: { + success: new Emitter>(), + error: new Emitter>(), + }, + openaiCompat: { + success: new Emitter>(), + error: new Emitter>(), + }, + } satisfies { + [providerName in 'ollama' | 'openaiCompat']: { + success: Emitter>, + error: Emitter>, + } + } + + // stupidly, channels can't take in @IService + constructor( + private readonly metricsService: IMetricsService, + private readonly logService: ILogService, + ) { } + + // browser uses this to listen for changes + listen(_: unknown, event: string): Event { + // text + if (event === 'onText_sendLLMMessage') return this.llmMessageEmitters.onText.event; + else if (event === 'onFinalMessage_sendLLMMessage') return this.llmMessageEmitters.onFinalMessage.event; + else if (event === 'onError_sendLLMMessage') return this.llmMessageEmitters.onError.event; + else if (event === 'onNotify_sendLLMMessage') return this.llmMessageEmitters.onNotify.event; + // list + else if (event === 'onSuccess_list_ollama') return this.listEmitters.ollama.success.event; + else if (event === 'onError_list_ollama') return this.listEmitters.ollama.error.event; + else if (event === 'onSuccess_list_openAICompatible') return this.listEmitters.openaiCompat.success.event; + else if (event === 'onError_list_openAICompatible') return this.listEmitters.openaiCompat.error.event; + // tool request (main -> renderer) + else if (event === 'onToolRequest') return this.toolRequestEmitter.event; + + else throw new Error(`Event not found: ${event}`); + } + + // browser uses this to call (see this.channel.call() in llmMessageService.ts for all usages) + async call(_: unknown, command: string, params: any): Promise { + try { + if (command === 'sendLLMMessage') { + this._callSendLLMMessage(params) + } + else if (command === 'abort') { + await this._callAbort(params) + } + else if (command === 'ollamaList') { + this._callOllamaList(params) + } + else if (command === 'openAICompatibleList') { + this._callOpenAICompatibleList(params) + } + else if (command === 'toolExecResult') { + this._receiveToolExecResult(params) + } + else { + throw new Error(`Void sendLLM: command "${command}" not recognized.`) + } + } + catch (e) { + this.logService.error?.('llmMessageChannel: Call Error:', e); + } + } + + private _receiveToolExecResult(params: { requestId: string; toolCallId: string; ok: boolean; value: string }) { + const waiter = this.toolWaiters.get(params.toolCallId); + if (!waiter) return; + if (params.ok) waiter.resolve(params.value); + else waiter.reject(new Error(params.value)); + this.toolWaiters.delete(params.toolCallId); + } + + private async _callSendLLMMessage(params: MainSendLLMMessageParams) { + const { requestId, additionalTools, ...rest } = params; + + this.logService.debug?.('[LLMChannel] sendLLMMessage', { + requestId, + hasAdditionalTools: !!additionalTools, + toolsCount: additionalTools?.length || 0, + tools: additionalTools?.map(t => t.name), + }); + + if (!(requestId in this._infoOfRunningRequest)) { + this._infoOfRunningRequest[requestId] = { waitForSend: undefined, abortRef: { current: null } }; + } + this._streamingTextStateByRequest[requestId] = emptyRequestStreamDeltaState(); + + const mainThreadParams = { + ...(rest as any), + additionalTools, + onText: (p: any) => { + const prev = this._streamingTextStateByRequest[requestId] ?? emptyRequestStreamDeltaState(); + const textDelta = toDeltaPayload(p?.fullText, prev.text); + const reasoningDelta = toDeltaPayload(p?.fullReasoning, prev.reasoning); + + this._streamingTextStateByRequest[requestId] = { + text: textDelta.next, + reasoning: reasoningDelta.next, + }; + + this.llmMessageEmitters.onText.fire({ + requestId, + ...p, + fullText: textDelta.payload, + fullReasoning: reasoningDelta.payload, + isFullTextDelta: textDelta.isDelta, + isFullReasoningDelta: reasoningDelta.isDelta, + }); + }, + onFinalMessage: (p: any) => { + this.llmMessageEmitters.onFinalMessage.fire({ requestId, ...p }); + delete this._streamingTextStateByRequest[requestId]; + }, + onError: (p: any) => { + this.logService.debug?.('[LLMChannel] sendLLMMessage -> onError fired', { requestId }); + this.llmMessageEmitters.onError.fire({ requestId, ...p }); + delete this._streamingTextStateByRequest[requestId]; + }, + abortRef: this._infoOfRunningRequest[requestId].abortRef, + } as SendLLMMessageParams; + + const notificationBridge = { + notify: (payload: any) => { + this.llmMessageEmitters.onNotify.fire({ requestId, payload }); + return undefined as any; + }, + } as unknown as INotificationService; + + const p = sendLLMMessage(mainThreadParams, this.metricsService, this.logService, notificationBridge); + this._infoOfRunningRequest[requestId].waitForSend = p; + } + + private async _callAbort(params: MainLLMMessageAbortParams) { + const { requestId } = params; + delete this._streamingTextStateByRequest[requestId] + if (!(requestId in this._infoOfRunningRequest)) return + const { waitForSend, abortRef } = this._infoOfRunningRequest[requestId] + await waitForSend // wait for the send to finish so we know abortRef was set + abortRef?.current?.() + delete this._infoOfRunningRequest[requestId] + } + + _callOllamaList = (params: MainModelListParams) => { + const { requestId } = params + const emitters = this.listEmitters.ollama + const mainThreadParams: ModelListParams = { + ...params, + onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); }, + onError: (p) => { emitters.error.fire({ requestId, ...p }); }, + } + listModelsRouter(mainThreadParams as any); + } + + _callOpenAICompatibleList = (params: MainModelListParams) => { + const { requestId } = params + const emitters = this.listEmitters.openaiCompat + const mainThreadParams: ModelListParams = { + ...params, + onSuccess: (p) => { emitters.success.fire({ requestId, ...p }); }, + onError: (p) => { emitters.error.fire({ requestId, ...p }); }, + } + listModelsRouter(mainThreadParams as any); + } +} diff --git a/src/vs/workbench/contrib/void/electron-main/voidSCMMainService.ts b/src/vs/platform/void/electron-main/voidSCMMainService.ts similarity index 100% rename from src/vs/workbench/contrib/void/electron-main/voidSCMMainService.ts rename to src/vs/platform/void/electron-main/voidSCMMainService.ts diff --git a/src/vs/workbench/contrib/void/electron-main/voidUpdateMainService.ts b/src/vs/platform/void/electron-main/voidUpdateMainService.ts similarity index 56% rename from src/vs/workbench/contrib/void/electron-main/voidUpdateMainService.ts rename to src/vs/platform/void/electron-main/voidUpdateMainService.ts index eafcf108b6e..d1c331ee1b1 100644 --- a/src/vs/workbench/contrib/void/electron-main/voidUpdateMainService.ts +++ b/src/vs/platform/void/electron-main/voidUpdateMainService.ts @@ -3,20 +3,16 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../base/common/lifecycle.js'; -import { IEnvironmentMainService } from '../../../../platform/environment/electron-main/environmentMainService.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; -import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { IEnvironmentMainService } from '../../../platform/environment/electron-main/environmentMainService.js'; +import { IUpdateService, StateType } from '../../../platform/update/common/update.js'; import { IVoidUpdateService } from '../common/voidUpdateService.js'; import { VoidCheckUpdateRespose } from '../common/voidUpdateServiceTypes.js'; - - export class VoidMainUpdateService extends Disposable implements IVoidUpdateService { _serviceBrand: undefined; constructor( - @IProductService private readonly _productService: IProductService, @IEnvironmentMainService private readonly _envMainService: IEnvironmentMainService, @IUpdateService private readonly _updateService: IUpdateService, ) { @@ -32,12 +28,6 @@ export class VoidMainUpdateService extends Disposable implements IVoidUpdateServ return { message: null } as const } - // if disabled and not explicitly checking, return early - if (this._updateService.state.type === StateType.Disabled) { - if (!explicit) - return { message: null } as const - } - this._updateService.checkForUpdates(false) // implicity check, then handle result ourselves console.log('updateState', this._updateService.state) @@ -83,69 +73,65 @@ export class VoidMainUpdateService extends Disposable implements IVoidUpdateServ } if (this._updateService.state.type === StateType.Disabled) { - return await this._manualCheckGHTagIfDisabled(explicit) + // Updates disabled: skip notification + return { message: null } as const } return null } - - - - - - private async _manualCheckGHTagIfDisabled(explicit: boolean): Promise { - try { - const response = await fetch('https://api.github.com/repos/voideditor/binaries/releases/latest'); - - const data = await response.json(); - const version = data.tag_name; - - const myVersion = this._productService.version - const latestVersion = version - - const isUpToDate = myVersion === latestVersion // only makes sense if response.ok - - let message: string | null - let action: 'reinstall' | undefined - - // explicit - if (explicit) { - if (response.ok) { - if (!isUpToDate) { - message = 'A new version of Void is available! Please reinstall (auto-updates are disabled on this OS) - it only takes a second!' - action = 'reinstall' - } - else { - message = 'Void is up-to-date!' - } - } - else { - message = `An error occurred when fetching the latest GitHub release tag. Please try again in ~5 minutes, or reinstall.` - action = 'reinstall' - } - } - // not explicit - else { - if (response.ok && !isUpToDate) { - message = 'A new version of Void is available! Please reinstall (auto-updates are disabled on this OS) - it only takes a second!' - action = 'reinstall' - } - else { - message = null - } - } - return { message, action } as const - } - catch (e) { - if (explicit) { - return { - message: `An error occurred when fetching the latest GitHub release tag: ${e}. Please try again in ~5 minutes.`, - action: 'reinstall', - } - } - else { - return { message: null } as const - } - } - } + // private async _manualCheckGHTagIfDisabled(explicit: boolean): Promise { + // try { + // const response = await fetch('https://api.github.com/repos/voideditor/binaries/releases/latest'); + + // const data = await response.json(); + // const version = data.tag_name; + + // const myVersion = this._productService.version + // const latestVersion = version + + // const isUpToDate = myVersion === latestVersion // only makes sense if response.ok + + // let message: string | null + // let action: 'reinstall' | undefined + + // // explicit + // if (explicit) { + // if (response.ok) { + // if (!isUpToDate) { + // message = 'A new version of Void is available! Please reinstall (auto-updates are disabled on this OS) - it only takes a second!' + // action = 'reinstall' + // } + // else { + // message = 'Void is up-to-date!' + // } + // } + // else { + // message = `An error occurred when fetching the latest GitHub release tag. Please try again in ~5 minutes, or reinstall.` + // action = 'reinstall' + // } + // } + // // not explicit + // else { + // if (response.ok && !isUpToDate) { + // message = 'A new version of Void is available! Please reinstall (auto-updates are disabled on this OS) - it only takes a second!' + // action = 'reinstall' + // } + // else { + // message = null + // } + // } + // return { message, action } as const + // } + // catch (e) { + // if (explicit) { + // return { + // message: `An error occurred when fetching the latest GitHub release tag: ${e}. Please try again in ~5 minutes.`, + // action: 'reinstall', + // } + // } + // else { + // return { message: null } as const + // } + // } + // } } diff --git a/src/vs/workbench/contrib/void/browser/ChatAcpHandler.ts b/src/vs/workbench/contrib/void/browser/ChatAcpHandler.ts new file mode 100644 index 00000000000..d9a6d4624f6 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/ChatAcpHandler.ts @@ -0,0 +1,1054 @@ +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { timeout } from '../../../../base/common/async.js'; +import { encodeBase64 } from '../../../../base/common/buffer.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IVoidSettingsService } from '../../../../platform/void/common/voidSettingsService.js'; +import { IVoidModelService } from '../common/voidModelService.js'; +import { IDirectoryStrService } from '../../../../platform/void/common/directoryStrService.js'; +import { IAcpService, IAcpUserMessage, IAcpChatMessage, IAcpMessageChunk } from '../../../../platform/acp/common/iAcpService.js'; +import { getErrorMessage, RawToolCallObj } from '../../../../platform/void/common/sendLLMMessageTypes.js'; +import { chat_userMessageContent, isAToolName, ToolName } from '../common/prompt/prompts.js'; +import { AnyToolName, ChatAttachment, StagingSelectionItem, ChatMessage } from '../../../../platform/void/common/chatThreadServiceTypes.js'; +import { IEditCodeService } from './editCodeServiceInterface.js'; +import { ChatHistoryCompressor } from './ChatHistoryCompressor.js'; +import { ChatToolOutputManager } from './ChatToolOutputManager.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; + +const _snakeToCamel = (s: string) => s.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); +const _deepCamelize = (v: any): any => { + if (Array.isArray(v)) return v.map(_deepCamelize); + if (v && typeof v === 'object') { + const out: any = {}; + for (const [k, val] of Object.entries(v)) { + out[_snakeToCamel(k)] = _deepCamelize(val); + } + return out; + } + return v; +}; + +export const normalizeAcpToolName = (raw: string): ToolName | string => { + const n = (raw || '').trim(); + const map: Record = { + 'fs/read_text_file': 'read_file', + 'fs/write_text_file': 'rewrite_file', + 'terminal/create': 'run_command', + 'terminal/kill': 'kill_persistent_terminal', + 'terminal/output': 'open_persistent_terminal', + 'terminal/release': 'kill_persistent_terminal', + 'terminal/wait_for_exit': 'run_persistent_command', + }; + return (map[n] ?? n) as ToolName | string; +}; + +export const normalizeAcpArgsForUi = ( + toolName: AnyToolName | string, + rawParams: Record | undefined, + workspaceRoot: URI | undefined +) => { + const src = rawParams && typeof rawParams === 'object' && 'args' in rawParams ? (rawParams as any).args : rawParams; + const p = _deepCamelize(src ?? {}); + + // --- NEW: parse "(from line..., limit ...)" embedded into uri for read_file --- + if (toolName === 'read_file' && p && typeof (p as any).uri === 'string') { + const uriStr = String((p as any).uri); + const range = _parseReadRangeFromText(uriStr); + + // only fill if missing + if (typeof (p as any).startLine !== 'number' && typeof range.startLine === 'number') (p as any).startLine = range.startLine; + if (typeof (p as any).linesCount !== 'number' && typeof range.linesCount === 'number') (p as any).linesCount = range.linesCount; + + // clean uri string before resolving to URI + (p as any).uri = _stripReadRangeSuffixFromUri(uriStr); + } + + const resolvePath = (pathStr: string): URI => { + try { + const s = String(pathStr ?? ''); + + // Windows absolute path: C:\... or C:/... + const isWindowsDriveAbs = /^[a-zA-Z]:[\\/]/.test(s); + // POSIX absolute path: /... + const isPosixAbs = s.startsWith('/'); + + if (isWindowsDriveAbs || isPosixAbs) { + return URI.file(s); + } + + // Real URI with scheme (file://, vscode-remote://, etc) + // Important: don't treat "C:foo" as a scheme URI + const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s) && !/^[a-zA-Z]:/.test(s); + if (hasScheme) { + return URI.parse(s); + } + + // Workspace-relative path + if (workspaceRoot) { + let cleanPath = s; + if (cleanPath.startsWith('./')) cleanPath = cleanPath.substring(2); + else if (cleanPath.startsWith('.\\')) cleanPath = cleanPath.substring(2); + return URI.joinPath(workspaceRoot, cleanPath); + } + + // Fallback: treat as file path + return URI.file(s); + } catch { + return URI.file(String(pathStr ?? '')); + } + }; + + if (p && typeof (p as any).uri === 'string') (p as any).uri = resolvePath((p as any).uri); + if (p && typeof (p as any).searchInFolder === 'string') (p as any).searchInFolder = resolvePath((p as any).searchInFolder); + if ((p as any)?.isFolder !== null && typeof (p as any).isFolder !== 'boolean') (p as any).isFolder = String((p as any).isFolder).toLowerCase() === 'true'; + return p; +}; + +// ---------- NEW helpers for external ACP normalization ---------- + +const _asObj = (v: any): Record | null => + (v && typeof v === 'object' && !Array.isArray(v)) ? (v as Record) : null; + +const _inferInternalToolNameFromResult = (rawName: string, result: any): AnyToolName | string => { + const rn = String(rawName ?? '').trim(); + const r = _asObj(result); + + // 1) If it already matches internal tool names, keep it + if (isAToolName(rn as any)) return rn as any; + + // 2) Strong structural signals + if (r) { + // read file: path + content + if (typeof r.path === 'string' && typeof r.content === 'string') return 'read_file'; + // edit/patch: diffs array + if (Array.isArray((r as any).diffs) && (r as any).diffs.length > 0) return 'edit_file'; + // terminal: output/exitCode/terminalId + if (typeof (r as any).output === 'string') return 'run_command'; + if (typeof (r as any).terminalId === 'string') return 'run_command'; + if (typeof (r as any).exitCode === 'number' || (r as any).exitCode === null) return 'run_command'; + if (Array.isArray((r as any).terminals) && (r as any).terminals.length > 0) return 'run_command'; + } + + // 3) Weak heuristic based on name strings (last resort) + if (rn.startsWith('read_file')) return 'read_file'; + if (rn.startsWith('edit_file') || rn.toLowerCase().includes('patch')) return 'edit_file'; + + // if it looks like a shell command line, treat as terminal + if (/^(cat|ls|pwd|echo|git|npm|pnpm|yarn|node|python|bash|sh)\b/i.test(rn)) return 'run_command'; + + return rawName; +}; + +const _parseReadRangeFromText = (s: string): { startLine?: number; linesCount?: number } => { + const str = String(s ?? ''); + + // (from line 650, limit 20 lines) + let m = str.match(/from line\s+(\d+)\s*,\s*limit\s+(\d+)\s+lines/i); + if (m) { + const startLine = Number(m[1]); + const linesCount = Number(m[2]); + if (Number.isFinite(startLine) && Number.isFinite(linesCount) && linesCount > 0) { + return { startLine, linesCount }; + } + return {}; + } + + + m = str.match(/limit\s+(\d+)\s+lines/i); + if (m) { + const linesCount = Number(m[1]); + if (Number.isFinite(linesCount) && linesCount > 0) { + return { startLine: 1, linesCount }; + } + } + + return {}; +}; + +const _stripReadRangeSuffixFromUri = (uriStr: string): string => { + const s = String(uriStr ?? ''); + + return s.replace(/\s*\(\s*(?:from line\s+\d+\s*,\s*)?limit\s+\d+\s+lines\s*\)\s*$/i, '').trim(); +}; + +const _buildCommandLine = (command: any, args: any): string | undefined => { + const cmd = typeof command === 'string' ? command : ''; + if (!cmd) return undefined; + const arr = Array.isArray(args) ? args.map(a => String(a ?? '')) : []; + return arr.length ? `${cmd} ${arr.join(' ')}` : cmd; +}; + +const _diffsToPatchUnified = (diffs: Array<{ path: string; oldText?: string; newText: string }>): string => { + const blocks: string[] = []; + for (const d of diffs) { + const path = String(d?.path ?? 'unknown'); + const oldText = String(d?.oldText ?? ''); + const newText = String(d?.newText ?? ''); + blocks.push( + [ + `--- a/${path}`, + `+++ b/${path}`, + `@@`, + ...oldText.split('\n').map(l => `-${l}`), + ...newText.split('\n').map(l => `+${l}`), + `` + ].join('\n') + ); + } + return blocks.join('\n'); +}; + +export interface IThreadStateAccess { + getThreadMessages(threadId: string): ChatMessage[]; + getStreamState(threadId: string): any; + setStreamState(threadId: string, state: any): void; + addMessageToThread(threadId: string, message: ChatMessage): void; + updateLatestTool(threadId: string, tool: any): void; + setThreadState(threadId: string, state: any): void; + accumulateTokenUsage(threadId: string, usage: any): void; + addUserCheckpoint(threadId: string): void; + currentModelSelectionProps(): { modelSelection: any; modelSelectionOptions: any }; +} + +export class ChatAcpHandler extends Disposable { + private readonly _acpStreamByThread = new Map(); + private readonly _acpToolCallInfoByKey = new Map; paramsForUi: any }>(); + + constructor( + @IAcpService private readonly _acpService: IAcpService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IVoidSettingsService private readonly _settingsService: IVoidSettingsService, + @IFileService private readonly _fileService: IFileService, + @IDirectoryStrService private readonly _directoryStringService: IDirectoryStrService, + @IVoidModelService private readonly _voidModelService: IVoidModelService, + @IEditCodeService private readonly _editCodeService: IEditCodeService, + @ILogService private readonly _logService: ILogService, + private readonly _historyCompressor: ChatHistoryCompressor, + private readonly _toolOutputManager: ChatToolOutputManager + ) { + super(); + } + + private _getExistingToolMsgById(threadId: string, toolCallId: string, access: IThreadStateAccess): any | null { + const msgs = access.getThreadMessages(threadId) ?? []; + for (let i = msgs.length - 1; i >= 0; i--) { + const m: any = msgs[i] as any; + if (m?.role === 'tool' && String(m.id ?? '') === String(toolCallId)) return m; + } + return null; + } + + private _acpToolKey(threadId: string, toolCallId: string): string { + return `${threadId}:${toolCallId}`; + } + + public enqueueToolRequestFromAcp( + threadId: string, + req: { id: string; name: AnyToolName | string; rawParams: Record; params?: Record }, + access: IThreadStateAccess + ): void { + const normName = normalizeAcpToolName(String(req.name)); + + // Flush assistant text if any + const st = access.getStreamState(threadId); + if (st?.isRunning === 'LLM' && st.llmInfo) { + const { displayContentSoFar, reasoningSoFar } = st.llmInfo; + if ((displayContentSoFar?.length ?? 0) || (reasoningSoFar?.length ?? 0)) { + access.addMessageToThread(threadId, { + role: 'assistant', + displayContent: displayContentSoFar ?? '', + reasoning: reasoningSoFar ?? '', + anthropicReasoning: null + }); + } + } + + const workspace = this._workspaceContextService.getWorkspace(); + const rootUri = workspace.folders.length > 0 ? workspace.folders[0].uri : undefined; + + const paramsForUi = normalizeAcpArgsForUi(normName, req.params ?? req.rawParams, rootUri); + + // Remember tool call info (used later for tool_result/progress) + this._acpToolCallInfoByKey.set(this._acpToolKey(threadId, String(req.id)), { + name: String(normName), + rawParams: req.rawParams ?? {}, + paramsForUi + }); + + // IMPORTANT: use updateLatestTool (by id) to avoid duplicate headers + access.updateLatestTool(threadId, { + role: 'tool', + type: 'tool_request', + content: 'Awaiting user permission...', + displayContent: 'Awaiting user permission...', + result: null, + name: (isAToolName(normName) ? normName : (normName as any)), + params: paramsForUi, + id: req.id, + rawParams: req.rawParams + }); + + // Keep stream in awaiting_user + access.setStreamState(threadId, { isRunning: 'awaiting_user' }); + } + + public async runAcp(opts: { + threadId: string; + userMessage: string; + _chatSelections?: StagingSelectionItem[]; + attachments?: ChatAttachment[]; + }, access: IThreadStateAccess): Promise { + + const { threadId, userMessage, _chatSelections, attachments } = opts; + + // --- Helper: Merge Text --- + const MAX_OVERLAP = 512; + function findOverlapLength(prev: string, incoming: string, max: number): number { + const prevSuffix = prev.slice(prev.length - max); + const incomingPrefix = incoming.slice(0, max); + const combined = `${incomingPrefix}\u0000${prevSuffix}`; + + const lps = new Array(combined.length).fill(0); + for (let i = 1; i < combined.length; i += 1) { + let len = lps[i - 1]; + while (len > 0 && combined[i] !== combined[len]) { + len = lps[len - 1]; + } + if (combined[i] === combined[len]) { + len += 1; + } + lps[i] = len; + } + + return Math.min(lps[lps.length - 1], max); + } + + function mergeWithOverlap(prev: string, incoming: string): string { + if (!prev) return incoming; + const max = Math.min(prev.length, incoming.length, MAX_OVERLAP); + if (max <= 0) return prev + incoming; + const overlap = findOverlapLength(prev, incoming, max); + if (overlap > 0) return prev + incoming.slice(overlap); + return prev + incoming; + } + + const mergeAcpText = (prev: string, incoming: string): string => { + if (incoming === '') return prev; + if (!prev) return incoming; + if (incoming === prev) return prev; + if (incoming.startsWith(prev)) return incoming; + if (prev.startsWith(incoming)) return prev; + + const merged = mergeWithOverlap(prev, incoming); + if (merged.length < prev.length + incoming.length) return merged; + + const trimmedPrev = prev.replace(/\s+$/u, ''); + const last = trimmedPrev[trimmedPrev.length - 1] ?? ''; + const first = incoming[0] ?? ''; + + let sep = ''; + if (last && first && !/\s/u.test(last) && !/\s/u.test(first)) { + if (last === ':') sep = ' '; + else if (/[.!?]/.test(last)) sep = ' '; + } + return prev + sep + incoming; + }; + + const mergeAcpReasoning = (prev: string, incoming: string): string => { + if (incoming === '') return prev; + if (!prev) return incoming; + if (incoming === prev) return prev; + if (incoming.startsWith(prev)) return incoming; + if (prev.startsWith(incoming)) return prev; + return mergeWithOverlap(prev, incoming); + }; + + const flushAssistantIfAny = () => { + const info = access.getStreamState(threadId)?.llmInfo; + if (!info) return; + const text = info.displayContentSoFar ?? ''; + const reasoning = info.reasoningSoFar ?? ''; + if (!text && !reasoning) return; + + access.addMessageToThread(threadId, { + role: 'assistant', + displayContent: text, + reasoning, + anthropicReasoning: null + }); + + access.setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { + displayContentSoFar: '', + reasoningSoFar: '', + toolCallSoFar: null, + planSoFar: info.planSoFar + }, + interrupt: interruptP + }); + }; + + let done = false; + + this.clearAcpState(threadId); + + const cancelFn = () => { + const entry = this._acpStreamByThread.get(threadId); + try { entry?.stream?.cancel?.(); } catch { /* noop */ } + }; + const interruptP = Promise.resolve(cancelFn); + + access.setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { displayContentSoFar: '', reasoningSoFar: '', toolCallSoFar: null }, + interrupt: interruptP + }); + + let history = this._buildAcpHistory(threadId, access); + + const currSelns = _chatSelections ?? null; + const builtContent = await chat_userMessageContent( + userMessage, + currSelns, + { + directoryStrService: this._directoryStringService, + fileService: this._fileService, + voidModelService: this._voidModelService, + } + ); + + const contentBlocks: any[] = []; + if (builtContent && builtContent.trim()) { + contentBlocks.push({ type: 'text', text: builtContent }); + } + if (attachments && attachments.length) { + for (const att of attachments) { + if (att.kind !== 'image') continue; + try { + const fileData = await this._fileService.readFile(att.uri); + const dataBase64 = encodeBase64(fileData.value); + contentBlocks.push({ + type: 'image', + mimeType: (att as any).mimeType || 'image/png', + data: dataBase64, + }); + } catch { /* ignore */ } + } + } + + let message: IAcpUserMessage = { role: 'user', content: builtContent } as IAcpUserMessage; + if (contentBlocks.length) { + (message as any).contentBlocks = contentBlocks; + } + + // Compression + try { + const { modelSelection, modelSelectionOptions } = access.currentModelSelectionProps(); + if (modelSelection) { + const { summaryText, compressionInfo } = await this._historyCompressor.maybeSummarizeHistoryBeforeLLM({ + threadId, + messages: access.getThreadMessages(threadId), + modelSelection, + modelSelectionOptions, + }); + if (compressionInfo) { + access.setThreadState(threadId, { historyCompression: compressionInfo }); + } + if (summaryText && summaryText.trim()) { + const tail = history.slice(-8); + history = [ + { role: 'assistant', content: summaryText.trim() } as IAcpChatMessage, + ...tail, + ]; + } + } + } catch { /* fail open */ } + + const gs = this._settingsService.state.globalSettings; + let stream: any; + let attempt = 0; + const chatRetries = gs.chatRetries; + const retryDelay = gs.retryDelay; + + while (attempt < chatRetries + 1 && !stream) { + attempt += 1; + try { + stream = await this._acpService.sendChatMessage(threadId, history, message, { + mode: gs.acpMode, + agentUrl: gs.acpAgentUrl || undefined, + command: gs.acpProcessCommand || undefined, + args: gs.acpProcessArgs || undefined, + env: gs.acpProcessEnv || undefined, + model: gs.acpModel || undefined, + system: gs.acpSystemPrompt || undefined, + featureName: 'Chat', + maxToolOutputLength: gs.maxToolOutputLength, + readFileChunkLines: gs.readFileChunkLines, + }); + } catch (e) { + const msg = getErrorMessage(e); + if (attempt > chatRetries) { + access.setStreamState(threadId, { + isRunning: undefined, + error: { message: msg, fullError: e instanceof Error ? e : null } + }); + access.addUserCheckpoint(threadId); + return; + } + if (retryDelay > 0) await timeout(retryDelay); + } + } + + if (!stream) { + access.setStreamState(threadId, { + isRunning: undefined, + error: { message: 'ACP: failed to start session', fullError: null } + }); + access.addUserCheckpoint(threadId); + return; + } + + this._acpStreamByThread.set(threadId, { stream }); + + let sub: IDisposable | undefined; + const finish = () => { + if (done) return; + done = true; + try { sub?.dispose(); } catch { } + this._acpStreamByThread.delete(threadId); + }; + + const onChunk = async (chunk: IAcpMessageChunk) => { + if (done) return; + + if (chunk.type === 'text') { + let incoming = chunk.text ?? ''; + const prevInfo = access.getStreamState(threadId)?.llmInfo; + const prev = prevInfo?.displayContentSoFar ?? ''; + let next = mergeAcpText(prev, incoming); + if (next === prev) return; + + access.setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { + displayContentSoFar: next, + reasoningSoFar: prevInfo?.reasoningSoFar ?? '', + toolCallSoFar: prevInfo?.toolCallSoFar ?? null, + planSoFar: prevInfo?.planSoFar + }, + interrupt: interruptP + }); + return; + } + + if (chunk.type === 'reasoning') { + const incoming = chunk.reasoning ?? ''; + const prevInfo = access.getStreamState(threadId)?.llmInfo; + const prev = prevInfo?.reasoningSoFar ?? ''; + const next = mergeAcpReasoning(prev, incoming); + if (next === prev) return; + + access.setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { + displayContentSoFar: prevInfo?.displayContentSoFar ?? '', + reasoningSoFar: next, + toolCallSoFar: prevInfo?.toolCallSoFar ?? null, + planSoFar: prevInfo?.planSoFar + }, + interrupt: interruptP + }); + return; + } + + if (chunk.type === 'plan' && chunk.plan) { + const prev = access.getStreamState(threadId)?.llmInfo ?? { displayContentSoFar: '', reasoningSoFar: '', toolCallSoFar: null }; + if (this._settingsService.state.globalSettings.showAcpPlanInChat !== false) { + const normalizedPlan = { + title: chunk.plan.title, + items: (chunk.plan.items ?? []).map(it => ({ + id: it.id, + text: it.text, + state: (it.state ?? 'pending') as 'pending' | 'running' | 'done' | 'error' + })) + }; + const cleanedText = (prev.displayContentSoFar ?? '').replace(/<\/plan\s*>/gi, '').trimEnd(); + access.setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { ...prev, displayContentSoFar: cleanedText, planSoFar: normalizedPlan }, + interrupt: interruptP + }); + access.setThreadState(threadId, { acpPlan: normalizedPlan }); + } + return; + } + + if (chunk.type === 'tool_call' && chunk.toolCall) { + flushAssistantIfAny(); + const { id, name, args } = chunk.toolCall; + + const normName = normalizeAcpToolName(String(name)); + const toolCallSoFar: RawToolCallObj = { + id, + name: (isAToolName(normName) ? normName : (normName as any)), + rawParams: args ?? {}, + isDone: false, + doneParams: [] + }; + + const workspace = this._workspaceContextService.getWorkspace(); + const rootUri = workspace.folders.length > 0 ? workspace.folders[0].uri : undefined; + + const paramsForUi = normalizeAcpArgsForUi(normName, args, rootUri); + this._acpToolCallInfoByKey.set(this._acpToolKey(threadId, String(id)), { + name: String(normName), + rawParams: args ?? {}, + paramsForUi + }); + + const prev = access.getStreamState(threadId)?.llmInfo; + access.updateLatestTool(threadId, { + role: 'tool', + type: 'running_now', + name: (isAToolName(normName) ? normName : (normName as any)), + params: paramsForUi, + content: 'running...', + displayContent: 'running...', + result: null, + id, + rawParams: args ?? {} + }); + access.setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { + displayContentSoFar: prev?.displayContentSoFar ?? '', + reasoningSoFar: prev?.reasoningSoFar ?? '', + toolCallSoFar, + planSoFar: prev?.planSoFar + }, + interrupt: interruptP + }); + return; + } + + if (chunk.type === 'tool_progress' && (chunk as any).toolProgress) { + const tp = (chunk as any).toolProgress; + const id = String(tp.id ?? ''); + if (!id) return; + + const existing = this._getExistingToolMsgById(threadId, id, access); + const prevLen = + (typeof existing?.displayContent === 'string' ? existing.displayContent.length : 0) + || (typeof existing?.content === 'string' ? existing.content.length : 0); + + // Normalize output + const output = (typeof tp.output === 'string') ? tp.output : String(tp.output ?? ''); + const exitStatus = tp.exitStatus; + const isDone = + !!exitStatus && ( + typeof exitStatus.exitCode === 'number' || exitStatus.exitCode === null || + typeof exitStatus.signal === 'string' || exitStatus.signal === null + ); + + // CRITICAL: never overwrite existing output with empty progress + if (!output || output.length === 0) { + this._logService.debug('[Void][ChatAcpHandler][tool_progress][SKIP_EMPTY]', JSON.stringify({ + threadId, + toolCallId: id, + prevLen, + terminalId: typeof tp.terminalId === 'string' ? tp.terminalId : null, + hasExitStatus: !!tp.exitStatus + })); + return; + } + + // While tool is still running, never shrink non-empty output. + if (!isDone && prevLen > 0 && output.length < prevLen) { + this._logService.debug('[Void][ChatAcpHandler][tool_progress][SKIP_SHRINK]', JSON.stringify({ + threadId, + toolCallId: id, + prevLen, + nextLen: output.length, + terminalId: typeof tp.terminalId === 'string' ? tp.terminalId : null, + isDone + })); + return; + } + + // Ignore late progress for skipped/rejected + if (existing && (existing.type === 'skipped' || existing.type === 'rejected')) return; + + const rawNameStr = String(tp.name ?? 'run_command'); + const normName = normalizeAcpToolName(rawNameStr); + const uiToolName = (isAToolName(normName) ? normName : (normName as any)); + + const workspace = this._workspaceContextService.getWorkspace(); + const rootUri = workspace.folders.length > 0 ? workspace.folders[0].uri : undefined; + + const key = this._acpToolKey(threadId, id); + const callInfo = this._acpToolCallInfoByKey.get(key); + + const rawParamsForUi: Record = { ...(callInfo?.rawParams ?? {}) }; + if (typeof tp.terminalId === 'string' && tp.terminalId) rawParamsForUi.terminalId = tp.terminalId; + + const paramsForUi = callInfo?.paramsForUi + ?? normalizeAcpArgsForUi(uiToolName, rawParamsForUi, rootUri) + ?? {}; + + // For streaming we DO NOT run ToolOutputManager: it can dedupe/normalize and accidentally blank output. + // Just show the current output. + const resultForUi: any = { + toolCallId: id, + ...(tp.terminalId ? { terminalId: String(tp.terminalId) } : {}), + output, + ...(typeof tp.truncated === 'boolean' ? { truncated: tp.truncated } : {}), + ...(exitStatus ? { + exitCode: (typeof exitStatus.exitCode === 'number' || exitStatus.exitCode === null) ? exitStatus.exitCode : undefined, + signal: (typeof exitStatus.signal === 'string' || exitStatus.signal === null) ? exitStatus.signal : undefined + } : {}) + }; + + this._logService.debug('[Void][ChatAcpHandler][tool_progress][APPLY]', JSON.stringify({ + threadId, + toolCallId: id, + uiToolName, + prevLen, + nextLen: output.length, + terminalId: resultForUi.terminalId ?? null, + truncated: typeof resultForUi.truncated === 'boolean' ? resultForUi.truncated : null + })); + + access.updateLatestTool(threadId, { + role: 'tool', + type: 'running_now', + name: uiToolName, + params: paramsForUi, + result: resultForUi, + content: output, + displayContent: output, + id, + rawParams: rawParamsForUi + }); + + return; + } + + if (chunk.type === 'tool_result' && chunk.toolResult) { + const { id, name, result, error } = chunk.toolResult; + const existing = this._getExistingToolMsgById(threadId, String(id), access); + if (existing && (existing.type === 'skipped' || existing.type === 'rejected')) { + return; + } + const workspace = this._workspaceContextService.getWorkspace(); + const rootUri = workspace.folders.length > 0 ? workspace.folders[0].uri : undefined; + + const rawNameStr = String(name ?? ''); + + // DEBUG LOG: Show raw ACP tool result for debugging + this._logService.debug('[Void][ChatAcpHandler][tool_result][RAW_ACP_DATA]', JSON.stringify({ + threadId, + toolCallId: id, + rawName: name, + rawResult: result, + rawError: error + }, null, 2)); + + // --- NEW: infer internal tool name from result structure --- + const inferredName = _inferInternalToolNameFromResult(rawNameStr, result); + const normName = normalizeAcpToolName(String(inferredName)); + const uiToolName = (isAToolName(normName) ? normName : (normName as any)); + + // DEBUG LOG: Show inferred tool name + this._logService.debug('[Void][ChatAcpHandler][tool_result][INFERRED_TOOL]', JSON.stringify({ + threadId, + toolCallId: id, + rawName: rawNameStr, + inferredName, + normName, + uiToolName + }, null, 2)); + + // Prefer params captured earlier (tool_request / tool_call), so read_file gets URI and line range + const key = this._acpToolKey(threadId, String(id)); + const callInfo = this._acpToolCallInfoByKey.get(key); + + const msgs = access.getThreadMessages(threadId); + const lastMsg = msgs[msgs.length - 1]; + const prevParams = + (existing && existing.role === 'tool') ? (existing as any).params : + (lastMsg && lastMsg.role === 'tool') ? (lastMsg as any).params : {}; + const prevRawParams = + (existing && existing.role === 'tool') ? (existing as any).rawParams : + (lastMsg && lastMsg.role === 'tool') ? (lastMsg as any).rawParams : {}; + + let rawParamsForUi: Record = + (callInfo?.rawParams ?? prevRawParams ?? {}) as Record; + + // --- NEW: enrich rawParamsForUi from tool_result.result (path/diffs/command) --- + const rObj = _asObj(result); + + if (uiToolName === 'read_file' && rObj) { + // DEBUG LOG: Show raw params before processing + this._logService.debug('[Void][ChatAcpHandler][tool_result][READ_FILE_BEFORE]', JSON.stringify({ + threadId, + toolCallId: id, + rawParamsForUi: rawParamsForUi, + rObjPath: rObj.path, + rObjContentLength: typeof rObj.content === 'string' ? rObj.content.length : undefined + }, null, 2)); + + const uriRaw = (rawParamsForUi as any).uri; + const uriStr = (typeof uriRaw === 'string') ? uriRaw : ''; + + + const rangeFromUri = _parseReadRangeFromText(uriStr); + if (typeof (rawParamsForUi as any).startLine !== 'number' && typeof rangeFromUri.startLine === 'number') { + (rawParamsForUi as any).startLine = rangeFromUri.startLine; + } + if (typeof (rawParamsForUi as any).linesCount !== 'number' && typeof rangeFromUri.linesCount === 'number') { + (rawParamsForUi as any).linesCount = rangeFromUri.linesCount; + } + + + const hasSuffix = /\(\s*(?:from line\s+\d+\s*,\s*)?limit\s+\d+\s+lines\s*\)\s*$/i.test(uriStr); + if (typeof rObj.path === 'string' && rObj.path && (hasSuffix || !uriStr)) { + (rawParamsForUi as any).uri = rObj.path; + } else if (typeof uriStr === 'string' && uriStr) { + (rawParamsForUi as any).uri = _stripReadRangeSuffixFromUri(uriStr); + } + // DEBUG LOG: Show final params after processing + this._logService.debug('[Void][ChatAcpHandler][tool_result][READ_FILE_AFTER]', JSON.stringify({ + threadId, + toolCallId: id, + finalParamsForUi: rawParamsForUi, + }, null, 2)); + } + + if (uiToolName === 'edit_file' && rObj) { + const diffs: Array<{ path: string; oldText?: string; newText: string }> | undefined = Array.isArray((rObj as any).diffs) ? (rObj as any).diffs : undefined; + const firstPath = diffs && diffs.length ? String(diffs[0]?.path ?? '') : ''; + const filePath = typeof (rObj as any).file === 'string' ? String((rObj as any).file) : ''; + + if (typeof (rawParamsForUi as any).uri !== 'string') { + const p = firstPath || filePath; + if (p) (rawParamsForUi as any).uri = p; + } + + // Provide snippets for EditTool preview/apply (best-effort) + if (diffs && diffs.length) { + const d0 = diffs[0]; + if (typeof (rawParamsForUi as any).originalSnippet !== 'string') (rawParamsForUi as any).originalSnippet = String(d0.oldText ?? ''); + if (typeof (rawParamsForUi as any).updatedSnippet !== 'string') (rawParamsForUi as any).updatedSnippet = String(d0.newText ?? ''); + } + } + + if (uiToolName === 'run_command' && rObj) { + // terminalId + if (typeof (rObj as any).terminalId === 'string' && typeof (rawParamsForUi as any).terminalId !== 'string') { + (rawParamsForUi as any).terminalId = (rObj as any).terminalId; + } + + // command line (best-effort, now host returns command on waitForTerminalExit) + const cmdLine = + _buildCommandLine((rawParamsForUi as any).command, (rawParamsForUi as any).args) + ?? (typeof (rObj as any).command === 'string' ? String((rObj as any).command) : undefined) + ?? _buildCommandLine((rObj as any).command, (rObj as any).args) + ?? (typeof (rObj as any).commandLine === 'string' ? String((rObj as any).commandLine) : undefined) + ?? (typeof rawNameStr === 'string' && rawNameStr.trim() ? rawNameStr.trim() : undefined); + + if (cmdLine && typeof (rawParamsForUi as any).command !== 'string') { + (rawParamsForUi as any).command = cmdLine; + } + + // output (host now returns output; but keep fallbacks) + if (typeof (rObj as any).output !== 'string') { + const t = typeof (rObj as any).text === 'string' ? (rObj as any).text : undefined; + if (t) (rObj as any).output = t; + } + if (typeof (rObj as any).output !== 'string' && typeof (rObj as any).rawOutput === 'string') { + (rObj as any).output = (rObj as any).rawOutput; + } + } + + // Compute paramsForUi from augmented raw params + const computedParamsForUi = normalizeAcpArgsForUi(uiToolName, rawParamsForUi, rootUri); + const paramsForUi = { ...(callInfo?.paramsForUi ?? prevParams ?? {}), ...(computedParamsForUi ?? {}) }; + + // --- NEW: for edit_file, generate patch_unified from diffs so UI shows Preview(diff) like internal --- + let normalizedResult: any = result; + if (uiToolName === 'edit_file') { + const ro = _asObj(result); + const diffs: Array<{ path: string; oldText?: string; newText: string }> | undefined = (ro && Array.isArray((ro as any).diffs)) ? (ro as any).diffs : undefined; + if (ro && diffs && diffs.length) { + const patch = _diffsToPatchUnified(diffs); + if (typeof (ro as any).patch_unified !== 'string' || !(ro as any).patch_unified) { + (ro as any).patch_unified = patch; + } + if (!ro.preview || typeof ro.preview !== 'object') { + (ro as any).preview = {}; + } + if (typeof (ro.preview as any).patch_unified !== 'string' || !(ro.preview as any).patch_unified) { + (ro.preview as any).patch_unified = (ro as any).patch_unified; + } + normalizedResult = ro; + } + } + + if (error) { + access.updateLatestTool(threadId, { + role: 'tool', + type: 'tool_error', + name: uiToolName, + params: paramsForUi, + result: error, + content: String(error), + id, + rawParams: rawParamsForUi + }); + } else { + const terminalIdForKey = + (rObj && typeof (rObj as any).terminalId === 'string' ? String((rObj as any).terminalId) : undefined) + ?? (typeof (rawParamsForUi as any)?.terminalId === 'string' ? String((rawParamsForUi as any).terminalId) : undefined) + ?? (typeof (paramsForUi as any)?.terminalId === 'string' ? String((paramsForUi as any).terminalId) : undefined); + + let resultForToolOutput: any = normalizedResult; + + if (_asObj(resultForToolOutput)) { + // Preserve existing result fields, but guarantee IDs exist. + resultForToolOutput = { + toolCallId: String(id), + ...(terminalIdForKey ? { terminalId: terminalIdForKey } : {}), + ...(resultForToolOutput as any), + }; + } else if (typeof resultForToolOutput === 'string') { + // Wrap string into object so ToolOutputManager can key by ids. + resultForToolOutput = { + toolCallId: String(id), + ...(terminalIdForKey ? { terminalId: terminalIdForKey } : {}), + text: resultForToolOutput + }; + } else { + // Fallback wrap + resultForToolOutput = { + toolCallId: String(id), + ...(terminalIdForKey ? { terminalId: terminalIdForKey } : {}), + value: resultForToolOutput + }; + } + + const { result: processedResult, content, displayContent } = + await this._toolOutputManager.processToolResult(resultForToolOutput, uiToolName); + + access.updateLatestTool(threadId, { + role: 'tool', + type: 'success', + name: uiToolName, + params: paramsForUi, + result: processedResult, + content, + displayContent, + id, + rawParams: rawParamsForUi + }); + } + + + const diffs: Array<{ path: string; oldText?: string; newText: string }> | undefined = (normalizedResult as any)?.diffs; + if (diffs && diffs.length > 0) { + for (const d of diffs) { + try { + const uri = URI.file(d.path); + await this._editCodeService.callBeforeApplyOrEdit(uri); + await (this._editCodeService as any).previewEditFileSimple?.({ + uri, + originalSnippet: d.oldText ?? '', + updatedSnippet: d.newText, + replaceAll: false, + locationHint: undefined, + encoding: null, + newline: null, + applyBoxId: undefined + }); + } catch { /* noop */ } + } + } + + // cleanup per-call cache + try { this._acpToolCallInfoByKey.delete(key); } catch { /* noop */ } + + return; + } + + if (chunk.type === 'error' || chunk.type === 'done') { + const info = access.getStreamState(threadId)?.llmInfo; + if (info?.displayContentSoFar || info?.reasoningSoFar) { + access.addMessageToThread(threadId, { + role: 'assistant', + displayContent: info.displayContentSoFar, + reasoning: info.reasoningSoFar, + anthropicReasoning: null + }); + } + + if (chunk.type === 'done' && chunk.tokenUsageSnapshot) { + access.accumulateTokenUsage(threadId, chunk.tokenUsageSnapshot); + } + + if (chunk.type === 'error') { + access.setStreamState(threadId, { isRunning: undefined, error: { message: chunk.error ?? 'ACP error', fullError: null } }); + } else { + access.setStreamState(threadId, undefined); + } + finish(); + access.addUserCheckpoint(threadId); + return; + } + }; + + sub = stream.onData(onChunk); + const entry = this._acpStreamByThread.get(threadId); + if (entry) entry.sub = sub; + } + + public clearAcpState(threadId: string) { + try { + const entry = this._acpStreamByThread.get(threadId); + try { entry?.stream?.cancel?.(); } catch { /* noop */ } + try { entry?.sub?.dispose?.(); } catch { /* noop */ } + } finally { + this._acpStreamByThread.delete(threadId); + try { + const prefix = `${threadId}:`; + for (const k of Array.from(this._acpToolCallInfoByKey.keys())) { + if (k.startsWith(prefix)) this._acpToolCallInfoByKey.delete(k); + } + } catch { /* noop */ } + } + } + + private _buildAcpHistory(threadId: string, access: IThreadStateAccess): IAcpChatMessage[] { + const msgs = access.getThreadMessages(threadId); + const upto = Math.max(0, msgs.length - 1); + + const history: IAcpChatMessage[] = []; + for (let i = 0; i < upto; i++) { + const m = msgs[i]; + if (m.role === 'user') { + const dc = (m as any).displayContent; + const c = (m as any).content; + const display = typeof dc === 'string' ? dc : ''; + const fallback = typeof c === 'string' ? c : ''; + const content = (display && display.trim().length > 0) ? display : fallback; + if (!content.trim()) continue; + history.push({ role: 'user', content }); + } else if (m.role === 'assistant') { + const content = (m as any).displayContent ?? ''; + if (!String(content).trim()) continue; + history.push({ role: 'assistant', content: String(content) }); + } + } + return history; + } +} diff --git a/src/vs/workbench/contrib/void/browser/ChatCheckpointManager.ts b/src/vs/workbench/contrib/void/browser/ChatCheckpointManager.ts new file mode 100644 index 00000000000..da772fef0b7 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/ChatCheckpointManager.ts @@ -0,0 +1,265 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { findLastIdx } from '../../../../base/common/arraysFind.js'; +import { IEditCodeService } from './editCodeServiceInterface.js'; +import { IVoidModelService } from '../common/voidModelService.js'; +import { CheckpointEntry, ChatMessage } from '../../../../platform/void/common/chatThreadServiceTypes.js'; +import { VoidFileSnapshot } from '../../../../platform/void/common/editCodeServiceTypes.js'; + + + +export interface ICheckpointThreadAccess { + getThreadMessages(threadId: string): ChatMessage[]; + getThreadState(threadId: string): { currCheckpointIdx: number | null }; + + addMessageToThread(threadId: string, message: ChatMessage): void; + editMessageInThread(threadId: string, messageIdx: number, newMessage: ChatMessage): void; + setThreadState(threadId: string, state: Partial<{ currCheckpointIdx: number | null }>): void; + + isStreaming(threadId: string): boolean; +} + +export class ChatCheckpointManager { + constructor( + @IEditCodeService private readonly _editCodeService: IEditCodeService, + @IVoidModelService private readonly _voidModelService: IVoidModelService + ) { } + + public addToolEditCheckpoint(threadId: string, uri: URI, access: ICheckpointThreadAccess) { + const { model } = this._voidModelService.getModel(uri); + if (!model) return; + + const diffAreasSnapshot = this._editCodeService.getVoidFileSnapshot(uri); + this._addCheckpoint(threadId, { + role: 'checkpoint', + type: 'tool_edit', + voidFileSnapshotOfURI: { [uri.fsPath]: diffAreasSnapshot }, + userModifications: { voidFileSnapshotOfURI: {} }, + }, access); + } + + public addUserCheckpoint(threadId: string, access: ICheckpointThreadAccess) { + const { voidFileSnapshotOfURI } = this._computeNewCheckpointInfo({ threadId }, access) ?? {}; + this._addCheckpoint(threadId, { + role: 'checkpoint', + type: 'user_edit', + voidFileSnapshotOfURI: voidFileSnapshotOfURI ?? {}, + userModifications: { voidFileSnapshotOfURI: {}, }, + }, access); + } + + public jumpToCheckpointBeforeMessageIdx( + opts: { threadId: string, messageIdx: number, jumpToUserModified: boolean }, + access: ICheckpointThreadAccess + ) { + const { threadId, messageIdx, jumpToUserModified } = opts; + + // 1. Ensure we are standing on a checkpoint currently (create temp if needed) + this._makeUsStandOnCheckpoint(threadId, access); + + const msgs = access.getThreadMessages(threadId); + + if (access.isStreaming(threadId)) return; + + // 2. Find target checkpoint + const c = this._getCheckpointBeforeMessage(msgs, messageIdx); + if (c === undefined) return; // should never happen + + const fromIdx = access.getThreadState(threadId).currCheckpointIdx; + if (fromIdx === null) return; // should never happen based on step 1 + + const [_, toIdx] = c; + if (toIdx === fromIdx) return; + + // 3. Update the user's modifications to current checkpoint before jumping away + this._addUserModificationsToCurrCheckpoint({ threadId }, access); + + /* + UNDO Logic (Going Back) + A,B,C are all files. x means a checkpoint where the file changed. + We need to revert anything that happened between to+1 and from. + We do this by finding the last x from 0...`to` for each file and applying those contents. + */ + if (toIdx < fromIdx) { + const { lastIdxOfURI } = this._getCheckpointsBetween(msgs, toIdx + 1, fromIdx); + const pendingFsPaths = new Set(Object.keys(lastIdxOfURI)); + + const idxes = function* () { + for (let k = toIdx; k >= 0; k -= 1) { // first go up + yield k; + } + for (let k = toIdx + 1; k < msgs.length; k += 1) { // then go down + yield k; + } + }; + + for (const k of idxes()) { + if (pendingFsPaths.size === 0) break; + const message = msgs[k]; + if (message.role !== 'checkpoint') continue; + + for (const fsPath in message.voidFileSnapshotOfURI) { + if (!pendingFsPaths.has(fsPath)) continue; + + const res = this._getCheckpointInfo(message as CheckpointEntry, fsPath, { includeUserModifiedChanges: jumpToUserModified }); + if (!res) continue; + + const { voidFileSnapshot } = res; + if (!voidFileSnapshot) continue; + + this._editCodeService.restoreVoidFileSnapshot(URI.file(fsPath), voidFileSnapshot); + pendingFsPaths.delete(fsPath); + } + } + } + + /* + REDO Logic (Going Forward) + We need to apply latest change for anything that happened between from+1 and to. + */ + if (toIdx > fromIdx) { + const { lastIdxOfURI } = this._getCheckpointsBetween(msgs, fromIdx + 1, toIdx); + const pendingFsPaths = new Set(Object.keys(lastIdxOfURI)); + + // apply lowest down content for each uri + for (let k = toIdx; k >= fromIdx + 1; k -= 1) { + if (pendingFsPaths.size === 0) break; + const message = msgs[k]; + if (message.role !== 'checkpoint') continue; + + for (const fsPath in message.voidFileSnapshotOfURI) { + if (!pendingFsPaths.has(fsPath)) continue; + + const res = this._getCheckpointInfo(message as CheckpointEntry, fsPath, { includeUserModifiedChanges: jumpToUserModified }); + if (!res) continue; + + const { voidFileSnapshot } = res; + if (!voidFileSnapshot) continue; + + this._editCodeService.restoreVoidFileSnapshot(URI.file(fsPath), voidFileSnapshot); + pendingFsPaths.delete(fsPath); + } + } + } + + access.setThreadState(threadId, { currCheckpointIdx: toIdx }); + } + + // --- Private Helpers --- + + private _addCheckpoint(threadId: string, checkpoint: CheckpointEntry, access: ICheckpointThreadAccess) { + access.addMessageToThread(threadId, checkpoint); + } + + private _computeNewCheckpointInfo(opts: { threadId: string }, access: ICheckpointThreadAccess) { + const msgs = access.getThreadMessages(opts.threadId); + + const lastCheckpointIdx = findLastIdx(msgs, (m) => m.role === 'checkpoint') ?? -1; + if (lastCheckpointIdx === -1) return; + + const voidFileSnapshotOfURI: { [fsPath: string]: VoidFileSnapshot | undefined } = {}; + + // add a change for all the URIs in the checkpoint history + const { lastIdxOfURI } = this._getCheckpointsBetween(msgs, 0, lastCheckpointIdx) ?? {}; + + for (const fsPath in lastIdxOfURI ?? {}) { + const { model } = this._voidModelService.getModelFromFsPath(fsPath); + if (!model) continue; + + const checkpoint2 = msgs[lastIdxOfURI[fsPath]] || null; + if (!checkpoint2) continue; + if (checkpoint2.role !== 'checkpoint') continue; + + const res = this._getCheckpointInfo(checkpoint2, fsPath, { includeUserModifiedChanges: false }); + if (!res) continue; + const { voidFileSnapshot: oldVoidFileSnapshot } = res; + + // if there was any change to the str or diffAreaSnapshot, update + const voidFileSnapshot = this._editCodeService.getVoidFileSnapshot(URI.file(fsPath)); + if (oldVoidFileSnapshot === voidFileSnapshot) continue; + + voidFileSnapshotOfURI[fsPath] = voidFileSnapshot; + } + + return { voidFileSnapshotOfURI }; + } + + private _getCheckpointsBetween(messages: ChatMessage[], loIdx: number, hiIdx: number) { + const lastIdxOfURI: { [fsPath: string]: number } = {}; + for (let i = loIdx; i <= hiIdx; i += 1) { + const message = messages[i]; + if (message?.role !== 'checkpoint') continue; + for (const fsPath in message.voidFileSnapshotOfURI) { + // do not include userModified.beforeStrOfURI here + lastIdxOfURI[fsPath] = i; + } + } + return { lastIdxOfURI }; + } + + private _getCheckpointInfo(checkpointMessage: CheckpointEntry, fsPath: string, opts: { includeUserModifiedChanges: boolean }) { + const voidFileSnapshot = checkpointMessage.voidFileSnapshotOfURI ? checkpointMessage.voidFileSnapshotOfURI[fsPath] ?? null : null; + if (!opts.includeUserModifiedChanges) { return { voidFileSnapshot }; } + + const userModifiedVoidFileSnapshot = fsPath in checkpointMessage.userModifications.voidFileSnapshotOfURI + ? checkpointMessage.userModifications.voidFileSnapshotOfURI[fsPath] ?? null + : null; + + return { voidFileSnapshot: userModifiedVoidFileSnapshot ?? voidFileSnapshot }; + } + + private _makeUsStandOnCheckpoint(threadId: string, access: ICheckpointThreadAccess) { + const state = access.getThreadState(threadId); + + if (state.currCheckpointIdx === null) { + const msgs = access.getThreadMessages(threadId); + const lastMsg = msgs[msgs.length - 1]; + + if (lastMsg?.role !== 'checkpoint') { + this.addUserCheckpoint(threadId, access); + } + // Update state after adding checkpoint implies messages length changed + const updatedMsgs = access.getThreadMessages(threadId); + access.setThreadState(threadId, { currCheckpointIdx: updatedMsgs.length - 1 }); + } + } + + private _readCurrentCheckpoint(threadId: string, access: ICheckpointThreadAccess): [CheckpointEntry, number] | undefined { + const msgs = access.getThreadMessages(threadId); + const { currCheckpointIdx } = access.getThreadState(threadId); + + if (currCheckpointIdx === null) return; + + const checkpoint = msgs[currCheckpointIdx]; + if (!checkpoint) return; + if (checkpoint.role !== 'checkpoint') return; + + return [checkpoint, currCheckpointIdx]; + } + + private _addUserModificationsToCurrCheckpoint(opts: { threadId: string }, access: ICheckpointThreadAccess) { + const { voidFileSnapshotOfURI } = this._computeNewCheckpointInfo({ threadId: opts.threadId }, access) ?? {}; + const res = this._readCurrentCheckpoint(opts.threadId, access); + if (!res) return; + + const [checkpoint, checkpointIdx] = res; + access.editMessageInThread(opts.threadId, checkpointIdx, { + ...checkpoint, + userModifications: { voidFileSnapshotOfURI: voidFileSnapshotOfURI ?? {}, }, + }); + } + + private _getCheckpointBeforeMessage(messages: ChatMessage[], messageIdx: number): [CheckpointEntry, number] | undefined { + for (let i = messageIdx; i >= 0; i--) { + const message = messages[i]; + if (message.role === 'checkpoint') { + return [message, i]; + } + } + return undefined; + } +} diff --git a/src/vs/workbench/contrib/void/browser/ChatCodespanManager.ts b/src/vs/workbench/contrib/void/browser/ChatCodespanManager.ts new file mode 100644 index 00000000000..74335b060b2 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/ChatCodespanManager.ts @@ -0,0 +1,151 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { shorten } from '../../../../base/common/labels.js'; +import { Position } from '../../../../editor/common/core/position.js'; +import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; +import { IToolsService } from '../common/toolsService.js'; +import { IVoidModelService } from '../common/voidModelService.js'; +import { CodespanLocationLink, ChatMessage, } from '../../../../platform/void/common/chatThreadServiceTypes.js'; +import { ToolCallParams, } from '../../../../platform/void/common/toolsServiceTypes.js'; + + +export class ChatCodespanManager { + constructor( + @IToolsService private readonly _toolsService: IToolsService, + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @IVoidModelService private readonly _voidModelService: IVoidModelService + ) { } + + public async generateCodespanLink( + opts: { codespanStr: string, threadId: string }, + getThreadMessages: () => ChatMessage[] + ): Promise { + + const { codespanStr: targetStr } = opts; + const functionOrMethodPattern = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; + const functionParensPattern = /^([^\s(]+)\([^)]*\)$/; + + let target = targetStr; + let codespanType: 'file-or-folder' | 'function-or-class'; + + if (target.includes('.') || target.includes('/')) { + codespanType = 'file-or-folder'; + } else if (functionOrMethodPattern.test(target)) { + codespanType = 'function-or-class'; + } else if (functionParensPattern.test(target)) { + const match = target.match(functionParensPattern); + if (match && match[1]) { + codespanType = 'function-or-class'; + target = match[1]; + } else { return null; } + } else { + return null; + } + + const prevUris = this._getAllSeenFileURIs(getThreadMessages()).reverse(); + + // 1. Search Files + if (codespanType === 'file-or-folder') { + const doesUriMatchTarget = (uri: URI) => uri.path.includes(target); + + // A. Check seen files + for (const [idx, uri] of prevUris.entries()) { + if (doesUriMatchTarget(uri)) { + return this._createLinkResult(uri, prevUris, idx); + } + } + + // B. Search codebase + try { + const { result } = await this._toolsService.callTool['search_pathnames_only']({ query: target, includePattern: null, pageNumber: 0 }); + const { uris } = await result; + for (const uri of uris) { + if (doesUriMatchTarget(uri)) { + // find relative idx in uris is tricky for shorten, using 0 for simplicity or logic from main + return { uri, displayText: target }; // Simplified for extraction + } + } + } catch (e) { return null; } + } + + // 2. Search Symbols + if (codespanType === 'function-or-class') { + for (const uri of prevUris) { + const modelRef = await this._voidModelService.getModelSafe(uri); + const { model } = modelRef; + if (!model) continue; + const definitionProviders = this._languageFeaturesService.definitionProvider.ordered(model); + if (!definitionProviders.length) continue; + + const matches = model.findMatches(target, false, false, true, null, true); + const firstThree = matches.slice(0, 3); + const seenMatchPositions = new Set(); + + for (const match of firstThree) { + const matchKey = `${match.range.startLineNumber}:${match.range.startColumn}`; + if (seenMatchPositions.has(matchKey)) continue; + seenMatchPositions.add(matchKey); + + const position = new Position(match.range.startLineNumber, match.range.startColumn); + + for (const provider of definitionProviders) { + const _definitions = await provider.provideDefinition(model, position, CancellationToken.None); + if (!_definitions) continue; + const definitions = Array.isArray(_definitions) ? _definitions : [_definitions]; + const definition = definitions[0]; + if (!definition) continue; + + return { + uri: definition.uri, + selection: { + startLineNumber: definition.range.startLineNumber, + startColumn: definition.range.startColumn, + endLineNumber: definition.range.endLineNumber, + endColumn: definition.range.endColumn, + }, + displayText: targetStr, + }; + } + } + } + } + return null; + } + + private _createLinkResult(uri: URI, allUris: URI[], idx: number) { + const prevUriStrs = allUris.map(u => u.fsPath); + const shortenedUriStrs = shorten(prevUriStrs); + let displayText = shortenedUriStrs[idx]; + const ellipsisIdx = displayText.lastIndexOf('…/'); + if (ellipsisIdx >= 0) { + displayText = displayText.slice(ellipsisIdx + 2); + } + return { uri, displayText }; + } + + private _getAllSeenFileURIs(messages: ChatMessage[]): URI[] { + const fsPathsSet = new Set(); + const uris: URI[] = []; + const addURI = (uri: URI) => { + if (fsPathsSet.has(uri.fsPath)) return; + fsPathsSet.add(uri.fsPath); + uris.push(uri); + }; + + for (const m of messages) { + if (m.role === 'user') { + for (const sel of m.selections ?? []) addURI(sel.uri); + for (const att of m.attachments ?? []) addURI(att.uri); + } else if (m.role === 'tool' && m.type === 'success' && m.name === 'read_file') { + const params = m.params as ToolCallParams['read_file']; + addURI(params.uri); + } + } + return uris; + } +} diff --git a/src/vs/workbench/contrib/void/browser/ChatExecutionEngine.ts b/src/vs/workbench/contrib/void/browser/ChatExecutionEngine.ts new file mode 100644 index 00000000000..47e21482b0e --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/ChatExecutionEngine.ts @@ -0,0 +1,902 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { timeout } from '../../../../base/common/async.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IVoidSettingsService } from '../../../../platform/void/common/voidSettingsService.js'; +import { ILLMMessageService } from '../common/sendLLMMessageService.js'; +import { IToolsService, isDangerousTerminalCommand } from '../common/toolsService.js'; +import { ILanguageModelToolsService } from '../../chat/common/languageModelToolsService.js'; +import { IMetricsService } from '../../../../platform/void/common/metricsService.js'; +import { IConvertToLLMMessageService } from './convertToLLMMessageService.js'; +import { LLMLoopDetector, LOOP_DETECTED_MESSAGE } from '../../../../platform/void/common/loopGuard.js'; +import { getErrorMessage, RawToolCallObj, RawToolParamsObj, LLMTokenUsage } from '../../../../platform/void/common/sendLLMMessageTypes.js'; +import { isAToolName } from '../common/prompt/prompts.js'; +import { approvalTypeOfToolName, } from '../../../../platform/void/common/toolsServiceTypes.js'; +import { ChatMessage, ToolMessage, ChatAttachment } from '../../../../platform/void/common/chatThreadServiceTypes.js'; +import { ModelSelection, ModelSelectionOptions } from '../../../../platform/void/common/voidSettingsTypes.js'; +import { getModelCapabilities } from '../../../../platform/void/common/modelInference.js'; +import { type JsonObject, type JsonValue, isJsonObject, stringifyUnknown, toJsonObject } from '../../../../platform/void/common/jsonTypes.js'; + +import { ChatHistoryCompressor } from './ChatHistoryCompressor.js'; +import { ChatToolOutputManager } from './ChatToolOutputManager.js'; +import { IThreadStateAccess } from './ChatAcpHandler.js'; +import { IMCPService } from '../common/mcpService.js'; + +export type IsRunningType = + | 'LLM' // the LLM is currently streaming + | 'tool' // whether a tool is currently running + | 'awaiting_user' // awaiting user call + | 'idle' // nothing is running now, but the chat should still appear like it's going (used in-between calls) + | undefined + + +export class ChatExecutionEngine { + + private readonly toolErrMsgs = { + rejected: 'Tool call was rejected by the user.', + interrupted: 'Tool call was interrupted by the user.', + errWhenStringifying: (error: any) => `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}` + }; + + private _getDisabledToolNamesSet(): Set { + const arr = this._settingsService.state.globalSettings.disabledToolNames; + if (!Array.isArray(arr)) return new Set(); + return new Set(arr.map(v => String(v ?? '').trim()).filter(Boolean)); + } + + private _isToolDisabled(name: string): boolean { + return this._getDisabledToolNamesSet().has(String(name ?? '').trim()); + } + + private _disabledToolError(toolName: string): string { + return `Tool "${toolName}" is disabled in Void settings.`; + } + + public readonly skippedToolCallIds = new Set(); + + constructor( + @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, + @IToolsService private readonly _toolsService: IToolsService, + @IVoidSettingsService private readonly _settingsService: IVoidSettingsService, + @ILanguageModelToolsService private readonly _lmToolsService: ILanguageModelToolsService, + @IMetricsService private readonly _metricsService: IMetricsService, + @IConvertToLLMMessageService private readonly _convertToLLMMessagesService: IConvertToLLMMessageService, + @IFileService private readonly _fileService: IFileService, + @IMCPService private readonly _mcpService: IMCPService, + private readonly _historyCompressor: ChatHistoryCompressor, + private readonly _toolOutputManager: ChatToolOutputManager + ) { } + + public async runChatAgent(opts: { + threadId: string, + modelSelection: ModelSelection | null, + modelSelectionOptions: ModelSelectionOptions | undefined, + callThisToolFirst?: ToolMessage & { type: 'tool_request' } + }, access: IThreadStateAccess) { + + const { threadId, modelSelection, modelSelectionOptions, callThisToolFirst } = opts; + + let interruptedWhenIdle = false; + const idleInterruptor = Promise.resolve(() => { interruptedWhenIdle = true }); + + const gs = this._settingsService.state.globalSettings; + const chatMode = gs.chatMode; + const chatRetries = gs.chatRetries; + const retryDelay = gs.retryDelay; + const { overridesOfModel } = this._settingsService.state; + + let nMessagesSent = 0; + let shouldSendAnotherMessage = true; + let isRunningWhenEnd: IsRunningType = undefined + + const loopDetector = new LLMLoopDetector({ + maxTurnsPerPrompt: gs.loopGuardMaxTurnsPerPrompt, + maxSameAssistantPrefix: gs.loopGuardMaxSameAssistantPrefix, + maxSameToolCall: gs.loopGuardMaxSameToolCall, + }); + + + if (callThisToolFirst) { + if (isAToolName(callThisToolFirst.name)) { + const { interrupted } = await this._runToolCall(threadId, callThisToolFirst.name, callThisToolFirst.id, { + preapproved: true, + unvalidatedToolParams: callThisToolFirst.rawParams, + validatedParams: callThisToolFirst.params + }, access); + + if (interrupted) { + if (this.skippedToolCallIds.delete(callThisToolFirst.id)) { + + } else { + access.setStreamState(threadId, undefined); + access.addUserCheckpoint(threadId); + return; + } + } + } else { + // Dynamic tool (MCP) + if (this._isToolDisabled(callThisToolFirst.name)) { + const disabledError = this._disabledToolError(callThisToolFirst.name); + access.addMessageToThread(threadId, { + role: 'tool', + type: 'tool_error', + params: callThisToolFirst.rawParams as any, + result: disabledError, + name: callThisToolFirst.name as any, + content: disabledError, + displayContent: disabledError, + id: callThisToolFirst.id, + rawParams: callThisToolFirst.rawParams, + }); + } else { + access.updateLatestTool(threadId, { + role: 'tool', + type: 'running_now', + params: callThisToolFirst.params as any, + name: callThisToolFirst.name as any, + content: 'running...', + displayContent: 'running...', + result: null, + id: callThisToolFirst.id, + rawParams: callThisToolFirst.rawParams + }); + + const exec = await this._runDynamicToolExec( + callThisToolFirst.name, + toJsonObject(callThisToolFirst.rawParams) + ); + + if (!exec.ok) { + access.updateLatestTool(threadId, { + role: 'tool', + type: 'tool_error', + params: callThisToolFirst.params as any, + result: exec.error, + name: callThisToolFirst.name as any, + content: exec.error, + displayContent: exec.error, + id: callThisToolFirst.id, + rawParams: callThisToolFirst.rawParams + }); + } else { + const { result: processedResult, content, displayContent } = + await this._toolOutputManager.processToolResult(exec.value, callThisToolFirst.name); + + access.updateLatestTool(threadId, { + role: 'tool', + type: 'success', + params: callThisToolFirst.params as any, + result: processedResult, + name: callThisToolFirst.name as any, + content, + displayContent: displayContent, + id: callThisToolFirst.id, + rawParams: callThisToolFirst.rawParams + }); + } + } + } + + } + + access.setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }); + + + + while (shouldSendAnotherMessage) { + shouldSendAnotherMessage = false; + isRunningWhenEnd = undefined; + nMessagesSent += 1; + + access.setStreamState(threadId, { isRunning: 'idle', interrupt: idleInterruptor }); + + + const baseChatMessages = access.getThreadMessages(threadId); + let historySummaryForTurn: string | null = null; + + if (nMessagesSent === 1) { + try { + const { summaryText, compressionInfo } = await this._historyCompressor.maybeSummarizeHistoryBeforeLLM({ + threadId, + messages: baseChatMessages, + modelSelection, + modelSelectionOptions, + }); + historySummaryForTurn = summaryText; + if (compressionInfo) { + access.setThreadState(threadId, { historyCompression: compressionInfo }); + } + } catch { /* fail open */ } + } + + const chatMessages: ChatMessage[] = historySummaryForTurn + ? ([{ + role: 'assistant', + displayContent: historySummaryForTurn, + reasoning: '', + anthropicReasoning: null, + } as ChatMessage, ...baseChatMessages]) + : baseChatMessages; + + const { messages, separateSystemMessage } = await this._convertToLLMMessagesService.prepareLLMChatMessages({ + chatMessages, + modelSelection, + chatMode + }); + + await this._patchImagesIntoMessages({ messages, chatMessages, modelSelection }); + + if (interruptedWhenIdle) { + access.setStreamState(threadId, undefined); + return; + } + + let shouldRetryLLM = true; + let nAttempts = 0; + + + while (shouldRetryLLM) { + shouldRetryLLM = false; + nAttempts += 1; + let lastUsageForTurn: LLMTokenUsage | undefined; + + let limitsForThisRequest: any; +void limitsForThisRequest; + try { + if (modelSelection) { + const { providerName, modelName } = modelSelection; + const caps = getModelCapabilities(providerName as any, modelName, overridesOfModel); + limitsForThisRequest = { contextWindow: caps.contextWindow }; + const reserved = caps.reservedOutputTokenSpace ?? 0; + const maxInputTokens = Math.max(0, caps.contextWindow - reserved); + access.setThreadState(threadId, { tokenUsageLastRequestLimits: { maxInputTokens } }); + } + } catch { /* noop */ } + + type ResTypes = + | { type: 'llmDone'; toolCall?: RawToolCallObj; info: { fullText: string; fullReasoning: string; anthropicReasoning: any }; tokenUsage?: LLMTokenUsage } + | { type: 'llmError'; error?: { message: string; fullError: Error | null } } + | { type: 'llmAborted' }; + + let resMessageIsDonePromise: (res: ResTypes) => void; + const messageIsDonePromise = new Promise((res) => { resMessageIsDonePromise = res; }); + + const llmCancelToken = this._llmMessageService.sendLLMMessage({ + messagesType: 'chatMessages', + chatMode, + messages: messages, + modelSelection, + modelSelectionOptions, + overridesOfModel, + logging: { loggingName: `Chat - ${chatMode}`, loggingExtras: { threadId, nMessagesSent, chatMode } }, + separateSystemMessage: separateSystemMessage, + onText: ({ fullText, fullReasoning, toolCall, tokenUsage }) => { + if (tokenUsage) lastUsageForTurn = tokenUsage; + access.setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { displayContentSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall ?? null }, + interrupt: Promise.resolve(() => { if (llmCancelToken) this._llmMessageService.abort(llmCancelToken); }) + }); + }, + onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, tokenUsage, }) => { + if (tokenUsage) lastUsageForTurn = tokenUsage; + resMessageIsDonePromise({ type: 'llmDone', toolCall, info: { fullText, fullReasoning, anthropicReasoning }, tokenUsage }); + }, + onError: async (error) => { + resMessageIsDonePromise({ type: 'llmError', error: error }); + }, + onAbort: () => { + if (lastUsageForTurn) access.accumulateTokenUsage(threadId, lastUsageForTurn); + resMessageIsDonePromise({ type: 'llmAborted' }); + this._metricsService.capture('Agent Loop Done (Aborted)', { nMessagesSent, chatMode }); + }, + }); + + if (!llmCancelToken) { + access.setStreamState(threadId, { isRunning: undefined, error: { message: 'Unexpected error sending chat message.', fullError: null } }); + break; + } + + access.setStreamState(threadId, { isRunning: 'LLM', llmInfo: { displayContentSoFar: '', reasoningSoFar: '', toolCallSoFar: null }, interrupt: Promise.resolve(() => this._llmMessageService.abort(llmCancelToken)) }); + + const llmRes = await messageIsDonePromise; + + const currStream = access.getStreamState(threadId); + if (currStream?.isRunning !== 'LLM') return; // interrupted by new thread + + if (llmRes.type === 'llmAborted') { + access.setStreamState(threadId, undefined); + return; + } + else if (llmRes.type === 'llmError') { + if (lastUsageForTurn) access.accumulateTokenUsage(threadId, lastUsageForTurn); + + if (nAttempts < chatRetries) { + shouldRetryLLM = true; + access.setStreamState(threadId, { isRunning: 'idle', interrupt: idleInterruptor }); + await timeout(retryDelay); + if (interruptedWhenIdle) { + access.setStreamState(threadId, undefined); + return; + } + continue; + } else { + const { error } = llmRes; + const info = access.getStreamState(threadId).llmInfo; + access.addMessageToThread(threadId, { role: 'assistant', displayContent: info.displayContentSoFar, reasoning: info.reasoningSoFar, anthropicReasoning: null }); + if (info.toolCallSoFar) access.addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: info.toolCallSoFar.name }); + + access.setStreamState(threadId, { isRunning: undefined, error }); + access.addUserCheckpoint(threadId); + return; + } + } + + // Success + const { toolCall, info, tokenUsage } = llmRes; + const effectiveUsage = tokenUsage ?? lastUsageForTurn; + if (effectiveUsage) access.accumulateTokenUsage(threadId, effectiveUsage); + + access.addMessageToThread(threadId, { role: 'assistant', displayContent: info.fullText, reasoning: info.fullReasoning, anthropicReasoning: info.anthropicReasoning }); + + // Loop Detection (Assistant) + const loopAfterAssistant = loopDetector.registerAssistantTurn(info.fullText); + if (loopAfterAssistant.isLoop) { + access.setStreamState(threadId, { isRunning: undefined, error: { message: LOOP_DETECTED_MESSAGE, fullError: null } }); + access.addUserCheckpoint(threadId); + return; + } + + access.setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }); + + + if (toolCall && toolCall.name) { + const loopAfterTool = loopDetector.registerToolCall(toolCall.name, toolCall.rawParams); + if (loopAfterTool.isLoop) { + access.setStreamState(threadId, { isRunning: undefined, error: { message: LOOP_DETECTED_MESSAGE, fullError: null } }); + access.addUserCheckpoint(threadId); + return; + } + + if (isAToolName(toolCall.name)) { + const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, toolCall.name, toolCall.id, { + preapproved: false, + unvalidatedToolParams: toolCall.rawParams + }, access); + + if (interrupted) { + if (this.skippedToolCallIds.delete(toolCall.id)) { + shouldSendAnotherMessage = true; + access.setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }); + } else { + access.setStreamState(threadId, undefined); + return; + } + } else { + if (awaitingUserApproval) { isRunningWhenEnd = 'awaiting_user'; } + else { shouldSendAnotherMessage = true; } + access.setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }); + } + } else { + // Dynamic Tool (MCP) + if (this._isToolDisabled(toolCall.name)) { + const disabledError = this._disabledToolError(toolCall.name); + access.addMessageToThread(threadId, { + role: 'tool', + type: 'tool_error', + params: toolCall.rawParams as any, + result: disabledError, + name: toolCall.name as any, + content: disabledError, + displayContent: disabledError, + id: toolCall.id, + rawParams: toolCall.rawParams + }); + shouldSendAnotherMessage = true; + access.setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }); + continue; + } + + if (this._settingsService.state.globalSettings.mcpAutoApprove) { + access.updateLatestTool(threadId, { + role: 'tool', + type: 'running_now', + name: toolCall.name as any, + params: toolCall.rawParams as any, + content: 'running...', + displayContent: 'running...', + result: null, + id: toolCall.id, + rawParams: toolCall.rawParams + }); + + const exec = await this._runDynamicToolExec( + toolCall.name, + toJsonObject(toolCall.rawParams) + ); + + if (!exec.ok) { + access.updateLatestTool(threadId, { + role: 'tool', + type: 'tool_error', + params: toolCall.rawParams as any, + result: exec.error, + name: toolCall.name as any, + content: exec.error, + displayContent: exec.error, + id: toolCall.id, + rawParams: toolCall.rawParams + }); + + shouldSendAnotherMessage = true; + access.setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }); + } else { + const { result: processedResult, content, displayContent } = + await this._toolOutputManager.processToolResult(exec.value, toolCall.name); + + access.updateLatestTool(threadId, { + role: 'tool', + type: 'success', + params: toolCall.rawParams as any, + result: processedResult, + name: toolCall.name as any, + content, + displayContent, + id: toolCall.id, + rawParams: toolCall.rawParams + }); + + shouldSendAnotherMessage = true; + access.setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }); + } + } else { + access.addMessageToThread(threadId, { + role: 'tool', + type: 'tool_request', + content: '(Awaiting user permission...)', + result: null, + name: toolCall.name as any, + params: toolCall.rawParams as any, + id: toolCall.id, + rawParams: toolCall.rawParams + }); + isRunningWhenEnd = 'awaiting_user'; + } + } + } + } + } + + access.setStreamState(threadId, { isRunning: isRunningWhenEnd }); + if (!isRunningWhenEnd) access.addUserCheckpoint(threadId); + this._metricsService.capture('Agent Loop Done', { nMessagesSent, chatMode }); + } + + private async _runToolCall( + threadId: string, + toolName: string, + toolId: string, + opts: { preapproved: boolean, unvalidatedToolParams: RawToolParamsObj, validatedParams?: any }, + access: IThreadStateAccess + ): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> { + + let toolParams: any; + let toolResult: any; + + const isTerminalTool = toolName === 'run_command' || toolName === 'run_persistent_command'; + + if (this._isToolDisabled(toolName)) { + const disabledError = this._disabledToolError(toolName); + access.addMessageToThread(threadId, { + role: 'tool', + type: 'tool_error', + params: (opts.validatedParams ?? opts.unvalidatedToolParams) as any, + result: disabledError, + name: toolName as any, + content: disabledError, + displayContent: disabledError, + id: toolId, + rawParams: opts.unvalidatedToolParams + }); + return {}; + } + + // 1. Validation & Approval + if (!opts.preapproved) { + try { + if (isAToolName(toolName)) { + toolParams = this._toolsService.validateParams[toolName](opts.unvalidatedToolParams); + } else { + toolParams = opts.unvalidatedToolParams; + } + } catch (error) { + const errorMessage = getErrorMessage(error); + access.addMessageToThread(threadId, { + role: 'tool', + type: 'invalid_params', + rawParams: opts.unvalidatedToolParams, + result: null, + name: toolName as any, + content: errorMessage, + id: toolId + }); + return {}; + } + + if (isAToolName(toolName)) { + const approvalType = approvalTypeOfToolName[toolName]; + if (approvalType) { + let autoApprove = this._settingsService.state.globalSettings.autoApprove[approvalType]; + if (approvalType === 'terminal' && (toolName === 'run_command' || toolName === 'run_persistent_command')) { + try { + const cmd = (toolParams as any)?.command ?? String((opts.unvalidatedToolParams as any)?.command ?? ''); + if (isDangerousTerminalCommand(cmd)) autoApprove = false; + } catch { } + } + if (!autoApprove) { + access.addMessageToThread(threadId, { + role: 'tool', + type: 'tool_request', + content: '(Awaiting user permission...)', + result: null, + name: toolName as any, + params: toolParams, + id: toolId, + rawParams: opts.unvalidatedToolParams + }); + return { awaitingUserApproval: true }; + } + } + } else { + access.addMessageToThread(threadId, { + role: 'tool', + type: 'tool_request', + content: '(Awaiting user permission...)', + result: null, + name: toolName as any, + params: toolParams, + id: toolId, + rawParams: opts.unvalidatedToolParams + }); + return { awaitingUserApproval: true }; + } + } else { + toolParams = opts.validatedParams; + } + + // 2. Execution + access.updateLatestTool(threadId, { + role: 'tool', + type: 'running_now', + name: toolName as any, + params: toolParams as any, + content: '', + displayContent: '', + result: null, + id: toolId, + rawParams: opts.unvalidatedToolParams + } as const); + + let interrupted = false; + let resolveInterruptor: (r: () => void) => void = () => { }; + const interruptorPromise = new Promise<() => void>(res => { resolveInterruptor = res; }); + + // streamState init + access.setStreamState(threadId, { + isRunning: 'tool', + interrupt: interruptorPromise, + toolInfo: { + toolName: isAToolName(toolName) ? toolName : (toolName as any), + toolParams: toolParams as any, + id: toolId, + content: '', + rawParams: opts.unvalidatedToolParams + } + }); + + // streaming accumulator + let streamed = ''; + let pushTimer: any = null; + let lastPushAt = 0; + const PUSH_INTERVAL_MS = 80; + const MAX_KEEP = 200_000; + + const push = (force: boolean) => { + if (interrupted) return; + const now = Date.now(); + if (!force && now - lastPushAt < PUSH_INTERVAL_MS) { + if (!pushTimer) { + pushTimer = setTimeout(() => { + pushTimer = null; + push(true); + }, PUSH_INTERVAL_MS); + } + return; + } + lastPushAt = now; + + access.setStreamState(threadId, { + isRunning: 'tool', + interrupt: interruptorPromise, + toolInfo: { + toolName: isAToolName(toolName) ? toolName : (toolName as any), + toolParams: toolParams as any, + id: toolId, + content: streamed, + rawParams: opts.unvalidatedToolParams + } + }); + }; + + // For ephemeral commands show "$ cmd" immediately in stream + if (toolName === 'run_command') { + const cmd = String((toolParams as any)?.command ?? ''); + if (cmd) { + streamed = `$ ${cmd}\n`; + push(true); + } + } + + const onOutput = (chunk: string) => { + if (interrupted) return; + if (typeof chunk !== 'string' || !chunk) return; + + streamed += chunk; + if (streamed.length > MAX_KEEP) { + streamed = streamed.slice(streamed.length - MAX_KEEP); + } + push(false); + }; + + try { + let result: Promise; + let interruptTool: (() => void) | undefined; + + if (isAToolName(toolName)) { + // Pass ctx only for terminal tools + const res = isTerminalTool + ? await (this._toolsService.callTool as any)[toolName](toolParams as any, { onOutput }) + : await this._toolsService.callTool[toolName](toolParams as any); + + result = Promise.resolve(res.result as any); + interruptTool = res.interruptTool; + } else { + result = Promise.resolve({}); + } + + const interruptor = () => { interrupted = true; interruptTool?.(); }; + resolveInterruptor(interruptor); + + toolResult = await result; + + if (pushTimer) { + try { clearTimeout(pushTimer); } catch { } + pushTimer = null; + } + push(true); + + if (interrupted) return { interrupted: true }; + } catch (error) { + resolveInterruptor(() => { }); + if (interrupted) return { interrupted: true }; + + const errorMessage = getErrorMessage(error); + access.updateLatestTool(threadId, { + role: 'tool', + type: 'tool_error', + params: toolParams, + result: errorMessage, + name: toolName, + content: errorMessage, + displayContent: errorMessage, + id: toolId, + rawParams: opts.unvalidatedToolParams + }); + return {}; + } + + // 3. Stringify & Process Result + let toolResultStr: string; + try { + if (isAToolName(toolName)) { + toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any); + } else { + toolResultStr = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); + } + } catch (error) { + const errorMessage = this.toolErrMsgs.errWhenStringifying(error); + access.updateLatestTool(threadId, { + role: 'tool', + type: 'tool_error', + params: toolParams, + result: errorMessage, + name: toolName as any, + content: errorMessage, + displayContent: errorMessage, + id: toolId, + rawParams: opts.unvalidatedToolParams + }); + return {}; + } + + let processedResult = toolResult; + if ((toolName === 'edit_file' || toolName === 'rewrite_file') && toolResult) { + const resultAny = toolResult as any; + if (!resultAny.patch_unified && resultAny.preview?.patch_unified) { + processedResult = { ...toolResult, patch_unified: resultAny.preview.patch_unified }; + } + } + + const { content, displayContent } = await this._toolOutputManager.processToolResult(toolResultStr, toolName); + + access.updateLatestTool(threadId, { + role: 'tool', + type: 'success', + params: toolParams, + result: processedResult, + name: toolName, + content, + displayContent, + id: toolId, + rawParams: opts.unvalidatedToolParams + }); + + return {}; + } + + private async _runDynamicToolExec( + name: string, + args: JsonObject + ): Promise<{ ok: true, value: string | JsonValue } | { ok: false, error: string }> { + if (this._isToolDisabled(name)) { + return { ok: false, error: this._disabledToolError(name) }; + } + + try { + type LmToolShape = { id: string; toolReferenceName?: string; displayName?: string }; + + const isToolShape = (v: unknown): v is LmToolShape => { + if (!isJsonObject(v)) return false; + return typeof v.id === 'string' && v.id.length > 0; + }; + + // ---------------------------- + // 1) Try execute via ILanguageModelToolsService (settings.json MCP path) + // ---------------------------- + const toolFromByNameUnknown = this._lmToolsService.getToolByName?.(name) as unknown; + let tool: LmToolShape | undefined = isToolShape(toolFromByNameUnknown) ? toolFromByNameUnknown : undefined; + + const allToolsUnknown = Array.from(this._lmToolsService.getTools?.() ?? []) as unknown[]; + const allTools: LmToolShape[] = allToolsUnknown.filter(isToolShape); + + if (!tool) { + for (const t of allTools) { + if (t.toolReferenceName === name || t.displayName === name) { tool = t; break; } + } + } + + // Fallback for prefixed names (e.g. "server__tool") + if (!tool && name.includes('__')) { + const baseName = name.split('__').pop(); + if (baseName) { + for (const t of allTools) { + if (t.toolReferenceName === baseName || t.displayName === baseName) { tool = t; break; } + } + } + } + + if (tool) { + const invocation = { + callId: generateUuid(), + toolId: tool.id, + parameters: args ?? {}, + context: undefined, + skipConfirmation: true, + }; + + const resUnknown = await this._lmToolsService.invokeTool(invocation, async () => 0, CancellationToken.None); + + const tryGetTextParts = (content: unknown): string | null => { + if (!Array.isArray(content)) return null; + const texts: string[] = []; + for (const p of content) { + if (!p || typeof p !== 'object') continue; + const kind = (p as { kind?: unknown }).kind; + const value = (p as { value?: unknown }).value; + if (kind === 'text' && typeof value === 'string') { + texts.push(value); + } + } + return texts.length ? texts.join('\n') : null; + }; + + const resObj = isJsonObject(resUnknown) ? (resUnknown as JsonObject) : null; + + const textParts = tryGetTextParts(resObj?.content); + if (textParts) return { ok: true, value: textParts }; + + if (resObj && typeof resObj.toolResultDetails !== 'undefined') return { ok: true, value: resObj.toolResultDetails }; + if (resObj && typeof resObj.toolResultMessage !== 'undefined') return { ok: true, value: resObj.toolResultMessage }; + + return { ok: true, value: {} }; + } + + // ---------------------------- + // 2) If not found: try execute via IMCPService (mcp.json path) + // ---------------------------- + if (name.includes('__')) { + // Best effort: resolve serverName by searching current MCP state tools + let resolvedServerName: string | null = null; + + const state = this._mcpService.state?.mcpServerOfName ?? {}; + for (const [serverName, server] of Object.entries(state)) { + const tools = (server as any)?.tools as Array<{ name: string }> | undefined; + if (tools?.some(t => t.name === name)) { + resolvedServerName = serverName; + break; + } + } + + // Fallback: prefix before '__' (works when prefix equals config serverName) + if (!resolvedServerName) { + resolvedServerName = name.split('__')[0] || null; + } + + if (resolvedServerName) { + const { result } = await this._mcpService.callMCPTool({ + serverName: resolvedServerName, + toolName: name, + params: args ?? {}, + }); + + const text = this._mcpService.stringifyResult(result); + return { ok: true, value: text }; + } + } + + return { ok: false, error: `Unknown dynamic tool: ${name}` }; + } catch (e: unknown) { + return { ok: false, error: stringifyUnknown(e) }; + } + } + + private async _patchImagesIntoMessages(opts: { messages: any[]; chatMessages: ChatMessage[]; modelSelection: ModelSelection | null }) { + const { messages, chatMessages, modelSelection } = opts; + if (!modelSelection) return; + + const lastUserChat = [...chatMessages].reverse().find(m => m.role === 'user') as (ChatMessage & { attachments?: ChatAttachment[] | null }) | undefined; + if (!lastUserChat || !lastUserChat.attachments || !lastUserChat.attachments.length) return; + + let lastUserIdx = -1; + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (messages[i]?.role === 'user') { + lastUserIdx = i; + break; + } + } + if (lastUserIdx === -1) return; + + const lastUser = messages[lastUserIdx]; + const baseContent = typeof lastUser.content === 'string' ? lastUser.content : ''; + const parts: any[] = []; + const trimmed = baseContent.trim(); + if (trimmed) { + parts.push({ type: 'text', text: trimmed }); + } + + for (const att of lastUserChat.attachments) { + try { + const content = await this._fileService.readFile(att.uri); + const dataBase64 = (await import('../../../../base/common/buffer.js')).encodeBase64(content.value); + const mime = (att as any).mimeType || 'image/png'; + const dataUrl = `data:${mime};base64,${dataBase64}`; + parts.push({ type: 'image_url', image_url: { url: dataUrl } }); + } catch { } + } + } +} diff --git a/src/vs/workbench/contrib/void/browser/ChatHistoryCompressor.ts b/src/vs/workbench/contrib/void/browser/ChatHistoryCompressor.ts new file mode 100644 index 00000000000..28b417a8dd1 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/ChatHistoryCompressor.ts @@ -0,0 +1,218 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { ChatMessage } from '../../../../platform/void/common/chatThreadServiceTypes.js'; +import { ILLMMessageService } from '../common/sendLLMMessageService.js'; +import { IConvertToLLMMessageService } from './convertToLLMMessageService.js'; +import { IVoidSettingsService } from '../../../../platform/void/common/voidSettingsService.js'; +import { ModelSelection, ModelSelectionOptions } from '../../../../platform/void/common/voidSettingsTypes.js'; +import { getModelCapabilities, getReservedOutputTokenSpace, getIsReasoningEnabledState } from '../../../../platform/void/common/modelInference.js'; +import { CHAT_HISTORY_COMPRESSION_SYSTEM_PROMPT, buildChatHistoryCompressionUserMessage } from '../common/prompt/prompts.js'; + + +const CHARS_PER_TOKEN_ESTIMATE = 4; +const HISTORY_COMPRESSION_TAIL_MESSAGE_COUNT = 8; +const HISTORY_COMPRESSION_TOOL_SNIPPET_CHARS = 400; + +export type ThreadHistoryCompressionInfo = { + hasCompressed: boolean; + summarizedMessageCount: number; + approxTokensBefore: number; + approxTokensAfter: number; +}; + +export class ChatHistoryCompressor { + + constructor( + @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, + @IConvertToLLMMessageService private readonly _convertToLLMMessagesService: IConvertToLLMMessageService, + @IVoidSettingsService private readonly _settingsService: IVoidSettingsService + ) { } + + public estimateTokensForMessages(messages: ChatMessage[]): number { + let totalChars = 0; + for (const m of messages) { + if (m.role === 'checkpoint' || m.role === 'interrupted_streaming_tool') continue; + if (m.role === 'user') { + totalChars += (m.content ?? '').length; + } else if (m.role === 'assistant') { + totalChars += (m.displayContent ?? '').length; + } else if (m.role === 'tool') { + totalChars += (m.content ?? '').length; + } + } + if (totalChars <= 0) return 0; + return Math.ceil(totalChars / CHARS_PER_TOKEN_ESTIMATE); + } + + public async maybeSummarizeHistoryBeforeLLM(opts: { + threadId: string; + messages: ChatMessage[]; + modelSelection: ModelSelection | null; + modelSelectionOptions: ModelSelectionOptions | undefined; + }): Promise<{ summaryText: string | null; compressionInfo?: ThreadHistoryCompressionInfo }> { + const { threadId, messages: chatMessages, modelSelection, modelSelectionOptions } = opts; + + if (!modelSelection || !chatMessages.length) { + return { summaryText: null }; + } + + const { overridesOfModel } = this._settingsService.state; + const { providerName, modelName } = modelSelection; + + let contextWindow: number; + try { + const caps = getModelCapabilities(providerName as any, modelName, overridesOfModel); + contextWindow = caps.contextWindow; + } catch { + return { summaryText: null }; + } + + if (!contextWindow || contextWindow <= 0) return { summaryText: null }; + + const isReasoningEnabled = getIsReasoningEnabledState( + 'Chat', + providerName, + modelName, + modelSelectionOptions, + overridesOfModel + ); + const reservedOutputTokenSpace = getReservedOutputTokenSpace(providerName, modelName, { isReasoningEnabled, overridesOfModel }) ?? 0; + const maxInputTokens = Math.max(0, contextWindow - reservedOutputTokenSpace); + + if (maxInputTokens <= 0) return { summaryText: null }; + + const approxTokensBefore = this.estimateTokensForMessages(chatMessages); + + if (approxTokensBefore <= maxInputTokens) { + return { summaryText: null }; + } + + + const tailCount = HISTORY_COMPRESSION_TAIL_MESSAGE_COUNT; + const splitIdx = Math.max(0, chatMessages.length - tailCount); + const prefixMessages = splitIdx > 0 + ? chatMessages.slice(0, splitIdx) + : chatMessages.slice(0, Math.max(0, chatMessages.length - 1)); + + if (!prefixMessages.length) return { summaryText: null }; + + const tailMessages = chatMessages.slice(prefixMessages.length); + const approxTailTokens = this.estimateTokensForMessages(tailMessages); + + const rawTarget = Math.floor(maxInputTokens * 0.2); + const targetTokensApprox = Math.max(128, Math.min(rawTarget, 1024)); + + const historyText = this._buildHistoryTextForCompression(prefixMessages); + if (!historyText.trim()) return { summaryText: null }; + + const systemMessage = CHAT_HISTORY_COMPRESSION_SYSTEM_PROMPT; + const userMessageContent = buildChatHistoryCompressionUserMessage({ + historyText, + approxTokensBefore, + targetTokensApprox, + }); + + const simpleMessages: any[] = [ + { role: 'user', content: userMessageContent }, + ]; + + const { messages, separateSystemMessage } = this._convertToLLMMessagesService.prepareLLMSimpleMessages({ + simpleMessages, + systemMessage, + modelSelection, + featureName: 'Chat', + }); + + let resolved = false; + let summaryText = ''; + + await new Promise((resolve) => { + const reqId = this._llmMessageService.sendLLMMessage({ + messagesType: 'chatMessages', + messages, + separateSystemMessage, + chatMode: 'normal', + modelSelection, + modelSelectionOptions, + overridesOfModel, + logging: { loggingName: 'Chat - history compression', loggingExtras: { threadId, approxTokensBefore, maxInputTokens } }, + tool_choice: 'none', + onText: () => { /* ignore streaming for compression */ }, + onFinalMessage: ({ fullText }) => { + if (!resolved) { + summaryText = fullText ?? ''; + resolved = true; + resolve(); + } + }, + onError: () => { + if (!resolved) { + summaryText = ''; + resolved = true; + resolve(); + } + }, + onAbort: () => { + if (!resolved) { + summaryText = ''; + resolved = true; + resolve(); + } + }, + }); + + if (!reqId && !resolved) { + resolved = true; + resolve(); + } + }); + + const trimmedSummary = summaryText.trim(); + if (!trimmedSummary) return { summaryText: null }; + + const approxSummaryTokens = Math.ceil(trimmedSummary.length / CHARS_PER_TOKEN_ESTIMATE); + const approxTokensAfter = approxTailTokens + approxSummaryTokens; + + const compressionInfo: ThreadHistoryCompressionInfo = { + hasCompressed: true, + summarizedMessageCount: prefixMessages.length, + approxTokensBefore, + approxTokensAfter, + }; + + return { summaryText: trimmedSummary, compressionInfo }; + } + + private _buildHistoryTextForCompression(messages: ChatMessage[]): string { + const lines: string[] = []; + for (const m of messages) { + if (m.role === 'checkpoint' || m.role === 'interrupted_streaming_tool') continue; + if (m.role === 'user') { + const content = m.displayContent || ''; + if (!content.trim()) continue; + lines.push(`User: ${content}`); + } else if (m.role === 'assistant') { + const content = m.displayContent || ''; + if (!content.trim()) continue; + lines.push(`Assistant: ${content}`); + } else if (m.role === 'tool') { + const header = `Tool ${m.name} (${m.type})`; + const body = (m.content || '').trim(); + if (!body) { + lines.push(header); + continue; + } + let snippet = body; + + if (snippet.length > HISTORY_COMPRESSION_TOOL_SNIPPET_CHARS) { + snippet = `${snippet.slice(0, HISTORY_COMPRESSION_TOOL_SNIPPET_CHARS)}...`; + } + lines.push(`${header}\n${snippet}`); + } + } + return lines.join('\n\n'); + } +} diff --git a/src/vs/workbench/contrib/void/browser/ChatNotificationManager.ts b/src/vs/workbench/contrib/void/browser/ChatNotificationManager.ts new file mode 100644 index 00000000000..7b8cc68f63d --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/ChatNotificationManager.ts @@ -0,0 +1,57 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { truncate } from '../../../../base/common/strings.js'; +import { getErrorMessage } from '../../../../platform/void/common/sendLLMMessageTypes.js'; + +export class ChatNotificationManager { + constructor( + private readonly _notificationService: INotificationService + ) { } + + + public wrapRunAgentToNotify( + p: Promise, + threadId: string, + getCurrentThreadId: () => string, + getLastUserMessageContent: () => string | undefined, + onJumpToChat: (threadId: string) => void + ) { + const notify = ({ error }: { error: string | null }) => { + + const userMsgContent = getLastUserMessageContent(); + if (!userMsgContent) return; + + const messageContentTruncated = truncate(userMsgContent, 50, '...'); + + this._notificationService.notify({ + severity: error ? Severity.Warning : Severity.Info, + message: error ? `Error: ${error} ` : `A new Chat result is ready.`, + source: messageContentTruncated, + sticky: true, + actions: { + primary: [{ + id: 'void.goToChat', + enabled: true, + label: `Jump to Chat`, + tooltip: '', + class: undefined, + run: () => { + onJumpToChat(threadId); + } + }] + }, + }); + }; + + p.then(() => { + if (threadId !== getCurrentThreadId()) notify({ error: null }); + }).catch((e) => { + if (threadId !== getCurrentThreadId()) notify({ error: getErrorMessage(e) }); + throw e; + }); + } +} diff --git a/src/vs/workbench/contrib/void/browser/ChatToolOutputManager.ts b/src/vs/workbench/contrib/void/browser/ChatToolOutputManager.ts new file mode 100644 index 00000000000..e4c0d3651b5 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/ChatToolOutputManager.ts @@ -0,0 +1,523 @@ +import { URI } from '../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../base/common/buffer.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { IVoidSettingsService } from '../../../../platform/void/common/voidSettingsService.js'; +import { defaultGlobalSettings } from '../../../../platform/void/common/voidSettingsTypes.js'; +import { computeTruncatedToolOutput } from '../../../../platform/void/common/toolOutputTruncation.js'; +import { type JsonObject, type JsonValue, type ToolOutputInput, getStringField, isJsonObject } from '../../../../platform/void/common/jsonTypes.js'; + +import { + normalizeMetaLogFilePath, + looksLikeStableToolOutputsRelPath, + stableToolOutputsRelPath, +} from '../../../../platform/void/common/toolOutputFileNames.js'; + + +export class ChatToolOutputManager { + + constructor( + @IFileService private readonly _fileService: IFileService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @IVoidSettingsService private readonly _settingsService: IVoidSettingsService + ) { } + + private async _getToolOutputsDir(): Promise { + const workspace = this._workspaceContextService.getWorkspace(); + const folderUri = workspace.folders.length > 0 ? workspace.folders[0].uri : null; + if (!folderUri) return null; + + const outputDir = URI.joinPath(folderUri, '.void', 'tool_outputs'); + if (!(await this._fileService.exists(outputDir))) { + await this._fileService.createFolder(outputDir); + } + return outputDir; + } + + private async _toolOutputsFileUri(relPathOrAnything: string): Promise { + const outputDir = await this._getToolOutputsDir(); + if (!outputDir) return null; + + const normalized = normalizeMetaLogFilePath(relPathOrAnything); + if (!normalized) return null; + + const base = normalized.split('/').filter(Boolean).slice(-1)[0]; + if (!base) return null; + + return URI.joinPath(outputDir, base); + } + + private async _existsToolOutputsFile(relPathOrAnything: string): Promise { + try { + const uri = await this._toolOutputsFileUri(relPathOrAnything); + if (!uri) return false; + return await this._fileService.exists(uri); + } catch { + return false; + } + } + + private async _writeToolOutputsFileOverwrite(relPathOrAnything: string, content: string): Promise { + try { + const fileUri = await this._toolOutputsFileUri(relPathOrAnything); + if (!fileUri) return false; + await this._fileService.writeFile(fileUri, VSBuffer.fromString(content)); + return true; + } catch { + return false; + } + } + + private async _copyToolOutputsFileBestEffort(fromRelOrAnything: string, toRelOrAnything: string): Promise { + const fromUri = await this._toolOutputsFileUri(fromRelOrAnything); + const toUri = await this._toolOutputsFileUri(toRelOrAnything); + if (!fromUri || !toUri) return false; + + try { + const exists = await this._fileService.exists(fromUri); + if (!exists) return false; + } catch { + return false; + } + + // Prefer native copy if available + try { + const fileService = this._fileService as { copy?: (from: URI, to: URI, overwrite: boolean) => Promise }; + if (typeof fileService.copy === 'function') { + await fileService.copy(fromUri, toUri, true); + return true; + } + } catch { /* ignore */ } + + // Fallback: read+write + try { + const data = await this._fileService.readFile(fromUri); + await this._fileService.writeFile(toUri, data.value); + return true; + } catch { + return false; + } + } + + private _cleanContentForDisplay(content: string): string { + if (!content) return ''; + + let text = String(content).replace(/\r\n/g, '\n'); + let lines = text.split('\n'); + + // Preserve existing behavior: remove leading absolute path line before a code fence + if (lines.length >= 2) { + const firstRaw = (lines[0] ?? '').trim(); + const second = (lines[1] ?? '').trim(); + + const secondIsFence = /^```[a-zA-Z0-9_-]*\s*$/.test(second); + + const firstSansSuffix = firstRaw.replace( + /\s+\(lines?\s+\d+(?:\s*-\s*\d+)?\)\s*$/i, + '' + ); + + const looksLikeAbsPath = + ( + firstSansSuffix.startsWith('/') || + /^[A-Za-z]:[\\/]/.test(firstSansSuffix) + ) && + !firstSansSuffix.includes('```') && + firstSansSuffix.length < 500; + + if (looksLikeAbsPath && secondIsFence) { + lines.shift(); + if ((lines[0] ?? '').trim() === '') { + lines.shift(); + } + } + } + + text = lines.join('\n'); + text = text.replace(/^\s*```[a-zA-Z0-9_-]*\s*\n/, ''); + text = text.replace(/\n\s*```\s*$/, ''); + + return text.trim(); + } + + public async processToolResult(result: ToolOutputInput, toolName?: string): Promise<{ result: ToolOutputInput; content: string; displayContent: string }> { + + const rawMax = (this._settingsService.state.globalSettings as { maxToolOutputLength?: JsonValue }).maxToolOutputLength; + const maxToolOutputLength = + (typeof rawMax === 'number' && Number.isFinite(rawMax) && rawMax > 0) ? rawMax : + (typeof rawMax === 'string' && Number.isFinite(Number(rawMax)) && Number(rawMax) > 0) ? Number(rawMax) : + 16000; + + const safeJson = (v: ToolOutputInput, max = 300): string => { +void safeJson; + try { + const s = JSON.stringify(v); + return s.length > max ? s.slice(0, max) + '…' : s; + } catch { + const s = String(v); + return s.length > max ? s.slice(0, max) + '…' : s; + } + }; + + const tool = String(toolName ?? '').trim(); + const isRunCommand = tool === 'run_command' || tool === 'run_persistent_command'; + const isReadFile = tool === 'read_file'; + + const tryExtractReadFileInfo = (): { filePath?: string; startLine?: number; endLine?: number; fileTotalLines?: number } => { + if (!resObj) return {}; + + // read_file payload is often either: + // { uri, startLine, endLine, fileContents } + // or: + // { result: { uri, startLine, endLine, fileContents } } + const container: any = (() => { + const r = (resObj as any).result; + return (r && typeof r === 'object' && !Array.isArray(r)) ? r : resObj; + })(); + + const startLine = (() => { + const n = Number(container?.startLine); + return Number.isFinite(n) && n > 0 ? n : undefined; + })(); + + const endLine = (() => { + const n = Number(container?.endLine); + return Number.isFinite(n) && n > 0 ? n : undefined; + })(); + + const fileTotalLines = (() => { + const n = Number(container?.totalNumLines); + return Number.isFinite(n) && n > 0 ? n : undefined; + })(); + + const uriObj = container?.uri; + if (uriObj && typeof uriObj === 'object' && !Array.isArray(uriObj)) { + const fsPath = (uriObj as any).fsPath; + if (typeof fsPath === 'string' && fsPath.trim()) { + return { + filePath: fsPath, + startLine, + endLine, + fileTotalLines, + }; + } + } + + // Fallback: parse from output text like "/abs/path/file.ts (lines 10-200)" + const candidate = String(fullText || uiText || '').replace(/\r\n/g, '\n'); + const firstNonEmpty = candidate.split('\n').find(l => l.trim().length > 0) ?? ''; + const m = firstNonEmpty.trim().match(/^(.+?)\s+\(lines?\s+(\d+)(?:\s*-\s*(\d+))?\)\s*$/i); + if (m) { + const p = (m[1] ?? '').trim(); + const s = Number(m[2]); + const e = m[3] ? Number(m[3]) : undefined; + return { + filePath: p || undefined, + startLine: Number.isFinite(s) && s > 0 ? s : undefined, + endLine: typeof e === 'number' && Number.isFinite(e) && e > 0 ? e : undefined, + }; + } + + return {}; + }; + + const isStringInput = typeof result === 'string'; + const resObj: JsonObject | null = (!isStringInput && isJsonObject(result)) ? result : null; + + const TRUNC_META_RE = /TRUNCATION_META:\s*(\{[\s\S]*\})\s*$/; + + const extractTruncationMeta = (text: string): JsonObject | null => { + if (!text) return null; + const tail = text.slice(-4000); + const m = tail.match(TRUNC_META_RE); + if (!m) return null; + try { + const parsed = JSON.parse(m[1]) as JsonValue; + return isJsonObject(parsed) ? parsed : null; + } catch { + return null; + } + }; + + const hasTruncationFooter = (text: string): boolean => { + if (!text) return false; + const tail = text.slice(-4000); + return tail.includes('[VOID] TOOL OUTPUT TRUNCATED') && !!extractTruncationMeta(text); + }; + + let uiText: string; + let uiTextSource: string; + + const footerText = + (resObj && typeof getStringField(resObj, 'text') === 'string' && hasTruncationFooter(getStringField(resObj, 'text')!)) ? getStringField(resObj, 'text')! : + (resObj && typeof getStringField(resObj, 'content') === 'string' && hasTruncationFooter(getStringField(resObj, 'content')!)) ? getStringField(resObj, 'content')! : + (resObj && typeof getStringField(resObj, 'output') === 'string' && hasTruncationFooter(getStringField(resObj, 'output')!)) ? getStringField(resObj, 'output')! : + undefined; + + if (typeof footerText === 'string') { + uiText = footerText; + uiTextSource = 'footer_any'; + } else if (resObj && typeof getStringField(resObj, 'output') === 'string') { + uiText = getStringField(resObj, 'output')!; + uiTextSource = 'result.output'; + } else if (resObj && typeof getStringField(resObj, 'content') === 'string') { + uiText = getStringField(resObj, 'content')!; + uiTextSource = 'result.content'; + } else if (isStringInput) { + uiText = result; + uiTextSource = 'string_input'; + } else if (resObj && typeof getStringField(resObj, 'text') === 'string') { + uiText = getStringField(resObj, 'text')!; + uiTextSource = 'result.text'; + } else if (resObj && getStringField(resObj, '_type') === 'text' && typeof getStringField(resObj, 'content') === 'string') { + uiText = getStringField(resObj, 'content')!; + uiTextSource = '_type_text.content'; + } else if (resObj && typeof getStringField(resObj, 'fileContents') === 'string') { + uiText = getStringField(resObj, 'fileContents')!; + uiTextSource = 'fileContents_as_uiText'; + } else { + uiText = safeJson(result, 10_000); + uiTextSource = 'json_fallback'; + } + + const keyText = + (resObj && typeof getStringField(resObj, 'output') === 'string') ? getStringField(resObj, 'output')! : + (resObj && typeof getStringField(resObj, 'content') === 'string') ? getStringField(resObj, 'content')! : + (resObj && typeof getStringField(resObj, 'text') === 'string') ? getStringField(resObj, 'text')! : + uiText; + + const fullText = + (resObj && typeof getStringField(resObj, 'fileContents') === 'string') ? getStringField(resObj, 'fileContents')! : + keyText; + + const hasValidTruncationFooter = hasTruncationFooter(uiText); + + const makeLeanResult = (stripFileContents: boolean): ToolOutputInput => { + if (!resObj) return result; + if (!stripFileContents) return result; + + const lean: JsonObject = { ...resObj }; + if (typeof lean.fileContents === 'string') delete lean.fileContents; + return lean; + }; + + const terminalId = resObj ? getStringField(resObj, 'terminalId') : undefined; + const toolCallId = resObj ? getStringField(resObj, 'toolCallId') : undefined; + + const stablePath = stableToolOutputsRelPath({ + toolName: tool, + terminalId, + toolCallId, + keyText, + fullText, + }); + + // ========================= + // A: footer already present + // ========================= + if (hasValidTruncationFooter) { + + if (isReadFile) { + const uiContent = uiText; + const displayContent = isRunCommand ? uiContent : this._cleanContentForDisplay(uiContent); + return { + result: makeLeanResult(true), + content: uiContent, + displayContent, + }; + } + + + const metaMatch = uiText.match(TRUNC_META_RE); + + if (metaMatch) { + try { + const parsed = JSON.parse(metaMatch[1]) as JsonValue; + if (isJsonObject(parsed)) { + const meta = parsed; + + const metaLogFilePath = typeof meta.logFilePath === 'string' ? meta.logFilePath : undefined; + + const footerNorm = metaLogFilePath ? normalizeMetaLogFilePath(metaLogFilePath) : undefined; + const footerLooksStable = looksLikeStableToolOutputsRelPath(footerNorm); + + const desired = footerLooksStable ? (footerNorm as string | undefined) : stablePath; + + let canRewrite = false; + + if (desired && await this._existsToolOutputsFile(desired)) { + canRewrite = true; + } else { + const fileContents = resObj ? getStringField(resObj, 'fileContents') : undefined; + const hasFullForSave = typeof fileContents === 'string' && fileContents.length > maxToolOutputLength; + + if (hasFullForSave && desired) { + canRewrite = await this._writeToolOutputsFileOverwrite(desired, fileContents); + } else if (footerNorm && desired && footerNorm !== desired) { + canRewrite = await this._copyToolOutputsFileBestEffort(footerNorm, desired); + } + } + + if (canRewrite && desired && meta.logFilePath !== desired) { + meta.logFilePath = desired; + uiText = uiText.replace( + /TRUNCATION_META:\s*\{[\s\S]*\}\s*$/m, + `TRUNCATION_META: ${JSON.stringify(meta)}` + ); + } + } + } catch (e) { + console.error('failed to parse meta', e); + } + } + + let uiContent = uiText; + + if (uiTextSource === 'result.text' && resObj && typeof getStringField(resObj, 'fileContents') === 'string' && getStringField(resObj, 'fileContents')!.length) { + const lines = uiText.split('\n'); + if (lines.length > 0 && /[\\/]/.test(lines[0]) && !lines[0].startsWith('[VOID]')) { + lines.shift(); + if (lines.length > 0 && lines[0].trim() === '') lines.shift(); + uiContent = lines.join('\n'); + } + } + + const displayContent = isRunCommand ? uiContent : this._cleanContentForDisplay(uiContent); + const defaultStrip = ((resObj && typeof getStringField(resObj, 'fileContents') === 'string') ? getStringField(resObj, 'fileContents')!.length : 0) > maxToolOutputLength; + + return { + result: makeLeanResult(defaultStrip), + content: uiContent, + displayContent, + }; + } + + // ========================= + // B: no footer — truncate ourselves + // ========================= + if (!fullText || fullText.length <= maxToolOutputLength) { + const displayContent = isRunCommand ? uiText : this._cleanContentForDisplay(uiText); + return { result: makeLeanResult(false), content: uiText, displayContent }; + } + + const { truncatedBody, originalLength, needsTruncation, lineAfterTruncation } = + computeTruncatedToolOutput(fullText, maxToolOutputLength); + + if (!needsTruncation) { + const displayContent = isRunCommand ? uiText : this._cleanContentForDisplay(uiText); + return { result: makeLeanResult(false), content: uiText, displayContent }; + } + + const startLineExclusive = lineAfterTruncation > 0 ? lineAfterTruncation : 0; + + const headerLines = [ + `[VOID] TOOL OUTPUT TRUNCATED, SEE TRUNCATION_META BELOW.`, + `Only the first ${maxToolOutputLength} characters are included in this message.`, + `Display limit: maxToolOutputLength = ${maxToolOutputLength} characters.`, + ]; + + let instructionsLines: string[]; + let meta: any; + + if (isReadFile) { + const info = tryExtractReadFileInfo(); + const filePath = info.filePath; + + const { truncatedBody, originalLength, needsTruncation, lineAfterTruncation } = + computeTruncatedToolOutput(fullText, maxToolOutputLength); + + if (!needsTruncation) { + const displayContent = isRunCommand ? uiText : this._cleanContentForDisplay(uiText); + return { result: makeLeanResult(false), content: uiText, displayContent }; + } + + const startLineExclusive = lineAfterTruncation > 0 ? lineAfterTruncation : 0; + const requestedStartLine = info.startLine ?? 1; + const nextStartLine = requestedStartLine + startLineExclusive; + + const rawChunk = (this._settingsService.state.globalSettings as { readFileChunkLines?: JsonValue }).readFileChunkLines; + const chunkSize = + (typeof rawChunk === 'number' && Number.isFinite(rawChunk) && rawChunk > 0) ? rawChunk : + (typeof rawChunk === 'string' && Number.isFinite(Number(rawChunk)) && Number(rawChunk) > 0) ? Number(rawChunk) : + defaultGlobalSettings.readFileChunkLines; + const suggestedEndLine = nextStartLine + chunkSize - 1; + const fileTotalLines = info.fileTotalLines; + const suggested = filePath ? { + startLine: nextStartLine, + endLine: suggestedEndLine, + chunkLines: chunkSize, + endLineIsFileEnd: false, + } : undefined; + + const headerLines = [ + `[VOID] TOOL OUTPUT TRUNCATED, SEE TRUNCATION_META BELOW.`, + `Only the first ${maxToolOutputLength} characters are included in this message.`, + `Display limit: maxToolOutputLength = ${maxToolOutputLength} characters.`, + ]; + + const instructionsLines = filePath ? [ + `IMPORTANT FOR THE MODEL:`, + ` 1. Do NOT guess based only on this truncated output.`, + ` 2. Continue by calling read_file on the ORIGINAL uri (NOT on a tool-output log):`, + ` read_file({ uri: ${JSON.stringify(filePath)}, startLine: ${nextStartLine}, endLine: ${suggestedEndLine} })`, + ` 3. IMPORTANT: endLine above is a chunk boundary, NOT the end of file.`, + ` 4. Recommended next chunk size: readFileChunkLines = ${chunkSize}.`, + ...(typeof fileTotalLines === 'number' + ? [` Known total file lines (from tool): ${fileTotalLines}.`] + : []), + ` 5. If still truncated, increase startLine by about ${chunkSize} and repeat.`, + ] : [ + `IMPORTANT FOR THE MODEL:`, + ` 1. Do NOT guess based only on this truncated output.`, + ` 2. Re-run read_file with a smaller range (startLine/endLine).`, + ]; + + const meta = { + tool: 'read_file', + uri: filePath, + requestedStartLine, + nextStartLine, + suggested, + ...(typeof fileTotalLines === 'number' ? { fileTotalLines } : {}), + maxChars: maxToolOutputLength, + originalLength + }; + + const finalText = + `${truncatedBody}...\n\n` + + `${headerLines.join('\n')}\n` + + `${instructionsLines.join('\n')}\n` + + `TRUNCATION_META: ${JSON.stringify(meta)}`; + + const displayContent = isRunCommand ? finalText : this._cleanContentForDisplay(finalText); + return { result: makeLeanResult(true), content: finalText, displayContent }; + } else { + await this._writeToolOutputsFileOverwrite(stablePath, fullText); + + instructionsLines = [ + `IMPORTANT FOR THE MODEL:`, + ` 1. Do NOT guess based only on this truncated output.`, + ` 2. To see the rest of this tool output, you MUST call your file-reading tool (for example, read_file)`, + ` on logFilePath, starting from line startLineExclusive + 1.`, + ]; + + meta = { logFilePath: stablePath, startLineExclusive, maxChars: maxToolOutputLength, originalLength }; + } + + const metaLine = `TRUNCATION_META: ${JSON.stringify(meta)}`; + + const finalText = + `${truncatedBody}...\n\n` + + `${headerLines.join('\n')}\n` + + `${instructionsLines.join('\n')}\n` + + `${metaLine}`; + + const displayContent = isRunCommand ? finalText : this._cleanContentForDisplay(finalText); + + return { + result: makeLeanResult(true), + content: finalText, + displayContent, + }; + } +} diff --git a/src/vs/workbench/contrib/void/browser/_markerCheckService.ts b/src/vs/workbench/contrib/void/browser/_markerCheckService.ts index be1ca0074bf..e4247e76a72 100644 --- a/src/vs/workbench/contrib/void/browser/_markerCheckService.ts +++ b/src/vs/workbench/contrib/void/browser/_markerCheckService.ts @@ -12,7 +12,6 @@ import { ITextModelService } from '../../../../editor/common/services/resolverSe import { Range } from '../../../../editor/common/core/range.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CodeActionContext, CodeActionTriggerType } from '../../../../editor/common/languages.js'; -import { URI } from '../../../../base/common/uri.js'; import * as dom from '../../../../base/browser/dom.js'; export interface IMarkerCheckService { @@ -34,19 +33,35 @@ class MarkerCheckService extends Disposable implements IMarkerCheckService { const allMarkers = this._markerService.read(); const errors = allMarkers.filter(marker => marker.severity === MarkerSeverity.Error); - if (errors.length > 0) { - for (const error of errors) { + if (errors.length === 0) { + return; + } + + const errorsByResource = new Map(); + for (const error of errors) { + const key = error.resource.toString(); + const group = errorsByResource.get(key); + if (group) { + group.push(error); + } else { + errorsByResource.set(key, [error]); + } + } - console.log(`----------------------------------------------`); + for (const resourceErrors of errorsByResource.values()) { + const resource = resourceErrors[0].resource; + let modelReference: Awaited> | undefined; - console.log(`${error.resource.fsPath}: ${error.startLineNumber} ${error.message} ${error.severity}`); // ! all errors in the file + try { + modelReference = await this._textModelService.createModelReference(resource); + const model = modelReference.object.textEditorModel; + const providers = this._languageFeaturesService.codeActionProvider.ordered(model); + if (providers.length === 0) continue; - try { - // Get the text model for the file - const modelReference = await this._textModelService.createModelReference(error.resource); - const model = modelReference.object.textEditorModel; + for (const error of resourceErrors) { + console.log(`----------------------------------------------`); + console.log(`${error.resource.fsPath}: ${error.startLineNumber} ${error.message} ${error.severity}`); - // Create a range from the marker const range = new Range( error.startLineNumber, error.startColumn, @@ -54,84 +69,40 @@ class MarkerCheckService extends Disposable implements IMarkerCheckService { error.endColumn ); - // Get code action providers for this model - const codeActionProvider = this._languageFeaturesService.codeActionProvider; - const providers = codeActionProvider.ordered(model); - - if (providers.length > 0) { - // Request code actions from each provider - for (const provider of providers) { - const context: CodeActionContext = { - trigger: CodeActionTriggerType.Invoke, // keeping 'trigger' since it works - only: 'quickfix' // adding this to filter for quick fixes - }; - - const actions = await provider.provideCodeActions( - model, - range, - context, - CancellationToken.None - ); - - if (actions?.actions?.length) { - - const quickFixes = actions.actions.filter(action => action.isPreferred); // ! all quickFixes for the error - // const quickFixesForImports = actions.actions.filter(action => action.isPreferred && action.title.includes('import')); // ! all possible imports - // quickFixesForImports - - if (quickFixes.length > 0) { - console.log('Available Quick Fixes:'); - quickFixes.forEach(action => { - console.log(`- ${action.title}`); - }); - } - } + for (const provider of providers) { + const context: CodeActionContext = { + trigger: CodeActionTriggerType.Invoke, + only: 'quickfix' + }; + + const actions = await provider.provideCodeActions( + model, + range, + context, + CancellationToken.None + ); + + if (!actions?.actions?.length) continue; + + const quickFixes = actions.actions.filter(action => action.isPreferred); + if (quickFixes.length > 0) { + console.log('Available Quick Fixes:'); + quickFixes.forEach(action => { + console.log(`- ${action.title}`); + }); } } - - // Dispose the model reference - modelReference.dispose(); - } catch (e) { - console.error('Error getting quick fixes:', e); } + } catch (e) { + console.error('Error getting quick fixes:', e); + } finally { + modelReference?.dispose(); } } } const { window } = dom.getActiveWindow() window.setInterval(check, 5000); } - - - - - fixErrorsInFiles(uris: URI[], contextSoFar: []) { - // const allMarkers = this._markerService.read(); - - - // check errors in files - - - // give LLM errors in files - - - - } - - // private _onMarkersChanged = (changedResources: readonly URI[]): void => { - // for (const resource of changedResources) { - // const markers = this._markerService.read({ resource }); - - // if (markers.length === 0) { - // console.log(`${resource.fsPath}: No diagnostics`); - // continue; - // } - - // console.log(`Diagnostics for ${resource.fsPath}:`); - // markers.forEach(marker => this._logMarker(marker)); - // } - // }; - - } registerSingleton(IMarkerCheckService, MarkerCheckService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/aiRegexService.ts b/src/vs/workbench/contrib/void/browser/aiRegexService.ts deleted file mode 100644 index b0d02024018..00000000000 --- a/src/vs/workbench/contrib/void/browser/aiRegexService.ts +++ /dev/null @@ -1,108 +0,0 @@ -/*-------------------------------------------------------------------------------------- - * Copyright 2025 Glass Devtools, Inc. All rights reserved. - * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. - *--------------------------------------------------------------------------------------*/ - -// 1. search(ai) -// - tool use to find all possible changes -// - if search only: is this file related to the search? -// - if search + replace: should I modify this file? -// 2. replace(ai) -// - what changes to make? -// 3. postprocess errors -// -fastapply changes simultaneously -// -iterate on syntax errors (all files can be changed from a syntax error, not just the one with the error) - - -// private async _searchUsingAI({ searchClause }: { searchClause: string }) { - -// // const relevantURIs: URI[] = [] -// // const gatherPrompt = `\ -// // asdasdas -// // ` -// // const filterPrompt = `\ -// // Is this file relevant? -// // ` - - -// // // optimizations (DO THESE LATER!!!!!!) -// // // if tool includes a uri in uriSet, skip it obviously -// // let uriSet = new Set() -// // // gather -// // let messages = [] -// // while (true) { -// // const result = await new Promise((res, rej) => { -// // sendLLMMessage({ -// // messages, -// // tools: ['search_for_files'], -// // onFinalMessage: ({ result: r, }) => { -// // res(r) -// // }, -// // onError: (error) => { -// // rej(error) -// // } -// // }) -// // }) - -// // messages.push({ role: 'tool', content: turnToString(result) }) - -// // sendLLMMessage({ -// // messages: { 'Output ': result }, -// // onFinalMessage: (r) => { -// // // output is file1\nfile2\nfile3\n... -// // } -// // }) - -// // uriSet.add(...) -// // } - -// // // writes -// // if (!replaceClause) return - -// // for (const uri of uriSet) { -// // // in future, batch these -// // applyWorkflow({ uri, applyStr: replaceClause }) -// // } - - - - - - -// // while (true) { -// // const result = new Promise((res, rej) => { -// // sendLLMMessage({ -// // messages, -// // tools: ['search_for_files'], -// // onResult: (r) => { -// // res(r) -// // } -// // }) -// // }) - -// // messages.push(result) - -// // } - - -// } - - -// private async _replaceUsingAI({ searchClause, replaceClause, relevantURIs }: { searchClause: string, replaceClause: string, relevantURIs: URI[] }) { - -// for (const uri of relevantURIs) { - -// uri - -// } - - - -// // should I change this file? -// // if so what changes to make? - - - -// // fast apply the changes -// } - diff --git a/src/vs/workbench/contrib/void/browser/autocompleteService.ts b/src/vs/workbench/contrib/void/browser/autocompleteService.ts index 22c86eb6afc..02020ab2622 100644 --- a/src/vs/workbench/contrib/void/browser/autocompleteService.ts +++ b/src/vs/workbench/contrib/void/browser/autocompleteService.ts @@ -14,17 +14,16 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { isCodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { EditorResourceAccessor } from '../../../common/editor.js'; import { IModelService } from '../../../../editor/common/services/model.js'; -import { extractCodeFromRegular } from '../common/helpers/extractCodeFromResult.js'; +import { extractCodeFromRegular } from '../../../../platform/void/common/helpers/extractCodeFromResult.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; import { isWindows } from '../../../../base/common/platform.js'; -import { IVoidSettingsService } from '../common/voidSettingsService.js'; -import { FeatureName } from '../common/voidSettingsTypes.js'; +import { IVoidSettingsService } from '../../../../platform/void/common/voidSettingsService.js'; +import { FeatureName } from '../../../../platform/void/common/voidSettingsTypes.js'; import { IConvertToLLMMessageService } from './convertToLLMMessageService.js'; // import { IContextGatheringService } from './contextGatheringService.js'; - const allLinebreakSymbols = ['\r\n', '\n'] const _ln = isWindows ? allLinebreakSymbols[0] : allLinebreakSymbols[1] @@ -811,30 +810,8 @@ export class AutocompleteService extends Disposable implements IAutocompleteServ overridesOfModel, logging: { loggingName: 'Autocomplete' }, onText: () => { }, // unused in FIMMessage - // onText: async ({ fullText, newText }) => { - - // newAutocompletion.insertText = fullText - - // // count newlines in newText - // const numNewlines = newText.match(/\n|\r\n/g)?.length || 0 - // newAutocompletion._newlineCount += numNewlines - - // // if too many newlines, resolve up to last newline - // if (newAutocompletion._newlineCount > 10) { - // const lastNewlinePos = fullText.lastIndexOf('\n') - // newAutocompletion.insertText = fullText.substring(0, lastNewlinePos) - // resolve(newAutocompletion.insertText) - // return - // } - - // // if (!getAutocompletionMatchup({ prefix: this._lastPrefix, autocompletion: newAutocompletion })) { - // // reject('LLM response did not match user\'s text.') - // // } - // }, onFinalMessage: ({ fullText }) => { - // console.log('____res: ', JSON.stringify(newAutocompletion.insertText)) - newAutocompletion.endTime = Date.now() newAutocompletion.status = 'finished' const [text, _] = extractCodeFromRegular({ text: fullText, recentlyAddedTextLen: 0 }) diff --git a/src/vs/workbench/contrib/void/browser/chatThreadService.ts b/src/vs/workbench/contrib/void/browser/chatThreadService.ts index 30f38f10ba8..490d8d1c206 100644 --- a/src/vs/workbench/contrib/void/browser/chatThreadService.ts +++ b/src/vs/workbench/contrib/void/browser/chatThreadService.ts @@ -7,159 +7,94 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; - import { URI } from '../../../../base/common/uri.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { ILLMMessageService } from '../common/sendLLMMessageService.js'; -import { chat_userMessageContent, isABuiltinToolName } from '../common/prompt/prompts.js'; -import { AnthropicReasoning, getErrorMessage, RawToolCallObj, RawToolParamsObj } from '../common/sendLLMMessageTypes.js'; import { generateUuid } from '../../../../base/common/uuid.js'; -import { FeatureName, ModelSelection, ModelSelectionOptions } from '../common/voidSettingsTypes.js'; -import { IVoidSettingsService } from '../common/voidSettingsService.js'; -import { approvalTypeOfBuiltinToolName, BuiltinToolCallParams, ToolCallParams, ToolName, ToolResult } from '../common/toolsServiceTypes.js'; -import { IToolsService } from './toolsService.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { IVoidSettingsService } from '../../../../platform/void/common/voidSettingsService.js'; +import { ILLMMessageService } from '../common/sendLLMMessageService.js'; +import { IToolsService } from '../common/toolsService.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; -import { ChatMessage, CheckpointEntry, CodespanLocationLink, StagingSelectionItem, ToolMessage } from '../common/chatThreadServiceTypes.js'; -import { Position } from '../../../../editor/common/core/position.js'; -import { IMetricsService } from '../common/metricsService.js'; -import { shorten } from '../../../../base/common/labels.js'; +import { ILanguageModelToolsService } from '../../chat/common/languageModelToolsService.js'; +import { IMetricsService } from '../../../../platform/void/common/metricsService.js'; import { IVoidModelService } from '../common/voidModelService.js'; -import { findLast, findLastIdx } from '../../../../base/common/arraysFind.js'; import { IEditCodeService } from './editCodeServiceInterface.js'; -import { VoidFileSnapshot } from '../common/editCodeServiceTypes.js'; -import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; -import { truncate } from '../../../../base/common/strings.js'; -import { THREAD_STORAGE_KEY } from '../common/storageKeys.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IConvertToLLMMessageService } from './convertToLLMMessageService.js'; -import { timeout } from '../../../../base/common/async.js'; -import { deepClone } from '../../../../base/common/objects.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IDirectoryStrService } from '../common/directoryStrService.js'; +import { IDirectoryStrService } from '../../../../platform/void/common/directoryStrService.js'; import { IFileService } from '../../../../platform/files/common/files.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { IAcpService } from '../../../../platform/acp/common/iAcpService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { deepClone } from '../../../../base/common/objects.js'; import { IMCPService } from '../common/mcpService.js'; -import { RawMCPToolCall } from '../common/mcpServiceTypes.js'; - - -// related to retrying when LLM message has error -const CHAT_RETRIES = 3 -const RETRY_DELAY = 2500 - - -const findStagingSelectionIndex = (currentSelections: StagingSelectionItem[] | undefined, newSelection: StagingSelectionItem): number | null => { - if (!currentSelections) return null - - for (let i = 0; i < currentSelections.length; i += 1) { - const s = currentSelections[i] - - if (s.uri.fsPath !== newSelection.uri.fsPath) continue - - if (s.type === 'File' && newSelection.type === 'File') { - return i - } - if (s.type === 'CodeSelection' && newSelection.type === 'CodeSelection') { - if (s.uri.fsPath !== newSelection.uri.fsPath) continue - // if there's any collision return true - const [oldStart, oldEnd] = s.range - const [newStart, newEnd] = newSelection.range - if (oldStart !== newStart || oldEnd !== newEnd) continue - return i - } - if (s.type === 'Folder' && newSelection.type === 'Folder') { - return i - } - } - return null -} - - -/* - -Store a checkpoint of all "before" files on each x. -x's show up before user messages and LLM edit tool calls. - -x A (edited A -> A') -(... user modified changes ...) -User message - -x A' B C (edited A'->A'', B->B', C->C') -LLM Edit -x -LLM Edit -x -LLM Edit - - -INVARIANT: -A checkpoint appears before every LLM message, and before every user message (before user really means directly after LLM is done). -*/ - - -type UserMessageType = ChatMessage & { role: 'user' } -type UserMessageState = UserMessageType['state'] -const defaultMessageState: UserMessageState = { - stagingSelections: [], - isBeingEdited: false, -} - -// a 'thread' means a chat message history - -type WhenMounted = { - textAreaRef: { current: HTMLTextAreaElement | null }; // the textarea that this thread has, gets set in SidebarChat - scrollToBottom: () => void; -} - - +import { + ChatMessage, StagingSelectionItem, ChatAttachment, CodespanLocationLink, + AnyToolName +} from '../../../../platform/void/common/chatThreadServiceTypes.js'; + +import { chat_userMessageContent } from '../common/prompt/prompts.js'; +import { LLMTokenUsage, RawToolCallObj, RawToolParamsObj } from '../../../../platform/void/common/sendLLMMessageTypes.js'; +import { THREAD_STORAGE_KEY } from '../../../../platform/void/common/storageKeys.js'; + +import { ChatNotificationManager } from './ChatNotificationManager.js'; +import { ChatHistoryCompressor } from './ChatHistoryCompressor.js'; +import { ChatToolOutputManager } from './ChatToolOutputManager.js'; +import { ChatCheckpointManager, ICheckpointThreadAccess } from './ChatCheckpointManager.js'; +import { ChatCodespanManager } from './ChatCodespanManager.js'; +import { ChatAcpHandler, IThreadStateAccess } from './ChatAcpHandler.js'; +import { ChatExecutionEngine } from './ChatExecutionEngine.js'; +import { getModelCapabilities } from '../../../../platform/void/common/modelInference.js'; + +export type ThreadHistoryCompressionInfo = { + hasCompressed: boolean; + summarizedMessageCount: number; + approxTokensBefore: number; + approxTokensAfter: number; +}; export type ThreadType = { - id: string; // store the id here too - createdAt: string; // ISO string - lastModified: string; // ISO string + id: string; + createdAt: string; + lastModified: string; messages: ChatMessage[]; filesWithUserChanges: Set; - // this doesn't need to go in a state object, but feels right state: { - currCheckpointIdx: number | null; // the latest checkpoint we're at (null if not at a particular checkpoint, like if the chat is streaming, or chat just finished and we haven't clicked on a checkpt) - + currCheckpointIdx: number | null; stagingSelections: StagingSelectionItem[]; - focusedMessageIdx: number | undefined; // index of the user message that is being edited (undefined if none) - - linksOfMessageIdx: { // eg. link = linksOfMessageIdx[4]['RangeFunction'] + focusedMessageIdx: number | undefined; + linksOfMessageIdx: { [messageIdx: number]: { [codespanName: string]: CodespanLocationLink } } - - + acpPlan?: { + title?: string; + items: Array<{ id?: string; text: string; state: 'pending' | 'running' | 'done' | 'error' }>; + }; + tokenUsageSession?: LLMTokenUsage; + tokenUsageLastRequest?: LLMTokenUsage; + tokenUsageLastRequestLimits?: any; + historyCompression?: ThreadHistoryCompressionInfo; mountedInfo?: { - whenMounted: Promise - _whenMountedResolver: (res: WhenMounted) => void + whenMounted: Promise + _whenMountedResolver: (res: any) => void mountedIsResolvedRef: { current: boolean }; } - - }; } -type ChatThreads = { +export type ChatThreads = { [id: string]: undefined | ThreadType; } - export type ThreadsState = { allThreads: ChatThreads; - currentThreadId: string; // intended for internal use only + currentThreadId: string; } -export type IsRunningType = - | 'LLM' // the LLM is currently streaming - | 'tool' // whether a tool is currently running - | 'awaiting_user' // awaiting user call - | 'idle' // nothing is running now, but the chat should still appear like it's going (used in-between calls) - | undefined - export type ThreadStreamState = { [threadId: string]: undefined | { isRunning: undefined; @@ -167,27 +102,27 @@ export type ThreadStreamState = { llmInfo?: undefined; toolInfo?: undefined; interrupt?: undefined; - } | { // an assistant message is being written + } | { isRunning: 'LLM'; error?: undefined; llmInfo: { displayContentSoFar: string; reasoningSoFar: string; toolCallSoFar: RawToolCallObj | null; + planSoFar?: any; }; toolInfo?: undefined; - interrupt: Promise<() => void>; // calling this should have no effect on state - would be too confusing. it just cancels the tool - } | { // a tool is being run + interrupt: Promise<() => void>; + } | { isRunning: 'tool'; error?: undefined; llmInfo?: undefined; toolInfo: { - toolName: ToolName; - toolParams: ToolCallParams; + toolName: AnyToolName; + toolParams: any; id: string; content: string; rawParams: RawToolParamsObj; - mcpServerName: string | undefined; }; interrupt: Promise<() => void>; } | { @@ -201,1684 +136,914 @@ export type ThreadStreamState = { error?: undefined; llmInfo?: undefined; toolInfo?: undefined; - interrupt: 'not_needed' | Promise<() => void>; // calling this should have no effect on state - would be too confusing. it just cancels the tool + interrupt: 'not_needed' | Promise<() => void>; } } -const newThreadObject = () => { - const now = new Date().toISOString() - return { - id: generateUuid(), - createdAt: now, - lastModified: now, - messages: [], - state: { - currCheckpointIdx: null, - stagingSelections: [], - focusedMessageIdx: undefined, - linksOfMessageIdx: {}, - }, - filesWithUserChanges: new Set() - } satisfies ThreadType -} - - - - - +// --- INTERFACES --- export interface IChatThreadService { readonly _serviceBrand: undefined; - readonly state: ThreadsState; - readonly streamState: ThreadStreamState; // not persistent - + readonly streamState: ThreadStreamState; onDidChangeCurrentThread: Event; - onDidChangeStreamState: Event<{ threadId: string }> - + onDidChangeStreamState: Event<{ threadId: string }>; getCurrentThread(): ThreadType; openNewThread(): void; switchToThread(threadId: string): void; - - // thread selector deleteThread(threadId: string): void; duplicateThread(threadId: string): void; - - // exposed getters/setters - // these all apply to current thread - getCurrentMessageState: (messageIdx: number) => UserMessageState - setCurrentMessageState: (messageIdx: number, newState: Partial) => void - getCurrentThreadState: () => ThreadType['state'] - setCurrentThreadState: (newState: Partial) => void - - // you can edit multiple messages - the one you're currently editing is "focused", and we add items to that one when you press cmd+L. + getCurrentMessageState: (messageIdx: number) => any; + setCurrentMessageState: (messageIdx: number, newState: any) => void; + getCurrentThreadState: () => ThreadType['state']; + setCurrentThreadState: (newState: Partial) => void; getCurrentFocusedMessageIdx(): number | undefined; isCurrentlyFocusingMessage(): boolean; setCurrentlyFocusedMessageIdx(messageIdx: number | undefined): void; - popStagingSelections(numPops?: number): void; addNewStagingSelection(newSelection: StagingSelectionItem): void; - dangerousSetState: (newState: ThreadsState) => void; resetState: () => void; - - // // current thread's staging selections - // closeCurrentStagingSelectionsInMessage(opts: { messageIdx: number }): void; - // closeCurrentStagingSelectionsInThread(): void; - - // codespan links (link to symbols in the markdown) getCodespanLink(opts: { codespanStr: string, messageIdx: number, threadId: string }): CodespanLocationLink | undefined; addCodespanLink(opts: { newLinkText: string, newLinkLocation: CodespanLocationLink, messageIdx: number, threadId: string }): void; - generateCodespanLink(opts: { codespanStr: string, threadId: string }): Promise; - getRelativeStr(uri: URI): string | undefined - - // entry pts + generateCodespanLink(opts: { codespanStr: string, threadId: string }): Promise; + getRelativeStr(uri: URI): string | undefined; abortRunning(threadId: string): Promise; dismissStreamError(threadId: string): void; - - // call to edit a message editUserMessageAndStreamResponse({ userMessage, messageIdx, threadId }: { userMessage: string, messageIdx: number, threadId: string }): Promise; - - // call to add a message - addUserMessageAndStreamResponse({ userMessage, threadId }: { userMessage: string, threadId: string }): Promise; - - // approve/reject + addUserMessageAndStreamResponse({ userMessage, threadId, attachments }: { userMessage: string, threadId: string, attachments?: ChatAttachment[] }): Promise; approveLatestToolRequest(threadId: string): void; rejectLatestToolRequest(threadId: string): void; - - // jump to history + skipLatestToolRequest(threadId: string): void; + skipRunningTool(threadId: string): void; jumpToCheckpointBeforeMessageIdx(opts: { threadId: string, messageIdx: number, jumpToUserModified: boolean }): void; + focusCurrentChat: () => Promise; + blurCurrentChat: () => Promise; + enqueueToolRequestFromAcp(threadId: string, req: { id: string; name: AnyToolName | string; rawParams: Record; params?: Record }): void; + onExternalToolDecision: Event<{ threadId: string; toolCallId: string; decision: 'approved' | 'rejected' | 'skipped' }>; +} + +export function normalizeSelectionRelativePath(uri: URI, workspaceFolderUris: readonly URI[]): string | undefined { + if (!workspaceFolderUris.length) return undefined; + const folder = workspaceFolderUris.find(f => uri.fsPath.startsWith(f.fsPath)); + if (!folder) return undefined; + let rel = uri.fsPath.slice(folder.fsPath.length); + rel = rel.replace(/^[\\/]+/, ''); + if (!rel) return './'; + return `./${rel}`; +} - focusCurrentChat: () => Promise - blurCurrentChat: () => Promise +const newThreadObject = () => { + const now = new Date().toISOString() + return { + id: generateUuid(), + createdAt: now, + lastModified: now, + messages: [], + state: { + currCheckpointIdx: null, + stagingSelections: [], + focusedMessageIdx: undefined, + linksOfMessageIdx: {}, + tokenUsageSession: undefined, + historyCompression: undefined, + }, + filesWithUserChanges: new Set() + } satisfies ThreadType } + +// --- MAIN CLASS --- + export const IChatThreadService = createDecorator('voidChatThreadService'); -class ChatThreadService extends Disposable implements IChatThreadService { + +export class ChatThreadService extends Disposable implements IChatThreadService { _serviceBrand: undefined; - // this fires when the current thread changes at all (a switch of currentThread, or a message added to it, etc) + // Events private readonly _onDidChangeCurrentThread = new Emitter(); readonly onDidChangeCurrentThread: Event = this._onDidChangeCurrentThread.event; private readonly _onDidChangeStreamState = new Emitter<{ threadId: string }>(); readonly onDidChangeStreamState: Event<{ threadId: string }> = this._onDidChangeStreamState.event; - readonly streamState: ThreadStreamState = {} - state: ThreadsState // allThreads is persisted, currentThread is not + private readonly _onExternalToolDecision = new Emitter<{ threadId: string; toolCallId: string; decision: 'approved' | 'rejected' | 'skipped' }>(); + readonly onExternalToolDecision = this._onExternalToolDecision.event; - // used in checkpointing - // private readonly _userModifiedFilesToCheckInCheckpoints = new LRUCache(50) + // State + readonly streamState: ThreadStreamState = {}; + state: ThreadsState; + // Sub-Services + private readonly _notificationManager: ChatNotificationManager; + private readonly _historyCompressor: ChatHistoryCompressor; + private readonly _toolOutputManager: ChatToolOutputManager; + private readonly _checkpointManager: ChatCheckpointManager; + private readonly _codespanManager: ChatCodespanManager; + private readonly _acpHandler: ChatAcpHandler; + private readonly _executionEngine: ChatExecutionEngine; + // Access Bridge + private readonly _threadAccess: IThreadStateAccess & ICheckpointThreadAccess; constructor( + @IAcpService _acpService: IAcpService, @IStorageService private readonly _storageService: IStorageService, @IVoidModelService private readonly _voidModelService: IVoidModelService, - @ILLMMessageService private readonly _llmMessageService: ILLMMessageService, - @IToolsService private readonly _toolsService: IToolsService, + @ILLMMessageService _llmMessageService: ILLMMessageService, + @IToolsService _toolsService: IToolsService, @IVoidSettingsService private readonly _settingsService: IVoidSettingsService, - @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, - @IMetricsService private readonly _metricsService: IMetricsService, - @IEditCodeService private readonly _editCodeService: IEditCodeService, - @INotificationService private readonly _notificationService: INotificationService, - @IConvertToLLMMessageService private readonly _convertToLLMMessagesService: IConvertToLLMMessageService, + @ILanguageFeaturesService _languageFeaturesService: ILanguageFeaturesService, + @ILanguageModelToolsService _lmToolsService: ILanguageModelToolsService, + @IMCPService _mcpService: IMCPService, + @IMetricsService _metricsService: IMetricsService, + @IEditCodeService _editCodeService: IEditCodeService, + @INotificationService _notificationService: INotificationService, + @IConvertToLLMMessageService _convertToLLMMessagesService: IConvertToLLMMessageService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IDirectoryStrService private readonly _directoryStringService: IDirectoryStrService, @IFileService private readonly _fileService: IFileService, - @IMCPService private readonly _mcpService: IMCPService, + @ILabelService private readonly _labelService: ILabelService, + @ILogService private readonly _logService: ILogService, ) { - super() - this.state = { allThreads: {}, currentThreadId: null as unknown as string } // default state - - const readThreads = this._readAllThreads() || {} + super(); - const allThreads = readThreads + // 1. Init State + const readThreads = this._readAllThreads() || {}; this.state = { - allThreads: allThreads, - currentThreadId: null as unknown as string, // gets set in startNewThread() - } + allThreads: readThreads, + currentThreadId: null as unknown as string, + }; + this.openNewThread(); - // always be in a thread - this.openNewThread() - - - // keep track of user-modified files - // const disposablesOfModelId: { [modelId: string]: IDisposable[] } = {} - // this._register( - // this._modelService.onModelAdded(e => { - // if (!(e.id in disposablesOfModelId)) disposablesOfModelId[e.id] = [] - // disposablesOfModelId[e.id].push( - // e.onDidChangeContent(() => { this._userModifiedFilesToCheckInCheckpoints.set(e.uri.fsPath, null) }) - // ) - // }) - // ) - // this._register(this._modelService.onModelRemoved(e => { - // if (!(e.id in disposablesOfModelId)) return - // disposablesOfModelId[e.id].forEach(d => d.dispose()) - // })) + // 2. Init Access Bridge + this._threadAccess = { + getThreadMessages: (tid: string) => this.state.allThreads[tid]?.messages || [], + getThreadState: (tid: string) => this.state.allThreads[tid]?.state || { currCheckpointIdx: null }, + getStreamState: (tid: string) => this.streamState[tid], - } + setStreamState: (tid: string, s: any) => this._setStreamState(tid, s), + setThreadState: (tid: string, s: any) => this._setThreadState(tid, s), - async focusCurrentChat() { - const threadId = this.state.currentThreadId - const thread = this.state.allThreads[threadId] - if (!thread) return - const s = await thread.state.mountedInfo?.whenMounted - if (!this.isCurrentlyFocusingMessage()) { - s?.textAreaRef.current?.focus() - } - } - async blurCurrentChat() { - const threadId = this.state.currentThreadId - const thread = this.state.allThreads[threadId] - if (!thread) return - const s = await thread.state.mountedInfo?.whenMounted - if (!this.isCurrentlyFocusingMessage()) { - s?.textAreaRef.current?.blur() - } - } + addMessageToThread: (tid: string, msg: ChatMessage) => this._addMessageToThread(tid, msg), + editMessageInThread: (tid: string, idx: number, msg: ChatMessage) => this._editMessageInThread(tid, idx, msg), + updateLatestTool: (tid: string, tool: any) => this._updateLatestTool(tid, tool), + + accumulateTokenUsage: (tid: string, usage: any) => this._accumulateTokenUsage(tid, usage), + addUserCheckpoint: (tid: string) => this._checkpointManager.addUserCheckpoint(tid, this._threadAccess), + currentModelSelectionProps: () => this._currentModelSelectionProps(), + isStreaming: (tid: string) => !!this.streamState[tid]?.isRunning + }; + // 3. Init Sub-Services + this._notificationManager = new ChatNotificationManager(_notificationService); + this._historyCompressor = new ChatHistoryCompressor( + _llmMessageService, + _convertToLLMMessagesService, + _settingsService + ); - dangerousSetState = (newState: ThreadsState) => { - this.state = newState - this._onDidChangeCurrentThread.fire() - } - resetState = () => { - this.state = { allThreads: {}, currentThreadId: null as unknown as string } // see constructor - this.openNewThread() - this._onDidChangeCurrentThread.fire() - } + this._toolOutputManager = new ChatToolOutputManager( + _fileService, + _workspaceContextService, + _settingsService + ); - // !!! this is important for properly restoring URIs from storage - // should probably re-use code from void/src/vs/base/common/marshalling.ts instead. but this is simple enough - private _convertThreadDataFromStorage(threadsStr: string): ChatThreads { - return JSON.parse(threadsStr, (key, value) => { - if (value && typeof value === 'object' && value.$mid === 1) { // $mid is the MarshalledId. $mid === 1 means it is a URI - return URI.from(value); // TODO URI.revive instead of this? - } - return value; - }); - } + this._checkpointManager = new ChatCheckpointManager(_editCodeService, _voidModelService); - private _readAllThreads(): ChatThreads | null { - const threadsStr = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION); - if (!threadsStr) { - return null - } - const threads = this._convertThreadDataFromStorage(threadsStr); + this._codespanManager = new ChatCodespanManager(_toolsService, _languageFeaturesService, _voidModelService); - return threads - } + this._acpHandler = new ChatAcpHandler( + _acpService, _workspaceContextService, _settingsService, _fileService, + _directoryStringService, _voidModelService, _editCodeService, this._logService, + this._historyCompressor, this._toolOutputManager, + ); - private _storeAllThreads(threads: ChatThreads) { - const serializedThreads = JSON.stringify(threads); - this._storageService.store( - THREAD_STORAGE_KEY, - serializedThreads, - StorageScope.APPLICATION, - StorageTarget.USER + this._executionEngine = new ChatExecutionEngine( + _llmMessageService, _toolsService, _settingsService, _lmToolsService, + _metricsService, _convertToLLMMessagesService, _fileService, _mcpService, + this._historyCompressor, this._toolOutputManager ); } + private _findLastToolMessageIndexById(threadId: string, toolCallId: string): number | null { + const t = this.state.allThreads[threadId]; + if (!t) return null; - // this should be the only place this.state = ... appears besides constructor - private _setState(state: Partial, doNotRefreshMountInfo?: boolean) { - const newState = { - ...this.state, - ...state + for (let i = t.messages.length - 1; i >= 0; i--) { + const m: any = t.messages[i] as any; + if (m && m.role === 'tool' && String(m.id ?? '') === String(toolCallId)) { + return i; + } } + return null; + } - this.state = newState + private _editToolMessageById(threadId: string, toolCallId: string, patch: Record): void { + const idx = this._findLastToolMessageIndexById(threadId, toolCallId); + if (idx === null) return; - this._onDidChangeCurrentThread.fire() + const t = this.state.allThreads[threadId]; + if (!t) return; + const prev: any = t.messages[idx] as any; + const next: any = { ...prev, ...patch }; + this._editMessageInThread(threadId, idx, next); + } - // if we just switched to a thread, update its current stream state if it's not streaming to possibly streaming - const threadId = newState.currentThreadId - const streamState = this.streamState[threadId] - if (streamState?.isRunning === undefined && !streamState?.error) { + // --- Public API --- - // set streamState - const messages = newState.allThreads[threadId]?.messages - const lastMessage = messages && messages[messages.length - 1] - // if awaiting user but stream state doesn't indicate it (happens if restart Void) - if (lastMessage && lastMessage.role === 'tool' && lastMessage.type === 'tool_request') - this._setStreamState(threadId, { isRunning: 'awaiting_user', }) + async addUserMessageAndStreamResponse({ userMessage, _chatSelections, attachments, threadId }: { userMessage: string, _chatSelections?: StagingSelectionItem[], attachments?: ChatAttachment[], threadId: string }) { + const thread = this.state.allThreads[threadId]; + if (!thread) return; - // if running now but stream state doesn't indicate it (happens if restart Void), cancel that last tool - if (lastMessage && lastMessage.role === 'tool' && lastMessage.type === 'running_now') { + if (thread.state.currCheckpointIdx !== null) { + const checkpointIdx = thread.state.currCheckpointIdx; + const newMessages = thread.messages.slice(0, checkpointIdx + 1); + const newThreads = { + ...this.state.allThreads, + [threadId]: { ...thread, lastModified: new Date().toISOString(), messages: newMessages } + }; + this._storeAllThreads(newThreads); + this._setState({ allThreads: newThreads }); + } - this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', content: lastMessage.content, id: lastMessage.id, rawParams: lastMessage.rawParams, result: null, name: lastMessage.name, params: lastMessage.params, mcpServerName: lastMessage.mcpServerName }) - } + if (this.streamState[threadId]?.isRunning) { + await this.abortRunning(threadId); + } + if (thread.messages.length === 0) { + this._checkpointManager.addUserCheckpoint(threadId, this._threadAccess); } + const currSelns = _chatSelections ?? thread.state.stagingSelections; + const userMessageContent = await chat_userMessageContent(userMessage, currSelns, { + directoryStrService: this._directoryStringService, + fileService: this._fileService, + voidModelService: this._voidModelService, + getRelativePath: (uri: URI) => this._labelService.getUriLabel(uri, { relative: true }) + }); - // if we did not just set the state to true, set mount info - if (doNotRefreshMountInfo) return + this._addMessageToThread(threadId, { + role: 'user', + content: userMessageContent, + displayContent: userMessage, + selections: currSelns, + attachments: attachments && attachments.length ? attachments : null, + state: { stagingSelections: [], isBeingEdited: false }, + }); - let whenMountedResolver: (w: WhenMounted) => void - const whenMountedPromise = new Promise((res) => whenMountedResolver = res) + this._setThreadState(threadId, { currCheckpointIdx: null }); - this._setThreadState(threadId, { - mountedInfo: { - whenMounted: whenMountedPromise, - mountedIsResolvedRef: { current: false }, - _whenMountedResolver: (w: WhenMounted) => { - whenMountedResolver(w) - const mountInfo = this.state.allThreads[threadId]?.state.mountedInfo - if (mountInfo) mountInfo.mountedIsResolvedRef.current = true - }, + try { + const { modelSelection } = this._currentModelSelectionProps(); + if (modelSelection) { + const caps = getModelCapabilities( + modelSelection.providerName as any, + modelSelection.modelName, + this._settingsService.state.overridesOfModel + ); + const reserved = caps.reservedOutputTokenSpace ?? 0; + const maxInputTokens = Math.max(0, caps.contextWindow - reserved); + this._setThreadState(threadId, { tokenUsageLastRequestLimits: { maxInputTokens } }); } - }, true) // do not trigger an update + } catch { } + + if (this._settingsService.state.globalSettings.useAcp === true) { + this._notificationManager.wrapRunAgentToNotify( + this._acpHandler.runAcp( + { + threadId, + userMessage, + _chatSelections: currSelns, + attachments + }, + this._threadAccess + ), + threadId, + () => this.state.currentThreadId, + () => this._getLastUserMessageContent(threadId), + (id: string) => this.switchToThread(id) + ); + } else { + this._notificationManager.wrapRunAgentToNotify( + this._executionEngine.runChatAgent({ threadId, ...this._currentModelSelectionProps() }, this._threadAccess), + threadId, + () => this.state.currentThreadId, + () => this._getLastUserMessageContent(threadId), + (id: string) => this.switchToThread(id) + ); + } + this.state.allThreads[threadId]?.state.mountedInfo?.whenMounted.then((m: any) => m.scrollToBottom()); + } + async abortRunning(threadId: string) { + const st = this.streamState[threadId]; + if (st?.isRunning === 'LLM' && st.llmInfo) { + this._addMessageToThread(threadId, { + role: 'assistant', + displayContent: st.llmInfo.displayContentSoFar, + reasoning: st.llmInfo.reasoningSoFar, + anthropicReasoning: null + }); + if (st.llmInfo.toolCallSoFar) { + this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: st.llmInfo.toolCallSoFar.name as any }); + } + } else if (st?.isRunning === 'tool' && st.toolInfo) { + const { toolName, toolParams, id, content } = st.toolInfo; + this._updateLatestTool(threadId, { + role: 'tool', name: toolName, params: toolParams, id, + content: content || 'Interrupted', displayContent: content || 'Interrupted', + type: 'rejected', result: null, rawParams: st.toolInfo.rawParams + }); + } - } + this._checkpointManager.addUserCheckpoint(threadId, this._threadAccess); + try { + const interrupt = await this.streamState[threadId]?.interrupt; + if (typeof interrupt === 'function') (interrupt as any)(); + } catch { } - private _setStreamState(threadId: string, state: ThreadStreamState[string]) { - this.streamState[threadId] = state - this._onDidChangeStreamState.fire({ threadId }) + this._acpHandler.clearAcpState(threadId); + this._setStreamState(threadId, undefined); } + approveLatestToolRequest(threadId: string) { + const thread = this.state.allThreads[threadId]; + const lastMsg = thread?.messages[thread.messages.length - 1]; + if (!(lastMsg?.role === 'tool' && lastMsg.type === 'tool_request')) return; + + this._onExternalToolDecision.fire({ threadId, toolCallId: lastMsg.id, decision: 'approved' }); + + if (this._settingsService.state.globalSettings.useAcp === true) { + this._updateLatestTool(threadId, { + role: 'tool', + type: 'running_now', + name: lastMsg.name as any, + params: (lastMsg as any).params, + content: 'running...', + displayContent: 'running...', + result: null, + id: lastMsg.id, + rawParams: lastMsg.rawParams + }); + + const prevAny: any = this.streamState[threadId] as any; + const prevInterrupt = prevAny?.interrupt; + const prevLlmInfo = prevAny?.llmInfo; + + this._setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { + displayContentSoFar: prevLlmInfo?.displayContentSoFar ?? '', + reasoningSoFar: prevLlmInfo?.reasoningSoFar ?? '', + toolCallSoFar: null, + planSoFar: prevLlmInfo?.planSoFar + }, + interrupt: (prevInterrupt && typeof prevInterrupt !== 'string') ? prevInterrupt : Promise.resolve(() => { }) + }); + return; + } - // ---------- streaming ---------- + // non-ACP unchanged + this._notificationManager.wrapRunAgentToNotify( + this._executionEngine.runChatAgent({ + threadId, + callThisToolFirst: lastMsg as any, + ...this._currentModelSelectionProps() + }, this._threadAccess), + threadId, + () => this.state.currentThreadId, + () => this._getLastUserMessageContent(threadId), + (id: string) => this.switchToThread(id) + ); + } + rejectLatestToolRequest(threadId: string) { + const thread = this.state.allThreads[threadId]; + const lastMsg = thread?.messages[thread.messages.length - 1]; + if (!lastMsg || lastMsg.role !== 'tool') return; + const params = (lastMsg as any).params; - private _currentModelSelectionProps = () => { - // these settings should not change throughout the loop (eg anthropic breaks if you change its thinking mode and it's using tools) - const featureName: FeatureName = 'Chat' - const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] - const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[featureName][modelSelection.providerName]?.[modelSelection.modelName] : undefined - return { modelSelection, modelSelectionOptions } + this._updateLatestTool(threadId, { + role: 'tool', type: 'rejected', params: params, name: lastMsg.name, + content: 'Tool call was rejected by the user.', displayContent: 'Tool call was rejected by the user.', + result: null, id: lastMsg.id, rawParams: lastMsg.rawParams + }); + this._setStreamState(threadId, undefined); + this._onExternalToolDecision.fire({ threadId, toolCallId: lastMsg.id, decision: 'rejected' }); } + skipLatestToolRequest(threadId: string) { + const thread = this.state.allThreads[threadId]; + const lastMsg: any = thread?.messages[thread.messages.length - 1]; + if (!lastMsg || lastMsg.role !== 'tool') return; + + // after Approve the tool becomes "running_now". + // In that case, "Skip" should behave like skipping a running tool. + if (lastMsg.type === 'running_now') { + this.skipRunningTool(threadId); + return; + } + + if (lastMsg.type !== 'tool_request') return; + const params = (lastMsg as any).params; - private _swapOutLatestStreamingToolWithResult = (threadId: string, tool: ChatMessage & { role: 'tool' }) => { - const messages = this.state.allThreads[threadId]?.messages - if (!messages) return false - const lastMsg = messages[messages.length - 1] - if (!lastMsg) return false + this._updateLatestTool(threadId, { + role: 'tool', + type: 'skipped', + name: lastMsg.name, + params, + id: lastMsg.id, + content: 'User skipped this tool.', + displayContent: 'User skipped this tool.', + result: null, + rawParams: lastMsg.rawParams + }); - if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') { - this._editMessageInThread(threadId, messages.length - 1, tool) - return true + // IMPORTANT: ACP permission resolution + this._onExternalToolDecision.fire({ threadId, toolCallId: lastMsg.id, decision: 'skipped' }); + + // ACP: do NOT cancel/clear ACP stream. builtin agent continues after permission resolution. + if (this._settingsService.state.globalSettings.useAcp === true) { + // Preserve interrupt if any + const prevAny: any = this.streamState[threadId] as any; + const prevInterrupt = prevAny?.interrupt; + const prevLlmInfo = prevAny?.llmInfo; + + this._setStreamState(threadId, { + isRunning: 'LLM', + llmInfo: { + displayContentSoFar: prevLlmInfo?.displayContentSoFar ?? '', + reasoningSoFar: prevLlmInfo?.reasoningSoFar ?? '', + toolCallSoFar: null, + planSoFar: prevLlmInfo?.planSoFar + }, + interrupt: (prevInterrupt && typeof prevInterrupt !== 'string') ? prevInterrupt : Promise.resolve(() => { }) + }); + return; } - return false - } - private _updateLatestTool = (threadId: string, tool: ChatMessage & { role: 'tool' }) => { - const swapped = this._swapOutLatestStreamingToolWithResult(threadId, tool) - if (swapped) return - this._addMessageToThread(threadId, tool) + + // non-ACP: old behavior + this._addMessageToThread(threadId, { + role: 'user', + content: `Skip ${lastMsg.name}. Continue with next steps.`, + displayContent: '', + selections: [], + state: { stagingSelections: [], isBeingEdited: false }, + hidden: true + }); + + this._notificationManager.wrapRunAgentToNotify( + this._executionEngine.runChatAgent({ threadId, ...this._currentModelSelectionProps() }, this._threadAccess), + threadId, + () => this.state.currentThreadId, + () => this._getLastUserMessageContent(threadId), + (id: string) => this.switchToThread(id) + ); } - approveLatestToolRequest(threadId: string) { - const thread = this.state.allThreads[threadId] - if (!thread) return // should never happen + skipRunningTool(threadId: string): void { + const stAny: any = this.streamState[threadId] as any; + const useAcp = this._settingsService.state.globalSettings.useAcp === true; + + // Determine toolCallId + best-effort metadata + let toolCallId = ''; + let toolName: any = undefined; + let toolParams: any = undefined; + let rawParams: any = undefined; + + // Case A: classic non-ACP "tool" stream state + if (stAny?.isRunning === 'tool' && stAny.toolInfo) { + toolCallId = String(stAny.toolInfo.id ?? ''); + toolName = stAny.toolInfo.toolName; + toolParams = stAny.toolInfo.toolParams; + rawParams = stAny.toolInfo.rawParams; + } + + // Case B: ACP often sits in isRunning === 'LLM' while a tool is running + if (!toolCallId && stAny?.isRunning === 'LLM' && stAny.llmInfo?.toolCallSoFar) { + toolCallId = String(stAny.llmInfo.toolCallSoFar.id ?? ''); + toolName = stAny.llmInfo.toolCallSoFar.name; + rawParams = stAny.llmInfo.toolCallSoFar.rawParams; + toolParams = rawParams; + } - const lastMsg = thread.messages[thread.messages.length - 1] - if (!(lastMsg.role === 'tool' && lastMsg.type === 'tool_request')) return // should never happen + // Case C: fallback by scanning last tool message with type "running_now" + if (!toolCallId) { + const t = this.state.allThreads[threadId]; + const lastTool = t?.messages?.slice()?.reverse()?.find((m: any) => m?.role === 'tool' && m?.type === 'running_now') as any; + if (lastTool) { + toolCallId = String(lastTool.id ?? ''); + toolName = lastTool.name; + toolParams = lastTool.params; + rawParams = lastTool.rawParams; + } + } - const callThisToolFirst: ToolMessage = lastMsg + if (!toolCallId) return; - this._wrapRunAgentToNotify( - this._runChatAgent({ callThisToolFirst, threadId, ...this._currentModelSelectionProps() }) - , threadId - ) - } - rejectLatestToolRequest(threadId: string) { - const thread = this.state.allThreads[threadId] - if (!thread) return // should never happen + // Mark skipped in engine (non-ACP uses this) + this._executionEngine.skippedToolCallIds.add(toolCallId); + + // Update the tool message even if it's not the latest one + this._editToolMessageById(threadId, toolCallId, { + type: 'skipped', + name: toolName, + params: toolParams, + rawParams, + content: 'Skipped', + displayContent: 'Skipped', + result: null + }); - const lastMsg = thread.messages[thread.messages.length - 1] + // ACP: do NOT fire permission decision here (permission already resolved). + // non-ACP: keep existing behavior (hidden user msg helps agent continue). + if (!useAcp) { + this._addMessageToThread(threadId, { + role: 'user', + content: `Skip ${toolName}. Continue with next steps.`, + displayContent: '', + selections: [], + state: { stagingSelections: [], isBeingEdited: false }, + hidden: true + }); + + // existing behavior (harmless if nobody listens) + this._onExternalToolDecision.fire({ threadId, toolCallId, decision: 'skipped' }); + } - let params: ToolCallParams - if (lastMsg.role === 'tool' && lastMsg.type !== 'invalid_params') { - params = lastMsg.params + // Best-effort interrupt/cancel + const interruptPromise = stAny?.interrupt; + if (interruptPromise && typeof interruptPromise !== 'string') { + interruptPromise.then((fn: any) => { + if (typeof fn === 'function') fn(); + }).catch(() => { }); } - else return + } - const { name, id, rawParams, mcpServerName } = lastMsg + // --- State & CRUD --- - const errorMessage = this.toolErrMsgs.rejected - this._updateLatestTool(threadId, { role: 'tool', type: 'rejected', params: params, name: name, content: errorMessage, result: null, id, rawParams, mcpServerName }) - this._setStreamState(threadId, undefined) + getCurrentThread(): ThreadType { + const thread = this.state.allThreads[this.state.currentThreadId]; + if (!thread) throw new Error(`Current thread should never be undefined`); + return thread; } - private _computeMCPServerOfToolName = (toolName: string) => { - return this._mcpService.getMCPTools()?.find(t => t.name === toolName)?.mcpServerName + switchToThread(threadId: string) { + this._setState({ currentThreadId: threadId }); } - async abortRunning(threadId: string) { - const thread = this.state.allThreads[threadId] - if (!thread) return // should never happen - - // add assistant message - if (this.streamState[threadId]?.isRunning === 'LLM') { - const { displayContentSoFar, reasoningSoFar, toolCallSoFar } = this.streamState[threadId].llmInfo - this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) - if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name, mcpServerName: this._computeMCPServerOfToolName(toolCallSoFar.name) }) - } - // add tool that's running - else if (this.streamState[threadId]?.isRunning === 'tool') { - const { toolName, toolParams, id, content: content_, rawParams, mcpServerName } = this.streamState[threadId].toolInfo - const content = content_ || this.toolErrMsgs.interrupted - this._updateLatestTool(threadId, { role: 'tool', name: toolName, params: toolParams, id, content, rawParams, type: 'rejected', result: null, mcpServerName }) - } - // reject the tool for the user if relevant - else if (this.streamState[threadId]?.isRunning === 'awaiting_user') { - this.rejectLatestToolRequest(threadId) - } - else if (this.streamState[threadId]?.isRunning === 'idle') { - // do nothing + openNewThread() { + for (const tid in this.state.allThreads) { + if (this.state.allThreads[tid]!.messages.length === 0) { + this.switchToThread(tid); + return; + } } + const newThread = newThreadObject(); + const newThreads = { ...this.state.allThreads, [newThread.id]: newThread }; + this._storeAllThreads(newThreads); + this._setState({ allThreads: newThreads, currentThreadId: newThread.id }); + } - this._addUserCheckpoint({ threadId }) + deleteThread(threadId: string): void { + const newThreads = { ...this.state.allThreads }; + delete newThreads[threadId]; + this._storeAllThreads(newThreads); + this._setState({ ...this.state, allThreads: newThreads }); + } - // interrupt any effects - const interrupt = await this.streamState[threadId]?.interrupt - if (typeof interrupt === 'function') - interrupt() + duplicateThread(threadId: string) { + const thread = this.state.allThreads[threadId]; + if (!thread) return; + const firstUser = thread.messages.find(m => m.role === 'user'); + if (!firstUser) { + this.openNewThread(); + return; + } - this._setStreamState(threadId, undefined) + const clonedMsg = deepClone(firstUser); + const newThread = { ...newThreadObject(), id: generateUuid(), messages: [clonedMsg] }; + const newThreads = { ...this.state.allThreads, [newThread.id]: newThread }; + this._storeAllThreads(newThreads); + this._setState({ allThreads: newThreads }); } + enqueueToolRequestFromAcp(threadId: string, req: { id: string; name: AnyToolName | string; rawParams: Record; params?: Record }): void { + this._acpHandler.enqueueToolRequestFromAcp(threadId, req, this._threadAccess); + } + // --- Helpers --- - private readonly toolErrMsgs = { - rejected: 'Tool call was rejected by the user.', - interrupted: 'Tool call was interrupted by the user.', - errWhenStringifying: (error: any) => `Tool call succeeded, but there was an error stringifying the output.\n${getErrorMessage(error)}` + jumpToCheckpointBeforeMessageIdx(opts: { threadId: string, messageIdx: number, jumpToUserModified: boolean }) { + this._checkpointManager.jumpToCheckpointBeforeMessageIdx(opts, this._threadAccess); } + generateCodespanLink(opts: { codespanStr: string, threadId: string }): Promise { + return this._codespanManager.generateCodespanLink(opts, () => this.state.allThreads[opts.threadId]?.messages || []) as any; + } - // private readonly _currentlyRunningToolInterruptor: { [threadId: string]: (() => void) | undefined } = {} + getCodespanLink({ codespanStr, messageIdx, threadId }: { codespanStr: string, messageIdx: number, threadId: string }) { + return this.state.allThreads[threadId]?.state.linksOfMessageIdx?.[messageIdx]?.[codespanStr]; + } + addCodespanLink({ newLinkText, newLinkLocation, messageIdx, threadId }: { newLinkText: string, newLinkLocation: CodespanLocationLink, messageIdx: number, threadId: string }) { + const thread = this.state.allThreads[threadId]; + if (!thread) return; + this._setThreadState(threadId, { + linksOfMessageIdx: { + ...thread.state.linksOfMessageIdx, + [messageIdx]: { ...thread.state.linksOfMessageIdx?.[messageIdx], [newLinkText]: newLinkLocation } + } + }); + } - // returns true when the tool call is waiting for user approval - private _runToolCall = async ( - threadId: string, - toolName: ToolName, - toolId: string, - mcpServerName: string | undefined, - opts: { preapproved: true, unvalidatedToolParams: RawToolParamsObj, validatedParams: ToolCallParams } | { preapproved: false, unvalidatedToolParams: RawToolParamsObj }, - ): Promise<{ awaitingUserApproval?: boolean, interrupted?: boolean }> => { + getRelativeStr(uri: URI) { + const folders = this._workspaceContextService.getWorkspace().folders.map(f => f.uri); + return normalizeSelectionRelativePath(uri, folders); + } - // compute these below - let toolParams: ToolCallParams - let toolResult: ToolResult - let toolResultStr: string + async focusCurrentChat() { + const t = this.getCurrentThread(); + const s = await t.state.mountedInfo?.whenMounted; + if (!this.isCurrentlyFocusingMessage()) s?.textAreaRef.current?.focus(); + } + async blurCurrentChat() { + const t = this.getCurrentThread(); + const s = await t.state.mountedInfo?.whenMounted; + if (!this.isCurrentlyFocusingMessage()) s?.textAreaRef.current?.blur(); + } + getCurrentFocusedMessageIdx() { + const t = this.getCurrentThread(); + if (t.state.focusedMessageIdx === undefined) return; + const m = t.messages[t.state.focusedMessageIdx]; + // FIX: safe check for role + if (m.role !== 'user' || !(m as any).state) return; + return t.state.focusedMessageIdx; + } + isCurrentlyFocusingMessage() { return this.getCurrentFocusedMessageIdx() !== undefined; } + setCurrentlyFocusedMessageIdx(idx: number | undefined) { + this._setThreadState(this.state.currentThreadId, { focusedMessageIdx: idx }); + } - // Check if it's a built-in tool - const isBuiltInTool = isABuiltinToolName(toolName) + addNewStagingSelection(newSelection: StagingSelectionItem) { + const focusedIdx = this.getCurrentFocusedMessageIdx(); + let selections: StagingSelectionItem[] = []; + let setSelections = (s: StagingSelectionItem[]) => { }; + if (focusedIdx === undefined) { + selections = this.getCurrentThreadState().stagingSelections; + setSelections = (s) => this.setCurrentThreadState({ stagingSelections: s }); + } else { + selections = this.getCurrentMessageState(focusedIdx).stagingSelections; + setSelections = (s) => this.setCurrentMessageState(focusedIdx, { stagingSelections: s }); + } - if (!opts.preapproved) { // skip this if pre-approved - // 1. validate tool params - try { - if (isBuiltInTool) { - const params = this._toolsService.validateParams[toolName](opts.unvalidatedToolParams) - toolParams = params - } - else { - toolParams = opts.unvalidatedToolParams - } + const findIndex = (arr: any[], item: any) => { + for (let i = 0; i < arr.length; i++) { + if (arr[i].uri.fsPath === item.uri.fsPath && arr[i].type === item.type) return i; } - catch (error) { - const errorMessage = getErrorMessage(error) - this._addMessageToThread(threadId, { role: 'tool', type: 'invalid_params', rawParams: opts.unvalidatedToolParams, result: null, name: toolName, content: errorMessage, id: toolId, mcpServerName }) - return {} - } - // once validated, add checkpoint for edit - if (toolName === 'edit_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as BuiltinToolCallParams['edit_file']).uri }) } - if (toolName === 'rewrite_file') { this._addToolEditCheckpoint({ threadId, uri: (toolParams as BuiltinToolCallParams['rewrite_file']).uri }) } - - // 2. if tool requires approval, break from the loop, awaiting approval - - const approvalType = isBuiltInTool ? approvalTypeOfBuiltinToolName[toolName] : 'MCP tools' - if (approvalType) { - const autoApprove = this._settingsService.state.globalSettings.autoApprove[approvalType] - // add a tool_request because we use it for UI if a tool is loading (this should be improved in the future) - this._addMessageToThread(threadId, { role: 'tool', type: 'tool_request', content: '(Awaiting user permission...)', result: null, name: toolName, params: toolParams, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName }) - if (!autoApprove) { - return { awaitingUserApproval: true } - } - } - } - else { - toolParams = opts.validatedParams + return -1; + }; + const idx = findIndex(selections, newSelection); + if (idx !== -1) { + setSelections([...selections.slice(0, idx), newSelection, ...selections.slice(idx + 1)]); + } else { + setSelections([...selections, newSelection]); } + } + popStagingSelections(numPops: number = 1) { + const focusedIdx = this.getCurrentFocusedMessageIdx(); + let selections: StagingSelectionItem[] = []; + let setSelections = (s: StagingSelectionItem[]) => { }; + if (focusedIdx === undefined) { + selections = this.getCurrentThreadState().stagingSelections; + setSelections = (s) => this.setCurrentThreadState({ stagingSelections: s }); + } else { + selections = this.getCurrentMessageState(focusedIdx).stagingSelections; + setSelections = (s) => this.setCurrentMessageState(focusedIdx, { stagingSelections: s }); + } + setSelections(selections.slice(0, Math.max(0, selections.length - numPops))); + } + // FIX: safe access to state + getCurrentMessageState(idx: number) { + const m = this.getCurrentThread()?.messages?.[idx]; + if (m && m.role === 'user') return m.state; + return { stagingSelections: [], isBeingEdited: false } as any; + } + setCurrentMessageState(idx: number, newState: any) { this._setCurrentMessageState(newState, idx); } + getCurrentThreadState() { return this.getCurrentThread().state; } + setCurrentThreadState(newState: any) { this._setThreadState(this.state.currentThreadId, newState); } + editUserMessageAndStreamResponse: IChatThreadService['editUserMessageAndStreamResponse'] = async ({ userMessage, messageIdx, threadId }) => { + const thread = this.state.allThreads[threadId]; + if (!thread || thread.messages[messageIdx].role !== 'user') return; + const currSelns = thread.messages[messageIdx].state.stagingSelections || []; + const prevAttachments = thread.messages[messageIdx].attachments ?? undefined; + this._setState({ allThreads: { ...this.state.allThreads, [threadId]: { ...thread, messages: thread.messages.slice(0, messageIdx) } } }); + this.addUserMessageAndStreamResponse({ userMessage, _chatSelections: currSelns, attachments: prevAttachments, threadId }); + } + dismissStreamError(threadId: string) { this._setStreamState(threadId, undefined); } + dangerousSetState = (newState: ThreadsState) => { this.state = newState; this._onDidChangeCurrentThread.fire(); } + resetState = () => { this.state = { allThreads: {}, currentThreadId: null as unknown as string }; this.openNewThread(); this._onDidChangeCurrentThread.fire(); } - // 3. call the tool - // this._setStreamState(threadId, { isRunning: 'tool' }, 'merge') - const runningTool = { role: 'tool', type: 'running_now', name: toolName, params: toolParams, content: '(value not received yet...)', result: null, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName } as const - this._updateLatestTool(threadId, runningTool) - + // --- Private --- - let interrupted = false - let resolveInterruptor: (r: () => void) => void = () => { } - const interruptorPromise = new Promise<() => void>(res => { resolveInterruptor = res }) - try { + private _getLastUserMessageContent(threadId: string) { + const m = this.state.allThreads[threadId]?.messages; + if (!m) return undefined; + for (let i = m.length - 1; i >= 0; i--) { + if (m[i].role === 'user') return (m[i] as any).displayContent; + } + return undefined; + } - // set stream state - this._setStreamState(threadId, { isRunning: 'tool', interrupt: interruptorPromise, toolInfo: { toolName, toolParams, id: toolId, content: 'interrupted...', rawParams: opts.unvalidatedToolParams, mcpServerName } }) + private _setState(state: Partial, doNotRefreshMountInfo?: boolean) { + const newState = { ...this.state, ...state }; + this.state = newState; + this._onDidChangeCurrentThread.fire(); + + const tid = newState.currentThreadId; + const st = this.streamState[tid]; + if (st?.isRunning === undefined && !st?.error) { + const msgs = newState.allThreads[tid]?.messages; + const last = msgs?.[msgs.length - 1]; + if (last?.role === 'tool' && last.type === 'tool_request') this._setStreamState(tid, { isRunning: 'awaiting_user' }); + } - if (isBuiltInTool) { - const { result, interruptTool } = await this._toolsService.callTool[toolName](toolParams as any) - const interruptor = () => { interrupted = true; interruptTool?.() } - resolveInterruptor(interruptor) + if (doNotRefreshMountInfo) return; - toolResult = await result - } - else { - const mcpTools = this._mcpService.getMCPTools() - const mcpTool = mcpTools?.find(t => t.name === toolName) - if (!mcpTool) { throw new Error(`MCP tool ${toolName} not found`) } - - resolveInterruptor(() => { }) - - toolResult = (await this._mcpService.callMCPTool({ - serverName: mcpTool.mcpServerName ?? 'unknown_mcp_server', - toolName: toolName, - params: toolParams - })).result + let resolver: any; + const p = new Promise(r => resolver = r); + this._setThreadState(tid, { + mountedInfo: { + whenMounted: p, mountedIsResolvedRef: { current: false }, + _whenMountedResolver: (w: any) => { resolver(w); const m = this.state.allThreads[tid]?.state.mountedInfo; if (m) m.mountedIsResolvedRef.current = true; } } + }, true); + } - if (interrupted) { return { interrupted: true } } // the tool result is added where we interrupt, not here - } - catch (error) { - resolveInterruptor(() => { }) // resolve for the sake of it - if (interrupted) { return { interrupted: true } } // the tool result is added where we interrupt, not here - - const errorMessage = getErrorMessage(error) - this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName }) - return {} - } - - // 4. stringify the result to give to the LLM - try { - if (isBuiltInTool) { - toolResultStr = this._toolsService.stringOfResult[toolName](toolParams as any, toolResult as any) - } - // For MCP tools, handle the result based on its type - else { - toolResultStr = this._mcpService.stringifyResult(toolResult as RawMCPToolCall) - } - } catch (error) { - const errorMessage = this.toolErrMsgs.errWhenStringifying(error) - this._updateLatestTool(threadId, { role: 'tool', type: 'tool_error', params: toolParams, result: errorMessage, name: toolName, content: errorMessage, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName }) - return {} - } - - // 5. add to history and keep going - this._updateLatestTool(threadId, { role: 'tool', type: 'success', params: toolParams, result: toolResult, name: toolName, content: toolResultStr, id: toolId, rawParams: opts.unvalidatedToolParams, mcpServerName }) - return {} - }; - - - - - private async _runChatAgent({ - threadId, - modelSelection, - modelSelectionOptions, - callThisToolFirst, - }: { - threadId: string, - modelSelection: ModelSelection | null, - modelSelectionOptions: ModelSelectionOptions | undefined, - - callThisToolFirst?: ToolMessage & { type: 'tool_request' } - }) { - - - let interruptedWhenIdle = false - const idleInterruptor = Promise.resolve(() => { interruptedWhenIdle = true }) - // _runToolCall does not need setStreamState({idle}) before it, but it needs it after it. (handles its own setStreamState) - - // above just defines helpers, below starts the actual function - const { chatMode } = this._settingsService.state.globalSettings // should not change as we loop even if user changes it, so it goes here - const { overridesOfModel } = this._settingsService.state - - let nMessagesSent = 0 - let shouldSendAnotherMessage = true - let isRunningWhenEnd: IsRunningType = undefined - - // before enter loop, call tool - if (callThisToolFirst) { - const { interrupted } = await this._runToolCall(threadId, callThisToolFirst.name, callThisToolFirst.id, callThisToolFirst.mcpServerName, { preapproved: true, unvalidatedToolParams: callThisToolFirst.rawParams, validatedParams: callThisToolFirst.params }) - if (interrupted) { - this._setStreamState(threadId, undefined) - this._addUserCheckpoint({ threadId }) - - } - } - this._setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }) // just decorative, for clarity - - - // tool use loop - while (shouldSendAnotherMessage) { - // false by default each iteration - shouldSendAnotherMessage = false - isRunningWhenEnd = undefined - nMessagesSent += 1 - - this._setStreamState(threadId, { isRunning: 'idle', interrupt: idleInterruptor }) - - const chatMessages = this.state.allThreads[threadId]?.messages ?? [] - const { messages, separateSystemMessage } = await this._convertToLLMMessagesService.prepareLLMChatMessages({ - chatMessages, - modelSelection, - chatMode - }) - - if (interruptedWhenIdle) { - this._setStreamState(threadId, undefined) - return - } - - let shouldRetryLLM = true - let nAttempts = 0 - while (shouldRetryLLM) { - shouldRetryLLM = false - nAttempts += 1 - - type ResTypes = - | { type: 'llmDone', toolCall?: RawToolCallObj, info: { fullText: string, fullReasoning: string, anthropicReasoning: AnthropicReasoning[] | null } } - | { type: 'llmError', error?: { message: string; fullError: Error | null; } } - | { type: 'llmAborted' } - - let resMessageIsDonePromise: (res: ResTypes) => void // resolves when user approves this tool use (or if tool doesn't require approval) - const messageIsDonePromise = new Promise((res, rej) => { resMessageIsDonePromise = res }) - - const llmCancelToken = this._llmMessageService.sendLLMMessage({ - messagesType: 'chatMessages', - chatMode, - messages: messages, - modelSelection, - modelSelectionOptions, - overridesOfModel, - logging: { loggingName: `Chat - ${chatMode}`, loggingExtras: { threadId, nMessagesSent, chatMode } }, - separateSystemMessage: separateSystemMessage, - onText: ({ fullText, fullReasoning, toolCall }) => { - this._setStreamState(threadId, { isRunning: 'LLM', llmInfo: { displayContentSoFar: fullText, reasoningSoFar: fullReasoning, toolCallSoFar: toolCall ?? null }, interrupt: Promise.resolve(() => { if (llmCancelToken) this._llmMessageService.abort(llmCancelToken) }) }) - }, - onFinalMessage: async ({ fullText, fullReasoning, toolCall, anthropicReasoning, }) => { - resMessageIsDonePromise({ type: 'llmDone', toolCall, info: { fullText, fullReasoning, anthropicReasoning } }) // resolve with tool calls - }, - onError: async (error) => { - resMessageIsDonePromise({ type: 'llmError', error: error }) - }, - onAbort: () => { - // stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it) - resMessageIsDonePromise({ type: 'llmAborted' }) - this._metricsService.capture('Agent Loop Done (Aborted)', { nMessagesSent, chatMode }) - }, - }) - - // mark as streaming - if (!llmCancelToken) { - this._setStreamState(threadId, { isRunning: undefined, error: { message: 'There was an unexpected error when sending your chat message.', fullError: null } }) - break - } - - this._setStreamState(threadId, { isRunning: 'LLM', llmInfo: { displayContentSoFar: '', reasoningSoFar: '', toolCallSoFar: null }, interrupt: Promise.resolve(() => this._llmMessageService.abort(llmCancelToken)) }) - const llmRes = await messageIsDonePromise // wait for message to complete - - // if something else started running in the meantime - if (this.streamState[threadId]?.isRunning !== 'LLM') { - // console.log('Chat thread interrupted by a newer chat thread', this.streamState[threadId]?.isRunning) - return - } - - // llm res aborted - if (llmRes.type === 'llmAborted') { - this._setStreamState(threadId, undefined) - return - } - // llm res error - else if (llmRes.type === 'llmError') { - // error, should retry - if (nAttempts < CHAT_RETRIES) { - shouldRetryLLM = true - this._setStreamState(threadId, { isRunning: 'idle', interrupt: idleInterruptor }) - await timeout(RETRY_DELAY) - if (interruptedWhenIdle) { - this._setStreamState(threadId, undefined) - return - } - else - continue // retry - } - // error, but too many attempts - else { - const { error } = llmRes - const { displayContentSoFar, reasoningSoFar, toolCallSoFar } = this.streamState[threadId].llmInfo - this._addMessageToThread(threadId, { role: 'assistant', displayContent: displayContentSoFar, reasoning: reasoningSoFar, anthropicReasoning: null }) - if (toolCallSoFar) this._addMessageToThread(threadId, { role: 'interrupted_streaming_tool', name: toolCallSoFar.name, mcpServerName: this._computeMCPServerOfToolName(toolCallSoFar.name) }) - - this._setStreamState(threadId, { isRunning: undefined, error }) - this._addUserCheckpoint({ threadId }) - return - } - } - - // llm res success - const { toolCall, info } = llmRes - - this._addMessageToThread(threadId, { role: 'assistant', displayContent: info.fullText, reasoning: info.fullReasoning, anthropicReasoning: info.anthropicReasoning }) - - this._setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }) // just decorative for clarity - - // call tool if there is one - if (toolCall) { - const mcpTools = this._mcpService.getMCPTools() - const mcpTool = mcpTools?.find(t => t.name === toolCall.name) - - const { awaitingUserApproval, interrupted } = await this._runToolCall(threadId, toolCall.name, toolCall.id, mcpTool?.mcpServerName, { preapproved: false, unvalidatedToolParams: toolCall.rawParams }) - if (interrupted) { - this._setStreamState(threadId, undefined) - return - } - if (awaitingUserApproval) { isRunningWhenEnd = 'awaiting_user' } - else { shouldSendAnotherMessage = true } - - this._setStreamState(threadId, { isRunning: 'idle', interrupt: 'not_needed' }) // just decorative, for clarity - } - - } // end while (attempts) - } // end while (send message) - - // if awaiting user approval, keep isRunning true, else end isRunning - this._setStreamState(threadId, { isRunning: isRunningWhenEnd }) - - // add checkpoint before the next user message - if (!isRunningWhenEnd) this._addUserCheckpoint({ threadId }) - - // capture number of messages sent - this._metricsService.capture('Agent Loop Done', { nMessagesSent, chatMode }) - } - - - private _addCheckpoint(threadId: string, checkpoint: CheckpointEntry) { - this._addMessageToThread(threadId, checkpoint) - // // update latest checkpoint idx to the one we just added - // const newThread = this.state.allThreads[threadId] - // if (!newThread) return // should never happen - // const currCheckpointIdx = newThread.messages.length - 1 - // this._setThreadState(threadId, { currCheckpointIdx: currCheckpointIdx }) - } - - - - private _editMessageInThread(threadId: string, messageIdx: number, newMessage: ChatMessage,) { - const { allThreads } = this.state - const oldThread = allThreads[threadId] - if (!oldThread) return // should never happen - // update state and store it - const newThreads = { - ...allThreads, - [oldThread.id]: { - ...oldThread, - lastModified: new Date().toISOString(), - messages: [ - ...oldThread.messages.slice(0, messageIdx), - newMessage, - ...oldThread.messages.slice(messageIdx + 1, Infinity), - ], - } - } - this._storeAllThreads(newThreads) - this._setState({ allThreads: newThreads }) // the current thread just changed (it had a message added to it) - } - - - private _getCheckpointInfo = (checkpointMessage: ChatMessage & { role: 'checkpoint' }, fsPath: string, opts: { includeUserModifiedChanges: boolean }) => { - const voidFileSnapshot = checkpointMessage.voidFileSnapshotOfURI ? checkpointMessage.voidFileSnapshotOfURI[fsPath] ?? null : null - if (!opts.includeUserModifiedChanges) { return { voidFileSnapshot, } } - - const userModifiedVoidFileSnapshot = fsPath in checkpointMessage.userModifications.voidFileSnapshotOfURI ? checkpointMessage.userModifications.voidFileSnapshotOfURI[fsPath] ?? null : null - return { voidFileSnapshot: userModifiedVoidFileSnapshot ?? voidFileSnapshot, } - } - - private _computeNewCheckpointInfo({ threadId }: { threadId: string }) { - const thread = this.state.allThreads[threadId] - if (!thread) return - - const lastCheckpointIdx = findLastIdx(thread.messages, (m) => m.role === 'checkpoint') ?? -1 - if (lastCheckpointIdx === -1) return - - const voidFileSnapshotOfURI: { [fsPath: string]: VoidFileSnapshot | undefined } = {} - - // add a change for all the URIs in the checkpoint history - const { lastIdxOfURI } = this._getCheckpointsBetween({ threadId, loIdx: 0, hiIdx: lastCheckpointIdx, }) ?? {} - for (const fsPath in lastIdxOfURI ?? {}) { - const { model } = this._voidModelService.getModelFromFsPath(fsPath) - if (!model) continue - const checkpoint2 = thread.messages[lastIdxOfURI[fsPath]] || null - if (!checkpoint2) continue - if (checkpoint2.role !== 'checkpoint') continue - const res = this._getCheckpointInfo(checkpoint2, fsPath, { includeUserModifiedChanges: false }) - if (!res) continue - const { voidFileSnapshot: oldVoidFileSnapshot } = res - - // if there was any change to the str or diffAreaSnapshot, update. rough approximation of equality, oldDiffAreasSnapshot === diffAreasSnapshot is not perfect - const voidFileSnapshot = this._editCodeService.getVoidFileSnapshot(URI.file(fsPath)) - if (oldVoidFileSnapshot === voidFileSnapshot) continue - voidFileSnapshotOfURI[fsPath] = voidFileSnapshot - } - - // // add a change for all user-edited files (that aren't in the history) - // for (const fsPath of this._userModifiedFilesToCheckInCheckpoints.keys()) { - // if (fsPath in lastIdxOfURI) continue // if already visisted, don't visit again - // const { model } = this._voidModelService.getModelFromFsPath(fsPath) - // if (!model) continue - // currStrOfFsPath[fsPath] = model.getValue(EndOfLinePreference.LF) - // } - - return { voidFileSnapshotOfURI } - } - - - private _addUserCheckpoint({ threadId }: { threadId: string }) { - const { voidFileSnapshotOfURI } = this._computeNewCheckpointInfo({ threadId }) ?? {} - this._addCheckpoint(threadId, { - role: 'checkpoint', - type: 'user_edit', - voidFileSnapshotOfURI: voidFileSnapshotOfURI ?? {}, - userModifications: { voidFileSnapshotOfURI: {}, }, - }) - } - // call this right after LLM edits a file - private _addToolEditCheckpoint({ threadId, uri, }: { threadId: string, uri: URI }) { - const thread = this.state.allThreads[threadId] - if (!thread) return - const { model } = this._voidModelService.getModel(uri) - if (!model) return // should never happen - const diffAreasSnapshot = this._editCodeService.getVoidFileSnapshot(uri) - this._addCheckpoint(threadId, { - role: 'checkpoint', - type: 'tool_edit', - voidFileSnapshotOfURI: { [uri.fsPath]: diffAreasSnapshot }, - userModifications: { voidFileSnapshotOfURI: {} }, - }) - } - - - private _getCheckpointBeforeMessage = ({ threadId, messageIdx }: { threadId: string, messageIdx: number }): [CheckpointEntry, number] | undefined => { - const thread = this.state.allThreads[threadId] - if (!thread) return undefined - for (let i = messageIdx; i >= 0; i--) { - const message = thread.messages[i] - if (message.role === 'checkpoint') { - return [message, i] - } - } - return undefined - } - - private _getCheckpointsBetween({ threadId, loIdx, hiIdx }: { threadId: string, loIdx: number, hiIdx: number }) { - const thread = this.state.allThreads[threadId] - if (!thread) return { lastIdxOfURI: {} } // should never happen - const lastIdxOfURI: { [fsPath: string]: number } = {} - for (let i = loIdx; i <= hiIdx; i += 1) { - const message = thread.messages[i] - if (message?.role !== 'checkpoint') continue - for (const fsPath in message.voidFileSnapshotOfURI) { // do not include userModified.beforeStrOfURI here, jumping should not include those changes - lastIdxOfURI[fsPath] = i - } - } - return { lastIdxOfURI } - } - - private _readCurrentCheckpoint(threadId: string): [CheckpointEntry, number] | undefined { - const thread = this.state.allThreads[threadId] - if (!thread) return - - const { currCheckpointIdx } = thread.state - if (currCheckpointIdx === null) return - - const checkpoint = thread.messages[currCheckpointIdx] - if (!checkpoint) return - if (checkpoint.role !== 'checkpoint') return - return [checkpoint, currCheckpointIdx] - } - private _addUserModificationsToCurrCheckpoint({ threadId }: { threadId: string }) { - const { voidFileSnapshotOfURI } = this._computeNewCheckpointInfo({ threadId }) ?? {} - const res = this._readCurrentCheckpoint(threadId) - if (!res) return - const [checkpoint, checkpointIdx] = res - this._editMessageInThread(threadId, checkpointIdx, { - ...checkpoint, - userModifications: { voidFileSnapshotOfURI: voidFileSnapshotOfURI ?? {}, }, - }) - } - - - private _makeUsStandOnCheckpoint({ threadId }: { threadId: string }) { - const thread = this.state.allThreads[threadId] - if (!thread) return - if (thread.state.currCheckpointIdx === null) { - const lastMsg = thread.messages[thread.messages.length - 1] - if (lastMsg?.role !== 'checkpoint') - this._addUserCheckpoint({ threadId }) - this._setThreadState(threadId, { currCheckpointIdx: thread.messages.length - 1 }) - } - } - - jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified }: { threadId: string, messageIdx: number, jumpToUserModified: boolean }) { - - // if null, add a new temp checkpoint so user can jump forward again - this._makeUsStandOnCheckpoint({ threadId }) - - const thread = this.state.allThreads[threadId] - if (!thread) return - if (this.streamState[threadId]?.isRunning) return - - const c = this._getCheckpointBeforeMessage({ threadId, messageIdx }) - if (c === undefined) return // should never happen - - const fromIdx = thread.state.currCheckpointIdx - if (fromIdx === null) return // should never happen - - const [_, toIdx] = c - if (toIdx === fromIdx) return - - // console.log(`going from ${fromIdx} to ${toIdx}`) - - // update the user's checkpoint - this._addUserModificationsToCurrCheckpoint({ threadId }) - - /* -if undoing - -A,B,C are all files. -x means a checkpoint where the file changed. - -A B C D E F G H I - x x x x x x <-- you can't always go up to find the "before" version; sometimes you need to go down - | | | | | | x ---x-|-|-|-x---x-|----- <-- to - | | | | x x - | | x x | - | | | | -----x-|---x-x------- <-- from - x - -We need to revert anything that happened between to+1 and from. -**We do this by finding the last x from 0...`to` for each file and applying those contents.** -We only need to do it for files that were edited since `to`, ie files between to+1...from. -*/ - if (toIdx < fromIdx) { - const { lastIdxOfURI } = this._getCheckpointsBetween({ threadId, loIdx: toIdx + 1, hiIdx: fromIdx }) - - const idxes = function* () { - for (let k = toIdx; k >= 0; k -= 1) { // first go up - yield k - } - for (let k = toIdx + 1; k < thread.messages.length; k += 1) { // then go down - yield k - } - } - - for (const fsPath in lastIdxOfURI) { - // find the first instance of this file starting at toIdx (go up to latest file; if there is none, go down) - for (const k of idxes()) { - const message = thread.messages[k] - if (message.role !== 'checkpoint') continue - const res = this._getCheckpointInfo(message, fsPath, { includeUserModifiedChanges: jumpToUserModified }) - if (!res) continue - const { voidFileSnapshot } = res - if (!voidFileSnapshot) continue - this._editCodeService.restoreVoidFileSnapshot(URI.file(fsPath), voidFileSnapshot) - break - } - } - } - - /* -if redoing - -A B C D E F G H I J - x x x x x x x - | | | | | | x x x ---x-|-|-|-x---x-|-|--- <-- from - | | | | x x - | | x x | - | | | | -----x-|---x-x-----|--- <-- to - x x - - -We need to apply latest change for anything that happened between from+1 and to. -We only need to do it for files that were edited since `from`, ie files between from+1...to. -*/ - if (toIdx > fromIdx) { - const { lastIdxOfURI } = this._getCheckpointsBetween({ threadId, loIdx: fromIdx + 1, hiIdx: toIdx }) - for (const fsPath in lastIdxOfURI) { - // apply lowest down content for each uri - for (let k = toIdx; k >= fromIdx + 1; k -= 1) { - const message = thread.messages[k] - if (message.role !== 'checkpoint') continue - const res = this._getCheckpointInfo(message, fsPath, { includeUserModifiedChanges: jumpToUserModified }) - if (!res) continue - const { voidFileSnapshot } = res - if (!voidFileSnapshot) continue - this._editCodeService.restoreVoidFileSnapshot(URI.file(fsPath), voidFileSnapshot) - break - } - } - } - - this._setThreadState(threadId, { currCheckpointIdx: toIdx }) - } - - - private _wrapRunAgentToNotify(p: Promise, threadId: string) { - const notify = ({ error }: { error: string | null }) => { - const thread = this.state.allThreads[threadId] - if (!thread) return - const userMsg = findLast(thread.messages, m => m.role === 'user') - if (!userMsg) return - if (userMsg.role !== 'user') return - const messageContent = truncate(userMsg.displayContent, 50, '...') - - this._notificationService.notify({ - severity: error ? Severity.Warning : Severity.Info, - message: error ? `Error: ${error} ` : `A new Chat result is ready.`, - source: messageContent, - sticky: true, - actions: { - primary: [{ - id: 'void.goToChat', - enabled: true, - label: `Jump to Chat`, - tooltip: '', - class: undefined, - run: () => { - this.switchToThread(threadId) - // scroll to bottom - this.state.allThreads[threadId]?.state.mountedInfo?.whenMounted.then(m => { - m.scrollToBottom() - }) - } - }] - }, - }) - } - - p.then(() => { - if (threadId !== this.state.currentThreadId) notify({ error: null }) - }).catch((e) => { - if (threadId !== this.state.currentThreadId) notify({ error: getErrorMessage(e) }) - throw e - }) - } - - dismissStreamError(threadId: string): void { - this._setStreamState(threadId, undefined) - } - - - private async _addUserMessageAndStreamResponse({ userMessage, _chatSelections, threadId }: { userMessage: string, _chatSelections?: StagingSelectionItem[], threadId: string }) { - const thread = this.state.allThreads[threadId] - if (!thread) return // should never happen - - // interrupt existing stream - if (this.streamState[threadId]?.isRunning) { - await this.abortRunning(threadId) - } - - // add dummy before this message to keep checkpoint before user message idea consistent - if (thread.messages.length === 0) { - this._addUserCheckpoint({ threadId }) - } - - - // add user's message to chat history - const instructions = userMessage - const currSelns: StagingSelectionItem[] = _chatSelections ?? thread.state.stagingSelections - - const userMessageContent = await chat_userMessageContent(instructions, currSelns, { directoryStrService: this._directoryStringService, fileService: this._fileService }) // user message + names of files (NOT content) - const userHistoryElt: ChatMessage = { role: 'user', content: userMessageContent, displayContent: instructions, selections: currSelns, state: defaultMessageState } - this._addMessageToThread(threadId, userHistoryElt) - - this._setThreadState(threadId, { currCheckpointIdx: null }) // no longer at a checkpoint because started streaming - - this._wrapRunAgentToNotify( - this._runChatAgent({ threadId, ...this._currentModelSelectionProps(), }), - threadId, - ) - - // scroll to bottom - this.state.allThreads[threadId]?.state.mountedInfo?.whenMounted.then(m => { - m.scrollToBottom() - }) - } - - - async addUserMessageAndStreamResponse({ userMessage, _chatSelections, threadId }: { userMessage: string, _chatSelections?: StagingSelectionItem[], threadId: string }) { - const thread = this.state.allThreads[threadId]; - if (!thread) return - - // if there's a current checkpoint, delete all messages after it - if (thread.state.currCheckpointIdx !== null) { - const checkpointIdx = thread.state.currCheckpointIdx; - const newMessages = thread.messages.slice(0, checkpointIdx + 1); - - // Update the thread with truncated messages - const newThreads = { - ...this.state.allThreads, - [threadId]: { - ...thread, - lastModified: new Date().toISOString(), - messages: newMessages, - } - }; - this._storeAllThreads(newThreads); - this._setState({ allThreads: newThreads }); - } - - // Now call the original method to add the user message and stream the response - await this._addUserMessageAndStreamResponse({ userMessage, _chatSelections, threadId }); - - } - - editUserMessageAndStreamResponse: IChatThreadService['editUserMessageAndStreamResponse'] = async ({ userMessage, messageIdx, threadId }) => { - - const thread = this.state.allThreads[threadId] - if (!thread) return // should never happen - - if (thread.messages?.[messageIdx]?.role !== 'user') { - throw new Error(`Error: editing a message with role !=='user'`) - } - - // get prev and curr selections before clearing the message - const currSelns = thread.messages[messageIdx].state.stagingSelections || [] // staging selections for the edited message - - // clear messages up to the index - const slicedMessages = thread.messages.slice(0, messageIdx) - this._setState({ - allThreads: { - ...this.state.allThreads, - [thread.id]: { - ...thread, - messages: slicedMessages - } - } - }) - - // re-add the message and stream it - this._addUserMessageAndStreamResponse({ userMessage, _chatSelections: currSelns, threadId }) - } - - // ---------- the rest ---------- - - private _getAllSeenFileURIs(threadId: string) { - const thread = this.state.allThreads[threadId] - if (!thread) return [] - - const fsPathsSet = new Set() - const uris: URI[] = [] - const addURI = (uri: URI) => { - if (!fsPathsSet.has(uri.fsPath)) uris.push(uri) - fsPathsSet.add(uri.fsPath) - uris.push(uri) - } - - for (const m of thread.messages) { - // URIs of user selections - if (m.role === 'user') { - for (const sel of m.selections ?? []) { - addURI(sel.uri) - } - } - // URIs of files that have been read - else if (m.role === 'tool' && m.type === 'success' && m.name === 'read_file') { - const params = m.params as BuiltinToolCallParams['read_file'] - addURI(params.uri) - } - } - return uris - } - - - - getRelativeStr = (uri: URI) => { - const isInside = this._workspaceContextService.isInsideWorkspace(uri) - if (isInside) { - const f = this._workspaceContextService.getWorkspace().folders.find(f => uri.fsPath.startsWith(f.uri.fsPath)) - if (f) { return uri.fsPath.replace(f.uri.fsPath, '') } - else { return undefined } - } - else { - return undefined - } - } - - - // gets the location of codespan link so the user can click on it - generateCodespanLink: IChatThreadService['generateCodespanLink'] = async ({ codespanStr: _codespanStr, threadId }) => { - - // process codespan to understand what we are searching for - // TODO account for more complicated patterns eg `ITextEditorService.openEditor()` - const functionOrMethodPattern = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; // `fUnCt10n_name` - const functionParensPattern = /^([^\s(]+)\([^)]*\)$/; // `functionName( args )` - - let target = _codespanStr // the string to search for - let codespanType: 'file-or-folder' | 'function-or-class' - if (target.includes('.') || target.includes('/')) { - - codespanType = 'file-or-folder' - target = _codespanStr - - } else if (functionOrMethodPattern.test(target)) { - - codespanType = 'function-or-class' - target = _codespanStr - - } else if (functionParensPattern.test(target)) { - const match = target.match(functionParensPattern) - if (match && match[1]) { - - codespanType = 'function-or-class' - target = match[1] - - } - else { return null } - } - else { - return null - } - - // get history of all AI and user added files in conversation + store in reverse order (MRU) - const prevUris = this._getAllSeenFileURIs(threadId).reverse() - - if (codespanType === 'file-or-folder') { - const doesUriMatchTarget = (uri: URI) => uri.path.includes(target) - - // check if any prevFiles are the `target` - for (const [idx, uri] of prevUris.entries()) { - if (doesUriMatchTarget(uri)) { - - // shorten it - - // TODO make this logic more general - const prevUriStrs = prevUris.map(uri => uri.fsPath) - const shortenedUriStrs = shorten(prevUriStrs) - let displayText = shortenedUriStrs[idx] - const ellipsisIdx = displayText.lastIndexOf('…/'); - if (ellipsisIdx >= 0) { - displayText = displayText.slice(ellipsisIdx + 2) - } - - return { uri, displayText } - } - } - - // else search codebase for `target` - let uris: URI[] = [] - try { - const { result } = await this._toolsService.callTool['search_pathnames_only']({ query: target, includePattern: null, pageNumber: 0 }) - const { uris: uris_ } = await result - uris = uris_ - } catch (e) { - return null - } - - for (const [idx, uri] of uris.entries()) { - if (doesUriMatchTarget(uri)) { - - // TODO make this logic more general - const prevUriStrs = prevUris.map(uri => uri.fsPath) - const shortenedUriStrs = shorten(prevUriStrs) - let displayText = shortenedUriStrs[idx] - const ellipsisIdx = displayText.lastIndexOf('…/'); - if (ellipsisIdx >= 0) { - displayText = displayText.slice(ellipsisIdx + 2) - } - - - return { uri, displayText } - } - } - - } - - - if (codespanType === 'function-or-class') { - - - // check all prevUris for the target - for (const uri of prevUris) { - - const modelRef = await this._voidModelService.getModelSafe(uri) - const { model } = modelRef - if (!model) continue - - const matches = model.findMatches( - target, - false, // searchOnlyEditableRange - false, // isRegex - true, // matchCase - null, //' ', // wordSeparators - true // captureMatches - ); - - const firstThree = matches.slice(0, 3); - - // take first 3 occurences, attempt to goto definition on them - for (const match of firstThree) { - const position = new Position(match.range.startLineNumber, match.range.startColumn); - const definitionProviders = this._languageFeaturesService.definitionProvider.ordered(model); - - for (const provider of definitionProviders) { - - const _definitions = await provider.provideDefinition(model, position, CancellationToken.None); - - if (!_definitions) continue; - - const definitions = Array.isArray(_definitions) ? _definitions : [_definitions]; - - for (const definition of definitions) { - - return { - uri: definition.uri, - selection: { - startLineNumber: definition.range.startLineNumber, - startColumn: definition.range.startColumn, - endLineNumber: definition.range.endLineNumber, - endColumn: definition.range.endColumn, - }, - displayText: _codespanStr, - }; - - // const defModelRef = await this._textModelService.createModelReference(definition.uri); - // const defModel = defModelRef.object.textEditorModel; - - // try { - // const symbolProviders = this._languageFeaturesService.documentSymbolProvider.ordered(defModel); - - // for (const symbolProvider of symbolProviders) { - // const symbols = await symbolProvider.provideDocumentSymbols( - // defModel, - // CancellationToken.None - // ); - - // if (symbols) { - // const symbol = symbols.find(s => { - // const symbolRange = s.range; - // return symbolRange.startLineNumber <= definition.range.startLineNumber && - // symbolRange.endLineNumber >= definition.range.endLineNumber && - // (symbolRange.startLineNumber !== definition.range.startLineNumber || symbolRange.startColumn <= definition.range.startColumn) && - // (symbolRange.endLineNumber !== definition.range.endLineNumber || symbolRange.endColumn >= definition.range.endColumn); - // }); - - // // if we got to a class/function get the full range and return - // if (symbol?.kind === SymbolKind.Function || symbol?.kind === SymbolKind.Method || symbol?.kind === SymbolKind.Class) { - // return { - // uri: definition.uri, - // selection: { - // startLineNumber: definition.range.startLineNumber, - // startColumn: definition.range.startColumn, - // endLineNumber: definition.range.endLineNumber, - // endColumn: definition.range.endColumn, - // } - // }; - // } - // } - // } - // } finally { - // defModelRef.dispose(); - // } - } - } - } - } - - // unlike above do not search codebase (doesnt make sense) - - } - - return null - + private _setStreamState(threadId: string, state: ThreadStreamState[string]) { + this.streamState[threadId] = state; + this._onDidChangeStreamState.fire({ threadId }); } - getCodespanLink({ codespanStr, messageIdx, threadId }: { codespanStr: string, messageIdx: number, threadId: string }): CodespanLocationLink | undefined { - const thread = this.state.allThreads[threadId] - if (!thread) return undefined; - - const links = thread.state.linksOfMessageIdx?.[messageIdx] - if (!links) return undefined; - - const link = links[codespanStr] - - return link + private _setThreadState(threadId: string, state: Partial, noRefresh?: boolean) { + const t = this.state.allThreads[threadId]; + if (!t) return; + this._setState({ allThreads: { ...this.state.allThreads, [t.id]: { ...t, state: { ...t.state, ...state } } } }, noRefresh); } - async addCodespanLink({ newLinkText, newLinkLocation, messageIdx, threadId }: { newLinkText: string, newLinkLocation: CodespanLocationLink, messageIdx: number, threadId: string }) { - const thread = this.state.allThreads[threadId] - if (!thread) return - + private _setCurrentMessageState(state: any, idx: number) { + const tid = this.state.currentThreadId; + const t = this.state.allThreads[tid]; + if (!t) return; this._setState({ - allThreads: { - ...this.state.allThreads, - [threadId]: { - ...thread, - state: { - ...thread.state, - linksOfMessageIdx: { - ...thread.state.linksOfMessageIdx, - [messageIdx]: { - ...thread.state.linksOfMessageIdx?.[messageIdx], - [newLinkText]: newLinkLocation - } - } - } - + ...this.state.allThreads, [tid]: { + ...t, messages: t.messages.map((m, i) => i === idx && m.role === 'user' ? { ...m, state: { ...m.state, ...state } } : m) } } - }) - } - - - getCurrentThread(): ThreadType { - const state = this.state - const thread = state.allThreads[state.currentThreadId] - if (!thread) throw new Error(`Current thread should never be undefined`) - return thread - } - - getCurrentFocusedMessageIdx() { - const thread = this.getCurrentThread() - - // get the focusedMessageIdx - const focusedMessageIdx = thread.state.focusedMessageIdx - if (focusedMessageIdx === undefined) return; - - // check that the message is actually being edited - const focusedMessage = thread.messages[focusedMessageIdx] - if (focusedMessage.role !== 'user') return; - if (!focusedMessage.state) return; - - return focusedMessageIdx - } - - isCurrentlyFocusingMessage() { - return this.getCurrentFocusedMessageIdx() !== undefined - } - - switchToThread(threadId: string) { - this._setState({ currentThreadId: threadId }) - } - - - openNewThread() { - // if a thread with 0 messages already exists, switch to it - const { allThreads: currentThreads } = this.state - for (const threadId in currentThreads) { - if (currentThreads[threadId]!.messages.length === 0) { - // switch to the existing empty thread and exit - this.switchToThread(threadId) - return - } - } - // otherwise, start a new thread - const newThread = newThreadObject() - - // update state - const newThreads: ChatThreads = { - ...currentThreads, - [newThread.id]: newThread - } - this._storeAllThreads(newThreads) - this._setState({ allThreads: newThreads, currentThreadId: newThread.id }) + }); } - - deleteThread(threadId: string): void { - const { allThreads: currentThreads } = this.state - - // delete the thread - const newThreads = { ...currentThreads }; - delete newThreads[threadId]; - - // store the updated threads + private _addMessageToThread(threadId: string, message: ChatMessage) { + const t = this.state.allThreads[threadId]; + if (!t) return; + const newThreads = { ...this.state.allThreads, [t.id]: { ...t, lastModified: new Date().toISOString(), messages: [...t.messages, message] } }; this._storeAllThreads(newThreads); - this._setState({ ...this.state, allThreads: newThreads }) + this._setState({ allThreads: newThreads }); } - duplicateThread(threadId: string) { - const { allThreads: currentThreads } = this.state - const threadToDuplicate = currentThreads[threadId] - if (!threadToDuplicate) return - const newThread = { - ...deepClone(threadToDuplicate), - id: generateUuid(), - } + private _editMessageInThread(threadId: string, idx: number, msg: ChatMessage) { + const t = this.state.allThreads[threadId]; + if (!t) return; const newThreads = { - ...currentThreads, - [newThread.id]: newThread, - } - this._storeAllThreads(newThreads) - this._setState({ allThreads: newThreads }) - } - - - private _addMessageToThread(threadId: string, message: ChatMessage) { - const { allThreads } = this.state - const oldThread = allThreads[threadId] - if (!oldThread) return // should never happen - // update state and store it - const newThreads = { - ...allThreads, - [oldThread.id]: { - ...oldThread, - lastModified: new Date().toISOString(), - messages: [ - ...oldThread.messages, - message - ], + ...this.state.allThreads, [t.id]: { + ...t, lastModified: new Date().toISOString(), messages: [...t.messages.slice(0, idx), msg, ...t.messages.slice(idx + 1)] } - } - this._storeAllThreads(newThreads) - this._setState({ allThreads: newThreads }) // the current thread just changed (it had a message added to it) + }; + this._storeAllThreads(newThreads); + this._setState({ allThreads: newThreads }); } - // sets the currently selected message (must be undefined if no message is selected) - setCurrentlyFocusedMessageIdx(messageIdx: number | undefined) { - - const threadId = this.state.currentThreadId - const thread = this.state.allThreads[threadId] - if (!thread) return + private _updateLatestTool(threadId: string, tool: any) { + const msgs = this.state.allThreads[threadId]?.messages; - this._setState({ - allThreads: { - ...this.state.allThreads, - [threadId]: { - ...thread, - state: { - ...thread.state, - focusedMessageIdx: messageIdx, - } + const safe = (v: any, max = 500) => { + try { + const s = typeof v === 'string' ? v : JSON.stringify(v); + return s.length > max ? s.slice(0, max) + '…' : s; + } catch { + try { + const s = String(v); + return s.length > max ? s.slice(0, max) + '…' : s; + } catch { + return ''; } } - }) - - // // when change focused message idx, jump - do not jump back when click edit, too confusing. - // if (messageIdx !== undefined) - // this.jumpToCheckpointBeforeMessageIdx({ threadId, messageIdx, jumpToUserModified: true }) - } - - - addNewStagingSelection(newSelection: StagingSelectionItem): void { - - const focusedMessageIdx = this.getCurrentFocusedMessageIdx() - - // set the selections to the proper value - let selections: StagingSelectionItem[] = [] - let setSelections = (s: StagingSelectionItem[]) => { } - - if (focusedMessageIdx === undefined) { - selections = this.getCurrentThreadState().stagingSelections - setSelections = (s: StagingSelectionItem[]) => this.setCurrentThreadState({ stagingSelections: s }) - } else { - selections = this.getCurrentMessageState(focusedMessageIdx).stagingSelections - setSelections = (s) => this.setCurrentMessageState(focusedMessageIdx, { stagingSelections: s }) - } - - // if matches with existing selection, overwrite (since text may change) - const idx = findStagingSelectionIndex(selections, newSelection) - if (idx !== null && idx !== -1) { - setSelections([ - ...selections!.slice(0, idx), - newSelection, - ...selections!.slice(idx + 1, Infinity) - ]) - } - // if no match, add it - else { - setSelections([...(selections ?? []), newSelection]) - } - } - - - // Pops the staging selections from the current thread's state - popStagingSelections(numPops: number): void { - - numPops = numPops ?? 1; - - const focusedMessageIdx = this.getCurrentFocusedMessageIdx() + }; - // set the selections to the proper value - let selections: StagingSelectionItem[] = [] - let setSelections = (s: StagingSelectionItem[]) => { } + const payloadBase = (() => { + const id = String((tool as any)?.id ?? ''); + const name = String((tool as any)?.name ?? ''); + const type = String((tool as any)?.type ?? ''); + const contentLen = typeof (tool as any)?.content === 'string' ? (tool as any).content.length : null; + const displayLen = typeof (tool as any)?.displayContent === 'string' ? (tool as any).displayContent.length : null; + const resultKeys = (tool as any)?.result && typeof (tool as any).result === 'object' + ? Object.keys((tool as any).result) + : []; + const resultOutLen = + typeof (tool as any)?.result?.output === 'string' + ? (tool as any).result.output.length + : null; + + return { + threadId, + toolId: id, + toolName: name, + toolType: type, + contentLen, + displayLen, + resultKeys, + resultOutLen, + contentPreview: safe((tool as any)?.content), + resultOutputPreview: safe((tool as any)?.result?.output), + }; + })(); + + //update by id anywhere in the tail, not only "last message is tool" + if (msgs && tool && typeof tool === 'object') { + const wantId = String((tool as any).id ?? ''); + + for (let i = msgs.length - 1; i >= 0; i--) { + const m: any = msgs[i] as any; + if (m && m.role === 'tool' && m.type !== 'invalid_params' && String(m.id ?? '') === wantId) { + this._logService.debug('[Void][ChatThreadService][_updateLatestTool][EDIT]', JSON.stringify({ + ...payloadBase, + foundIdx: i, + prevType: String(m.type ?? ''), + prevContentLen: typeof m.content === 'string' ? m.content.length : null, + prevResultKeys: (m.result && typeof m.result === 'object') ? Object.keys(m.result) : [], + prevResultOutLen: typeof m?.result?.output === 'string' ? m.result.output.length : null, + })); + + this._editMessageInThread(threadId, i, tool); + return; + } + } - if (focusedMessageIdx === undefined) { - selections = this.getCurrentThreadState().stagingSelections - setSelections = (s: StagingSelectionItem[]) => this.setCurrentThreadState({ stagingSelections: s }) - } else { - selections = this.getCurrentMessageState(focusedMessageIdx).stagingSelections - setSelections = (s) => this.setCurrentMessageState(focusedMessageIdx, { stagingSelections: s }) + this._logService.debug('[Void][ChatThreadService][_updateLatestTool][ADD_NO_MATCH]', JSON.stringify({ + ...payloadBase, + reason: 'no message with same toolId found', + msgsLen: msgs.length, + lastMsgRole: msgs.length ? String((msgs[msgs.length - 1] as any)?.role ?? '') : null, + })); } - setSelections([ - ...selections.slice(0, selections.length - numPops) - ]) - + this._logService.debug('[Void][ChatThreadService][_updateLatestTool][ADD_FALLBACK]', JSON.stringify(payloadBase)); + this._addMessageToThread(threadId, tool); } - // set message.state - private _setCurrentMessageState(state: Partial, messageIdx: number): void { - - const threadId = this.state.currentThreadId - const thread = this.state.allThreads[threadId] - if (!thread) return - - this._setState({ - allThreads: { - ...this.state.allThreads, - [threadId]: { - ...thread, - messages: thread.messages.map((m, i) => - i === messageIdx && m.role === 'user' ? { - ...m, - state: { - ...m.state, - ...state - }, - } : m - ) - } - } - }) - + private _accumulateTokenUsage(threadId: string, next: LLMTokenUsage) { + const t = this.state.allThreads[threadId]; + const prev = t?.state?.tokenUsageSession; + const result = prev ? { + input: prev.input + next.input, cacheCreation: prev.cacheCreation + next.cacheCreation, + cacheRead: prev.cacheRead + next.cacheRead, output: prev.output + next.output + } : { ...next }; + this._setThreadState(threadId, { tokenUsageSession: result, tokenUsageLastRequest: next }); } - // set thread.state - private _setThreadState(threadId: string, state: Partial, doNotRefreshMountInfo?: boolean): void { - const thread = this.state.allThreads[threadId] - if (!thread) return - - this._setState({ - allThreads: { - ...this.state.allThreads, - [thread.id]: { - ...thread, - state: { - ...thread.state, - ...state - } - } - } - }, doNotRefreshMountInfo) - + private _currentModelSelectionProps() { + const featureName = 'Chat'; + const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName]; + const modelSelectionOptions = modelSelection + ? this._settingsService.state.optionsOfModelSelection[featureName][modelSelection.providerName]?.[modelSelection.modelName] + : undefined; + return { modelSelection, modelSelectionOptions }; } - - // closeCurrentStagingSelectionsInThread = () => { - // const currThread = this.getCurrentThreadState() - - // // close all stagingSelections - // const closedStagingSelections = currThread.stagingSelections.map(s => ({ ...s, state: { ...s.state, isOpened: false } })) - - // const newThread = currThread - // newThread.stagingSelections = closedStagingSelections - - // this.setCurrentThreadState(newThread) - - // } - - // closeCurrentStagingSelectionsInMessage: IChatThreadService['closeCurrentStagingSelectionsInMessage'] = ({ messageIdx }) => { - // const currMessage = this.getCurrentMessageState(messageIdx) - - // // close all stagingSelections - // const closedStagingSelections = currMessage.stagingSelections.map(s => ({ ...s, state: { ...s.state, isOpened: false } })) - - // const newMessage = currMessage - // newMessage.stagingSelections = closedStagingSelections - - // this.setCurrentMessageState(messageIdx, newMessage) - - // } - - - - getCurrentThreadState = () => { - const currentThread = this.getCurrentThread() - return currentThread.state - } - setCurrentThreadState = (newState: Partial) => { - this._setThreadState(this.state.currentThreadId, newState) + private _readAllThreads(): ChatThreads | null { + const s = this._storageService.get(THREAD_STORAGE_KEY, StorageScope.APPLICATION); + if (!s) return null; + return JSON.parse(s, (k, v) => (v && typeof v === 'object' && v.$mid === 1) ? URI.from(v) : v); } - // gets `staging` and `setStaging` of the currently focused element, given the index of the currently selected message (or undefined if no message is selected) - - getCurrentMessageState(messageIdx: number): UserMessageState { - const currMessage = this.getCurrentThread()?.messages?.[messageIdx] - if (!currMessage || currMessage.role !== 'user') return defaultMessageState - return currMessage.state - } - setCurrentMessageState(messageIdx: number, newState: Partial) { - const currMessage = this.getCurrentThread()?.messages?.[messageIdx] - if (!currMessage || currMessage.role !== 'user') return - this._setCurrentMessageState(newState, messageIdx) + private _storeAllThreads(threads: ChatThreads) { + this._storageService.store(THREAD_STORAGE_KEY, JSON.stringify(threads), StorageScope.APPLICATION, StorageTarget.USER); } - - - } registerSingleton(IChatThreadService, ChatThreadService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/contextGatheringService.ts b/src/vs/workbench/contrib/void/browser/contextGatheringService.ts index 6de8205c327..acf28d9cb77 100644 --- a/src/vs/workbench/contrib/void/browser/contextGatheringService.ts +++ b/src/vs/workbench/contrib/void/browser/contextGatheringService.ts @@ -23,6 +23,8 @@ interface IVisitedInterval { endLine: number; } +type DefinitionSymbol = DocumentSymbol & { uri: URI }; + export interface IContextGatheringService { readonly _serviceBrand: undefined; updateCache(model: ITextModel, pos: Position): Promise; @@ -65,10 +67,11 @@ class ContextGatheringService extends Disposable implements IContextGatheringSer public async updateCache(model: ITextModel, pos: Position): Promise { const snippets = new Set(); + const visitedDefinitionKeys = new Set(); this._snippetIntervals = []; // Reset intervals for new cache update - await this._gatherNearbySnippets(model, pos, this._NUM_LINES, 3, snippets, this._snippetIntervals); - await this._gatherParentSnippets(model, pos, this._NUM_LINES, 3, snippets, this._snippetIntervals); + await this._gatherNearbySnippets(model, pos, this._NUM_LINES, 3, snippets, this._snippetIntervals, visitedDefinitionKeys); + await this._gatherParentSnippets(model, pos, this._NUM_LINES, 3, snippets, this._snippetIntervals, visitedDefinitionKeys); // Convert to array and filter overlapping snippets this._cache = Array.from(snippets); @@ -141,7 +144,8 @@ class ContextGatheringService extends Disposable implements IContextGatheringSer numLines: number, depth: number, snippets: Set, - visited: IVisitedInterval[] + visited: IVisitedInterval[], + visitedDefinitionKeys: Set ): Promise { if (depth <= 0) return; @@ -152,14 +156,23 @@ class ContextGatheringService extends Disposable implements IContextGatheringSer this._addSnippetIfNotOverlapping(model, range, snippets, visited); const symbols = await this._getSymbolsNearPosition(model, pos, numLines); + const seenSymbolKeys = new Set(); for (const sym of symbols) { + const symbolKey = this._symbolKey(model.uri, sym); + if (seenSymbolKeys.has(symbolKey)) continue; + seenSymbolKeys.add(symbolKey); + const defs = await this._getDefinitionSymbols(model, sym); for (const def of defs) { + const definitionKey = this._definitionKey(def); + if (visitedDefinitionKeys.has(definitionKey)) continue; + visitedDefinitionKeys.add(definitionKey); + const defModel = this._modelService.getModel(def.uri); if (defModel) { const defPos = new Position(def.range.startLineNumber, def.range.startColumn); this._addSnippetIfNotOverlapping(defModel, def.range, snippets, visited); - await this._gatherNearbySnippets(defModel, defPos, numLines, depth - 1, snippets, visited); + await this._gatherNearbySnippets(defModel, defPos, numLines, depth - 1, snippets, visited, visitedDefinitionKeys); } } } @@ -171,7 +184,8 @@ class ContextGatheringService extends Disposable implements IContextGatheringSer numLines: number, depth: number, snippets: Set, - visited: IVisitedInterval[] + visited: IVisitedInterval[], + visitedDefinitionKeys: Set ): Promise { if (depth <= 0) return; @@ -182,20 +196,29 @@ class ContextGatheringService extends Disposable implements IContextGatheringSer this._addSnippetIfNotOverlapping(model, containerRange, snippets, visited); const symbols = await this._getSymbolsNearRange(model, containerRange, numLines); + const seenSymbolKeys = new Set(); for (const sym of symbols) { + const symbolKey = this._symbolKey(model.uri, sym); + if (seenSymbolKeys.has(symbolKey)) continue; + seenSymbolKeys.add(symbolKey); + const defs = await this._getDefinitionSymbols(model, sym); for (const def of defs) { + const definitionKey = this._definitionKey(def); + if (visitedDefinitionKeys.has(definitionKey)) continue; + visitedDefinitionKeys.add(definitionKey); + const defModel = this._modelService.getModel(def.uri); if (defModel) { const defPos = new Position(def.range.startLineNumber, def.range.startColumn); this._addSnippetIfNotOverlapping(defModel, def.range, snippets, visited); - await this._gatherNearbySnippets(defModel, defPos, numLines, depth - 1, snippets, visited); + await this._gatherNearbySnippets(defModel, defPos, numLines, depth - 1, snippets, visited, visitedDefinitionKeys); } } } const containerPos = new Position(containerRange.startLineNumber, containerRange.startColumn); - await this._gatherParentSnippets(model, containerPos, numLines, depth - 1, snippets, visited); + await this._gatherParentSnippets(model, containerPos, numLines, depth - 1, snippets, visited, visitedDefinitionKeys); } private _isRangeVisited(uri: string, startLine: number, endLine: number, visited: IVisitedInterval[]): boolean { @@ -237,29 +260,36 @@ class ContextGatheringService extends Disposable implements IContextGatheringSer } // Also check reference providers. const refProviders = this._langFeaturesService.referenceProvider.ordered(model); + if (!refProviders.length) return symbols; + + const seenRefSymbols = new Set(); for (let line = range.startLineNumber; line <= range.endLineNumber; line++) { const content = model.getLineContent(line); - const words = content.match(/[a-zA-Z_]\w*/g) || []; - for (const word of words) { - const startColumn = content.indexOf(word) + 1; + const wordsWithColumn = this._getDistinctWordPositions(content); + for (const { word, startColumn } of wordsWithColumn) { const pos = new Position(line, startColumn); if (!this._positionInRange(pos, range)) continue; for (const provider of refProviders) { try { const refs = await provider.provideReferences(model, pos, { includeDeclaration: true }, CancellationToken.None); - if (refs) { - const filtered = refs.filter(ref => this._rangesIntersect(ref.range, range)); - for (const ref of filtered) { - symbols.push({ - name: word, - detail: '', - kind: SymbolKind.Variable, - range: ref.range, - selectionRange: ref.range, - children: [], - tags: [] - }); - } + if (!refs) continue; + + for (const ref of refs) { + if (!this._rangesIntersect(ref.range, range)) continue; + + const refKey = this._referenceKey(model.uri, ref.range, word); + if (seenRefSymbols.has(refKey)) continue; + seenRefSymbols.add(refKey); + + symbols.push({ + name: word, + detail: '', + kind: SymbolKind.Variable, + range: ref.range, + selectionRange: ref.range, + children: [], + tags: [] + }); } } catch (e) { console.warn('Reference provider error:', e); @@ -297,26 +327,68 @@ class ContextGatheringService extends Disposable implements IContextGatheringSer (pos.lineNumber !== range.endLineNumber || pos.column <= range.endColumn); } + private _getDistinctWordPositions(content: string): { word: string; startColumn: number }[] { + const wordPositions: { word: string; startColumn: number }[] = []; + const seenWords = new Set(); + const wordRegex = /[a-zA-Z_]\w*/g; + + let match: RegExpExecArray | null; + while ((match = wordRegex.exec(content)) !== null) { + const word = match[0]; + if (seenWords.has(word)) continue; + seenWords.add(word); + + wordPositions.push({ + word, + startColumn: match.index + 1 + }); + } + return wordPositions; + } + + private _rangeKey(range: IRange): string { + return `${range.startLineNumber}:${range.startColumn}:${range.endLineNumber}:${range.endColumn}`; + } + + private _symbolKey(uri: URI, symbol: DocumentSymbol): string { + return `${uri.toString()}#${symbol.name}#${this._rangeKey(symbol.range)}`; + } + + private _definitionKey(def: DefinitionSymbol): string { + return `${def.uri.toString()}#${this._rangeKey(def.range)}`; + } + + private _referenceKey(uri: URI, range: IRange, word: string): string { + return `${uri.toString()}#${word}#${this._rangeKey(range)}`; + } + // Get definition symbols for a given symbol. - private async _getDefinitionSymbols(model: ITextModel, symbol: DocumentSymbol): Promise<(DocumentSymbol & { uri: URI })[]> { + private async _getDefinitionSymbols(model: ITextModel, symbol: DocumentSymbol): Promise { const pos = new Position(symbol.range.startLineNumber, symbol.range.startColumn); const providers = this._langFeaturesService.definitionProvider.ordered(model); - const defs: (DocumentSymbol & { uri: URI })[] = []; + const defs: DefinitionSymbol[] = []; + const seenDefinitionKeys = new Set(); for (const provider of providers) { try { const res = await provider.provideDefinition(model, pos, CancellationToken.None); if (res) { const links = Array.isArray(res) ? res : [res]; - defs.push(...links.map(link => ({ - name: symbol.name, - detail: symbol.detail, - kind: symbol.kind, - range: link.range, - selectionRange: link.range, - children: [], - tags: symbol.tags || [], - uri: link.uri // Now keeping it as URI instead of converting to string - }))); + for (const link of links) { + const definitionKey = `${link.uri.toString()}#${this._rangeKey(link.range)}`; + if (seenDefinitionKeys.has(definitionKey)) continue; + seenDefinitionKeys.add(definitionKey); + + defs.push({ + name: symbol.name, + detail: symbol.detail, + kind: symbol.kind, + range: link.range, + selectionRange: link.range, + children: [], + tags: symbol.tags || [], + uri: link.uri // Now keeping it as URI instead of converting to string + }); + } } } catch (e) { console.warn('Definition provider error:', e); diff --git a/src/vs/workbench/contrib/void/browser/convertToLLMMessageService.ts b/src/vs/workbench/contrib/void/browser/convertToLLMMessageService.ts index 94545c0d751..a17fae91df0 100644 --- a/src/vs/workbench/contrib/void/browser/convertToLLMMessageService.ts +++ b/src/vs/workbench/contrib/void/browser/convertToLLMMessageService.ts @@ -1,51 +1,72 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { deepClone } from '../../../../base/common/objects.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { ChatMessage } from '../common/chatThreadServiceTypes.js'; -import { getIsReasoningEnabledState, getReservedOutputTokenSpace, getModelCapabilities } from '../common/modelCapabilities.js'; -import { reParsedToolXMLString, chat_systemMessage } from '../common/prompt/prompts.js'; -import { AnthropicLLMChatMessage, AnthropicReasoning, GeminiLLMChatMessage, LLMChatMessage, LLMFIMMessage, OpenAILLMChatMessage, RawToolParamsObj } from '../common/sendLLMMessageTypes.js'; -import { IVoidSettingsService } from '../common/voidSettingsService.js'; -import { ChatMode, FeatureName, ModelSelection, ProviderName } from '../common/voidSettingsTypes.js'; -import { IDirectoryStrService } from '../common/directoryStrService.js'; -import { ITerminalToolService } from './terminalToolService.js'; +import { AnyToolName, ChatAttachment, ChatMessage } from '../../../../platform/void/common/chatThreadServiceTypes.js'; +import { + getIsReasoningEnabledState, + setDynamicModelService, + getModelCapabilities, + VoidStaticModelInfo, + getReservedOutputTokenSpace, + getModelApiConfiguration +} from '../../../../platform/void/common/modelInference.js'; +import { isAToolName, reParsedToolXMLString, chat_systemMessage, ToolName, SYSTEM_PROMPT_OVERRIDE } from '../common/prompt/prompts.js'; +import { + AnthropicLLMChatMessage, + AnthropicReasoning, + AnthropicUserBlock, + GeminiLLMChatMessage, + LLMChatMessage, + LLMFIMMessage, + OpenAILLMChatMessage, + OpenAITextPart, + OpenAIImageURLPart, + RawToolParamsObj +} from '../../../../platform/void/common/sendLLMMessageTypes.js'; +import { IVoidSettingsService } from '../../../../platform/void/common/voidSettingsService.js'; +import { + ChatMode, + specialToolFormat, + supportsSystemMessage, + FeatureName, + ModelSelection, + ProviderName +} from '../../../../platform/void/common/voidSettingsTypes.js'; import { IVoidModelService } from '../common/voidModelService.js'; import { URI } from '../../../../base/common/uri.js'; import { EndOfLinePreference } from '../../../../editor/common/model.js'; -import { ToolName } from '../common/toolsServiceTypes.js'; -import { IMCPService } from '../common/mcpService.js'; - -export const EMPTY_MESSAGE = '(empty message)' +import { ILocalPtyService } from '../../../../platform/terminal/common/terminal.js' +import { IDynamicProviderRegistryService } from '../../../../platform/void/common/providerReg.js'; +import { IDynamicModelService } from '../../../../platform/void/common/dynamicModelService.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { encodeBase64 } from '../../../../base/common/buffer.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +export const EMPTY_MESSAGE = '' +type ResolvedChatAttachment = ChatAttachment & { dataBase64?: string }; type SimpleLLMMessage = { role: 'tool'; content: string; id: string; - name: ToolName; + name: AnyToolName; rawParams: RawToolParamsObj; } | { role: 'user'; content: string; + attachments?: ResolvedChatAttachment[]; } | { role: 'assistant'; content: string; anthropicReasoning: AnthropicReasoning[] | null; } - - const CHARS_PER_TOKEN = 4 // assume abysmal chars per token const TRIM_TO_LEN = 120 - - - // convert messages as if about to send to openai /* reference - https://platform.openai.com/docs/guides/function-calling#function-calling-steps @@ -69,7 +90,25 @@ openai on developer system message - https://cdn.openai.com/spec/model-spec-2024 */ -const prepareMessages_openai_tools = (messages: SimpleLLMMessage[]): AnthropicOrOpenAILLMMessage[] => { +const buildOpenAIUserContent = (msg: Extract): string | (OpenAITextPart | OpenAIImageURLPart)[] => { + const atts = msg.attachments ?? []; + if (!atts.length) return msg.content; + + const parts: (OpenAITextPart | OpenAIImageURLPart)[] = []; + const trimmed = msg.content.trim(); + if (trimmed) { + parts.push({ type: 'text', text: trimmed }); + } + for (const att of atts) { + if (!att.dataBase64) continue; + const mime = att.mimeType || 'image/png'; + const dataUrl = `data:${mime};base64,${att.dataBase64}`; + parts.push({ type: 'image_url', image_url: { url: dataUrl } }); + } + return parts.length ? parts : msg.content; +}; + +const prepareOpenAIToolsMessages = (messages: SimpleLLMMessage[]): AnthropicOrOpenAILLMMessage[] => { const newMessages: OpenAILLMChatMessage[] = []; @@ -77,7 +116,14 @@ const prepareMessages_openai_tools = (messages: SimpleLLMMessage[]): AnthropicOr const currMsg = messages[i] if (currMsg.role !== 'tool') { - newMessages.push(currMsg) + if (currMsg.role === 'user') { + newMessages.push({ role: 'user', content: buildOpenAIUserContent(currMsg) }); + } else if (currMsg.role === 'assistant') { + newMessages.push({ role: 'assistant', content: currMsg.content }); + } else { + // Fallback for unexpected roles – treat as simple user message + newMessages.push({ role: 'user', content: (currMsg as any).content }); + } continue } @@ -138,65 +184,87 @@ user: ...content, result(id, content) type AnthropicOrOpenAILLMMessage = AnthropicLLMChatMessage | OpenAILLMChatMessage -const prepareMessages_anthropic_tools = (messages: SimpleLLMMessage[], supportsAnthropicReasoning: boolean): AnthropicOrOpenAILLMMessage[] => { - const newMessages: (AnthropicLLMChatMessage | (SimpleLLMMessage & { role: 'tool' }))[] = messages; +const buildAnthropicUserContent = (msg: Extract): string | AnthropicUserBlock[] => { + const atts = msg.attachments ?? []; + if (!atts.length) return msg.content; + + const parts: AnthropicUserBlock[] = []; + const trimmed = msg.content.trim(); + if (trimmed) { + parts.push({ type: 'text', text: trimmed }); + } + for (const att of atts) { + if (!att.dataBase64) continue; + // Restrict to Anthropic-allowed image media types + let mediaType: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' = 'image/png'; + if (att.mimeType === 'image/jpeg' || att.mimeType === 'image/jpg') mediaType = 'image/jpeg'; + else if (att.mimeType === 'image/gif') mediaType = 'image/gif'; + else if (att.mimeType === 'image/webp') mediaType = 'image/webp'; + parts.push({ + type: 'image', + source: { type: 'base64', media_type: mediaType, data: att.dataBase64 }, + }); + } + return parts.length ? parts : msg.content; +}; + +const prepareAnthropicToolsMessages = (messages: SimpleLLMMessage[], supportsAnthropicReasoning: boolean): AnthropicOrOpenAILLMMessage[] => { + const newMessages: AnthropicLLMChatMessage[] = []; for (let i = 0; i < messages.length; i += 1) { - const currMsg = messages[i] + const currMsg = messages[i]; - // add anthropic reasoning if (currMsg.role === 'assistant') { if (currMsg.anthropicReasoning && supportsAnthropicReasoning) { - const content = currMsg.content - newMessages[i] = { - role: 'assistant', - content: content ? [...currMsg.anthropicReasoning, { type: 'text' as const, text: content }] : currMsg.anthropicReasoning - } - } - else { - newMessages[i] = { + const content = currMsg.content; + newMessages.push({ role: 'assistant', - content: currMsg.content, - // strip away anthropicReasoning - } + content: content + ? [...currMsg.anthropicReasoning, { type: 'text' as const, text: content }] + : currMsg.anthropicReasoning + }); + } else { + newMessages.push({ role: 'assistant', content: currMsg.content }); } - continue + continue; } if (currMsg.role === 'user') { - newMessages[i] = { + newMessages.push({ role: 'user', - content: currMsg.content, - } - continue + content: buildAnthropicUserContent(currMsg), + }); + continue; } if (currMsg.role === 'tool') { - // add anthropic tools - const prevMsg = 0 <= i - 1 && i - 1 <= newMessages.length ? newMessages[i - 1] : undefined + const prevMsg = newMessages.length ? newMessages[newMessages.length - 1] : undefined; - // make it so the assistant called the tool if (prevMsg?.role === 'assistant') { - if (typeof prevMsg.content === 'string') prevMsg.content = [{ type: 'text', text: prevMsg.content }] - prevMsg.content.push({ type: 'tool_use', id: currMsg.id, name: currMsg.name, input: currMsg.rawParams }) + if (typeof prevMsg.content === 'string') { + prevMsg.content = [{ type: 'text', text: prevMsg.content }]; + } + (prevMsg.content as any[]).push({ + type: 'tool_use', + id: currMsg.id, + name: currMsg.name as string, + input: currMsg.rawParams, + }); } - // turn each tool into a user message with tool results at the end - newMessages[i] = { + newMessages.push({ role: 'user', - content: [{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content }] - } - continue + content: [{ type: 'tool_result', tool_use_id: currMsg.id, content: currMsg.content }], + }); + continue; } - } - // we just removed the tools - return newMessages as AnthropicLLMChatMessage[] + return newMessages; } -const prepareMessages_XML_tools = (messages: SimpleLLMMessage[], supportsAnthropicReasoning: boolean): AnthropicOrOpenAILLMMessage[] => { +const prepareXMLToolsMessages = (messages: SimpleLLMMessage[], supportsAnthropicReasoning: boolean): AnthropicOrOpenAILLMMessage[] => { const llmChatMessages: AnthropicOrOpenAILLMMessage[] = []; for (let i = 0; i < messages.length; i += 1) { @@ -208,7 +276,7 @@ const prepareMessages_XML_tools = (messages: SimpleLLMMessage[], supportsAnthrop // if called a tool (message after it), re-add its XML to the message // alternatively, could just hold onto the original output, but this way requires less piping raw strings everywhere let content: AnthropicOrOpenAILLMMessage['content'] = c.content - if (next?.role === 'tool') { + if (next?.role === 'tool' && isAToolName(next.name)) { content = `${content}\n\n${reParsedToolXMLString(next.name, next.rawParams)}` } @@ -226,6 +294,16 @@ const prepareMessages_XML_tools = (messages: SimpleLLMMessage[], supportsAnthrop if (c.role === 'tool') c.content = `<${c.name}_result>\n${c.content}\n` + // NOTE: For XML tool format we cannot send true image parts, so we append + // a lightweight textual placeholder for any attachments. + if (c.role === 'user' && (c as any).attachments && (c as any).attachments.length) { + const atts = (c as any).attachments as ResolvedChatAttachment[]; + const placeholderLines = atts.map(att => `Attached image: ${att.name}`); + c.content = c.content + ? `${c.content}\n\n${placeholderLines.join('\n')}` + : placeholderLines.join('\n'); + } + if (llmChatMessages.length === 0 || llmChatMessages[llmChatMessages.length - 1].role !== 'user') llmChatMessages.push({ role: 'user', @@ -254,8 +332,8 @@ const prepareOpenAIOrAnthropicMessages = ({ messages: SimpleLLMMessage[], systemMessage: string, aiInstructions: string, - supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated', - specialToolFormat: 'openai-style' | 'anthropic-style' | undefined, + supportsSystemMessage: supportsSystemMessage, + specialToolFormat: specialToolFormat, supportsAnthropicReasoning: boolean, contextWindow: number, reservedOutputTokenSpace: number | null | undefined, @@ -370,14 +448,14 @@ const prepareOpenAIOrAnthropicMessages = ({ // SYSTEM MESSAGE HACK: we shifted (removed) the system message role, so now SimpleLLMMessage[] is valid let llmChatMessages: AnthropicOrOpenAILLMMessage[] = [] - if (!specialToolFormat) { // XML tool behavior - llmChatMessages = prepareMessages_XML_tools(messages as SimpleLLMMessage[], supportsAnthropicReasoning) + if (specialToolFormat === 'disabled') { // XML tool behavior + llmChatMessages = prepareXMLToolsMessages(messages as SimpleLLMMessage[], supportsAnthropicReasoning) } else if (specialToolFormat === 'anthropic-style') { - llmChatMessages = prepareMessages_anthropic_tools(messages as SimpleLLMMessage[], supportsAnthropicReasoning) + llmChatMessages = prepareAnthropicToolsMessages(messages as SimpleLLMMessage[], supportsAnthropicReasoning) } else if (specialToolFormat === 'openai-style') { - llmChatMessages = prepareMessages_openai_tools(messages as SimpleLLMMessage[]) + llmChatMessages = prepareOpenAIToolsMessages(messages as SimpleLLMMessage[]) } const llmMessages = llmChatMessages @@ -420,7 +498,6 @@ const prepareOpenAIOrAnthropicMessages = ({ else { // allowed to be empty if has a tool in it or following it if (currMsg.content.find(c => c.type === 'tool_result' || c.type === 'tool_use')) { - currMsg.content = currMsg.content.filter(c => !(c.type === 'text' && !c.text)) as any continue } if (nextMsg?.role === 'tool') continue @@ -440,8 +517,6 @@ const prepareOpenAIOrAnthropicMessages = ({ } - - type GeminiUserPart = (GeminiLLMChatMessage & { role: 'user' })['parts'][0] type GeminiModelPart = (GeminiLLMChatMessage & { role: 'model' })['parts'][0] const prepareGeminiMessages = (messages: AnthropicLLMChatMessage[]) => { @@ -457,6 +532,9 @@ const prepareGeminiMessages = (messages: AnthropicLLMChatMessage[]) => { return { text: c.text } } else if (c.type === 'tool_use') { + if (!isAToolName(c.name)) { + return { text: JSON.stringify({ tool_use: c }) } + } latestToolName = c.name return { functionCall: { id: c.id, name: c.name, args: c.input } } } @@ -475,9 +553,15 @@ const prepareGeminiMessages = (messages: AnthropicLLMChatMessage[]) => { return { text: c.text } } else if (c.type === 'tool_result') { - if (!latestToolName) return null + if (!latestToolName) { + return { text: JSON.stringify({ tool_result: c }) } + } return { functionResponse: { id: c.tool_use_id, name: latestToolName, response: { output: c.content } } } } + else if ((c as any).type === 'image' && (c as any).source?.type === 'base64') { + const src = (c as any).source as { media_type: string; data: string }; + return { inlineData: { mimeType: src.media_type, data: src.data } } as any; + } else return null }).filter(m => !!m) return { role: 'user', parts, } @@ -495,30 +579,25 @@ const prepareMessages = (params: { messages: SimpleLLMMessage[], systemMessage: string, aiInstructions: string, - supportsSystemMessage: false | 'system-role' | 'developer-role' | 'separated', - specialToolFormat: 'openai-style' | 'anthropic-style' | 'gemini-style' | undefined, + supportsSystemMessage: supportsSystemMessage, + specialToolFormat: specialToolFormat, supportsAnthropicReasoning: boolean, contextWindow: number, reservedOutputTokenSpace: number | null | undefined, providerName: ProviderName }): { messages: LLMChatMessage[], separateSystemMessage: string | undefined } => { - - const specialFormat = params.specialToolFormat // this is just for ts stupidness - - // if need to convert to gemini style of messaes, do that (treat as anthropic style, then convert to gemini style) - if (params.providerName === 'gemini' || specialFormat === 'gemini-style') { - const res = prepareOpenAIOrAnthropicMessages({ ...params, specialToolFormat: specialFormat === 'gemini-style' ? 'anthropic-style' : undefined }) + // if need to convert to gemini style of messages, do that (treat as anthropic style, then convert to gemini style) + if (params.providerName === 'gemini' || params.specialToolFormat === 'gemini-style') { + const res = prepareOpenAIOrAnthropicMessages({ ...params, specialToolFormat: 'anthropic-style' }) const messages = res.messages as AnthropicLLMChatMessage[] const messages2 = prepareGeminiMessages(messages) return { messages: messages2, separateSystemMessage: res.separateSystemMessage } } - return prepareOpenAIOrAnthropicMessages({ ...params, specialToolFormat: specialFormat }) + const res = prepareOpenAIOrAnthropicMessages({ ...params }) + return { messages: res.messages, separateSystemMessage: res.separateSystemMessage } } - - - export interface IConvertToLLMMessageService { readonly _serviceBrand: undefined; prepareLLMSimpleMessages: (opts: { simpleMessages: SimpleLLMMessage[], systemMessage: string, modelSelection: ModelSelection | null, featureName: FeatureName }) => { messages: LLMChatMessage[], separateSystemMessage: string | undefined } @@ -529,20 +608,48 @@ export interface IConvertToLLMMessageService { export const IConvertToLLMMessageService = createDecorator('ConvertToLLMMessageService'); + class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMessageService { _serviceBrand: undefined; constructor( - @IModelService private readonly modelService: IModelService, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, - @IEditorService private readonly editorService: IEditorService, - @IDirectoryStrService private readonly directoryStrService: IDirectoryStrService, - @ITerminalToolService private readonly terminalToolService: ITerminalToolService, + @ILocalPtyService private readonly ptyHostService: ILocalPtyService, @IVoidSettingsService private readonly voidSettingsService: IVoidSettingsService, @IVoidModelService private readonly voidModelService: IVoidModelService, - @IMCPService private readonly mcpService: IMCPService, + @IDynamicProviderRegistryService private readonly dynamicRegistry: IDynamicProviderRegistryService, + @IDynamicModelService private readonly dynamicModelService: IDynamicModelService, + @IFileService private readonly fileService: IFileService, + @ILogService private readonly logService: ILogService, ) { - super() + super(); + try { + setDynamicModelService(this.dynamicModelService); + void this.dynamicModelService.initialize?.(); + } catch { + // ignore + } + } + + // Resolve explicit user override for supportsSystemMessage, case-insensitive provider and flexible model key + private _getUserSupportsSystemMessageOverride(providerName: ProviderName, modelName: string): supportsSystemMessage | undefined { + try { + const overrides = this.voidSettingsService.state.overridesOfModel; + if (!overrides) return undefined; + const provKey = Object.keys(overrides).find(k => k.toLowerCase() === String(providerName).toLowerCase()); + if (!provKey) return undefined; + const byModel = (overrides as any)[provKey] as Record; + const exact = byModel?.[modelName]?.supportsSystemMessage; + if (exact !== undefined) return exact; + if (modelName.includes('/')) { + const after = modelName.slice(modelName.indexOf('/') + 1); + const alt = byModel?.[after]?.supportsSystemMessage; + if (alt !== undefined) return alt; + } + return undefined; + } catch { + return undefined; + } } // Read .voidrules files from workspace folders @@ -551,59 +658,86 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess const workspaceFolders = this.workspaceContextService.getWorkspace().folders; let voidRules = ''; for (const folder of workspaceFolders) { - const uri = URI.joinPath(folder.uri, '.voidrules') - const { model } = this.voidModelService.getModel(uri) - if (!model) continue + const uri = URI.joinPath(folder.uri, '.voidrules'); + const { model } = this.voidModelService.getModel(uri); + if (!model) continue; voidRules += model.getValue(EndOfLinePreference.LF) + '\n\n'; } return voidRules.trim(); - } - catch (e) { - return '' + } catch { + return ''; } } - // Get combined AI instructions from settings and .voidrules files - private _getCombinedAIInstructions(): string { - const globalAIInstructions = this.voidSettingsService.state.globalSettings.aiInstructions; - const voidRulesFileContent = this._getVoidRulesFileContents(); + private _findCustomProviderSlugForModel(fullId: string): string | null { + try { + const cps = this.voidSettingsService.state.customProviders || {}; - const ans: string[] = [] - if (globalAIInstructions) ans.push(globalAIInstructions) - if (voidRulesFileContent) ans.push(voidRulesFileContent) - return ans.join('\n\n') - } + if (fullId.includes('/')) { + const prefix = fullId.split('/')[0]; + if (cps[prefix]) return prefix; + } + for (const [slug, entry] of Object.entries(cps)) { + const list: string[] = Array.isArray(entry?.models) ? entry.models : []; + if (list.includes(fullId)) return slug; + } + } catch { + // ignore + } + return null; + } - // system message - private _generateChatMessagesSystemMessage = async (chatMode: ChatMode, specialToolFormat: 'openai-style' | 'anthropic-style' | 'gemini-style' | undefined) => { - const workspaceFolders = this.workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath) + private async _getDynamicCapsForSelection(_providerName: ProviderName, modelName: string): Promise | undefined> { + const slug = this._findCustomProviderSlugForModel(modelName); + if (!slug) return undefined; - const openedURIs = this.modelService.getModels().filter(m => m.isAttachedToEditor()).map(m => m.uri.fsPath) || []; - const activeURI = this.editorService.activeEditor?.resource?.fsPath; + await this.dynamicRegistry.initialize?.(); - const directoryStr = await this.directoryStrService.getAllDirectoriesStr({ - cutOffMessage: chatMode === 'agent' || chatMode === 'gather' ? - `...Directories string cut off, use tools to read more...` - : `...Directories string cut off, ask user for more if necessary...` - }) - const includeXMLToolDefinitions = !specialToolFormat - - const mcpTools = this.mcpService.getMCPTools() + let argModelId = modelName; + if (slug.toLowerCase() !== 'openrouter') { + const i = modelName.indexOf('/'); + argModelId = i >= 0 ? modelName.slice(i + 1) : modelName; + } - const persistentTerminalIDs = this.terminalToolService.listPersistentTerminalIds() - const systemMessage = chat_systemMessage({ workspaceFolders, openedURIs, directoryStr, activeURI, persistentTerminalIDs, chatMode, mcpTools, includeXMLToolDefinitions }) - return systemMessage + try { + return await this.dynamicRegistry.getEffectiveModelCapabilities(slug, argModelId); + } catch { + return undefined; + } } + // Get combined AI instructions from settings and .voidrules files + private _getCombinedAIInstructions(): string { + const globalAIInstructions = this.voidSettingsService.state.globalSettings.aiInstructions; + const voidRulesFileContent = this._getVoidRulesFileContents(); + const ans: string[] = []; + if (globalAIInstructions) ans.push(globalAIInstructions); + if (voidRulesFileContent) ans.push(voidRulesFileContent); + return ans.join('\n\n'); + } + // system message + private _generateChatMessagesSystemMessage = async ( + chatMode: ChatMode, + specialToolFormat: 'openai-style' | 'anthropic-style' | 'gemini-style' | 'disabled' | undefined, + ) => { + const workspaceFolders = this.workspaceContextService.getWorkspace().folders.map(f => f.uri.fsPath); + const systemMessage = await chat_systemMessage({ + workspaceFolders, + chatMode, + toolFormat: (specialToolFormat ?? 'openai-style') as specialToolFormat, + ptyHostService: this.ptyHostService, + }); + return systemMessage; + }; // --- LLM Chat messages --- private _chatMessagesToSimpleMessages(chatMessages: ChatMessage[]): SimpleLLMMessage[] { - const simpleLLMMessages: SimpleLLMMessage[] = [] + const simpleLLMMessages: SimpleLLMMessage[] = []; for (const m of chatMessages) { if (m.role === 'checkpoint') continue @@ -616,6 +750,15 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess }) } else if (m.role === 'tool') { + + this.logService.debug('[DEBUG] _chatMessagesToSimpleMessages tool:', JSON.stringify({ + name: m.name, + type: m.type, + contentLength: m.content?.length, + hasTruncationMeta: m.content?.includes('TRUNCATION_META'), + contentTail: m.content?.slice(-200), + })); + simpleLLMMessages.push({ role: m.role, content: m.content, @@ -625,9 +768,13 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess }) } else if (m.role === 'user') { + const attachments: ResolvedChatAttachment[] | undefined = m.attachments + ? m.attachments.map(att => ({ ...att })) + : undefined simpleLLMMessages.push({ role: m.role, content: m.content, + ...(attachments && attachments.length ? { attachments } : {}), }) } } @@ -640,11 +787,24 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess const { overridesOfModel } = this.voidSettingsService.state const { providerName, modelName } = modelSelection - const { - specialToolFormat, - contextWindow, - supportsSystemMessage, - } = getModelCapabilities(providerName, modelName, overridesOfModel) + const caps = getModelCapabilities(providerName, modelName, overridesOfModel) + let specialToolFormat: specialToolFormat = caps.specialToolFormat ?? 'disabled' + let { contextWindow, supportsSystemMessage } = caps + + // Fallback to provider API config only when tool format is truly missing + // Do NOT override an explicit or inferred 'disabled' value – that means + // "no native tools", and must be respected. + if (!specialToolFormat) { + try { + const modelId = modelName.includes('/') ? modelName : `${providerName}/${modelName}`; + const apiCfg = getModelApiConfiguration(modelId); + specialToolFormat = apiCfg.specialToolFormat; + } catch { /* ignore */ } + } + + // Enforce explicit user override if present (override wins over dynamic caps) + const userSSMOverride = this._getUserSupportsSystemMessageOverride(providerName as ProviderName, modelName); + if (userSSMOverride !== undefined) supportsSystemMessage = userSSMOverride; const modelSelectionOptions = this.voidSettingsService.state.optionsOfModelSelection[featureName][modelSelection.providerName]?.[modelSelection.modelName] @@ -654,6 +814,11 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess const isReasoningEnabled = getIsReasoningEnabledState(featureName, providerName, modelName, modelSelectionOptions, overridesOfModel) const reservedOutputTokenSpace = getReservedOutputTokenSpace(providerName, modelName, { isReasoningEnabled, overridesOfModel }) + // Force global override if provided + if (typeof SYSTEM_PROMPT_OVERRIDE === 'string' && SYSTEM_PROMPT_OVERRIDE.trim() !== '') { + systemMessage = SYSTEM_PROMPT_OVERRIDE + } + const { messages, separateSystemMessage } = prepareMessages({ messages: simpleMessages, systemMessage, @@ -667,29 +832,65 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess }) return { messages, separateSystemMessage }; } + + prepareLLMChatMessages: IConvertToLLMMessageService['prepareLLMChatMessages'] = async ({ chatMessages, chatMode, modelSelection }) => { if (modelSelection === null) return { messages: [], separateSystemMessage: undefined } const { overridesOfModel } = this.voidSettingsService.state const { providerName, modelName } = modelSelection - const { - specialToolFormat, - contextWindow, - supportsSystemMessage, - } = getModelCapabilities(providerName, modelName, overridesOfModel) + const caps = getModelCapabilities(providerName, modelName, overridesOfModel) + let specialToolFormat: specialToolFormat = caps.specialToolFormat ?? 'disabled' + let { contextWindow, supportsSystemMessage } = caps + + try { + const dynCaps = await this._getDynamicCapsForSelection(providerName, modelName); + if (dynCaps) { + // adopt dynamic value only when present; keeps strict typing and lints happy + specialToolFormat = dynCaps.specialToolFormat ?? specialToolFormat; + // Only adopt dynamic supportsSystemMessage when user didn't explicitly override it + const userSSMOverride = this._getUserSupportsSystemMessageOverride(providerName as ProviderName, modelName); + if (userSSMOverride === undefined) { + const ssm = dynCaps.supportsSystemMessage; + supportsSystemMessage = ssm ?? supportsSystemMessage; + } + if (typeof dynCaps.contextWindow === 'number') contextWindow = dynCaps.contextWindow; + } + } catch { + // ignore + } + // Enforce explicit user override again after all fallbacks + { + const userSSMOverride2 = this._getUserSupportsSystemMessageOverride(providerName as ProviderName, modelName); + if (userSSMOverride2 !== undefined) supportsSystemMessage = userSSMOverride2; + } + + // Fallback to provider API config only when tool format is truly missing. + // Never override explicit or inferred 'disabled', since that means + // "no native tools" and must be honored. + if (!specialToolFormat) { + try { + const modelId = modelName.includes('/') ? modelName : `${providerName}/${modelName}`; + const apiCfg = getModelApiConfiguration(modelId); + specialToolFormat = apiCfg.specialToolFormat; + } catch { /* ignore */ } + } - const { disableSystemMessage } = this.voidSettingsService.state.globalSettings; - const fullSystemMessage = await this._generateChatMessagesSystemMessage(chatMode, specialToolFormat) - const systemMessage = disableSystemMessage ? '' : fullSystemMessage; + let systemMessage = await this._generateChatMessagesSystemMessage(chatMode, specialToolFormat) + if (typeof SYSTEM_PROMPT_OVERRIDE === 'string' && SYSTEM_PROMPT_OVERRIDE.trim() !== '') { + systemMessage = SYSTEM_PROMPT_OVERRIDE + } const modelSelectionOptions = this.voidSettingsService.state.optionsOfModelSelection['Chat'][modelSelection.providerName]?.[modelSelection.modelName] // Get combined AI instructions const aiInstructions = this._getCombinedAIInstructions(); + const isReasoningEnabled = getIsReasoningEnabledState('Chat', providerName, modelName, modelSelectionOptions, overridesOfModel) const reservedOutputTokenSpace = getReservedOutputTokenSpace(providerName, modelName, { isReasoningEnabled, overridesOfModel }) const llmMessages = this._chatMessagesToSimpleMessages(chatMessages) + await this._populateAttachmentData(llmMessages) const { messages, separateSystemMessage } = prepareMessages({ messages: llmMessages, @@ -704,8 +905,6 @@ class ConvertToLLMMessageService extends Disposable implements IConvertToLLMMess }) return { messages, separateSystemMessage }; } - - // --- FIM --- prepareFIMMessage: IConvertToLLMMessageService['prepareFIMMessage'] = ({ messages }) => { @@ -725,44 +924,23 @@ ${messages.prefix}` return { prefix, suffix, stopTokens } } - -} - - -registerSingleton(IConvertToLLMMessageService, ConvertToLLMMessageService, InstantiationType.Eager); - - - - - - - - -/* -Gemini has this, but they're openai-compat so we don't need to implement this -gemini request: -{ "role": "assistant", - "content": null, - "function_call": { - "name": "get_weather", - "arguments": { - "latitude": 48.8566, - "longitude": 2.3522 - } - } -} - -gemini response: -{ "role": "assistant", - "function_response": { - "name": "get_weather", - "response": { - "temperature": "15°C", - "condition": "Cloudy" + private async _populateAttachmentData(messages: SimpleLLMMessage[]): Promise { + for (const m of messages) { + if (m.role !== 'user') continue + const atts = m.attachments + if (!atts || !atts.length) continue + for (const att of atts) { + if (att.dataBase64) continue + try { + const content = await this.fileService.readFile(att.uri) + att.dataBase64 = encodeBase64(content.value) + } catch { + // ignore individual attachment failures + } + } } } } -*/ - +registerSingleton(IConvertToLLMMessageService, ConvertToLLMMessageService, InstantiationType.Eager); diff --git a/src/vs/workbench/contrib/void/browser/editCodeService.ts b/src/vs/workbench/contrib/void/browser/editCodeService.ts index 80ee4bc9925..c464f2b2c8e 100644 --- a/src/vs/workbench/contrib/void/browser/editCodeService.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeService.ts @@ -3,53 +3,465 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ +import type * as Parser from '@vscode/tree-sitter-wasm'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; -import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor, IOverlayWidget, IViewZone } from '../../../../editor/browser/editorBrowser.js'; - -// import { IUndoRedoService } from '../../../../platform/undoRedo/common/undoRedo.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; -// import { throttle } from '../../../../base/common/decorators.js'; import { findDiffs } from './helpers/findDiffs.js'; import { EndOfLinePreference, IModelDecorationOptions, ITextModel } from '../../../../editor/common/model.js'; import { IRange } from '../../../../editor/common/core/range.js'; import { IModelService } from '../../../../editor/common/services/model.js'; +import { ITreeSitterParserService } from '../../../../editor/common/services/treeSitterParserService.js'; +import { getModuleLocation } from '../../../../editor/common/services/treeSitter/treeSitterLanguages.js'; import { IUndoRedoElement, IUndoRedoService, UndoRedoElementType } from '../../../../platform/undoRedo/common/undoRedo.js'; import { RenderOptions } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js'; -// import { IModelService } from '../../../../editor/common/services/model.js'; - +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import * as dom from '../../../../base/browser/dom.js'; import { Widget } from '../../../../base/browser/ui/widget.js'; import { URI } from '../../../../base/common/uri.js'; +import { FileAccess, type AppResourcePath } from '../../../../base/common/network.js'; +import { importAMDNodeModule } from '../../../../amdX.js'; import { IConsistentEditorItemService, IConsistentItemService } from './helperServices/consistentItemService.js'; -import { voidPrefixAndSuffix, ctrlKStream_userMessage, ctrlKStream_systemMessage, defaultQuickEditFimTags, rewriteCode_systemMessage, rewriteCode_userMessage, searchReplaceGivenDescription_systemMessage, searchReplaceGivenDescription_userMessage, tripleTick, } from '../common/prompt/prompts.js'; +import { buildNativeSysMessageForCtrlK, buildNativeUserMessageForCtrlK, buildXmlSysMessageForCtrlK, buildXmlUserMessageForCtrlK } from '../common/prompt/prompts.js'; +import { getModelCapabilities } from '../../../../platform/void/common/modelInference.js'; import { IVoidCommandBarService } from './voidCommandBarService.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { VOID_ACCEPT_DIFF_ACTION_ID, VOID_REJECT_DIFF_ACTION_ID } from './actionIDs.js'; - +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { mountCtrlK } from './react/out/quick-edit-tsx/index.js' import { QuickEditPropsType } from './quickEditActions.js'; import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js'; -import { extractCodeFromFIM, extractCodeFromRegular, ExtractedSearchReplaceBlock, extractSearchReplaceBlocks } from '../common/helpers/extractCodeFromResult.js'; import { INotificationService, } from '../../../../platform/notification/common/notification.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { EditorOption } from '../../../../editor/common/config/editorOptions.js'; import { Emitter } from '../../../../base/common/event.js'; import { ILLMMessageService } from '../common/sendLLMMessageService.js'; -import { LLMChatMessage } from '../common/sendLLMMessageTypes.js'; -import { IMetricsService } from '../common/metricsService.js'; +import { IMetricsService } from '../../../../platform/void/common/metricsService.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; import { IEditCodeService, AddCtrlKOpts, StartApplyingOpts, CallBeforeStartApplyingOpts, } from './editCodeServiceInterface.js'; -import { IVoidSettingsService } from '../common/voidSettingsService.js'; -import { FeatureName } from '../common/voidSettingsTypes.js'; +import { IVoidSettingsService } from '../../../../platform/void/common/voidSettingsService.js'; import { IVoidModelService } from '../common/voidModelService.js'; import { deepClone } from '../../../../base/common/objects.js'; -import { acceptBg, acceptBorder, buttonFontSize, buttonTextColor, rejectBg, rejectBorder } from '../common/helpers/colors.js'; -import { DiffArea, Diff, CtrlKZone, VoidFileSnapshot, DiffAreaSnapshotEntry, diffAreaSnapshotKeys, DiffZone, TrackingZone, ComputedDiff } from '../common/editCodeServiceTypes.js'; +import { acceptBg, acceptBorder, buttonFontSize, buttonTextColor, rejectBg, rejectBorder } from '../../../../platform/void/common/helpers/colors.js'; +import { DiffArea, Diff, CtrlKZone, VoidFileSnapshot, DiffAreaSnapshotEntry, diffAreaSnapshotKeys, DiffZone, ComputedDiff } from '../../../../platform/void/common/editCodeServiceTypes.js'; import { IConvertToLLMMessageService } from './convertToLLMMessageService.js'; -// import { isMacintosh } from '../../../../base/common/platform.js'; -// import { VOID_OPEN_SETTINGS_ACTION_ID } from './voidSettingsPane.js'; +import { IToolsService } from '../common/toolsService.js'; +import DiffMatchPatch from './lib/diff-match-patch.js' +import { inferSelectionFromCode, inferExactBlockFromCode, InferenceAstContext, InferredBlock } from './react/src/markdown/inferSelection.js' + +type DmpOp = -1 | 0 | 1 + + +// Fixes cases where inference returns only a prefix of a declaration line and we end up +// replacing mid-line (corrupting the file). + +function offsetToLineNumber(text: string, offset: number): number { + // 1-based + return text.slice(0, Math.max(0, offset)).split('\n').length; +} + +function findMatchingCurlyForwardJs(text: string, openIndex: number): number { + type Mode = 'code' | 'sgl' | 'dbl' | 'template' | 'line' | 'block'; + + let i = openIndex + 1; + let depth = 1; + + let mode: Mode = 'code'; + let escaped = false; + + + let templateNesting = 0; + + + + const tplExprDepthStack: number[] = []; + + while (i < text.length) { + const ch = text[i]; + const next = text[i + 1]; + + + if (mode === 'line') { + if (ch === '\n') mode = 'code'; + i++; + continue; + } + if (mode === 'block') { + if (ch === '*' && next === '/') { mode = 'code'; i += 2; continue; } + i++; + continue; + } + + + if (mode === 'sgl' || mode === 'dbl') { + if (escaped) { escaped = false; i++; continue; } + if (ch === '\\') { escaped = true; i++; continue; } + + if ((mode === 'sgl' && ch === '\'') || (mode === 'dbl' && ch === '"')) { + mode = 'code'; + i++; + continue; + } + i++; + continue; + } + + // --- Template raw: ` ... ${ ... } ... ` --- + if (mode === 'template') { + if (escaped) { escaped = false; i++; continue; } + if (ch === '\\') { escaped = true; i++; continue; } + + + if (ch === '$' && next === '{') { + tplExprDepthStack.push(depth); + depth++; + mode = 'code'; + i += 2; + continue; + } + + + if (ch === '`') { + templateNesting--; + mode = 'code'; + i++; + continue; + } + + i++; + continue; + } + + // --- mode === 'code' --- + + if (ch === '/' && next === '/') { mode = 'line'; i += 2; continue; } + if (ch === '/' && next === '*') { mode = 'block'; i += 2; continue; } + + + if (ch === '\'') { mode = 'sgl'; escaped = false; i++; continue; } + if (ch === '"') { mode = 'dbl'; escaped = false; i++; continue; } + + + if (ch === '`') { + templateNesting++; + mode = 'template'; + escaped = false; + i++; + continue; + } + + + if (ch === '{') { depth++; i++; continue; } + if (ch === '}') { + depth--; + + if (tplExprDepthStack.length > 0 && depth === tplExprDepthStack[tplExprDepthStack.length - 1]) { + tplExprDepthStack.pop(); + if (templateNesting > 0) { + mode = 'template'; + i++; + continue; + } + } + + if (depth === 0) return i; + i++; + continue; + } + + i++; + } + + return -1; +} + + +// Finds the "body" '{' for a TS/JS function/method-like declaration starting at startOffset. +// Ignores braces inside (...) (params), and heuristically skips return-type object literals. +function findTopLevelBodyOpenBraceJs(text: string, startOffset: number, searchWindow = 8000): number { + const limit = Math.min(text.length, startOffset + searchWindow); + + let inS = false, inD = false, inT = false, inSL = false, inML = false; + let prev = ''; + + let parenDepth = 0; + let sawParen = false; + + let inReturnType = false; + let typeBraceDepth = 0; + + for (let i = startOffset; i < limit; i++) { + const ch = text[i]; + const next = text[i + 1]; + + // comments (only when not in strings) + if (!inS && !inD && !inT) { + if (!inML && !inSL && ch === '/' && next === '/') { inSL = true; i++; prev = ''; continue; } + if (!inML && !inSL && ch === '/' && next === '*') { inML = true; i++; prev = ''; continue; } + if (inSL && ch === '\n') { inSL = false; prev = ch; continue; } + if (inML && ch === '*' && next === '/') { inML = false; i++; prev = ''; continue; } + if (inSL || inML) { prev = ch; continue; } + } + + // strings + if (!inSL && !inML) { + if (!inD && !inT && ch === '\'' && prev !== '\\') { inS = !inS; prev = ch; continue; } + if (!inS && !inT && ch === '"' && prev !== '\\') { inD = !inD; prev = ch; continue; } + if (!inS && !inD && ch === '`' && prev !== '\\') { inT = !inT; prev = ch; continue; } + } + + if (inS || inD || inT || inSL || inML) { prev = ch; continue; } + + // parentheses (params) + if (ch === '(') { parenDepth++; sawParen = true; prev = ch; continue; } + if (ch === ')') { if (parenDepth > 0) parenDepth--; prev = ch; continue; } + + if (!sawParen) { prev = ch; continue; } + + // Only consider top-level (outside params) + if (parenDepth === 0) { + // detect start of return type: `): Type ... {` + if (ch === ':' && typeBraceDepth === 0) { + inReturnType = true; + prev = ch; + continue; + } + + if (typeBraceDepth > 0) { + if (ch === '{') typeBraceDepth++; + else if (ch === '}') typeBraceDepth--; + prev = ch; + continue; + } + + // Candidate '{' + if (ch === '{') { + if (inReturnType) { + // Heuristic: decide if this is a type-literal `{ a: number; }` vs function body + const close = findMatchingCurlyForwardJs(text, i); + if (close !== -1) { + const inside = text.slice(i + 1, Math.min(close, i + 350)); + const looksTypey = /[:;]/.test(inside) && !/\b(return|const|let|var|if|for|while|switch|try|throw|import|export)\b/.test(inside); + if (looksTypey) { + typeBraceDepth = 1; // enter type-literal braces + prev = ch; + continue; + } + } + } + + // treat as body + return i; + } + + // End of signature without body (abstract/interface etc) + if (ch === ';') return -1; + } + + prev = ch; + } + + return -1; +} + +function looksLikeFullTopLevelBlockSnippet(snippet: string): boolean { + const s = normalizeEol(snippet ?? ''); + const open = findTopLevelBodyOpenBraceJs(s, 0, Math.min(8000, s.length)); + if (open === -1) return false; + const close = findMatchingCurlyForwardJs(s, open); + if (close === -1) return false; + const tail = s.slice(close + 1).trim(); + return tail === '' || tail === ';' || tail === ','; +} + +function expandToEnclosingCurlyBlockJs(text: string, startOffset: number, searchWindow = 8000) { + const open = findTopLevelBodyOpenBraceJs(text, startOffset, searchWindow); + if (open === -1) return null; + + const close = findMatchingCurlyForwardJs(text, open); + if (close === -1) return null; + + const endOffset = close + 1; + + const startLine = offsetToLineNumber(text, startOffset); + const endLine = offsetToLineNumber(text, endOffset); + + return { + startOffset, + endOffset, + text: text.slice(startOffset, endOffset), + range: [startLine, endLine] as [number, number], + }; +} + +// normalize EOLs to LF +// normalize EOLs to LF +function normalizeEol(s: string): string { + return (s ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') +} + +// (Very common LLM failure mode for edit_file snippets.) +function stripMarkdownFence(s: string): string { + const str = String(s ?? ''); + const m = str.match(/^\s*```[a-zA-Z0-9_-]*\s*\n([\s\S]*?)\n```\s*$/); + return m ? m[1] : str; +} + +function escapeForShellDoubleQuotes(s: string): string { + // minimal, practical escaping for bash/zsh inside "...": + return String(s).replace(/(["\\$`])/g, '\\$1'); +} + +function buildInvisibleCharsDebugCmd(filePathForCmd: string, startLine: number, endLine: number) { + const a = Math.max(1, Math.floor(startLine || 1)); + const b = Math.max(a, Math.floor(endLine || a)); + const file = `"${escapeForShellDoubleQuotes(filePathForCmd)}"`; + + return { + gnu: `sed -n '${a},${b}p' ${file} | cat -A`, + // macOS/BSD fallback (often available even when cat -A isn't) + bsd: `sed -n '${a},${b}p' ${file} | cat -vet`, + }; +} + +const EDIT_FILE_FALLBACK_MSG = 'LLM did not correctly provide an ORIGINAL code block.'; + +// Build unified diff in line-mode using DMP's line helpers +function createUnifiedFromLineDiffs(fileLabel: string, original: string, updated: string, context = 3): string { + const dmp = new DiffMatchPatch() + const a: any = (dmp as any).diff_linesToChars_(original, updated) + let diffs = dmp.diff_main(a.chars1, a.chars2, false) + try { dmp.diff_cleanupSemantic(diffs) } catch { } + (dmp as any).diff_charsToLines_(diffs, a.lineArray) + + const out: string[] = [] + out.push(`--- a/${fileLabel}`) + out.push(`+++ b/${fileLabel}`) + + let oldLine = 1 + let newLine = 1 + const ctxBuf: string[] = [] + let inHunk = false + let hunkLines: string[] = [] + let hunkStartOld = 0 + let hunkStartNew = 0 + let oldCount = 0 + let newCount = 0 + let postCtxLeft = 0 + + const flushHunk = () => { + if (!inHunk) return + out.push(`@@ -${hunkStartOld},${Math.max(1, oldCount)} +${hunkStartNew},${Math.max(1, newCount)} @@`) + out.push(...hunkLines) + inHunk = false + hunkLines = [] + oldCount = 0 + newCount = 0 + postCtxLeft = 0 + } + + const startHunkWithCtx = () => { + inHunk = true + hunkStartOld = oldLine - ctxBuf.length + hunkStartNew = newLine - ctxBuf.length + for (const ln of ctxBuf) { + hunkLines.push(' ' + ln) + oldCount++; newCount++ + } + } + + const splitLines = (s: string) => { + const arr = s.split('\n') + if (arr.length && arr[arr.length - 1] === '') arr.pop() + return arr + } + + for (const [op, text] of diffs as [DmpOp, string][]) { + const lines = splitLines(text) + if (op === 0) { + if (inHunk) { + for (const ln of lines) { + if (postCtxLeft > 0) { + hunkLines.push(' ' + ln) + oldCount++; newCount++; oldLine++; newLine++; postCtxLeft-- + } else { + flushHunk() + ctxBuf.push(ln) + if (ctxBuf.length > context) ctxBuf.shift() + oldLine++; newLine++ + } + } + } else { + for (const ln of lines) { + ctxBuf.push(ln) + if (ctxBuf.length > context) ctxBuf.shift() + oldLine++; newLine++ + } + } + } else if (op === -1) { + if (!inHunk) startHunkWithCtx() + for (const ln of lines) { + hunkLines.push('-' + ln) + oldCount++; oldLine++ + } + postCtxLeft = context + } else { + if (!inHunk) startHunkWithCtx() + for (const ln of lines) { + hunkLines.push('+' + ln) + newCount++; newLine++ + } + postCtxLeft = context + } + } + + flushHunk() + return out.join('\n') +} + +// Helper: apply a single diff to an 'original' baseline string, returning the new baseline. +// This is used for per-diff accept in edit_file preview zones so that only the accepted +// change is merged into the baseline while the remaining diffs stay visible. +function applyDiffToBaseline(original: string, diff: Diff): string { + // We mirror the line indexing used in findDiffs: 1-based lines via a leading empty element. + const lines = ('\n' + (original ?? '')).split('\n'); + + const start = diff.originalStartLine; + if (start < 1 || start > lines.length) { + return original; + } + + if (diff.type === 'insertion') { + // Insert the new lines at the insertion point. + const insertPos = Math.min(start, lines.length); + const newLines = (diff.code ?? '').split('\n'); + lines.splice(insertPos, 0, ...newLines); + return lines.slice(1).join('\n'); + } + + if (diff.type === 'deletion' || diff.type === 'edit') { + // For edits and deletions we have an originalEndLine. + const end = diff.originalEndLine; + if (end < start || start >= lines.length) { + return original; + } + + if (diff.type === 'deletion') { + // Drop the lines that were deleted in the new text. + const deleteCount = Math.min(end - start + 1, lines.length - start); + lines.splice(start, deleteCount); + } else { + // Replace the original range with the new content. + const deleteCount = Math.min(end - start + 1, lines.length - start); + const newLines = (diff.code ?? '').split('\n'); + lines.splice(start, deleteCount, ...newLines); + } + } -const numLinesOfStr = (str: string) => str.split('\n').length + return lines.slice(1).join('\n'); +} export const getLengthOfTextPx = ({ tabWidth, spaceWidth, content }: { tabWidth: number, spaceWidth: number, content: string }) => { @@ -61,12 +473,11 @@ export const getLengthOfTextPx = ({ tabWidth, spaceWidth, content }: { tabWidth: lengthOfTextPx += spaceWidth; } } - return lengthOfTextPx } -const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number => { +export const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number => { const model = editor.getModel(); if (!model) { @@ -99,89 +510,388 @@ const getLeadingWhitespacePx = (editor: ICodeEditor, startLine: number): number return leftWhitespacePx; }; +export class EditCodeService extends Disposable implements IEditCodeService { + _serviceBrand: undefined; -// Helper function to remove whitespace except newlines -const removeWhitespaceExceptNewlines = (str: string): string => { - return str.replace(/[^\S\n]+/g, ''); -} + // URI <--> model + diffAreasOfURI: Record | undefined> = {}; // uri -> diffareaId + + diffAreaOfId: Record = {}; // diffareaId -> diffArea + diffOfId: Record = {}; // diffid -> diff (redundant with diffArea._diffOfId) + + // events + + // uri: diffZones // listen on change diffZones + private readonly _onDidAddOrDeleteDiffZones = new Emitter<{ uri: URI }>(); + onDidAddOrDeleteDiffZones = this._onDidAddOrDeleteDiffZones.event; + + // diffZone: [uri], diffs, isStreaming // listen on change diffs, change streaming (uri is const) + private readonly _onDidChangeDiffsInDiffZoneNotStreaming = new Emitter<{ uri: URI, diffareaid: number }>(); + private readonly _onDidChangeStreamingInDiffZone = new Emitter<{ uri: URI, diffareaid: number }>(); + onDidChangeDiffsInDiffZoneNotStreaming = this._onDidChangeDiffsInDiffZoneNotStreaming.event; + onDidChangeStreamingInDiffZone = this._onDidChangeStreamingInDiffZone.event; + + // fired when instant apply fell back to locating ORIGINAL snippets and retried + private readonly _onDidUseFallback = new Emitter<{ uri: URI; message?: string }>(); + public readonly onDidUseFallback = this._onDidUseFallback.event; + + // ctrlKZone: [uri], isStreaming // listen on change streaming + private readonly _onDidChangeStreamingInCtrlKZone = new Emitter<{ uri: URI; diffareaid: number }>(); + onDidChangeStreamingInCtrlKZone = this._onDidChangeStreamingInCtrlKZone.event; + // remember last fallback message per file so UI can rehydrate state after status changes + private _lastFallbackMsgByFsPath = new Map(); + // optional public binding from applyBoxId -> uri for UI convenience + private _applyBoxIdToUri = new Map() + private _astContextByFsPath = new Map(); + private _astWarmupByFsPath = new Map>(); + private _treeSitterImportPromise: Promise | null = null; + private _treeSitterInitPromise: Promise | null = null; + private _bundledParserByGrammar = new Map(); + private _bundledLanguageByGrammar = new Map(); -// finds block.orig in fileContents and return its range in file -// startingAtLine is 1-indexed and inclusive -// returns 1-indexed lines -const findTextInCode = (text: string, fileContents: string, canFallbackToRemoveWhitespace: boolean, opts: { startingAtLine?: number, returnType: 'lines' }) => { + public bindApplyBoxUri(applyBoxId: string, uri: URI) { + this._applyBoxIdToUri.set(applyBoxId, uri) + } - const returnAns = (fileContents: string, idx: number) => { - const startLine = numLinesOfStr(fileContents.substring(0, idx + 1)) - const numLines = numLinesOfStr(text) - const endLine = startLine + numLines - 1 + public getUriByApplyBoxId(applyBoxId: string): URI | undefined { + return this._applyBoxIdToUri.get(applyBoxId) + } - return [startLine, endLine] as const + public getLastFallbackMessage(uri: URI): string | null { + return this._lastFallbackMsgByFsPath.get(uri.fsPath) ?? null } - const startingAtLineIdx = (fileContents: string) => opts?.startingAtLine !== undefined ? - fileContents.split('\n').slice(0, opts.startingAtLine).join('\n').length // num characters in all lines before startingAtLine - : 0 + public recordFallbackMessage(uri: URI, message: string) { + try { + this._lastFallbackMsgByFsPath.set(uri.fsPath, message); + } catch { /* ignore */ } + + try { + this._onDidUseFallback.fire({ uri, message }); + } catch { /* ignore */ } + } - // idx = starting index in fileContents - let idx = fileContents.indexOf(text, startingAtLineIdx(fileContents)) + private _isAstInferenceEnabled(): boolean { + try { + return !!this._settingsService.state.globalSettings.applyAstInference; + } catch { + return false; + } + } + + private async _promiseWithTimeout( + promise: Promise, + timeoutMs: number, + fallback: T, + label: string + ): Promise { + let timer: any; + let didTimeout = false; + const timeoutPromise = new Promise(resolve => { + timer = setTimeout(() => { + didTimeout = true; + resolve(fallback); + }, timeoutMs); + }); - // if idx was found - if (idx !== -1) { - return returnAns(fileContents, idx) + try { + return await Promise.race([promise, timeoutPromise]); + } catch (e) { + this.logService.debug(`[apply-ast] ${label} failed`, e); + return fallback; + } finally { + if (timer !== undefined) clearTimeout(timer); + if (didTimeout) { + this.logService.debug(`[apply-ast] ${label} timed out after ${timeoutMs}ms`); + } + } } - if (!canFallbackToRemoveWhitespace) - return 'Not found' as const + private _grammarNameForLanguageId(languageId: string): string | null { + const id = String(languageId || '').toLowerCase(); + const map: Record = { + typescript: 'tree-sitter-typescript', + typescriptreact: 'tree-sitter-tsx', + tsx: 'tree-sitter-tsx', + javascript: 'tree-sitter-javascript', + javascriptreact: 'tree-sitter-tsx', + css: 'tree-sitter-css', + ini: 'tree-sitter-ini', + regex: 'tree-sitter-regex', + python: 'tree-sitter-python', + go: 'tree-sitter-go', + java: 'tree-sitter-java', + csharp: 'tree-sitter-c-sharp', + 'c#': 'tree-sitter-c-sharp', + cpp: 'tree-sitter-cpp', + 'c++': 'tree-sitter-cpp', + php: 'tree-sitter-php', + ruby: 'tree-sitter-ruby', + rust: 'tree-sitter-rust', + }; + return map[id] ?? null; + } + + private async _getTreeSitterImport() { + if (!this._treeSitterImportPromise) { + this._treeSitterImportPromise = importAMDNodeModule( + '@vscode/tree-sitter-wasm', + 'wasm/tree-sitter.js' + ); + } + return this._treeSitterImportPromise; + } + + private async _ensureBundledTreeSitterInitialized() { + const mod = await this._getTreeSitterImport(); + if (!this._treeSitterInitPromise) { + const parserWasmPath = `${getModuleLocation(this._environmentService)}/tree-sitter.wasm` as AppResourcePath; + this._treeSitterInitPromise = mod.Parser.init({ + locateFile: () => FileAccess.asBrowserUri(parserWasmPath).toString(true) + }).then(() => undefined); + } + await this._treeSitterInitPromise; + return mod; + } + + private async _getBundledLanguage(grammarName: string): Promise { + const cached = this._bundledLanguageByGrammar.get(grammarName); + if (cached) return cached; - // try to find it ignoring all whitespace this time - text = removeWhitespaceExceptNewlines(text) - fileContents = removeWhitespaceExceptNewlines(fileContents) - idx = fileContents.indexOf(text, startingAtLineIdx(fileContents)); + try { + const mod = await this._ensureBundledTreeSitterInitialized(); + const wasmPath = `${getModuleLocation(this._environmentService)}/${grammarName}.wasm` as AppResourcePath; + const data = await this._fileService.readFile(FileAccess.asFileUri(wasmPath)); + const language = await mod.Language.load(data.value.buffer); + this._bundledLanguageByGrammar.set(grammarName, language); + return language; + } catch (e) { + this.logService.debug('[apply-ast] Failed to load bundled language', grammarName, e); + return null; + } + } - if (idx === -1) return 'Not found' as const - const lastIdx = fileContents.lastIndexOf(text) - if (lastIdx !== idx) return 'Not unique' as const + private async _getBundledParser(grammarName: string): Promise { + const language = await this._getBundledLanguage(grammarName); + if (!language) return null; - return returnAns(fileContents, idx) -} + let parser = this._bundledParserByGrammar.get(grammarName); + if (!parser) { + const mod = await this._ensureBundledTreeSitterInitialized(); + parser = new mod.Parser(); + this._bundledParserByGrammar.set(grammarName, parser); + } + parser.setLanguage(language); + return parser; + } + private _isInterestingAstNodeType(nodeType: string, span: number): boolean { + const t = nodeType.toLowerCase(); + if (span < 20) return false; + if (t === 'program' || t === 'source_file' || t === 'module') return span >= 120; + return /(function|method|class|interface|enum|struct|impl|namespace|module|declaration|definition|statement_block|block|object|trait|record|lambda|arrow_function|closure|if_statement|for_statement|while_statement|switch_statement|try_statement)/.test(t); + } -// line/col is the location, originalCodeStartLine is the start line of the original code being displayed -type StreamLocationMutable = { line: number, col: number, addedSplitYet: boolean, originalCodeStartLine: number } + private _collectAstCandidates(tree: Parser.Tree): InferenceAstContext['candidates'] { + const candidates: InferenceAstContext['candidates'] = []; + const seen = new Set(); + const cursor = tree.rootNode.walk(); + let goDown = true; + let visited = 0; + try { + while (true) { + if (goDown) { + const startOffset = cursor.startIndex; + const endOffset = cursor.endIndex; + visited += 1; + + if (visited > 45000 || candidates.length >= 1600) break; + + if (endOffset > startOffset && this._isInterestingAstNodeType(cursor.nodeType, endOffset - startOffset)) { + const key = `${startOffset}:${endOffset}`; + if (!seen.has(key)) { + seen.add(key); + candidates.push({ startOffset, endOffset, nodeType: cursor.nodeType }); + } + } + if (cursor.gotoFirstChild()) continue; + goDown = false; + } -class EditCodeService extends Disposable implements IEditCodeService { - _serviceBrand: undefined; + if (cursor.gotoNextSibling()) { + goDown = true; + continue; + } + if (!cursor.gotoParent()) break; + } + } finally { + cursor.delete(); + } - // URI <--> model - diffAreasOfURI: Record | undefined> = {}; // uri -> diffareaId + candidates.sort((a, b) => a.startOffset - b.startOffset); + return candidates; + } - diffAreaOfId: Record = {}; // diffareaId -> diffArea - diffOfId: Record = {}; // diffid -> diff (redundant with diffArea._diffOfId) + private _putAstContextCache(uri: URI, model: ITextModel | null, astContext: InferenceAstContext | null): void { + if (!model) return; + const entry = { + versionId: model.getVersionId(), + languageId: model.getLanguageId(), + astContext + }; + this._astContextByFsPath.delete(uri.fsPath); + this._astContextByFsPath.set(uri.fsPath, entry); - // events + const maxEntries = 80; + while (this._astContextByFsPath.size > maxEntries) { + const oldest = this._astContextByFsPath.keys().next().value as string | undefined; + if (!oldest) break; + this._astContextByFsPath.delete(oldest); + } + } - // uri: diffZones // listen on change diffZones - private readonly _onDidAddOrDeleteDiffZones = new Emitter<{ uri: URI }>(); - onDidAddOrDeleteDiffZones = this._onDidAddOrDeleteDiffZones.event; + private _getCachedAstContext(uri: URI, model: ITextModel | null): InferenceAstContext | null { + if (!model) return null; + const cached = this._astContextByFsPath.get(uri.fsPath); + if (!cached) return null; + if (cached.versionId !== model.getVersionId()) return null; + if (cached.languageId !== model.getLanguageId()) return null; + return cached.astContext; + } - // diffZone: [uri], diffs, isStreaming // listen on change diffs, change streaming (uri is const) - private readonly _onDidChangeDiffsInDiffZoneNotStreaming = new Emitter<{ uri: URI, diffareaid: number }>(); - private readonly _onDidChangeStreamingInDiffZone = new Emitter<{ uri: URI, diffareaid: number }>(); - onDidChangeDiffsInDiffZoneNotStreaming = this._onDidChangeDiffsInDiffZoneNotStreaming.event; - onDidChangeStreamingInDiffZone = this._onDidChangeStreamingInDiffZone.event; + private async _buildAstContextFromService(model: ITextModel): Promise { + try { + let tree = this._treeSitterParserService.getParseResult(model)?.parseResult?.tree; + if (!tree) { + const textModelTree = await this._promiseWithTimeout( + this._treeSitterParserService.getTextModelTreeSitter(model, true), + 500, + null, + 'service.getTextModelTreeSitter' + ); + if (textModelTree && !textModelTree.parseResult?.tree) { + await this._promiseWithTimeout( + textModelTree.parse(model.getLanguageId()), + 700, + undefined, + 'service.parse' + ); + } + tree = textModelTree?.parseResult?.tree; + } + if (!tree) return null; - // ctrlKZone: [uri], isStreaming // listen on change streaming - private readonly _onDidChangeStreamingInCtrlKZone = new Emitter<{ uri: URI; diffareaid: number }>(); - onDidChangeStreamingInCtrlKZone = this._onDidChangeStreamingInCtrlKZone.event; + const candidates = this._collectAstCandidates(tree); + if (candidates.length === 0) return null; + return { candidates, languageId: model.getLanguageId(), source: 'service' }; + } catch { + return null; + } + } + + private async _buildAstContextFromBundled(fileText: string, languageId: string): Promise { + const grammar = this._grammarNameForLanguageId(languageId); + if (!grammar) return null; + + const parser = await this._getBundledParser(grammar); + if (!parser) return null; + + let tree: Parser.Tree | null = null; + try { + tree = parser.parse(fileText); + if (!tree) return null; + const candidates = this._collectAstCandidates(tree); + if (candidates.length === 0) return null; + return { candidates, languageId, source: 'bundled' }; + } catch (e) { + this.logService.debug('[apply-ast] Bundled parser failed', languageId, e); + return null; + } finally { + tree?.delete?.(); + } + } + + private async _getOrBuildAstContext(uri: URI, fileText: string, model: ITextModel | null): Promise { + if (!this._isAstInferenceEnabled()) return null; + if (!fileText || fileText.length < 24) return null; + if (fileText.length > 2_000_000) return null; + + const cached = this._getCachedAstContext(uri, model); + if (cached) return cached; + + let astContext: InferenceAstContext | null = null; + if (model) { + astContext = await this._promiseWithTimeout( + this._buildAstContextFromService(model), + 900, + null, + 'buildAstContextFromService' + ); + } + if (!astContext) { + astContext = await this._promiseWithTimeout( + this._buildAstContextFromBundled(fileText, model?.getLanguageId() ?? ''), + 900, + null, + 'buildAstContextFromBundled' + ); + } + + this._putAstContextCache(uri, model, astContext); + return astContext; + } + + private async _prewarmAstForUri(uri: URI): Promise { + if (!this._isAstInferenceEnabled()) return; + if (this._astWarmupByFsPath.has(uri.fsPath)) { + await this._astWarmupByFsPath.get(uri.fsPath); + return; + } + + const warmupPromise = (async () => { + const model = this._modelService.getModel(uri); + if (!model) return; + const cached = this._getCachedAstContext(uri, model); + if (cached) return; + const astContext = await this._promiseWithTimeout( + this._buildAstContextFromService(model), + 900, + null, + 'prewarm.buildAstContextFromService' + ); + this._putAstContextCache(uri, model, astContext); + })(); + + this._astWarmupByFsPath.set(uri.fsPath, warmupPromise); + try { + await warmupPromise; + } finally { + this._astWarmupByFsPath.delete(uri.fsPath); + } + } + + public async inferSelectionForApply({ + uri, + codeStr, + fileText + }: { + uri: URI; + codeStr: string; + fileText: string; + }): Promise<{ text: string; range: [number, number] } | null> { + if (!codeStr || !fileText) return null; + const model = this._modelService.getModel(uri); + const astContext = await this._getOrBuildAstContext(uri, fileText, model); + return inferSelectionFromCode({ codeStr, fileText, astContext: astContext ?? undefined }); + } constructor( - // @IHistoryService private readonly _historyService: IHistoryService, // history service is the history of pressing alt left/right @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, @IModelService private readonly _modelService: IModelService, @IUndoRedoService private readonly _undoRedoService: IUndoRedoService, // undoRedo service is the history of pressing ctrl+z @@ -191,11 +901,14 @@ class EditCodeService extends Disposable implements IEditCodeService { @IConsistentEditorItemService private readonly _consistentEditorItemService: IConsistentEditorItemService, @IMetricsService private readonly _metricsService: IMetricsService, @INotificationService private readonly _notificationService: INotificationService, - // @ICommandService private readonly _commandService: ICommandService, @IVoidSettingsService private readonly _settingsService: IVoidSettingsService, - // @IFileService private readonly _fileService: IFileService, @IVoidModelService private readonly _voidModelService: IVoidModelService, @IConvertToLLMMessageService private readonly _convertToLLMMessageService: IConvertToLLMMessageService, + @ILogService private readonly logService: ILogService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + @ITreeSitterParserService private readonly _treeSitterParserService: ITreeSitterParserService, + @IFileService private readonly _fileService: IFileService, + @IEnvironmentService private readonly _environmentService: IEnvironmentService, ) { super(); @@ -240,10 +953,89 @@ class EditCodeService extends Disposable implements IEditCodeService { // add listeners for all existing editors + listen for editor being added for (let editor of this._codeEditorService.listCodeEditors()) { initializeEditor(editor) } this._register(this._codeEditorService.onCodeEditorAdd(editor => { initializeEditor(editor) })) + } + + private _getWorkspaceRelativePathForCmd(uri: URI): string { + try { + const ws = this._workspaceContextService.getWorkspace(); + const folders = ws?.folders ?? []; + if (folders.length === 0) return uri.fsPath; + + const norm = (p: string) => String(p ?? '').replace(/\\/g, '/').replace(/\/+$/g, ''); + const file = norm(uri.fsPath); + + for (const f of folders) { + const root = norm(f.uri.fsPath); + if (!root) continue; + + if (file === root) return '.'; + if (file.startsWith(root + '/')) { + const rel = file.slice(root.length + 1); + return rel || '.'; + } + } + } catch { /* ignore */ } + + return uri.fsPath; + } + + private async _formatDocumentAtUri(uri: URI): Promise { + try { + const editor = this._codeEditorService + .listCodeEditors() + .find(e => e.getModel()?.uri?.fsPath === uri.fsPath); + + if (!editor) { + this.logService.debug('[format] No editor found for uri:', uri.fsPath); + return; + } + const action = editor.getAction?.('editor.action.formatDocument'); + if (!action) { + this.logService.debug('[format] No formatDocument action on editor for uri:', uri.fsPath); + return; + } + + this.logService.debug('[format] Running editor.action.formatDocument for:', uri.fsPath); + await action.run(); + + } catch (e: any) { + this.logService.warn('[format] Failed to format document:', uri.fsPath, e); + } + } + + public hasIdleDiffZoneForApplyBox(uri: URI, applyBoxId: string): boolean { + const setIds = this.diffAreasOfURI?.[uri.fsPath]; + if (!setIds || setIds.size === 0) return false; + + for (const id of Array.from(setIds)) { + const da = this.diffAreaOfId?.[id]; + if (da && da.type === 'DiffZone' && !da._streamState?.isStreaming && da.applyBoxId === applyBoxId) { + return true; + } + } + return false; + } + private _getEditFileSimpleDiffZoneForApplyBox(uri: URI, applyBoxId: string): (DiffZone & { _editFileSimple?: any }) | null { + const setIds = this.diffAreasOfURI?.[uri.fsPath]; + if (!setIds || setIds.size === 0) return null; + + for (const id of Array.from(setIds)) { + const da = this.diffAreaOfId?.[id]; + if (da && da.type === 'DiffZone' && (da as any)._editFileSimple && da.applyBoxId === applyBoxId) { + return da as any; + } + } + return null; } + public async applyEditFileSimpleForApplyBox({ uri, applyBoxId }: { uri: URI; applyBoxId: string }): Promise { + const dz = this._getEditFileSimpleDiffZoneForApplyBox(uri, applyBoxId); + if (!dz) return false; + await this.applyEditFileSimpleFromDiffZone(dz as any); + return true; + } private _onUserChangeContent(uri: URI, e: IModelContentChangedEvent) { for (const change of e.changes) { @@ -268,34 +1060,12 @@ class EditCodeService extends Disposable implements IEditCodeService { } - public processRawKeybindingText(keybindingStr: string): string { return keybindingStr .replace(/Enter/g, '↵') // ⏎ .replace(/Backspace/g, '⌫'); } - // private _notifyError = (e: Parameters[0]) => { - // const details = errorDetails(e.fullError) - // this._notificationService.notify({ - // severity: Severity.Warning, - // message: `Void Error: ${e.message}`, - // actions: { - // secondary: [{ - // id: 'void.onerror.opensettings', - // enabled: true, - // label: `Open Void's settings`, - // tooltip: '', - // class: undefined, - // run: () => { this._commandService.executeCommand(VOID_OPEN_SETTINGS_ACTION_ID) } - // }] - // }, - // source: details ? `(Hold ${isMacintosh ? 'Option' : 'Alt'} to hover) - ${details}\n\nIf this persists, feel free to [report](https://github.com/voideditor/void/issues/new) it.` : undefined - // }) - // } - - - // highlight the region private _addLineDecoration = (model: ITextModel | null, startLine: number, endLine: number, className: string, options?: Partial) => { if (model === null) return @@ -313,7 +1083,6 @@ class EditCodeService extends Disposable implements IEditCodeService { return disposeHighlight } - private _addDiffAreaStylesToURI = (uri: URI) => { const { model } = this._voidModelService.getModel(uri) @@ -342,7 +1111,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } } - private _computeDiffsAndAddStylesToURI = (uri: URI) => { const { model } = this._voidModelService.getModel(uri) if (model === null) return @@ -368,8 +1136,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } } - - mostRecentTextOfCtrlKZoneId: Record = {} private _addCtrlKZoneInput = (ctrlKZone: CtrlKZone) => { @@ -414,13 +1180,13 @@ class EditCodeService extends Disposable implements IEditCodeService { textAreaRef.current = r if (!textAreaRef.current) return - if (!(ctrlKZone.diffareaid in this.mostRecentTextOfCtrlKZoneId)) { // detect first mount this way (a hack) + if (!(ctrlKZone.diffareaid in this.mostRecentTextOfCtrlKZoneId)) { this.mostRecentTextOfCtrlKZoneId[ctrlKZone.diffareaid] = undefined setTimeout(() => textAreaRef.current?.focus(), 100) } }, onChangeHeight(height) { - if (height === 0) return // the viewZone sets this height to the container if it's out of view, ignore it + if (height === 0) return viewZone.heightInPx = height // re-render with this new height editor.changeViewZones(accessor => { @@ -455,15 +1221,12 @@ class EditCodeService extends Disposable implements IEditCodeService { } satisfies CtrlKZone['_mountInfo'] } - - private _refreshCtrlKInputs = async (uri: URI) => { for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] if (diffArea.type !== 'CtrlKZone') continue if (!diffArea._mountInfo) { diffArea._mountInfo = this._addCtrlKZoneInput(diffArea) - console.log('MOUNTED CTRLK', diffArea.diffareaid) } else { diffArea._mountInfo.refresh() @@ -471,7 +1234,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } } - private _addDiffStylesToURI = (uri: URI, diff: Diff) => { const { type, diffid } = diff @@ -491,9 +1253,9 @@ class EditCodeService extends Disposable implements IEditCodeService { // red in a view zone if (type !== 'insertion') { - const consistentZoneId = this._consistentItemService.addConsistentItemToURI({ + const consistentZoneId = (this._consistentItemService as any)?.addConsistentItemToURI?.({ uri, - fn: (editor) => { + fn: (editor: ICodeEditor) => { const domNode = document.createElement('div'); domNode.className = 'void-redBG' @@ -553,9 +1315,11 @@ class EditCodeService extends Disposable implements IEditCodeService { editor.changeViewZones(accessor => { zoneId = accessor.addZone(viewZone) }) return () => editor.changeViewZones(accessor => { if (zoneId) accessor.removeZone(zoneId) }) }, - }) + }) ?? null - disposeInThisEditorFns.push(() => { this._consistentItemService.removeConsistentItemFromURI(consistentZoneId) }) + if (consistentZoneId !== null) { + disposeInThisEditorFns.push(() => { (this._consistentItemService as any)?.removeConsistentItemFromURI?.(consistentZoneId) }) + } } @@ -564,9 +1328,9 @@ class EditCodeService extends Disposable implements IEditCodeService { const diffZone = this.diffAreaOfId[diff.diffareaid] if (diffZone.type === 'DiffZone' && !diffZone._streamState.isStreaming) { // Accept | Reject widget - const consistentWidgetId = this._consistentItemService.addConsistentItemToURI({ + const consistentWidgetId = (this._consistentItemService as any)?.addConsistentItemToURI?.({ uri, - fn: (editor) => { + fn: (editor: ICodeEditor) => { let startLine: number let offsetLines: number if (diff.type === 'insertion' || diff.type === 'edit') { @@ -590,8 +1354,18 @@ class EditCodeService extends Disposable implements IEditCodeService { const buttonsWidget = this._instantiationService.createInstance(AcceptRejectInlineWidget, { editor, onAccept: () => { - this.acceptDiff({ diffid }) - this._metricsService.capture('Accept Diff', { diffid }) + try { + const currentDiffZone = this.diffAreaOfId[diff.diffareaid] + const isEditFile = currentDiffZone && (currentDiffZone as any)._editFileSimple + void this.acceptDiff({ diffid }).then(() => { + this._metricsService.capture(isEditFile ? 'Accept Diff (edit_file)' : 'Accept Diff', { diffid }) + }).catch((e) => { + this._notificationService?.warn?.(`Accept failed: ${e?.message ?? String(e)}`) + this.logService.error('acceptDiff error:', e) + }) + } catch (e) { + this.logService.error('Error in onAccept handler:', e) + } }, onReject: () => { this.rejectDiff({ diffid }) @@ -603,8 +1377,10 @@ class EditCodeService extends Disposable implements IEditCodeService { }) return () => { buttonsWidget.dispose() } } - }) - disposeInThisEditorFns.push(() => { this._consistentItemService.removeConsistentItemFromURI(consistentWidgetId) }) + }) ?? null + if (consistentWidgetId !== null) { + disposeInThisEditorFns.push(() => { (this._consistentItemService as any)?.removeConsistentItemFromURI?.(consistentWidgetId) }) + } } const disposeInEditor = () => { disposeInThisEditorFns.forEach(f => f()) } @@ -612,9 +1388,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } - - - private _getActiveEditorURI(): URI | null { const editor = this._codeEditorService.getActiveCodeEditor() if (!editor) return null @@ -624,6 +1397,7 @@ class EditCodeService extends Disposable implements IEditCodeService { } weAreWriting = false + private readonly _activeBulkAcceptRejectUris = new Set() private _writeURIText(uri: URI, text: string, range_: IRange | 'wholeFileRange', { shouldRealignDiffAreas, }: { shouldRealignDiffAreas: boolean, }) { const { model } = this._voidModelService.getModel(uri) if (!model) { @@ -641,7 +1415,6 @@ class EditCodeService extends Disposable implements IEditCodeService { const oldRange = range this._realignAllDiffAreasLines(uri, newText, oldRange) } - const uriStr = model.getValue(EndOfLinePreference.LF) // heuristic check @@ -659,10 +1432,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } - - - - private _getCurrentVoidFileSnapshot = (uri: URI): VoidFileSnapshot => { const { model } = this._voidModelService.getModel(uri) const snapshottedDiffAreaOfId: Record = {} @@ -733,10 +1502,9 @@ class EditCodeService extends Disposable implements IEditCodeService { 'wholeFileRange', { shouldRealignDiffAreas: false } ) - // this._noLongerNeedModelReference(uri) } - private _addToHistory(uri: URI, opts?: { onWillUndo?: () => void }) { + private _addToHistory(uri: URI, opts?: { onWillUndo?: () => void; save?: boolean }) { const beforeSnapshot: VoidFileSnapshot = this._getCurrentVoidFileSnapshot(uri) let afterSnapshot: VoidFileSnapshot | null = null @@ -752,7 +1520,9 @@ class EditCodeService extends Disposable implements IEditCodeService { const onFinishEdit = async () => { afterSnapshot = this._getCurrentVoidFileSnapshot(uri) - await this._voidModelService.saveModel(uri) + if (opts?.save !== false) { + await this._voidModelService.saveModel(uri) + } } return { onFinishEdit } } @@ -801,7 +1571,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } } - // delete all diffs, update diffAreaOfId, update diffAreasOfModelId private _deleteDiffZone(diffZone: DiffZone) { this._clearAllDiffAreaEffects(diffZone) @@ -810,11 +1579,6 @@ class EditCodeService extends Disposable implements IEditCodeService { this._onDidAddOrDeleteDiffZones.fire({ uri: diffZone._URI }) } - private _deleteTrackingZone(trackingZone: TrackingZone) { - delete this.diffAreaOfId[trackingZone.diffareaid] - this.diffAreasOfURI[trackingZone._URI.fsPath]?.delete(trackingZone.diffareaid.toString()) - } - private _deleteCtrlKZone(ctrlKZone: CtrlKZone) { this._clearAllEffects(ctrlKZone._URI) ctrlKZone._mountInfo?.dispose() @@ -870,14 +1634,9 @@ class EditCodeService extends Disposable implements IEditCodeService { return newDiff } - - - // changes the start/line locations of all DiffAreas on the page (adjust their start/end based on the change) based on the change that was recently made private _realignAllDiffAreasLines(uri: URI, text: string, recentChange: { startLineNumber: number; endLineNumber: number }) { - // console.log('recent change', recentChange) - // compute net number of newlines lines that were added/removed const startLine = recentChange.startLineNumber const endLine = recentChange.endLineNumber @@ -890,12 +1649,10 @@ class EditCodeService extends Disposable implements IEditCodeService { // if the diffArea is entirely above the range, it is not affected if (diffArea.endLine < startLine) { - // console.log('CHANGE FULLY BELOW DA (doing nothing)') continue } // if a diffArea is entirely below the range, shift the diffArea up/down by the delta amount of newlines else if (endLine < diffArea.startLine) { - // console.log('CHANGE FULLY ABOVE DA') const changedRangeHeight = endLine - startLine + 1 const deltaNewlines = newTextHeight - changedRangeHeight diffArea.startLine += deltaNewlines @@ -903,20 +1660,17 @@ class EditCodeService extends Disposable implements IEditCodeService { } // if the diffArea fully contains the change, elongate it by the delta amount of newlines else if (startLine >= diffArea.startLine && endLine <= diffArea.endLine) { - // console.log('DA FULLY CONTAINS CHANGE') const changedRangeHeight = endLine - startLine + 1 const deltaNewlines = newTextHeight - changedRangeHeight diffArea.endLine += deltaNewlines } // if the change fully contains the diffArea, make the diffArea have the same range as the change else if (diffArea.startLine > startLine && diffArea.endLine < endLine) { - // console.log('CHANGE FULLY CONTAINS DA') diffArea.startLine = startLine diffArea.endLine = startLine + newTextHeight } // if the change contains only the diffArea's top else if (startLine < diffArea.startLine && diffArea.startLine <= endLine) { - // console.log('CHANGE CONTAINS TOP OF DA ONLY') const numOverlappingLines = endLine - diffArea.startLine + 1 const numRemainingLinesInDA = diffArea.endLine - diffArea.startLine + 1 - numOverlappingLines const newHeight = (numRemainingLinesInDA - 1) + (newTextHeight - 1) + 1 @@ -925,7 +1679,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } // if the change contains only the diffArea's bottom else if (startLine <= diffArea.endLine && diffArea.endLine < endLine) { - // console.log('CHANGE CONTAINS BOTTOM OF DA ONLY') const numOverlappingLines = diffArea.endLine - startLine + 1 diffArea.endLine += newTextHeight - numOverlappingLines } @@ -933,8 +1686,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } - - private _fireChangeDiffsIfNotStreaming(uri: URI) { for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] @@ -946,7 +1697,6 @@ class EditCodeService extends Disposable implements IEditCodeService { } } - private _refreshStylesAndDiffsInURI(uri: URI) { // 1. clear DiffArea styles and Diffs @@ -965,88 +1715,6 @@ class EditCodeService extends Disposable implements IEditCodeService { this._fireChangeDiffsIfNotStreaming(uri) } - - - - // @throttle(100) - private _writeStreamedDiffZoneLLMText(uri: URI, originalCode: string, llmTextSoFar: string, deltaText: string, latestMutable: StreamLocationMutable) { - - let numNewLines = 0 - - // ----------- 1. Write the new code to the document ----------- - // figure out where to highlight based on where the AI is in the stream right now, use the last diff to figure that out - const computedDiffs = findDiffs(originalCode, llmTextSoFar) - - // if streaming, use diffs to figure out where to write new code - // these are two different coordinate systems - new and old line number - let endLineInLlmTextSoFar: number // get file[diffArea.startLine...newFileEndLine] with line=newFileEndLine highlighted - let startLineInOriginalCode: number // get original[oldStartingPoint...] (line in the original code, so starts at 1) - - const lastDiff = computedDiffs.pop() - - if (!lastDiff) { - // console.log('!lastDiff') - // if the writing is identical so far, display no changes - startLineInOriginalCode = 1 - endLineInLlmTextSoFar = 1 - } - else { - startLineInOriginalCode = lastDiff.originalStartLine - if (lastDiff.type === 'insertion' || lastDiff.type === 'edit') - endLineInLlmTextSoFar = lastDiff.endLine - else if (lastDiff.type === 'deletion') - endLineInLlmTextSoFar = lastDiff.startLine - else - throw new Error(`Void: diff.type not recognized on: ${lastDiff}`) - } - - // at the start, add a newline between the stream and originalCode to make reasoning easier - if (!latestMutable.addedSplitYet) { - this._writeURIText(uri, '\n', - { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col, }, - { shouldRealignDiffAreas: true } - ) - latestMutable.addedSplitYet = true - numNewLines += 1 - } - - // insert deltaText at latest line and col - this._writeURIText(uri, deltaText, - { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col }, - { shouldRealignDiffAreas: true } - ) - const deltaNumNewLines = deltaText.split('\n').length - 1 - latestMutable.line += deltaNumNewLines - const lastNewlineIdx = deltaText.lastIndexOf('\n') - latestMutable.col = lastNewlineIdx === -1 ? latestMutable.col + deltaText.length : deltaText.length - lastNewlineIdx - numNewLines += deltaNumNewLines - - // delete or insert to get original up to speed - if (latestMutable.originalCodeStartLine < startLineInOriginalCode) { - // moved up, delete - const numLinesDeleted = startLineInOriginalCode - latestMutable.originalCodeStartLine - this._writeURIText(uri, '', - { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line + numLinesDeleted, endColumn: Number.MAX_SAFE_INTEGER, }, - { shouldRealignDiffAreas: true } - ) - numNewLines -= numLinesDeleted - } - else if (latestMutable.originalCodeStartLine > startLineInOriginalCode) { - const newText = '\n' + originalCode.split('\n').slice((startLineInOriginalCode - 1), (latestMutable.originalCodeStartLine - 1) - 1 + 1).join('\n') - this._writeURIText(uri, newText, - { startLineNumber: latestMutable.line, startColumn: latestMutable.col, endLineNumber: latestMutable.line, endColumn: latestMutable.col }, - { shouldRealignDiffAreas: true } - ) - numNewLines += newText.split('\n').length - 1 - } - latestMutable.originalCodeStartLine = startLineInOriginalCode - - return { endLineInLlmTextSoFar, numNewLines } // numNewLines here might not be correct.... - } - - - - // called first, then call startApplying public addCtrlKZone({ startLine, endLine, editor }: AddCtrlKOpts) { @@ -1104,10 +1772,9 @@ class EditCodeService extends Disposable implements IEditCodeService { onFinishEdit() } - - - private _getURIBeforeStartApplying(opts: CallBeforeStartApplyingOpts) { + this.logService.debug('[DEBUG] _getURIBeforeStartApplying opts:', JSON.stringify(opts, null, 2)); + // SR if (opts.from === 'ClickApply') { const uri = this._uriOfGivenURI(opts.uri) @@ -1115,95 +1782,265 @@ class EditCodeService extends Disposable implements IEditCodeService { return uri } else if (opts.from === 'QuickEdit') { - const { diffareaid } = opts + const { diffareaid } = opts as any + this.logService.debug('[DEBUG] QuickEdit branch, diffareaid:', diffareaid); const ctrlKZone = this.diffAreaOfId[diffareaid] - if (ctrlKZone?.type !== 'CtrlKZone') return + this.logService.debug('[DEBUG] ctrlKZone:', ctrlKZone); + if (ctrlKZone?.type !== 'CtrlKZone') { + this.logService.debug('[DEBUG] Invalid ctrlKZone or wrong type'); + return + } const { _URI: uri } = ctrlKZone + this.logService.debug('[DEBUG] URI from ctrlKZone:', uri.toString()); return uri } return } - public async callBeforeApplyOrEdit(givenURI: URI | 'current') { - const uri = this._uriOfGivenURI(givenURI) - if (!uri) return + public async callBeforeApplyOrEdit(givenURI: URI | 'current' | CallBeforeStartApplyingOpts) { + this.logService.debug('[DEBUG] callBeforeApplyOrEdit givenURI:', JSON.stringify(givenURI)); + + let uri: URI | undefined; + if (givenURI === 'current' || URI.isUri(givenURI)) { + uri = this._uriOfGivenURI(givenURI as URI | 'current'); + } else { + uri = this._getURIBeforeStartApplying(givenURI); + } + + if (!uri) { + this.logService.debug('[DEBUG] No URI found in callBeforeApplyOrEdit'); + return + } + this.logService.debug('[DEBUG] Initializing model with URI:', JSON.stringify(uri)); await this._voidModelService.initializeModel(uri) await this._voidModelService.saveModel(uri) // save the URI + this._prewarmAstForUri(uri).catch(e => { + this.logService.debug('[apply-ast] prewarm failed', uri.fsPath, e); + }); } - - // the applyDonePromise this returns can reject, and should be caught with .catch public startApplying(opts: StartApplyingOpts): [URI, Promise] | null { + this.logService.debug('[startApplying] Called with opts:', JSON.stringify({ + from: opts.from, + uri: (opts as any).uri?.toString?.(), + applyStr: (opts as any).applyStr?.substring?.(0, 100) + '...', + applyBoxId: (opts as any).applyBoxId + })) + let res: [DiffZone, Promise] | undefined = undefined if (opts.from === 'QuickEdit') { - res = this._initializeWriteoverStream(opts) // rewrite + this.logService.debug('[startApplying] QuickEdit branch') + res = this._initializeWriteoverStream(opts) } else if (opts.from === 'ClickApply') { - if (this._settingsService.state.globalSettings.enableFastApply) { - const numCharsInFile = this._fileLengthOfGivenURI(opts.uri) - if (numCharsInFile === null) return null - if (numCharsInFile < 1000) { // slow apply for short files (especially important for empty files) - res = this._initializeWriteoverStream(opts) - } - else { - res = this._initializeSearchAndReplaceStream(opts) // fast apply - } - } - else { - res = this._initializeWriteoverStream(opts) // rewrite - } + this.logService.debug('[startApplying] ClickApply branch') + res = this._handleClickApply(opts) + } + + if (!res) { + this.logService.debug('[startApplying] No result, returning null') + return null } - if (!res) return null const [diffZone, applyDonePromise] = res + this.logService.debug('[startApplying] Success, returning URI:', diffZone._URI.toString()) return [diffZone._URI, applyDonePromise] } + private _handleClickApply(opts: StartApplyingOpts): [DiffZone, Promise] | undefined { + const startOpts = opts as any + this.logService.debug('[_handleClickApply] Start with opts:', JSON.stringify({ + uri: startOpts.uri?.toString?.(), + hasApplyStr: !!startOpts.applyStr, + applyStrLength: startOpts.applyStr?.length, + applyBoxId: startOpts.applyBoxId + })) - public instantlyApplySearchReplaceBlocks({ uri, searchReplaceBlocks }: { uri: URI, searchReplaceBlocks: string }) { - // start diffzone - const res = this._startStreamingDiffZone({ - uri, - streamRequestIdRef: { current: null }, - startBehavior: 'keep-conflicts', - linkedCtrlKZone: null, - onWillUndo: () => { }, - }) - if (!res) return - const { diffZone, onFinishEdit } = res - - - const onDone = () => { - diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) - this._refreshStylesAndDiffsInURI(uri) - onFinishEdit() - - // auto accept - if (this._settingsService.state.globalSettings.autoAcceptLLMChanges) { - this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: false, behavior: 'accept' }) - } + // Try to infer ORIGINAL and create preview + const inferredResult = this._tryInferAndPreview(startOpts) + if (inferredResult) { + this.logService.debug('[_handleClickApply] Inferred result found, using it') + return inferredResult } + // Fallback: reuse existing preview DiffZone + this.logService.debug('[_handleClickApply] No inferred result, falling back to existing preview') + const fallbackResult = this._previewAndPrepareEditFileSimple(startOpts) + this.logService.debug('[_handleClickApply] Fallback result:', fallbackResult ? 'found' : 'not found') + return fallbackResult + } - const onError = (e: { message: string; fullError: Error | null; }) => { - // this._notifyError(e) - onDone() - this._undoHistory(uri) - throw e.fullError || new Error(e.message) - } + private _getFileTextWithEol(uri: URI): { text: string | null, eol: '\n' | '\r\n' } { + try { + const { model } = this._voidModelService.getModel(uri) + if (!model) return { text: null, eol: '\n' } + const eol = (model.getEOL?.() === '\r\n') ? '\r\n' : '\n' + const text = model.getValue() + return { text, eol } + } catch (e) { + this.logService.error('[_getFileTextWithEol] Failed:', e) + return { text: null, eol: '\n' } + } + } + + private _inferOriginalSnippet(applyStr: string, fileText: string, uri?: URI): InferredBlock | null { + this.logService.debug('[_inferOriginalSnippet] Inferring with:', JSON.stringify({ + applyStrLength: applyStr.length, + fileTextLength: fileText.length, + applyStrPreview: applyStr.substring(0, 50) + '...' + })); try { - this._instantlyApplySRBlocks(uri, searchReplaceBlocks) - } - catch (e) { - onError({ message: e + '', fullError: null }) - } + const model = uri ? this._modelService.getModel(uri) : null; + const astContext = uri ? this._getCachedAstContext(uri, model) : null; + const inferred = inferExactBlockFromCode({ + codeStr: applyStr, + fileText, + astContext: astContext ?? undefined + }); + + this.logService.debug('[_inferOriginalSnippet] Inference result:', JSON.stringify({ + hasResult: !!inferred, + hasText: !!(inferred as any)?.text, + textLength: (inferred as any)?.text?.length, + range: (inferred as any)?.range, + offsets: (inferred as any)?.offsets, + occurrence: (inferred as any)?.occurrence, + astSource: astContext?.source ?? null, + preview: (inferred as any)?.text?.substring(0, 50) + '...' + })); + + if (!inferred || !(inferred as any).text) return inferred; + + const inferredText = String((inferred as any).text ?? ''); + const inferredNorm = normalizeEol(inferredText); + const applyNorm = normalizeEol(applyStr); + + const inferredLines = inferredNorm.split('\n').length; + const applyLines = applyNorm.split('\n').length; + + const inferredStart = + (Array.isArray((inferred as any).offsets) ? (inferred as any).offsets[0] : null) ?? + fileText.indexOf(inferredText); + + // Suspicious when: + // - inferred is a single line but applyStr is multi-line + // - inferred is very short compared to applyStr + // - inferred seems to be a prefix (common: "public foo({ a }") + const suspiciouslyShort = + (inferredLines === 1 && applyLines > 1) || + inferredText.length < 80 || + (inferredText.length < Math.max(40, Math.floor(applyStr.length * 0.25))); + + const canSafelyExpand = + suspiciouslyShort && + inferredStart !== -1 && + looksLikeFullTopLevelBlockSnippet(applyStr); + + if (!canSafelyExpand) { + return inferred; + } - onDone() - } + // Expand to full enclosing { ... } block, starting from the line start + const lineStart = Math.max(0, fileText.lastIndexOf('\n', Math.max(0, inferredStart - 1)) + 1); + const expanded = expandToEnclosingCurlyBlockJs(fileText, lineStart); + + if (!expanded || !expanded.text || expanded.text.length <= inferredText.length) { + this.logService.debug('[_inferOriginalSnippet] Expansion skipped (no better block found).', JSON.stringify({ + inferredStart, + inferredLen: inferredText.length, + expandedLen: expanded?.text?.length ?? null + })); + return inferred; + } + // Compute occurrence for expanded text based on start offset (so replace picks the right one if repeated) + const occurrences: number[] = []; + for (let from = 0; ;) { + const i = fileText.indexOf(expanded.text, from); + if (i === -1) break; + occurrences.push(i); + from = i + Math.max(1, expanded.text.length); + } + const occIdx = occurrences.indexOf(expanded.startOffset); + const occurrence = occIdx >= 0 ? (occIdx + 1) : 1; + + (inferred as any).text = expanded.text; + (inferred as any).range = expanded.range; + (inferred as any).offsets = [expanded.startOffset, expanded.endOffset]; + (inferred as any).occurrence = occurrence; + + this.logService.debug('[_inferOriginalSnippet] Expanded inferred ORIGINAL to enclosing block.', JSON.stringify({ + oldLen: inferredText.length, + newLen: expanded.text.length, + oldLines: inferredLines, + newLines: expanded.text.split('\n').length, + newRange: expanded.range, + occurrence + })); + + return inferred; + } catch (e) { + this.logService.error('[_inferOriginalSnippet] inferExactBlockFromCode failed:', JSON.stringify({ + error: (e as any)?.message || String(e), + applyStrLength: applyStr.length + })); + return null; + } + } + + private _tryInferAndPreview(startOpts: any): [DiffZone, Promise] | undefined { + this.logService.debug('[_tryInferAndPreview] Starting inference') + + const maybeUri = this._uriOfGivenURI(startOpts.uri) + this.logService.debug('[_tryInferAndPreview] Resolved URI:', maybeUri?.toString() || 'null') + if (!maybeUri) return undefined + + const { text: fileText, eol } = this._getFileTextWithEol(maybeUri) + this.logService.debug('[_tryInferAndPreview] File text retrieved:', JSON.stringify({ hasText: !!fileText, textLength: fileText?.length, eol })) + if (!fileText) return undefined + + const inferred = this._inferOriginalSnippet(startOpts.applyStr, fileText, maybeUri) + this.logService.debug('[_tryInferAndPreview] Inferred block:', JSON.stringify({ + ok: !!inferred, textLength: inferred?.text?.length, range: inferred?.range, offsets: inferred?.offsets, occurrence: inferred?.occurrence + })) + if (!inferred) return undefined + + + const foundAt = + (Array.isArray((inferred as any).offsets) ? (inferred as any).offsets[0] : null) ?? + fileText.indexOf(inferred.text); + this.logService.debug('[_tryInferAndPreview] Exact text recheck in model:', JSON.stringify({ foundAt })) + + const updatedWithModelEol = startOpts.applyStr.replace(/\r\n|\n/g, eol) + + const previewParams = { + uri: maybeUri, + originalSnippet: inferred.text, + updatedSnippet: updatedWithModelEol, + occurrence: inferred.occurrence, + replaceAll: false, + locationHint: { startLineNumber: inferred.range[0], endLineNumber: inferred.range[1] }, + encoding: null, + newline: eol, + applyBoxId: startOpts.applyBoxId, + } + + this.logService.debug('[_tryInferAndPreview] Calling _previewAndPrepareEditFileSimple with params:', JSON.stringify({ + uri: previewParams.uri.toString(), + originalLength: previewParams.originalSnippet.length, + updatedLength: previewParams.updatedSnippet.length, + occurrence: previewParams.occurrence, + locationHint: previewParams.locationHint, + newline: previewParams.newline, + applyBoxId: previewParams.applyBoxId + })) + + const result = this._previewAndPrepareEditFileSimple(previewParams) + this.logService.debug('[_tryInferAndPreview] Preview result:', result ? 'success' : 'failed') + return result + } public instantlyRewriteFile({ uri, newContent }: { uri: URI, newContent: string }) { // start diffzone @@ -1213,6 +2050,7 @@ class EditCodeService extends Disposable implements IEditCodeService { startBehavior: 'keep-conflicts', linkedCtrlKZone: null, onWillUndo: () => { }, + applyBoxId: undefined, }) if (!res) return const { diffZone, onFinishEdit } = res @@ -1223,51 +2061,44 @@ class EditCodeService extends Disposable implements IEditCodeService { this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) this._refreshStylesAndDiffsInURI(uri) onFinishEdit() - - // auto accept - if (this._settingsService.state.globalSettings.autoAcceptLLMChanges) { - this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: false, behavior: 'accept' }) - } } this._writeURIText(uri, newContent, 'wholeFileRange', { shouldRealignDiffAreas: true }) onDone() } - private _findOverlappingDiffArea({ startLine, endLine, uri, filter }: { startLine: number, endLine: number, uri: URI, filter?: (diffArea: DiffArea) => boolean }): DiffArea | null { // check if there's overlap with any other diffAreas and return early if there is for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { const diffArea = this.diffAreaOfId[diffareaid] - if (!diffArea) continue - if (!filter?.(diffArea)) continue - const noOverlap = diffArea.startLine > endLine || diffArea.endLine < startLine + if (!diffArea) { + continue; + } + if (!filter?.(diffArea)) { + continue; + } + const noOverlap = diffArea.startLine > endLine || diffArea.endLine < startLine; if (!noOverlap) { - return diffArea + return diffArea; } } - return null + return null; } - - - - - - - private _startStreamingDiffZone({ uri, startBehavior, streamRequestIdRef, linkedCtrlKZone, onWillUndo, + applyBoxId, }: { uri: URI, startBehavior: 'accept-conflicts' | 'reject-conflicts' | 'keep-conflicts', streamRequestIdRef: { current: string | null }, linkedCtrlKZone: CtrlKZone | null, onWillUndo: () => void, + applyBoxId?: string, }) { const { model } = this._voidModelService.getModel(uri) if (!model) return @@ -1292,7 +2123,7 @@ class EditCodeService extends Disposable implements IEditCodeService { } else { // keep conflict on whole file - to keep conflict, revert the change and use those contents as original, then un-revert the file - this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior: 'reject', _addToHistory: false }) + // this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior: 'reject', _addToHistory: false }) const oldFileStr = model.getValue(EndOfLinePreference.LF) // use this as original code this._writeURIText(uri, originalFileStr, 'wholeFileRange', { shouldRealignDiffAreas: true }) // un-revert originalCode = oldFileStr @@ -1300,8 +2131,9 @@ class EditCodeService extends Disposable implements IEditCodeService { } else if (startBehavior === 'accept-conflicts' || startBehavior === 'reject-conflicts') { - const behavior = startBehavior === 'accept-conflicts' ? 'accept' : 'reject' - this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior, _addToHistory: false }) + + // const behavior: 'accept' | 'reject' = startBehavior === 'accept-conflicts' ? 'accept' : 'reject' + // this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior, _addToHistory: false }) } const adding: Omit = { @@ -1317,9 +2149,11 @@ class EditCodeService extends Disposable implements IEditCodeService { }, _diffOfId: {}, // added later _removeStylesFns: new Set(), + applyBoxId: applyBoxId, } const diffZone = this._addDiffArea(adding) + this.logService.debug(`[_startStreamingDiffZone] Created DiffZone with applyBoxId: ${applyBoxId}, diffareaid: ${diffZone.diffareaid}`) this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) this._onDidAddOrDeleteDiffZones.fire({ uri }) @@ -1334,870 +2168,1442 @@ class EditCodeService extends Disposable implements IEditCodeService { return { diffZone, onFinishEdit } } - - - private _uriIsStreaming(uri: URI) { const diffAreas = this.diffAreasOfURI[uri.fsPath] if (!diffAreas) return false for (const diffareaid of diffAreas) { - const diffArea = this.diffAreaOfId[diffareaid] - if (diffArea?.type !== 'DiffZone') continue - if (diffArea._streamState.isStreaming) return true + const diffArea = this.diffAreaOfId[diffareaid]; + if (diffArea?.type !== 'DiffZone') { + continue; + } + if (diffArea._streamState.isStreaming) { + return true; + } } - return false + return false; } - private _initializeWriteoverStream(opts: StartApplyingOpts): [DiffZone, Promise] | undefined { - - const { from, } = opts - const featureName: FeatureName = opts.from === 'ClickApply' ? 'Apply' : 'Ctrl+K' - const overridesOfModel = this._settingsService.state.overridesOfModel - const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] - const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[featureName][modelSelection.providerName]?.[modelSelection.modelName] : undefined - + _uriOfGivenURI(givenURI: URI | 'current') { + if (givenURI === 'current') { + const uri_ = this._getActiveEditorURI() + if (!uri_) return + return uri_ + } + return givenURI + } - const uri = this._getURIBeforeStartApplying(opts) - if (!uri) return + _fileLengthOfGivenURI(givenURI: URI | 'current') { + const uri = this._uriOfGivenURI(givenURI) + if (!uri) return null + const { model } = this._voidModelService.getModel(uri) + if (!model) return null + const numCharsInFile = model.getValueLength(EndOfLinePreference.LF) + return numCharsInFile + } - let startRange: 'fullFile' | [number, number] - let ctrlKZoneIfQuickEdit: CtrlKZone | null = null + public async acceptOrRejectDiffAreasByApplyBox( + { uri, applyBoxId, behavior }: { uri: URI; applyBoxId: string; behavior: 'accept' | 'reject' } + ): Promise { + const diffareaids = this.diffAreasOfURI[uri.fsPath]; + this.logService.debug(`[acceptOrRejectDiffAreasByApplyBox] start uri=${uri.fsPath} applyBoxId=${applyBoxId} behavior=${behavior} totalAreas=${diffareaids?.size ?? 0}`); + if (!diffareaids || diffareaids.size === 0) return; + + const { onFinishEdit } = this._addToHistory(uri); + const serializeZones = (zones: DiffZone[]) => { + try { + return JSON.stringify(zones.map(z => ({ + diffareaid: z.diffareaid, + startLine: z.startLine, + endLine: z.endLine, + applyBoxId: z.applyBoxId ?? null, + isEditFileSimple: !!(z as any)._editFileSimple + }))); + } catch { + return String(zones.map(z => z.diffareaid).join(',')); + } + }; + + if (behavior === 'reject') { + const diffZones: DiffZone[] = []; + for (const id of diffareaids) { + const da = this.diffAreaOfId[id]; + if (da && da.type === 'DiffZone' && da.applyBoxId === applyBoxId) { + diffZones.push(da); + } + } + this.logService.debug(`[acceptOrRejectDiffAreasByApplyBox] reject targetZones(beforeSort)=${serializeZones(diffZones)}`); + // Revert bottom-to-top so earlier rewrites do not shift later ranges. + diffZones.sort((a, b) => { + if (a.startLine !== b.startLine) return b.startLine - a.startLine; + return b.diffareaid - a.diffareaid; + }); + this.logService.debug(`[acceptOrRejectDiffAreasByApplyBox] reject targetZones(sorted)=${serializeZones(diffZones)}`); + for (const dz of diffZones) { + this.logService.debug(`[acceptOrRejectDiffAreasByApplyBox] reject revert diffareaid=${dz.diffareaid} start=${dz.startLine} end=${dz.endLine}`); + this._revertDiffZone(dz); + this._deleteDiffZone(dz); + } - if (from === 'ClickApply') { - startRange = 'fullFile' - } - else if (from === 'QuickEdit') { - const { diffareaid } = opts - const ctrlKZone = this.diffAreaOfId[diffareaid] - if (ctrlKZone?.type !== 'CtrlKZone') return - ctrlKZoneIfQuickEdit = ctrlKZone - const { startLine: startLine_, endLine: endLine_ } = ctrlKZone - startRange = [startLine_, endLine_] + this._refreshStylesAndDiffsInURI(uri); + await onFinishEdit(); + this.logService.debug(`[acceptOrRejectDiffAreasByApplyBox] done uri=${uri.fsPath} applyBoxId=${applyBoxId} behavior=reject remainingAreas=${this.diffAreasOfURI[uri.fsPath]?.size ?? 0}`); + return; } - else { - throw new Error(`Void: diff.type not recognized on: ${from}`) + + // === accept === + const acceptedZones: DiffZone[] = []; + for (const id of diffareaids) { + const da = this.diffAreaOfId[id]; + if (da && da.type === 'DiffZone' && da.applyBoxId === applyBoxId) { + acceptedZones.push(da); + this._deleteDiffZone(da); + } } + this.logService.debug(`[acceptOrRejectDiffAreasByApplyBox] accept deletedZones=${serializeZones(acceptedZones)}`); - const { model } = this._voidModelService.getModel(uri) - if (!model) return + this._refreshStylesAndDiffsInURI(uri); - let streamRequestIdRef: { current: string | null } = { current: null } // can use this as a proxy to set the diffArea's stream state requestId - - // build messages - const quickEditFIMTags = defaultQuickEditFimTags // TODO can eventually let users customize modelFimTags - const originalFileCode = model.getValue(EndOfLinePreference.LF) - const originalCode = startRange === 'fullFile' ? originalFileCode : originalFileCode.split('\n').slice((startRange[0] - 1), (startRange[1] - 1) + 1).join('\n') - const language = model.getLanguageId() - let messages: LLMChatMessage[] - let separateSystemMessage: string | undefined - if (from === 'ClickApply') { - const { messages: a, separateSystemMessage: b } = this._convertToLLMMessageService.prepareLLMSimpleMessages({ - systemMessage: rewriteCode_systemMessage, - simpleMessages: [{ role: 'user', content: rewriteCode_userMessage({ originalCode, applyStr: opts.applyStr, language }), }], - featureName, - modelSelection, - }) - messages = a - separateSystemMessage = b - } - else if (from === 'QuickEdit') { - if (!ctrlKZoneIfQuickEdit) return - const { _mountInfo } = ctrlKZoneIfQuickEdit - const instructions = _mountInfo?.textAreaRef.current?.value ?? '' - - const startLine = startRange === 'fullFile' ? 1 : startRange[0] - const endLine = startRange === 'fullFile' ? model.getLineCount() : startRange[1] - const { prefix, suffix } = voidPrefixAndSuffix({ fullFileStr: originalFileCode, startLine, endLine }) - const userContent = ctrlKStream_userMessage({ selection: originalCode, instructions: instructions, prefix, suffix, fimTags: quickEditFIMTags, language }) - - const { messages: a, separateSystemMessage: b } = this._convertToLLMMessageService.prepareLLMSimpleMessages({ - systemMessage: ctrlKStream_systemMessage({ quickEditFIMTags: quickEditFIMTags }), - simpleMessages: [{ role: 'user', content: userContent, }], - featureName, - modelSelection, - }) - messages = a - separateSystemMessage = b + // Auto format after accept (Format Document) + await this._formatDocumentAtUri(uri); + // Formatting may change lines → refresh decorations again + this._refreshStylesAndDiffsInURI(uri); + + await onFinishEdit(); + this.logService.debug(`[acceptOrRejectDiffAreasByApplyBox] done uri=${uri.fsPath} applyBoxId=${applyBoxId} behavior=accept remainingAreas=${this.diffAreasOfURI[uri.fsPath]?.size ?? 0}`); + } + + private _previewAndPrepareEditFileSimple( + paramsOrOpts: StartApplyingOpts | { + uri: URI; originalSnippet: string; updatedSnippet: string; + occurrence?: number | null; replaceAll?: boolean; + locationHint?: any; encoding?: string | null; newline?: string | null; applyBoxId?: string } - else { throw new Error(`featureName ${from} is invalid`) } + ): [DiffZone, Promise] | undefined { + const safeStringify = (o: any) => { try { return JSON.stringify(o, null, 2) } catch { return String(o) } } - // if URI is already streaming, return (should never happen, caller is responsible for checking) - if (this._uriIsStreaming(uri)) return - // start diffzone - const res = this._startStreamingDiffZone({ - uri, - streamRequestIdRef, - startBehavior: opts.startBehavior, - linkedCtrlKZone: ctrlKZoneIfQuickEdit, - onWillUndo: () => { - if (streamRequestIdRef.current) { - this._llmMessageService.abort(streamRequestIdRef.current) + if ((paramsOrOpts as StartApplyingOpts).from) { + const opts = paramsOrOpts as StartApplyingOpts + const uri = this._getURIBeforeStartApplying(opts) + this.logService.debug(`[_previewAndPrepareEditFileSimple] called with StartApplyingOpts: ${safeStringify({ opts, resolvedUri: uri?.fsPath ?? null })}`) + if (!uri) return undefined + + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { + const da = this.diffAreaOfId[diffareaid] + if (da?.type === 'DiffZone' && (da as any)._editFileSimple) { + const optsApplyBoxId = opts.from === 'ClickApply' ? opts.applyBoxId : undefined + if (optsApplyBoxId && da.applyBoxId === optsApplyBoxId) { + return [da as DiffZone, Promise.resolve()] + } } - }, + } + this.logService.debug(`[_previewAndPrepareEditFileSimple] No preview available via UI state: ${safeStringify({ uri: uri.fsPath })}`) + return undefined + } - }) - if (!res) return - const { diffZone, onFinishEdit, } = res + this.logService.debug(`[_previewAndPrepareEditFileSimple] called with explicit params: ${safeStringify(paramsOrOpts)}`) - // helpers - const onDone = () => { - console.log('called onDone') - diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) - if (ctrlKZoneIfQuickEdit) { - const ctrlKZone = ctrlKZoneIfQuickEdit + const opts = paramsOrOpts as StartApplyingOpts + const applyBoxId = opts.from === 'ClickApply' ? opts.applyBoxId : + (paramsOrOpts as any).applyBoxId - ctrlKZone._linkedStreamingDiffZone = null - this._onDidChangeStreamingInCtrlKZone.fire({ uri, diffareaid: ctrlKZone.diffareaid }) - this._deleteCtrlKZone(ctrlKZone) - } - this._refreshStylesAndDiffsInURI(uri) - onFinishEdit() + const { uri, originalSnippet, updatedSnippet, occurrence, replaceAll, locationHint, encoding, newline } = + paramsOrOpts as { uri: URI; originalSnippet: string; updatedSnippet: string; occurrence?: number | null; replaceAll?: boolean; locationHint?: any; encoding?: string | null; newline?: string | null } - // auto accept - if (this._settingsService.state.globalSettings.autoAcceptLLMChanges) { - this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: false, behavior: 'accept' }) - } + + const beforeIds = new Set(this.diffAreasOfURI[uri.fsPath] || []) + + + + this.logService.debug(`[_previewAndPrepareEditFileSimple] Calling previewEditFileSimple with applyBoxId: ${applyBoxId}`) + const donePromise = this.previewEditFileSimple({ + uri, originalSnippet, updatedSnippet, occurrence, replaceAll, locationHint, encoding, newline, applyBoxId + }).then(() => { /* no-op */ }) + + + let newId: string | undefined + for (const id of this.diffAreasOfURI[uri.fsPath] || []) { + if (!beforeIds.has(id)) { newId = id; break } } - // throws - const onError = (e: { message: string; fullError: Error | null; }) => { - // this._notifyError(e) - onDone() - this._undoHistory(uri) - throw e.fullError || new Error(e.message) + let diffZone: DiffZone | undefined + if (newId) { + const da = this.diffAreaOfId[newId] + if (da?.type === 'DiffZone') diffZone = da as DiffZone } - const extractText = (fullText: string, recentlyAddedTextLen: number) => { - if (from === 'QuickEdit') { - return extractCodeFromFIM({ text: fullText, recentlyAddedTextLen, midTag: quickEditFIMTags.midTag }) - } - else if (from === 'ClickApply') { - return extractCodeFromRegular({ text: fullText, recentlyAddedTextLen }) - } - throw new Error('Void 1') + if (!diffZone) { + const list = [...(this.diffAreasOfURI[uri.fsPath] || [])] + .map(id => this.diffAreaOfId[id]) + .filter(da => da?.type === 'DiffZone') as DiffZone[] + diffZone = list.sort((a, b) => a.diffareaid - b.diffareaid).pop() } - // refresh now in case onText takes a while to get 1st message - this._refreshStylesAndDiffsInURI(uri) + if (!diffZone) { + this.logService.warn('[_previewAndPrepareEditFileSimple] Could not locate created DiffZone after previewEditFileSimple') + return undefined + } - const latestStreamLocationMutable: StreamLocationMutable = { line: diffZone.startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } + return [diffZone, donePromise] + } - // allowed to throw errors - this is called inside a promise that handles everything - const runWriteover = async () => { - let shouldSendAnotherMessage = true - while (shouldSendAnotherMessage) { - shouldSendAnotherMessage = false + private _initializeWriteoverStream(opts: StartApplyingOpts): [DiffZone, Promise] | undefined { + const { from } = opts; + if (from !== 'QuickEdit') return undefined; - let resMessageDonePromise: () => void = () => { } - const messageDonePromise = new Promise((res_) => { resMessageDonePromise = res_ }) + this.logService.debug('[DEBUG] _initializeWriteoverStreamSimple2 opts:', JSON.stringify(opts, null, 2)); - // state used in onText: - let fullTextSoFar = '' // so far (INCLUDING ignored suffix) - let prevIgnoredSuffix = '' - let aborted = false - let weAreAborting = false + const uri = this._getURIBeforeStartApplying(opts); + if (!uri) { + this.logService.debug('[DEBUG] No URI found, returning undefined'); + return undefined; + } + this.logService.debug('[DEBUG] URI found:', uri); + this.logService.debug('[DEBUG] URI type:', typeof uri); + this.logService.debug('[DEBUG] URI keys:', Object.keys(uri)); - streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ - messagesType: 'chatMessages', - logging: { loggingName: `Edit (Writeover) - ${from}` }, - messages, - modelSelection, - modelSelectionOptions, - overridesOfModel, - separateSystemMessage, - chatMode: null, // not chat - onText: (params) => { - const { fullText: fullText_ } = params - const newText_ = fullText_.substring(fullTextSoFar.length, Infinity) + const { model } = this._voidModelService.getModel(uri); + if (!model) return undefined; - const newText = prevIgnoredSuffix + newText_ // add the previously ignored suffix because it's no longer the suffix! - fullTextSoFar += newText // full text, including ```, etc + const { diffareaid } = opts as any; + const ctrlKZone = this.diffAreaOfId[diffareaid]; + if (!ctrlKZone || ctrlKZone.type !== 'CtrlKZone') return undefined; - const [croppedText, deltaCroppedText, croppedSuffix] = extractText(fullTextSoFar, newText.length) - const { endLineInLlmTextSoFar } = this._writeStreamedDiffZoneLLMText(uri, originalCode, croppedText, deltaCroppedText, latestStreamLocationMutable) - diffZone._streamState.line = (diffZone.startLine - 1) + endLineInLlmTextSoFar // change coordinate systems from originalCode to full file - this._refreshStylesAndDiffsInURI(uri) + const selectionRange = { + startLineNumber: ctrlKZone.startLine, + startColumn: 1, + endLineNumber: ctrlKZone.endLine, + endColumn: Number.MAX_SAFE_INTEGER + }; + const selectionCode = model.getValueInRange(selectionRange, EndOfLinePreference.LF); - prevIgnoredSuffix = croppedSuffix - }, - onFinalMessage: (params) => { - const { fullText } = params - // console.log('DONE! FULL TEXT\n', extractText(fullText), diffZone.startLine, diffZone.endLine) - // at the end, re-write whole thing to make sure no sync errors - const [croppedText, _1, _2] = extractText(fullText, 0) - this._writeURIText(uri, croppedText, - { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed - { shouldRealignDiffAreas: true } - ) - - onDone() - resMessageDonePromise() - }, - onError: (e) => { - onError(e) - }, - onAbort: () => { - if (weAreAborting) return - // stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it) - aborted = true - resMessageDonePromise() - }, - }) - // should never happen, just for safety - if (streamRequestIdRef.current === null) { return } + const language = model.getLanguageId(); - await messageDonePromise - if (aborted) { - throw new Error(`Edit was interrupted by the user.`) + + const streamRequestIdRef: { current: string | null } = { current: null }; + const started = this._startStreamingDiffZone({ + uri, + startBehavior: 'keep-conflicts', + streamRequestIdRef, + linkedCtrlKZone: ctrlKZone, + onWillUndo: () => { }, + applyBoxId: undefined, + }); + if (!started) return undefined; + + const { diffZone, onFinishEdit } = started; + + const instructions = ctrlKZone._mountInfo?.textAreaRef.current?.value ?? ''; + + + const modelSelection = this._settingsService.state.modelSelectionOfFeature['Ctrl+K']; + const overridesOfModel = this._settingsService.state.overridesOfModel; + const { specialToolFormat } = getModelCapabilities( + modelSelection?.providerName ?? 'openAI', + modelSelection?.modelName ?? '', + overridesOfModel + ); + + let systemMessage: string; + let userMessageContent: string; + + if (!specialToolFormat || specialToolFormat === 'disabled') { + systemMessage = buildXmlSysMessageForCtrlK(); + userMessageContent = buildXmlUserMessageForCtrlK({ + selectionRange, + selectionCode, + instructions, + language + }); + } else { + systemMessage = buildNativeSysMessageForCtrlK; + userMessageContent = buildNativeUserMessageForCtrlK({ + selectionRange, + selectionCode, + instructions, + language + }); + } + + // Logging for debugging + this.logService.debug('[Ctrl+K] User message content:', userMessageContent); + this.logService.debug('[Ctrl+K] System message:', systemMessage); + + const prepared = this._convertToLLMMessageService.prepareLLMSimpleMessages({ + systemMessage, + simpleMessages: [{ + role: 'user', + content: userMessageContent + }], + featureName: 'Ctrl+K', + modelSelection: this._settingsService.state.modelSelectionOfFeature['Ctrl+K'] + }); + + const messages = prepared.messages; + const separateSystemMessage = prepared.separateSystemMessage; + + // Logging for debugging + this.logService.debug('[Ctrl+K] Prepared messages:', JSON.stringify(messages, null, 2)); + this.logService.debug('[Ctrl+K] Separate system message:', separateSystemMessage); + + const modelSelectionOptions = modelSelection + ? this._settingsService.state.optionsOfModelSelection['Ctrl+K'][modelSelection.providerName]?.[modelSelection.modelName] + : undefined; + + let resolveDone: () => void = () => { }; + const donePromise = new Promise((res) => { resolveDone = res; }); + + + let toolChoice: any = undefined; + if (specialToolFormat === 'openai-style') { + toolChoice = { type: 'function', function: { name: 'edit_file' } }; + } else if (specialToolFormat === 'anthropic-style') { + toolChoice = { type: 'tool', name: 'edit_file' }; + } else if (specialToolFormat === 'gemini-style') { + toolChoice = 'auto'; + } + + const requestId = this._llmMessageService.sendLLMMessage({ + messagesType: 'chatMessages', + logging: { loggingName: `Edit (Ctrl+K)` }, + messages, + modelSelection, + modelSelectionOptions, + overridesOfModel, + separateSystemMessage, + ...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}), + chatMode: 'agent', + + onText: (_chunk) => { }, + + onFinalMessage: async (params) => { + try { + let toolApplied = false; + + + if (params.toolCall && params.toolCall.name === 'edit_file') { + const toolsService = this._instantiationService.invokeFunction((a: any) => a.get(IToolsService)) as any; + if (toolsService?.validateParams?.['edit_file'] && toolsService?.callTool?.['edit_file']) { + const rawParams = params.toolCall.rawParams || {}; + const paramsWithUri = { + ...rawParams, + uri: uri.fsPath + }; + + this.logService.debug('[DEBUG] Params with injected URI:', JSON.stringify(paramsWithUri, null, 2)); + + + await this.interruptStreamingIfActive(uri); + + const validated = toolsService.validateParams['edit_file'](paramsWithUri); + const { result } = await toolsService.callTool['edit_file'](validated); + await result; + toolApplied = true; + } + } + + if (!toolApplied) { + this.logService.warn('[Ctrl+K] No tool applied.'); + this._notificationService.warn('No changes applied: Model did not return a valid edit_file tool call.'); + } + } catch (toolErr) { + this.logService.error('Ctrl+K tool/codex apply error:', toolErr); + this._notificationService.error(`Edit failed: ${toolErr.message}`); + } finally { + + diffZone._streamState = { isStreaming: false }; + this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }); + resolveDone(); + onFinishEdit(); } - } // end while - } // end writeover + }, + + onError: (e) => { + this.logService.error('LLM error in Ctrl+K:', e); + diffZone._streamState = { isStreaming: false }; + this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }); + resolveDone(); + onFinishEdit(); + }, + + onAbort: () => { + diffZone._streamState = { isStreaming: false }; + this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }); + resolveDone(); + onFinishEdit(); + } + }); - const applyDonePromise = new Promise((res, rej) => { runWriteover().then(res).catch(rej) }) - return [diffZone, applyDonePromise] + + streamRequestIdRef.current = requestId; + + return [diffZone, donePromise]; } + private async interruptStreamingIfActive(uri: URI): Promise { + try { + const cmdBar = this._instantiationService.invokeFunction((a: any) => a.get(IVoidCommandBarService)) as any; + if (cmdBar && typeof cmdBar.getStreamState === 'function') { + const state = cmdBar.getStreamState(uri); + if (state === 'streaming') { + try { + await this.interruptURIStreaming({ uri }); + this.logService.debug('[DEBUG] Successfully interrupted streaming for', uri.fsPath); + } catch (ie) { + this.logService.warn('Interrupt failed for URI:', uri.fsPath, ie); + } + } + } + } catch (e) { + this.logService.warn('Error checking stream state before tool call:', e); + } + } + _undoHistory(uri: URI) { + this._undoRedoService.undo(uri) + } - _uriOfGivenURI(givenURI: URI | 'current') { - if (givenURI === 'current') { - const uri_ = this._getActiveEditorURI() - if (!uri_) return - return uri_ + isCtrlKZoneStreaming({ diffareaid }: { diffareaid: number }) { + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (!ctrlKZone) return false + if (ctrlKZone.type !== 'CtrlKZone') return false + return !!ctrlKZone._linkedStreamingDiffZone + } + + private _stopIfStreaming(diffZone: DiffZone) { + const uri = diffZone._URI + + const streamRequestId = diffZone._streamState.streamRequestIdRef?.current + if (!streamRequestId) return + + this._llmMessageService.abort(streamRequestId) + + diffZone._streamState = { isStreaming: false, } + this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) + } + + + // diffareaid of the ctrlKZone (even though the stream state is dictated by the linked diffZone) + interruptCtrlKStreaming({ diffareaid }: { diffareaid: number }) { + const ctrlKZone = this.diffAreaOfId[diffareaid] + if (ctrlKZone?.type !== 'CtrlKZone') return + if (!ctrlKZone._linkedStreamingDiffZone) return + + const linkedStreamingDiffZone = this.diffAreaOfId[ctrlKZone._linkedStreamingDiffZone] + if (!linkedStreamingDiffZone) return + if (linkedStreamingDiffZone.type !== 'DiffZone') return + + this._stopIfStreaming(linkedStreamingDiffZone) + this._undoHistory(linkedStreamingDiffZone._URI) + } + + + interruptURIStreaming({ uri }: { uri: URI }) { + if (!this._uriIsStreaming(uri)) return + this._undoHistory(uri) + // brute force for now is OK + for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { + const diffArea = this.diffAreaOfId[diffareaid] + if (diffArea?.type !== 'DiffZone') continue + if (!diffArea._streamState.isStreaming) continue + this._stopIfStreaming(diffArea) } - return givenURI } - _fileLengthOfGivenURI(givenURI: URI | 'current') { - const uri = this._uriOfGivenURI(givenURI) - if (!uri) return null + + private _revertDiffZone(diffZone: DiffZone) { + const uri = diffZone._URI const { model } = this._voidModelService.getModel(uri) - if (!model) return null - const numCharsInFile = model.getValueLength(EndOfLinePreference.LF) - return numCharsInFile + if (!model) return + + const writeText = diffZone.originalCode + const lineCount = model.getLineCount() + const startLineNumber = Math.max(1, Math.min(diffZone.startLine, lineCount)) + const endLineNumber = Math.max(startLineNumber, Math.min(diffZone.endLine, lineCount)) + + const toRange: IRange = { startLineNumber, startColumn: 1, endLineNumber, endColumn: Number.MAX_SAFE_INTEGER } + this.logService.debug(`[_revertDiffZone] uri=${uri.fsPath} diffareaid=${diffZone.diffareaid} applyBoxId=${diffZone.applyBoxId ?? 'none'} from=${diffZone.startLine}-${diffZone.endLine} to=${startLineNumber}-${endLineNumber} originalLen=${writeText.length}`) + this._writeURIText(uri, writeText, toRange, { shouldRealignDiffAreas: true }) } - /** - * Generates a human-readable error message for an invalid ORIGINAL search block. - */ - private _errContentOfInvalidStr = ( - str: 'Not found' | 'Not unique' | 'Has overlap', - blockOrig: string, - ): string => { - const problematicCode = `${tripleTick[0]}\n${JSON.stringify(blockOrig)}\n${tripleTick[1]}` + // remove a batch of diffareas all at once (and handle accept/reject of their diffs) + public acceptOrRejectAllDiffAreas: IEditCodeService['acceptOrRejectAllDiffAreas'] = async ({ uri, behavior, removeCtrlKs, _addToHistory }) => { - // use a switch for better readability / exhaustiveness check - let descStr: string - switch (str) { - case 'Not found': - descStr = `The edit was not applied. The text in ORIGINAL must EXACTLY match lines of code in the file, but there was no match for:\n${problematicCode}. Ensure you have the latest version of the file, and ensure the ORIGINAL code matches a code excerpt exactly.` - break - case 'Not unique': - descStr = `The edit was not applied. The text in ORIGINAL must be unique in the file being edited, but the following ORIGINAL code appears multiple times in the file:\n${problematicCode}. Ensure you have the latest version of the file, and ensure the ORIGINAL code is unique.` - break - case 'Has overlap': - descStr = `The edit was not applied. The text in the ORIGINAL blocks must not overlap, but the following ORIGINAL code had overlap with another ORIGINAL string:\n${problematicCode}. Ensure you have the latest version of the file, and ensure the ORIGINAL code blocks do not overlap.` - break - default: - descStr = '' + const uriKey = uri.fsPath + if (this._activeBulkAcceptRejectUris.has(uriKey)) { + this.logService.warn(`[acceptOrRejectAllDiffAreas] reentrant start uri=${uriKey} behavior=${behavior}`) } - return descStr - } + this._activeBulkAcceptRejectUris.add(uriKey) + + try { + const diffareaids = this.diffAreasOfURI[uri.fsPath] + this.logService.debug(`[acceptOrRejectAllDiffAreas] start uri=${uri.fsPath} behavior=${behavior} removeCtrlKs=${removeCtrlKs} addToHistory=${_addToHistory !== false} totalAreas=${diffareaids?.size ?? 0}`) + if ((diffareaids?.size ?? 0) === 0) return // do nothing + + const { onFinishEdit } = _addToHistory === false ? { onFinishEdit: () => { } } : this._addToHistory(uri) + const serializeZones = (zones: DiffZone[]) => { + try { + return JSON.stringify(zones.map(z => ({ + diffareaid: z.diffareaid, + startLine: z.startLine, + endLine: z.endLine, + applyBoxId: z.applyBoxId ?? null, + isEditFileSimple: !!(z as any)._editFileSimple + }))); + } catch { + return String(zones.map(z => z.diffareaid).join(',')); + } + }; + + if (behavior === 'reject') { + const diffZones: DiffZone[] = []; + for (const diffareaid of diffareaids ?? []) { + const diffArea = this.diffAreaOfId[diffareaid]; + if (diffArea && diffArea.type === 'DiffZone') { + diffZones.push(diffArea); + } + } + this.logService.debug(`[acceptOrRejectAllDiffAreas] reject targetZones(beforeSort)=${serializeZones(diffZones)}`) + + // Revert bottom-to-top so earlier rewrites do not shift later ranges. + diffZones.sort((a, b) => { + if (a.startLine !== b.startLine) return b.startLine - a.startLine; + return b.diffareaid - a.diffareaid; + }); + this.logService.debug(`[acceptOrRejectAllDiffAreas] reject targetZones(sorted)=${serializeZones(diffZones)}`) + for (const diffZone of diffZones) { + this.logService.debug(`[acceptOrRejectAllDiffAreas] reject revert diffareaid=${diffZone.diffareaid} start=${diffZone.startLine} end=${diffZone.endLine}`) + this._revertDiffZone(diffZone); + this._deleteDiffZone(diffZone); + } - private _instantlyApplySRBlocks(uri: URI, blocksStr: string) { - const blocks = extractSearchReplaceBlocks(blocksStr) - if (blocks.length === 0) throw new Error(`No Search/Replace blocks were received!`) - const { model } = this._voidModelService.getModel(uri) - if (!model) throw new Error(`Error applying Search/Replace blocks: File does not exist.`) - const modelStr = model.getValue(EndOfLinePreference.LF) - // .split('\n').map(l => '\t' + l).join('\n') // for testing purposes only, remember to remove this - const modelStrLines = modelStr.split('\n') + if (removeCtrlKs) { + for (const diffareaid of diffareaids ?? []) { + const diffArea = this.diffAreaOfId[diffareaid]; + if (diffArea && diffArea.type === 'CtrlKZone') { + this.logService.debug(`[acceptOrRejectAllDiffAreas] reject removeCtrlK diffareaid=${diffArea.diffareaid} start=${diffArea.startLine} end=${diffArea.endLine}`) + this._deleteCtrlKZone(diffArea); + } + } + } + } else { + const acceptedZones: DiffZone[] = []; + const removedCtrlKs: CtrlKZone[] = []; + + for (const diffareaid of diffareaids ?? []) { + const diffArea = this.diffAreaOfId[diffareaid]; + if (!diffArea) { + continue; + } + + if (diffArea.type === 'DiffZone') { + if (behavior === 'accept') { + acceptedZones.push(diffArea); + this._deleteDiffZone(diffArea); + } + } + else if (diffArea.type === 'CtrlKZone' && removeCtrlKs) { + removedCtrlKs.push(diffArea); + this._deleteCtrlKZone(diffArea); + } + } + if (behavior === 'accept') { + this.logService.debug(`[acceptOrRejectAllDiffAreas] accept deletedZones=${serializeZones(acceptedZones)}`) + } + if (removedCtrlKs.length > 0) { + this.logService.debug(`[acceptOrRejectAllDiffAreas] accept/removeCtrlKs removedCtrlKs=${JSON.stringify(removedCtrlKs.map(z => ({ diffareaid: z.diffareaid, startLine: z.startLine, endLine: z.endLine })))}`) + } + } + this._refreshStylesAndDiffsInURI(uri) + this.logService.debug(`[acceptOrRejectAllDiffAreas] finishEdit(start) uri=${uri.fsPath} behavior=${behavior}`) + await onFinishEdit() + this.logService.debug(`[acceptOrRejectAllDiffAreas] finishEdit(done) uri=${uri.fsPath} behavior=${behavior}`) + this.logService.debug(`[acceptOrRejectAllDiffAreas] done uri=${uri.fsPath} behavior=${behavior} remainingAreas=${this.diffAreasOfURI[uri.fsPath]?.size ?? 0}`) + } finally { + this._activeBulkAcceptRejectUris.delete(uriKey) + } + } + + private decodeHtmlEntities(text: string): string { + if (!text) return text; + const entities: Record = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': '\'', + ''': '\'', + ' ': ' ', + ''': '\'', + '/': '/', + '<': '<', + '>': '>', + '&': '&', + '"': '"', + }; + + const pattern = Object.keys(entities) + .sort((a, b) => b.length - a.length) + .map(entity => entity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + + const regex = new RegExp(pattern, 'g'); + const result = text.replace(regex, match => entities[match] || match); + return result; + } + + + private hasHtmlEntities(text: string): boolean { + if (!text) return false; + const hasEntities = /&(?:lt|gt|amp|quot|#39|apos|nbsp|#x27|#x2F|#60|#62|#38|#34);/i.test(text); + return hasEntities; + } + + + private shouldDecodeEntities(originalSnippet: string, updatedSnippet: string, fileExtension?: string): boolean { + + if (!this.hasHtmlEntities(originalSnippet) && !this.hasHtmlEntities(updatedSnippet)) { + return false; + } + + const originalHasEntities = this.hasHtmlEntities(originalSnippet); + const updatedHasEntities = this.hasHtmlEntities(updatedSnippet); + + const looksLikeJSXTag = (text: string): boolean => { + const patterns = [ + /<(\w+)[\s>]/, // <div> or <Component + /<\/(\w+)>/, // </div> + /<(\w+)\s+\w+=/, // <div className= + /<(\w+)\s*\/>/, // <Component /> + ]; + const isJSX = patterns.some(p => p.test(text)); + return isJSX; + }; + + const entitiesInString = (text: string): boolean => { + const lines = text.split('\n'); + for (const line of lines) { + const entityMatch = /&(?:lt|gt|amp|quot|#39);/.exec(line); + if (entityMatch) { + const beforeEntity = line.substring(0, entityMatch.index); + const afterEntity = line.substring(entityMatch.index + entityMatch[0].length); + + const quotesBefore = (beforeEntity.match(/['"]/g) || []).length; + const quotesAfter = (afterEntity.match(/['"]/g) || []).length; + + if (quotesBefore % 2 === 1 && quotesAfter % 2 === 1) { + return true; + } + } + } + return false; + }; + const codeExtensions = ['js', 'jsx', 'ts', 'tsx', 'vue', 'svelte', 'html', 'htm']; + const dataExtensions = ['json', 'xml', 'yaml', 'yml']; + const isCodeFile = fileExtension ? codeExtensions.includes(fileExtension.toLowerCase()) : false; + const isDataFile = fileExtension ? dataExtensions.includes(fileExtension.toLowerCase()) : false; - const replacements: { origStart: number; origEnd: number; block: ExtractedSearchReplaceBlock }[] = [] - for (const b of blocks) { - const res = findTextInCode(b.orig, modelStr, true, { returnType: 'lines' }) - if (typeof res === 'string') - throw new Error(this._errContentOfInvalidStr(res, b.orig)) - let [startLine, endLine] = res - startLine -= 1 // 0-index - endLine -= 1 + if (!originalHasEntities && updatedHasEntities) { + const looksLikeJSX = looksLikeJSXTag(updatedSnippet); + const inString = entitiesInString(updatedSnippet); - // including newline before start - const origStart = (startLine !== 0 ? - modelStrLines.slice(0, startLine).join('\n') + '\n' - : '').length + if (looksLikeJSX && !inString) { + return true; + } + return false; + } - // including endline at end - const origEnd = modelStrLines.slice(0, endLine + 1).join('\n').length - 1 + if (originalHasEntities && updatedHasEntities) { + const origDecoded = this.decodeHtmlEntities(originalSnippet); + const hasDecodedTags = origDecoded.includes('<') && origDecoded.includes('>'); + const hasOriginalTags = originalSnippet.includes('<') && originalSnippet.includes('>'); - replacements.push({ origStart, origEnd, block: b }); + if (hasDecodedTags && !hasOriginalTags) { + const decision = isCodeFile && !isDataFile; + return decision; + } } - // sort in increasing order - replacements.sort((a, b) => a.origStart - b.origStart) - // ensure no overlap - for (let i = 1; i < replacements.length; i++) { - if (replacements[i].origStart <= replacements[i - 1].origEnd) { - throw new Error(this._errContentOfInvalidStr('Has overlap', replacements[i]?.block?.orig)) + if (updatedHasEntities && !originalHasEntities) { + const decodedUpdated = this.decodeHtmlEntities(updatedSnippet); + const areIdentical = decodedUpdated === originalSnippet; + + if (areIdentical) { + return true; } } + return false; + } - // apply each replacement from right to left (so indexes don't shift) - let newCode: string = modelStr - for (let i = replacements.length - 1; i >= 0; i--) { - const { origStart, origEnd, block } = replacements[i] - newCode = newCode.slice(0, origStart) + block.final + newCode.slice(origEnd + 1, Infinity) - } - this._writeURIText(uri, newCode, - 'wholeFileRange', - { shouldRealignDiffAreas: true } - ) - } + public async previewEditFileSimple({ + uri, originalSnippet, updatedSnippet, occurrence, replaceAll, locationHint, encoding, newline, applyBoxId + }: { + uri: URI; originalSnippet: string; updatedSnippet: string; + occurrence?: number | null; replaceAll?: boolean; locationHint?: any; encoding?: string | null; newline?: string | null; applyBoxId?: string + }) { + this.logService.debug(`[previewEditFileSimple] Called with applyBoxId: ${applyBoxId}`) + const fileExtension = uri.path ? uri.path.split('.').pop()?.toLowerCase() : undefined; - private _initializeSearchAndReplaceStream(opts: StartApplyingOpts & { from: 'ClickApply' }): [DiffZone, Promise] | undefined { - const { from, applyStr, } = opts - const featureName: FeatureName = 'Apply' - const overridesOfModel = this._settingsService.state.overridesOfModel - const modelSelection = this._settingsService.state.modelSelectionOfFeature[featureName] - const modelSelectionOptions = modelSelection ? this._settingsService.state.optionsOfModelSelection[featureName][modelSelection.providerName]?.[modelSelection.modelName] : undefined + let cleanOriginalSnippet = originalSnippet; + let cleanUpdatedSnippet = updatedSnippet; + let entitiesDetected = false; + let entitiesAutoFixed = false; - const uri = this._getURIBeforeStartApplying(opts) - if (!uri) return + + if (this.shouldDecodeEntities(originalSnippet, updatedSnippet, fileExtension)) { + entitiesDetected = true; - const { model } = this._voidModelService.getModel(uri) - if (!model) return + const decodedOriginal = this.decodeHtmlEntities(originalSnippet); + const decodedUpdated = this.decodeHtmlEntities(updatedSnippet); - let streamRequestIdRef: { current: string | null } = { current: null } // can use this as a proxy to set the diffArea's stream state requestId + const looksValidAfterDecode = (original: string, decoded: string): boolean => { + const hasValidTags = /<\w+[\s>]/.test(decoded) || /<\/\w+>/.test(decoded); + const hasValidJSX = /\/>/.test(decoded) || /\{.*\}/.test(decoded); + if (hasValidTags || hasValidJSX) return true; - // build messages - ask LLM to generate search/replace block text - const originalFileCode = model.getValue(EndOfLinePreference.LF) - const userMessageContent = searchReplaceGivenDescription_userMessage({ originalCode: originalFileCode, applyStr: applyStr }) + const lengthRatio = decoded.length / original.length; + if (lengthRatio < 0.7 || lengthRatio > 1.3) return false; + return true; + }; - const { messages, separateSystemMessage: separateSystemMessage } = this._convertToLLMMessageService.prepareLLMSimpleMessages({ - systemMessage: searchReplaceGivenDescription_systemMessage, - simpleMessages: [{ role: 'user', content: userMessageContent, }], - featureName, - modelSelection, - }) + if (looksValidAfterDecode(originalSnippet, decodedOriginal) && + looksValidAfterDecode(updatedSnippet, decodedUpdated)) { + cleanOriginalSnippet = decodedOriginal; + cleanUpdatedSnippet = decodedUpdated; + entitiesAutoFixed = true; + } + } - // if URI is already streaming, return (should never happen, caller is responsible for checking) - if (this._uriIsStreaming(uri)) return + // ✅ Strip top-level ``` fences (common LLM mistake) + cleanOriginalSnippet = stripMarkdownFence(cleanOriginalSnippet); + cleanUpdatedSnippet = stripMarkdownFence(cleanUpdatedSnippet); - // start diffzone - const res = this._startStreamingDiffZone({ - uri, - streamRequestIdRef, - startBehavior: opts.startBehavior, - linkedCtrlKZone: null, - onWillUndo: () => { - if (streamRequestIdRef.current) { - this._llmMessageService.abort(streamRequestIdRef.current) // triggers onAbort() + const { model } = this._voidModelService.getModel(uri) + if (!model) return { + applied: false, + occurrences_found: 0, + error: 'File not found', + preview: { before: '', after: '' }, + entities_detected: entitiesDetected, + entities_auto_fixed: entitiesAutoFixed, + match_kind: 'none', + match_range: { startLine: 0, endLine: 0, startColumn: 0, endColumn: 0 }, + debug_cmd: null, + debug_cmd_alt: null + } + + const fullText = model.getValue(EndOfLinePreference.LF) + + let matchKind: 'exact' | 'whitespace' | 'inferred' | 'location_hint' | 'none' = 'none'; + let fallbackReason: string | null = null; + let debugCmd: { gnu: string; bsd: string } | null = null; + + const recordFallbackLater = (reason: string) => { + // store now, but we’ll enrich with range once we know startLine/endLine + fallbackReason = reason; + }; + + + const origNorm = normalizeEol(cleanOriginalSnippet) + const updNorm = normalizeEol(cleanUpdatedSnippet) + + + const collapseWsKeepNL = (s: string) => { + const out: string[] = []; + const map: number[] = []; + let i = 0; + while (i < s.length) { + const ch = s[i]; + // For the whitespace-agnostic search we ignore spaces/tabs completely + // but keep newlines so multi-line structure is preserved. + if (ch === ' ' || ch === '\t') { + i++; + continue; + } + out.push(ch); + map.push(i); + i++; + } + return { text: out.join(''), map }; + }; + + const startsWithWsAgnostic = (a: string, b: string) => { + const A = collapseWsKeepNL(a).text; + const B = collapseWsKeepNL(b).text; + return A.startsWith(B); + }; + + const findAllWsAgnostic = (haystack: string, needle: string): Array<{ start: number; end: number }> => { + const H = collapseWsKeepNL(haystack); + const N = collapseWsKeepNL(needle); + const found: Array<{ start: number; end: number }> = []; + let from = 0; + while (true) { + const pos = H.text.indexOf(N.text, from); + if (pos === -1) break; + const rawStart = H.map[pos]; + const rawEnd = H.map[Math.min(pos + N.text.length - 1, H.map.length - 1)] + 1; // exclusive + found.push({ start: rawStart, end: rawEnd }); + from = pos + Math.max(1, N.text.length); + } + return found; + }; + + const findMatchingCurlyForward = (text: string, openIndex: number): number => { + let i = openIndex + 1; + let depth = 1; + + let inSgl = false; // ' + let inDbl = false; // " + let inBT = false; // ` + let inLine = false; // // + let inBlock = false; // /* */ + let prev = ''; + + while (i < text.length) { + const ch = text[i]; + const next = text[i + 1]; + + if (!inSgl && !inDbl && !inBT) { + if (!inBlock && !inLine && ch === '/' && next === '/') { inLine = true; i += 2; prev = ''; continue; } + if (!inBlock && !inLine && ch === '/' && next === '*') { inBlock = true; i += 2; prev = ''; continue; } + if (inLine && ch === '\n') { inLine = false; i++; prev = ch; continue; } + if (inBlock && ch === '*' && next === '/') { inBlock = false; i += 2; prev = ''; continue; } + if (inLine || inBlock) { i++; prev = ch; continue; } } - }, - }) - if (!res) return - const { diffZone, onFinishEdit } = res + if (!inBlock && !inLine) { + if (!inDbl && !inBT && ch === '\'' && prev !== '\\') { inSgl = !inSgl; i++; prev = ch; continue; } + if (!inSgl && !inBT && ch === '"' && prev !== '\\') { inDbl = !inDbl; i++; prev = ch; continue; } + if (!inSgl && !inDbl && ch === '`' && prev !== '\\') { inBT = !inBT; i++; prev = ch; continue; } + } - // helpers - type SearchReplaceDiffAreaMetadata = { - originalBounds: [number, number], // 1-indexed - originalCode: string, - } - const convertOriginalRangeToFinalRange = (originalRange: readonly [number, number]): [number, number] => { - // adjust based on the changes by computing line offset - const [originalStart, originalEnd] = originalRange - let lineOffset = 0 - for (const blockDiffArea of addedTrackingZoneOfBlockNum) { - const { - startLine, endLine, - metadata: { originalBounds: [originalStart2, originalEnd2], }, - } = blockDiffArea - if (originalStart2 >= originalEnd) continue - const numNewLines = endLine - startLine + 1 - const numOldLines = originalEnd2 - originalStart2 + 1 - lineOffset += numNewLines - numOldLines + if (!inSgl && !inDbl && !inBT && !inBlock && !inLine) { + if (ch === '{') { depth++; i++; prev = ch; continue; } + if (ch === '}') { depth--; if (depth === 0) return i; i++; prev = ch; continue; } + } + + prev = ch; + i++; } - return [originalStart + lineOffset, originalEnd + lineOffset] - } + return -1; + }; + const updatedLooksLikeFullCurlyBlock = (updated: string): boolean => { + const open = updated.indexOf('{'); + if (open === -1) return false; - const onDone = () => { - diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) - this._refreshStylesAndDiffsInURI(uri) + const close = findMatchingCurlyForward(updated, open); + if (close === -1) return false; - // delete the tracking zones - for (const trackingZone of addedTrackingZoneOfBlockNum) - this._deleteTrackingZone(trackingZone) - onFinishEdit() - // auto accept - if (this._settingsService.state.globalSettings.autoAcceptLLMChanges) { - this.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: false, behavior: 'accept' }) - } - } + const tail = updated.slice(close + 1).trim(); + return tail === '' || tail === ';' || tail === ','; + }; - const onError = (e: { message: string; fullError: Error | null; }) => { - // this._notifyError(e) - onDone() - this._undoHistory(uri) - throw e.fullError || new Error(e.message) - } + const tryExpandHeaderRange = (text: string, guessStart: number, guessEnd: number, searchWindow = 200) => { + let startOffset = guessStart; + let endOffset = guessEnd; - // refresh now in case onText takes a while to get 1st message - this._refreshStylesAndDiffsInURI(uri) - // stream style related - TODO replace these with whatever block we're on initially if already started (if add caching of apply S/R blocks) - let latestStreamLocationMutable: StreamLocationMutable | null = null - let shouldUpdateOrigStreamStyle = true - let oldBlocks: ExtractedSearchReplaceBlock[] = [] - const addedTrackingZoneOfBlockNum: TrackingZone[] = [] - diffZone._streamState.line = 1 - - const N_RETRIES = 4 - - // allowed to throw errors - this is called inside a promise that handles everything - const runSearchReplace = async () => { - // this generates >>>>>>> ORIGINAL <<<<<<< REPLACE blocks and and simultaneously applies it - let shouldSendAnotherMessage = true - let nMessagesSent = 0 - let currStreamingBlockNum = 0 - let aborted = false - let weAreAborting = false - while (shouldSendAnotherMessage) { - shouldSendAnotherMessage = false - nMessagesSent += 1 - if (nMessagesSent >= N_RETRIES) { - const e = { - message: `Tried to Fast Apply ${N_RETRIES} times but failed. This may be related to model intelligence, or it may an edit that's too complex. Please retry or disable Fast Apply.`, - fullError: null - } - onError(e) - break - } - let resMessageDonePromise: () => void = () => { } - const messageDonePromise = new Promise((res, rej) => { resMessageDonePromise = res }) + const origTrim = (origNorm ?? '').trimEnd(); + const looksLikeHeaderOnly = /{\s*$/.test(origTrim) && !origTrim.includes('}'); - const onText = (params: { fullText: string; fullReasoning: string }) => { - const { fullText } = params - // blocks are [done done done ... {writingFinal|writingOriginal}] - // ^ - // currStreamingBlockNum + const looksLikeExpansion = + (updNorm ?? '').length > (origNorm ?? '').length && + startsWithWsAgnostic(updNorm ?? '', origNorm ?? ''); - const blocks = extractSearchReplaceBlocks(fullText) - for (let blockNum = currStreamingBlockNum; blockNum < blocks.length; blockNum += 1) { - const block = blocks[blockNum] - if (block.state === 'writingOriginal') { - // update stream state to the first line of original if some portion of original has been written - if (shouldUpdateOrigStreamStyle && block.orig.trim().length >= 20) { - const startingAtLine = diffZone._streamState.line ?? 1 // dont go backwards if already have a stream line - const originalRange = findTextInCode(block.orig, originalFileCode, false, { startingAtLine, returnType: 'lines' }) - if (typeof originalRange !== 'string') { - const [startLine, _] = convertOriginalRangeToFinalRange(originalRange) - diffZone._streamState.line = startLine - shouldUpdateOrigStreamStyle = false - } - } - // // starting line is at least the number of lines in the generated code minus 1 - // const numLinesInOrig = numLinesOfStr(block.orig) - // const newLine = Math.max(numLinesInOrig - 1, 1, diffZone._streamState.line ?? 1) - // if (newLine !== diffZone._streamState.line) { - // diffZone._streamState.line = newLine - // this._refreshStylesAndDiffsInURI(uri) - // } + const allowBlockExpansion = + looksLikeHeaderOnly && + looksLikeExpansion && + updatedLooksLikeFullCurlyBlock(updNorm ?? ''); + if (!allowBlockExpansion) { + return { startOffset, endOffset }; + } - // must be done writing original to move on to writing streamed content - continue - } - shouldUpdateOrigStreamStyle = true - - - // if this is the first time we're seeing this block, add it as a diffarea so we can start streaming in it - if (!(blockNum in addedTrackingZoneOfBlockNum)) { - - const originalBounds = findTextInCode(block.orig, originalFileCode, true, { returnType: 'lines' }) - // if error - // Check for overlap with existing modified ranges - const hasOverlap = addedTrackingZoneOfBlockNum.some(trackingZone => { - const [existingStart, existingEnd] = trackingZone.metadata.originalBounds; - const hasNoOverlap = endLine < existingStart || startLine > existingEnd - return !hasNoOverlap - }); - - if (typeof originalBounds === 'string' || hasOverlap) { - const errorMessage = typeof originalBounds === 'string' ? originalBounds : 'Has overlap' as const - - console.log('--------------Error finding text in code:') - console.log('originalFileCode', { originalFileCode }) - console.log('fullText', { fullText }) - console.log('error:', errorMessage) - console.log('block.orig:', block.orig) - console.log('---------') - const content = this._errContentOfInvalidStr(errorMessage, block.orig) - const retryMsg = 'All of your previous outputs have been ignored. Please re-output ALL SEARCH/REPLACE blocks starting from the first one, and avoid the error this time.' - messages.push( - { role: 'assistant', content: fullText }, // latest output - { role: 'user', content: content + '\n' + retryMsg } // user explanation of what's wrong - ) - - // REVERT ALL BLOCKS - currStreamingBlockNum = 0 - latestStreamLocationMutable = null - shouldUpdateOrigStreamStyle = true - oldBlocks = [] - for (const trackingZone of addedTrackingZoneOfBlockNum) - this._deleteTrackingZone(trackingZone) - addedTrackingZoneOfBlockNum.splice(0, Infinity) - - this._writeURIText(uri, originalFileCode, 'wholeFileRange', { shouldRealignDiffAreas: true }) - - // abort and resolve - shouldSendAnotherMessage = true - if (streamRequestIdRef.current) { - weAreAborting = true - this._llmMessageService.abort(streamRequestIdRef.current) - weAreAborting = false - } - diffZone._streamState.line = 1 - resMessageDonePromise() - this._refreshStylesAndDiffsInURI(uri) - return - } + let inS = false, inD = false, inT = false, inSL = false, inML = false; + let openPos = -1; + const limit = Math.min(text.length, guessStart + Math.max((origNorm ?? '').length + 20, searchWindow)); + for (let pos = guessStart; pos < limit; pos++) { + const c = text[pos]; + const next = pos + 1 < text.length ? text[pos + 1] : ''; - const [startLine, endLine] = convertOriginalRangeToFinalRange(originalBounds) + if (!inS && !inD && !inT) { + if (!inML && !inSL && c === '/' && next === '/') { inSL = true; pos++; continue; } + if (!inML && !inSL && c === '/' && next === '*') { inML = true; pos++; continue; } + if (inSL && c === '\n') { inSL = false; continue; } + if (inML && c === '*' && next === '/') { inML = false; pos++; continue; } + if (inSL || inML) continue; + } - // console.log('---------adding-------') - // console.log('CURRENT TEXT!!!', { current: model?.getValue(EndOfLinePreference.LF) }) - // console.log('block', deepClone(block)) - // console.log('origBounds', originalBounds) - // console.log('start end', startLine, endLine) - // otherwise if no error, add the position as a diffarea - const adding: Omit, 'diffareaid'> = { - type: 'TrackingZone', - startLine: startLine, - endLine: endLine, - _URI: uri, - metadata: { - originalBounds: [...originalBounds], - originalCode: block.orig, - }, - } - const trackingZone = this._addDiffArea(adding) - addedTrackingZoneOfBlockNum.push(trackingZone) - latestStreamLocationMutable = { line: startLine, addedSplitYet: false, col: 1, originalCodeStartLine: 1 } - } // end adding diffarea + if (!inML && !inSL) { + if (!inD && !inT && c === '\'') { inS = !inS; continue; } + if (!inS && !inT && c === '"') { inD = !inD; continue; } + if (!inS && !inD && c === '`') { inT = !inT; continue; } + } + if (inS || inD || inT) continue; + if (c === '{') { openPos = pos; break; } + } - // should always be in streaming state here - if (!diffZone._streamState.isStreaming) { - console.error('DiffZone was not in streaming state in _initializeSearchAndReplaceStream') - continue - } + if (openPos !== -1) { + const closePos = findMatchingCurlyForward(text, openPos); + if (closePos !== -1) { + endOffset = Math.max(endOffset, closePos + 1); + } + } + + return { startOffset, endOffset }; + }; - // if a block is done, finish it by writing all - if (block.state === 'done') { - const { startLine: finalStartLine, endLine: finalEndLine } = addedTrackingZoneOfBlockNum[blockNum] - this._writeURIText(uri, block.final, - { startLineNumber: finalStartLine, startColumn: 1, endLineNumber: finalEndLine, endColumn: Number.MAX_SAFE_INTEGER }, // 1-indexed - { shouldRealignDiffAreas: true } - ) - diffZone._streamState.line = finalEndLine + 1 - currStreamingBlockNum = blockNum + 1 - continue - } - // write the added text to the file - if (!latestStreamLocationMutable) continue - const oldBlock = oldBlocks[blockNum] - const oldFinalLen = (oldBlock?.final ?? '').length - const deltaFinalText = block.final.substring(oldFinalLen, Infinity) + const escapeRx = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const endsWithToken = (s: string, token: string) => new RegExp(`${escapeRx(token)}\\s*$`).test(s); + const nextNonWsIndex = (text: string, from: number) => { + let i = from; + while (i < text.length && /\s/.test(text[i])) i++; + return i; + }; - this._writeStreamedDiffZoneLLMText(uri, block.orig, block.final, deltaFinalText, latestStreamLocationMutable) - oldBlocks = blocks // oldblocks is only used if writingFinal - // const { endLine: currentEndLine } = addedTrackingZoneOfBlockNum[blockNum] // would be bad to do this because a lot of the bottom lines might be the same. more accurate to go with latestStreamLocationMutable - // diffZone._streamState.line = currentEndLine - diffZone._streamState.line = latestStreamLocationMutable.line - } // end for + const swallowTrailingTokenLen = (full: string, endOff: number, updated: string, original: string) => { + const i = nextNonWsIndex(full, endOff); + const ch = full[i]; + const tokens = [';', ',']; + for (const tok of tokens) { + if (ch === tok && endsWithToken(updated, tok) && !endsWithToken(original, tok)) { - this._refreshStylesAndDiffsInURI(uri) + return (i - endOff) + 1; } + } + return 0; + }; - streamRequestIdRef.current = this._llmMessageService.sendLLMMessage({ - messagesType: 'chatMessages', - logging: { loggingName: `Edit (Search/Replace) - ${from}` }, - messages, - modelSelection, - modelSelectionOptions, - overridesOfModel, - separateSystemMessage, - chatMode: null, // not chat - onText: (params) => { - onText(params) - }, - onFinalMessage: async (params) => { - const { fullText } = params - onText(params) - const blocks = extractSearchReplaceBlocks(fullText) - if (blocks.length === 0) { - this._notificationService.info(`Void: We ran Fast Apply, but the LLM didn't output any changes.`) - } - this._writeURIText(uri, originalFileCode, 'wholeFileRange', { shouldRealignDiffAreas: true }) + const indices: number[] = [] + for (let from = 0; ;) { + const i = fullText.indexOf(origNorm, from) + if (i === -1) break + indices.push(i) + from = i + Math.max(origNorm.length, 1) + } + let wsAgnosticMatches: Array<{ start: number; end: number }> = [] + if (indices.length === 0) { + wsAgnosticMatches = findAllWsAgnostic(fullText, origNorm) + if (wsAgnosticMatches.length > 0) { + matchKind = 'whitespace'; + recordFallbackLater('LLM did not correctly provide an ORIGINAL code block (whitespace-insensitive match used).'); + } + } - try { - this._instantlyApplySRBlocks(uri, fullText) - onDone() - resMessageDonePromise() - } - catch (e) { - onError(e) - } - }, - onError: (e) => { - onError(e) - }, - onAbort: () => { - if (weAreAborting) return - // stop the loop to free up the promise, but don't modify state (already handled by whatever stopped it) - aborted = true - resMessageDonePromise() - }, - }) + const linesCount = fullText.split('\n').length + let updatedText = fullText + let startLine = 1 + let endLine = linesCount + let startColumn = 1 + let endColumn = Number.MAX_SAFE_INTEGER + let occurrenceApplied = 0 + let originalCodeForZone = '' - // should never happen, just for safety - if (streamRequestIdRef.current === null) { break } + const offsetToLine = (text: string, offset: number) => text.slice(0, offset).split('\n').length + const offsetToLineCol = (text: string, offset: number) => { + const line = offsetToLine(text, offset) + const prevNL = text.lastIndexOf('\n', Math.max(0, offset - 1)) + const col = (prevNL === -1 ? offset : (offset - (prevNL + 1))) + 1 + return { line, column: col } + } - await messageDonePromise - if (aborted) { - throw new Error(`Edit was interrupted by the user.`) - } - } // end while + // replaceAll + if (replaceAll) { + if (indices.length > 0) { - } // end retryLoop + let textAcc = fullText + for (let k = indices.length - 1; k >= 0; k--) { + const start = indices[k] + let end = start + origNorm.length - const applyDonePromise = new Promise((res, rej) => { runSearchReplace().then(res).catch(rej) }) - return [diffZone, applyDonePromise] - } + { + const r = tryExpandHeaderRange(textAcc, start, end) + end = r.endOffset + } + const swallow = swallowTrailingTokenLen(textAcc, end, updNorm, origNorm) + textAcc = textAcc.slice(0, start) + updNorm + textAcc.slice(end + swallow) + } + updatedText = textAcc + startLine = 1 + endLine = linesCount + startColumn = 1 + endColumn = Number.MAX_SAFE_INTEGER + originalCodeForZone = fullText + } else if (wsAgnosticMatches.length > 0) { + + let textAcc = fullText + const matches = [...wsAgnosticMatches].sort((a, b) => b.start - a.start) + for (const m of matches) { + const start = m.start + let end = m.end + + { + const r = tryExpandHeaderRange(textAcc, start, end) + end = r.endOffset + } - _undoHistory(uri: URI) { - this._undoRedoService.undo(uri) - } + const swallow = swallowTrailingTokenLen(textAcc, end, updNorm, origNorm) + textAcc = textAcc.slice(0, start) + updNorm + textAcc.slice(end + swallow) + } + updatedText = textAcc + startLine = 1 + endLine = linesCount + startColumn = 1 + endColumn = Number.MAX_SAFE_INTEGER + originalCodeForZone = fullText + } else { + originalCodeForZone = fullText + } + } else { - isCtrlKZoneStreaming({ diffareaid }: { diffareaid: number }) { - const ctrlKZone = this.diffAreaOfId[diffareaid] - if (!ctrlKZone) return false - if (ctrlKZone.type !== 'CtrlKZone') return false - return !!ctrlKZone._linkedStreamingDiffZone - } + if (indices.length === 0 && wsAgnosticMatches.length === 0) { + let inferred: any = null + try { + const model = this._modelService.getModel(uri) + const astContext = this._getCachedAstContext(uri, model) + inferred = inferSelectionFromCode({ + codeStr: cleanOriginalSnippet, + fileText: fullText, + astContext: astContext ?? undefined + }) + } catch { } + + if (!inferred) { + const sample = fullText.split('\n').slice(0, 20).join('\n') + return { + applied: false, + occurrences_found: 0, + error: 'original_snippet not found', + preview: { before: sample, after: '' }, + entities_detected: entitiesDetected, + entities_auto_fixed: entitiesAutoFixed + } + } - private _stopIfStreaming(diffZone: DiffZone) { - const uri = diffZone._URI + matchKind = 'inferred'; + recordFallbackLater('LLM did not correctly provide an ORIGINAL code block (inferred selection used).'); + + originalCodeForZone = inferred.text + if (inferred.range) { + startLine = inferred.range[0] + endLine = inferred.range[1] + } else { + const idx2 = fullText.indexOf(originalCodeForZone) + if (idx2 !== -1) { + const s = offsetToLineCol(fullText, idx2) + startLine = s.line + startColumn = s.column + endLine = startLine + originalCodeForZone.split('\n').length - 1 + const endOffset = idx2 + originalCodeForZone.length + const e = offsetToLineCol(fullText, endOffset) + endColumn = e.column + } + } + return { + applied: false, + occurrences_found: 0, + error: 'original_snippet not found (inferred)', + preview: { before: originalCodeForZone.slice(0, 1000), after: '' }, + entities_detected: entitiesDetected, + entities_auto_fixed: entitiesAutoFixed + } + } - const streamRequestId = diffZone._streamState.streamRequestIdRef?.current - if (!streamRequestId) return + if (indices.length > 0) { + + let pickIndexInText = indices[0] + let which = 1 + if (typeof occurrence === 'number') { + const idxNum = occurrence < 0 ? indices.length + occurrence : occurrence - 1 + if (idxNum < 0 || idxNum >= indices.length) { + return { + applied: false, + occurrences_found: indices.length, + error: `occurrence ${occurrence} out of range`, + entities_detected: entitiesDetected, + entities_auto_fixed: entitiesAutoFixed + } + } + pickIndexInText = indices[idxNum] + which = idxNum + 1 + } + occurrenceApplied = which - this._llmMessageService.abort(streamRequestId) + let startReplaceOffset = pickIndexInText + let endReplaceOffset = pickIndexInText + origNorm.length - diffZone._streamState = { isStreaming: false, } - this._onDidChangeStreamingInDiffZone.fire({ uri, diffareaid: diffZone.diffareaid }) - } + { + const r = tryExpandHeaderRange(fullText, startReplaceOffset, endReplaceOffset) + startReplaceOffset = r.startOffset + endReplaceOffset = r.endOffset + } - // diffareaid of the ctrlKZone (even though the stream state is dictated by the linked diffZone) - interruptCtrlKStreaming({ diffareaid }: { diffareaid: number }) { - const ctrlKZone = this.diffAreaOfId[diffareaid] - if (ctrlKZone?.type !== 'CtrlKZone') return - if (!ctrlKZone._linkedStreamingDiffZone) return + endReplaceOffset += swallowTrailingTokenLen(fullText, endReplaceOffset, updNorm, origNorm) - const linkedStreamingDiffZone = this.diffAreaOfId[ctrlKZone._linkedStreamingDiffZone] - if (!linkedStreamingDiffZone) return - if (linkedStreamingDiffZone.type !== 'DiffZone') return + const s = offsetToLineCol(fullText, startReplaceOffset) + const e = offsetToLineCol(fullText, endReplaceOffset) + startLine = s.line + startColumn = s.column + endLine = e.line + endColumn = e.column - this._stopIfStreaming(linkedStreamingDiffZone) - this._undoHistory(linkedStreamingDiffZone._URI) - } + const originalCodeForZoneFull = model.getValueInRange( + { + startLineNumber: startLine, + startColumn: 1, + endLineNumber: endLine, + endColumn: Number.MAX_SAFE_INTEGER + }, + EndOfLinePreference.LF + ) + originalCodeForZone = originalCodeForZoneFull + + updatedText = fullText.slice(0, startReplaceOffset) + updNorm + fullText.slice(endReplaceOffset) + } else { + + let pickIdx = 0 + if (typeof occurrence === 'number') { + const idxNum = occurrence < 0 ? wsAgnosticMatches.length + occurrence : occurrence - 1 + if (idxNum < 0 || idxNum >= wsAgnosticMatches.length) { + return { + applied: false, + occurrences_found: wsAgnosticMatches.length, + error: `occurrence ${occurrence} out of range`, + entities_detected: entitiesDetected, + entities_auto_fixed: entitiesAutoFixed + } + } + pickIdx = idxNum + } + const picked = wsAgnosticMatches[pickIdx] + occurrenceApplied = pickIdx + 1 + let startReplaceOffset = picked.start + let endReplaceOffset = picked.end - interruptURIStreaming({ uri }: { uri: URI }) { - if (!this._uriIsStreaming(uri)) return - this._undoHistory(uri) - // brute force for now is OK - for (const diffareaid of this.diffAreasOfURI[uri.fsPath] || []) { - const diffArea = this.diffAreaOfId[diffareaid] - if (diffArea?.type !== 'DiffZone') continue - if (!diffArea._streamState.isStreaming) continue - this._stopIfStreaming(diffArea) - } - } + { + const r = tryExpandHeaderRange(fullText, startReplaceOffset, endReplaceOffset) + startReplaceOffset = r.startOffset + endReplaceOffset = r.endOffset + } - // public removeDiffZone(diffZone: DiffZone, behavior: 'reject' | 'accept') { - // const uri = diffZone._URI - // const { onFinishEdit } = this._addToHistory(uri) + endReplaceOffset += swallowTrailingTokenLen(fullText, endReplaceOffset, updNorm, origNorm) - // if (behavior === 'reject') this._revertAndDeleteDiffZone(diffZone) - // else if (behavior === 'accept') this._deleteDiffZone(diffZone) + const s = offsetToLineCol(fullText, startReplaceOffset) + const e = offsetToLineCol(fullText, endReplaceOffset) + startLine = s.line; startColumn = s.column + endLine = e.line; endColumn = e.column - // this._refreshStylesAndDiffsInURI(uri) - // onFinishEdit() - // } + const originalCodeForZoneFull = model.getValueInRange( + { + startLineNumber: startLine, + startColumn: 1, + endLineNumber: endLine, + endColumn: Number.MAX_SAFE_INTEGER + }, + EndOfLinePreference.LF + ) + originalCodeForZone = originalCodeForZoneFull - private _revertDiffZone(diffZone: DiffZone) { - const uri = diffZone._URI + updatedText = fullText.slice(0, startReplaceOffset) + updNorm + fullText.slice(endReplaceOffset) + } + } - const writeText = diffZone.originalCode - const toRange: IRange = { startLineNumber: diffZone.startLine, startColumn: 1, endLineNumber: diffZone.endLine, endColumn: Number.MAX_SAFE_INTEGER } - this._writeURIText(uri, writeText, toRange, { shouldRealignDiffAreas: true }) - } + const dmp = new DiffMatchPatch() + const diffs = dmp.diff_main(fullText, updatedText) + try { dmp.diff_cleanupSemantic(diffs) } catch { } + const fileLabel = (uri.path ?? uri.fsPath ?? 'file').toString().replace(/^[\\/]+/, '') + const patch_unified = createUnifiedFromLineDiffs(fileLabel, fullText, updatedText, 3) - // remove a batch of diffareas all at once (and handle accept/reject of their diffs) - public acceptOrRejectAllDiffAreas: IEditCodeService['acceptOrRejectAllDiffAreas'] = async ({ uri, behavior, removeCtrlKs, _addToHistory }) => { + if (fallbackReason) { + const linesTotal = linesCount || fullText.split('\n').length || 1; - const diffareaids = this.diffAreasOfURI[uri.fsPath] - if ((diffareaids?.size ?? 0) === 0) return // do nothing + // clamp to a reasonable window + let from = Math.max(1, (startLine || 1) - 3); + let to = Math.min(linesTotal, (endLine || startLine || 1) + 3); + if (to - from > 200) to = Math.min(linesTotal, from + 200); - const { onFinishEdit } = _addToHistory === false ? { onFinishEdit: () => { } } : this._addToHistory(uri) + const relForCmd = this._getWorkspaceRelativePathForCmd(uri); + debugCmd = buildInvisibleCharsDebugCmd(relForCmd, from, to); - for (const diffareaid of diffareaids ?? []) { - const diffArea = this.diffAreaOfId[diffareaid] - if (!diffArea) continue + this.recordFallbackMessage(uri, EDIT_FILE_FALLBACK_MSG); + } - if (diffArea.type === 'DiffZone') { - if (behavior === 'reject') { - this._revertDiffZone(diffArea) - this._deleteDiffZone(diffArea) - } - else if (behavior === 'accept') this._deleteDiffZone(diffArea) - } - else if (diffArea.type === 'CtrlKZone' && removeCtrlKs) { - this._deleteCtrlKZone(diffArea) - } + const adding: Omit = { + type: 'DiffZone', + originalCode: originalCodeForZone, + startLine, + endLine, + _URI: uri, + _streamState: { isStreaming: false }, + _diffOfId: {}, + _removeStylesFns: new Set(), + applyBoxId: applyBoxId, + } + const diffZone = this._addDiffArea(adding) + this.logService.debug(`[previewEditFileSimple] Created DiffZone with applyBoxId: ${applyBoxId}, diffareaid: ${diffZone.diffareaid}`); + (diffZone as any)._editFileSimple = { + original_snippet: cleanOriginalSnippet, + updated_snippet: cleanUpdatedSnippet, + occurrence: occurrence ?? null, + replace_all: !!replaceAll, + location_hint: locationHint, + encoding, + newline, + updated_text: updatedText, + patch_unified, + entities_auto_fixed: entitiesAutoFixed } + this._onDidAddOrDeleteDiffZones.fire({ uri }) this._refreshStylesAndDiffsInURI(uri) - onFinishEdit() - } + const { onFinishEdit } = this._addToHistory(uri) + if (replaceAll) { + this._writeURIText(uri, updatedText, 'wholeFileRange', { shouldRealignDiffAreas: true }) + } else { + const toRange: IRange = { + startLineNumber: startLine, + startColumn, + endLineNumber: endLine, + endColumn + } + this._writeURIText(uri, updNorm, toRange, { shouldRealignDiffAreas: true }) + } + await onFinishEdit?.() + + const occurrencesFound = indices.length > 0 ? indices.length : wsAgnosticMatches.length + + // Check if the original and updated snippets are identical + if (origNorm === updNorm) { + return { + applied: false, + occurrences_found: occurrencesFound, + error: 'original_snippet and updated_snippet are identical', + preview: { before: originalCodeForZone.slice(0, 1000), after: updNorm.slice(0, 1000) }, + entities_detected: entitiesDetected, + entities_auto_fixed: entitiesAutoFixed, + match_kind: matchKind, + match_range: { startLine, endLine, startColumn, endColumn }, + fallback_available: !!fallbackReason, + debug_cmd: debugCmd?.gnu ?? null, + debug_cmd_alt: debugCmd?.bsd ?? null + } + } + return { + applied: true, + occurrences_found: occurrencesFound, + occurrence_applied: occurrenceApplied || undefined, + updated_text: updatedText, + patch_unified, + preview: { before: originalCodeForZone.slice(0, 1000), after: updNorm.slice(0, 1000) }, + entities_detected: entitiesDetected, + entities_auto_fixed: entitiesAutoFixed, + fallback_available: !!fallbackReason, + debug_cmd: debugCmd?.gnu ?? null, + debug_cmd_alt: debugCmd?.bsd ?? null + } + } - // called on void.acceptDiff public async acceptDiff({ diffid }: { diffid: number }) { + const diff: Diff | undefined = this.diffOfId[diffid]; + if (!diff) { + this.logService.debug(`[acceptDiff] skipped missing diffid=${diffid}`); + return; + } - // TODO could use an ITextModelto do this instead, would be much simpler + const { diffareaid } = diff; + const diffArea = this.diffAreaOfId[diffareaid]; + if (!diffArea || diffArea.type !== 'DiffZone') { + this.logService.debug(`[acceptDiff] skipped diffid=${diffid} invalid diffareaid=${diffareaid}`); + return; + } - const diff = this.diffOfId[diffid] - if (!diff) return + const uri = diffArea._URI; + const loggedEndLine = diff.type === 'deletion' ? diff.startLine : diff.endLine; + this.logService.debug(`[acceptDiff] start uri=${uri.fsPath} diffid=${diffid} diffareaid=${diffareaid} type=${diff.type} range=${diff.startLine}-${loggedEndLine} applyBoxId=${diffArea.applyBoxId ?? 'none'} editFileSimple=${!!(diffArea as any)._editFileSimple}`); - const { diffareaid } = diff - const diffArea = this.diffAreaOfId[diffareaid] - if (!diffArea) return + // For edit_file preview zones, accepting a single diff should only merge that + // change into the baseline, keeping other diffs in the same zone intact. + if ((diffArea as any)._editFileSimple) { + try { + const before = diffArea.originalCode; + diffArea.originalCode = applyDiffToBaseline(before, diff); + } catch (e) { + console.error('[acceptDiff] Failed to update baseline for edit_file zone:', e); + } - if (diffArea.type !== 'DiffZone') return + // Remove this diff from the current zone bookkeeping + this._deleteDiff(diff); + if (Object.keys(diffArea._diffOfId).length === 0) { + this._deleteDiffZone(diffArea); + } - const uri = diffArea._URI + this._refreshStylesAndDiffsInURI(uri); + this.logService.debug(`[acceptDiff] done(edit_file) uri=${uri.fsPath} diffid=${diffid} remainingDiffs=${Object.keys(diffArea._diffOfId).length}`); + return; + } - // add to history - const { onFinishEdit } = this._addToHistory(uri) + // Default behavior for non-edit_file zones: apply the change to the model and + // then update the baseline for the whole zone. + const model = this._modelService.getModel(uri); + if (!model) { + console.warn('[acceptDiff] Model not found for URI:', uri); + return; + } - const originalLines = diffArea.originalCode.split('\n') - let newOriginalCode: string + const { onFinishEdit } = this._addToHistory(uri); + + let range: IRange; + let text: string; if (diff.type === 'deletion') { - newOriginalCode = [ - ...originalLines.slice(0, (diff.originalStartLine - 1)), // everything before startLine - // <-- deletion has nothing here - ...originalLines.slice((diff.originalEndLine - 1) + 1, Infinity) // everything after endLine - ].join('\n') - } - else if (diff.type === 'insertion') { - newOriginalCode = [ - ...originalLines.slice(0, (diff.originalStartLine - 1)), // everything before startLine - diff.code, // code - ...originalLines.slice((diff.originalStartLine - 1), Infinity) // startLine (inclusive) and on (no +1) - ].join('\n') - } - else if (diff.type === 'edit') { - newOriginalCode = [ - ...originalLines.slice(0, (diff.originalStartLine - 1)), // everything before startLine - diff.code, // code - ...originalLines.slice((diff.originalEndLine - 1) + 1, Infinity) // everything after endLine - ].join('\n') - } - else { - throw new Error(`Void error: ${diff}.type not recognized`) + range = { + startLineNumber: diff.originalStartLine, + startColumn: 1, + endLineNumber: diff.originalEndLine + 1, + endColumn: 1 + }; + text = ''; + } else if (diff.type === 'insertion') { + range = { + startLineNumber: diff.originalStartLine, + startColumn: 1, + endLineNumber: diff.originalStartLine, + endColumn: 1 + }; + text = diff.code; + } else if (diff.type === 'edit') { + range = { + startLineNumber: diff.originalStartLine, + startColumn: 1, + endLineNumber: diff.originalEndLine + 1, + endColumn: 1 + }; + text = diff.code; + } else { + throw new Error(`Void error: unknown diff type for diffid ${diffid}`); } - // console.log('DIFF', diff) - // console.log('DIFFAREA', diffArea) - // console.log('ORIGINAL', diffArea.originalCode) - // console.log('new original Code', newOriginalCode) + model.pushEditOperations([], [{ range, text }], () => null); - // update code now accepted as original - diffArea.originalCode = newOriginalCode + diffArea.originalCode = model.getValueInRange({ + startLineNumber: diffArea.startLine, + startColumn: 1, + endLineNumber: diffArea.endLine, + endColumn: Number.MAX_SAFE_INTEGER + }, EndOfLinePreference.LF); - // delete the diff - this._deleteDiff(diff) - - // diffArea should be removed if it has no more diffs in it + this._deleteDiff(diff); if (Object.keys(diffArea._diffOfId).length === 0) { - this._deleteDiffZone(diffArea) + this._deleteDiffZone(diffArea); } - this._refreshStylesAndDiffsInURI(uri) + this._refreshStylesAndDiffsInURI(uri); + await onFinishEdit(); + this.logService.debug(`[acceptDiff] done uri=${uri.fsPath} diffid=${diffid} remainingDiffs=${Object.keys(diffArea._diffOfId).length}`); + } - onFinishEdit() + public async applyEditFileSimpleFromDiffZone(diffZone: DiffZone & { _editFileSimple?: any }) { + if (!diffZone || !diffZone._editFileSimple) throw new Error('No edit_file metadata'); + const meta = diffZone._editFileSimple; + const uri = diffZone._URI; - } + const modelEntry = this._voidModelService.getModel(uri) + const model = modelEntry?.model + if (!model) throw new Error('File not found') + + + let text = String(meta.updated_text ?? '') + if (!text) throw new Error('No updated_text to apply') + if (meta.newline === 'lf') { + text = text.replace(/\r\n/g, '\n') + } else if (meta.newline === 'crlf') { + text = text.replace(/\r?\n/g, '\r\n') + } else { + const eol = model.getEOL() + text = (eol === '\r\n') ? text.replace(/\r?\n/g, '\r\n') : text.replace(/\r\n/g, '\n') + } + + + const currentLF = model.getValue(EndOfLinePreference.LF) + const targetLF = text.replace(/\r\n/g, '\n') + if (currentLF === targetLF) { + try { this.acceptOrRejectAllDiffAreas?.({ uri, behavior: 'accept', removeCtrlKs: false }) } catch { } + this._refreshStylesAndDiffsInURI(uri) + return + } + this._writeURIText(uri, text, 'wholeFileRange', { shouldRealignDiffAreas: true }) + try { await (this as any)._saveModelIfNeeded?.(uri, meta.encoding) } catch { } + try { this.acceptOrRejectAllDiffAreas?.({ uri, behavior: 'accept', removeCtrlKs: false }) } catch { } + this._refreshStylesAndDiffsInURI(uri) + } // called on void.rejectDiff public async rejectDiff({ diffid }: { diffid: number }) { const diff = this.diffOfId[diffid] - if (!diff) return + if (!diff) { + this.logService.debug(`[rejectDiff] skipped missing diffid=${diffid}`) + return + } const { diffareaid } = diff const diffArea = this.diffAreaOfId[diffareaid] - if (!diffArea) return + if (!diffArea) { + this.logService.debug(`[rejectDiff] skipped diffid=${diffid} missing diffareaid=${diffareaid}`) + return + } - if (diffArea.type !== 'DiffZone') return + if (diffArea.type !== 'DiffZone') { + this.logService.debug(`[rejectDiff] skipped diffid=${diffid} diffareaid=${diffareaid} non-diffZone`) + return + } const uri = diffArea._URI + const loggedEndLine = diff.type === 'deletion' ? diff.startLine : diff.endLine + this.logService.debug(`[rejectDiff] start uri=${uri.fsPath} diffid=${diffid} diffareaid=${diffareaid} type=${diff.type} range=${diff.startLine}-${loggedEndLine} applyBoxId=${diffArea.applyBoxId ?? 'none'}`) // add to history const { onFinishEdit } = this._addToHistory(uri) @@ -2227,7 +3633,6 @@ class EditCodeService extends Disposable implements IEditCodeService { // B| <-- endLine (we want to delete this whole line) // C else if (diff.type === 'insertion') { - // console.log('REJECTING:', diff) // handle the case where the insertion was a newline at end of diffarea (applying to the next line doesnt work because it doesnt exist, vscode just doesnt delete the correct # of newlines) if (diff.endLine === diffArea.endLine) { // delete the line before instead of after @@ -2252,6 +3657,7 @@ class EditCodeService extends Disposable implements IEditCodeService { else { throw new Error(`Void error: ${diff}.type not recognized`) } + this.logService.debug(`[rejectDiff] computedWrite uri=${uri.fsPath} diffid=${diffid} writeLen=${writeText.length} toRange=${JSON.stringify(toRange)}`) // update the file this._writeURIText(uri, writeText, toRange, { shouldRealignDiffAreas: true }) @@ -2269,15 +3675,22 @@ class EditCodeService extends Disposable implements IEditCodeService { this._refreshStylesAndDiffsInURI(uri) onFinishEdit() - + this.logService.debug(`[rejectDiff] done uri=${uri.fsPath} diffid=${diffid} remainingDiffs=${Object.keys(diffArea._diffOfId).length}`) } - } registerSingleton(IEditCodeService, EditCodeService, InstantiationType.Eager); - - +// Internal helpers exported for targeted unit testing +export const __test_only = { + normalizeEol, + createUnifiedFromLineDiffs, + getLengthOfTextPx, + getLeadingWhitespacePx, + processRawKeybindingText: (keybindingStr: string) => + EditCodeService.prototype.processRawKeybindingText.call({}, keybindingStr), + applyDiffToBaseline, +}; class AcceptRejectInlineWidget extends Widget implements IOverlayWidget { @@ -2386,7 +3799,6 @@ class AcceptRejectInlineWidget extends Widget implements IOverlayWidget { acceptButton.style.boxShadow = '0 2px 3px rgba(0,0,0,0.2)'; acceptButton.style.pointerEvents = 'auto'; - // Style reject button rejectButton.onclick = onReject; rejectButton.textContent = rejectText; @@ -2405,8 +3817,6 @@ class AcceptRejectInlineWidget extends Widget implements IOverlayWidget { rejectButton.style.boxShadow = '0 2px 3px rgba(0,0,0,0.2)'; rejectButton.style.pointerEvents = 'auto'; - - this._domNode = buttons; const updateTop = () => { @@ -2429,9 +3839,9 @@ class AcceptRejectInlineWidget extends Widget implements IOverlayWidget { updateLeft() }, 0) - this._register(editor.onDidScrollChange(e => { updateTop() })) - this._register(editor.onDidChangeModelContent(e => { updateTop() })) - this._register(editor.onDidLayoutChange(e => { updateTop(); updateLeft() })) + this._register(editor.onDidScrollChange(() => { updateTop() })) + this._register(editor.onDidChangeModelContent(() => { updateTop() })) + this._register(editor.onDidLayoutChange(() => { updateTop(); updateLeft() })) // Listen for state changes in the command bar service @@ -2449,7 +3859,6 @@ class AcceptRejectInlineWidget extends Widget implements IOverlayWidget { // mount this widget editor.addOverlayWidget(this); - // console.log('created elt', this._domNode) } public override dispose(): void { @@ -2458,8 +3867,3 @@ class AcceptRejectInlineWidget extends Widget implements IOverlayWidget { } } - - - - - diff --git a/src/vs/workbench/contrib/void/browser/editCodeServiceInterface.ts b/src/vs/workbench/contrib/void/browser/editCodeServiceInterface.ts index 9e33fbd21d9..8a11cb66dc1 100644 --- a/src/vs/workbench/contrib/void/browser/editCodeServiceInterface.ts +++ b/src/vs/workbench/contrib/void/browser/editCodeServiceInterface.ts @@ -7,7 +7,7 @@ import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { Diff, DiffArea, VoidFileSnapshot } from '../common/editCodeServiceTypes.js'; +import { Diff, DiffArea, VoidFileSnapshot } from '../../../../platform/void/common/editCodeServiceTypes.js'; export type StartBehavior = 'accept-conflicts' | 'reject-conflicts' | 'keep-conflicts' @@ -27,8 +27,10 @@ export type StartApplyingOpts = { } | { from: 'ClickApply'; applyStr: string; + selections?: string[]; uri: 'current' | URI; startBehavior: StartBehavior; + applyBoxId?: string; // Optional applyBoxId to associate with the diff zone } export type AddCtrlKOpts = { @@ -44,10 +46,14 @@ export interface IEditCodeService { processRawKeybindingText(keybindingStr: string): string; - callBeforeApplyOrEdit(uri: URI | 'current'): Promise; + callBeforeApplyOrEdit(uri: URI | 'current' | CallBeforeStartApplyingOpts): Promise; startApplying(opts: StartApplyingOpts): [URI, Promise] | null; - instantlyApplySearchReplaceBlocks(opts: { uri: URI; searchReplaceBlocks: string }): void; + //instantlyApplySearchReplaceBlocks(opts: { uri: URI; searchReplaceBlocks: string }): void; instantlyRewriteFile(opts: { uri: URI; newContent: string }): void; + getLastFallbackMessage(uri: URI): string | null; + + recordFallbackMessage(uri: URI, message: string): void; + addCtrlKZone(opts: AddCtrlKOpts): number | undefined; removeCtrlKZone(opts: { diffareaid: number }): void; @@ -59,11 +65,25 @@ export interface IEditCodeService { acceptDiff({ diffid }: { diffid: number }): void; rejectDiff({ diffid }: { diffid: number }): void; + previewEditFileSimple(params: { + uri: URI; + originalSnippet: string; + updatedSnippet: string; + occurrence?: number | null; + replaceAll?: boolean; + locationHint?: any; + encoding?: string | null; + newline?: string | null; + applyBoxId?: string; + }): Promise; + // events onDidAddOrDeleteDiffZones: Event<{ uri: URI }>; onDidChangeDiffsInDiffZoneNotStreaming: Event<{ uri: URI; diffareaid: number }>; // only fires when not streaming!!! streaming would be too much onDidChangeStreamingInDiffZone: Event<{ uri: URI; diffareaid: number }>; onDidChangeStreamingInCtrlKZone: Event<{ uri: URI; diffareaid: number }>; + // fired when instant apply fell back to locating ORIGINAL snippets and retried + onDidUseFallback?: Event<{ uri: URI; message?: string }>; // CtrlKZone streaming state isCtrlKZoneStreaming(opts: { diffareaid: number }): boolean; @@ -75,4 +95,20 @@ export interface IEditCodeService { // testDiffs(): void; getVoidFileSnapshot(uri: URI): VoidFileSnapshot; restoreVoidFileSnapshot(uri: URI, snapshot: VoidFileSnapshot): void; + + + bindApplyBoxUri(applyBoxId: string, uri: URI): void; + getUriByApplyBoxId(applyBoxId: string): URI | undefined; + + // UI helper: tells if there are non-streaming diff zones for a given applyBoxId on this file + hasIdleDiffZoneForApplyBox(uri: URI, applyBoxId: string): boolean; + + // UI helper: if a preview DiffZone was created by edit_file flow, apply it without UI poking internals + applyEditFileSimpleForApplyBox(args: { uri: URI; applyBoxId: string }): Promise; + + // UI helper: infer best snippet selection for Apply (AST-first with heuristic fallback) + inferSelectionForApply(args: { uri: URI; codeStr: string; fileText: string }): Promise<{ text: string; range: [number, number] } | null>; + + // Make accept/reject by applyBox awaitable (needed now that we format-on-accept) + acceptOrRejectDiffAreasByApplyBox(args: { uri: URI; applyBoxId: string; behavior: 'accept' | 'reject' }): Promise; } diff --git a/src/vs/workbench/contrib/void/browser/extensionTransferService.ts b/src/vs/workbench/contrib/void/browser/extensionTransferService.ts index b8843e98b96..89be86fcac8 100644 --- a/src/vs/workbench/contrib/void/browser/extensionTransferService.ts +++ b/src/vs/workbench/contrib/void/browser/extensionTransferService.ts @@ -11,7 +11,7 @@ import { IFileService } from '../../../../platform/files/common/files.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { TransferEditorType, TransferFilesInfo } from './extensionTransferTypes.js'; - +import { ILogService } from '../../../../platform/log/common/log.js'; export interface IExtensionTransferService { readonly _serviceBrand: undefined; // services need this, just leave it undefined @@ -22,10 +22,6 @@ export interface IExtensionTransferService { export const IExtensionTransferService = createDecorator('ExtensionTransferService'); - - - - // Define extensions to skip when transferring const extensionBlacklist = [ // ignore extensions @@ -51,6 +47,7 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS constructor( @IFileService private readonly _fileService: IFileService, + @ILogService private readonly logService: ILogService, ) { super() } @@ -65,7 +62,7 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS // Check if the source file exists before attempting to copy try { if (!isExtensions) { - console.log('transferring item', from, to) + this.logService.debug('transferring item', from, to) const exists = await fileService.exists(from) if (exists) { @@ -77,12 +74,12 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS } await fileService.copy(from, to, true) } else { - console.log(`Skipping file that doesn't exist: ${from.toString()}`) + this.logService.debug(`Skipping file that doesn't exist: ${from.toString()}`) } } // extensions folder else { - console.log('transferring extensions...', from, to) + this.logService.debug('transferring extensions...', from, to) const exists = await fileService.exists(from) if (exists) { const stat = await fileService.resolve(from) @@ -111,7 +108,7 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS await fileService.writeFile(to, VSBuffer.fromString(jsonStr)) } catch { - console.log('Error copying extensions.json, skipping') + this.logService.debug('Error copying extensions.json, skipping') } } } @@ -120,11 +117,11 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS } else { console.log(`Skipping file that doesn't exist: ${from.toString()}`) } - console.log('done transferring extensions.') + this.logService.debug('done transferring extensions.') } } catch (e) { - console.error('Error copying file:', e) + this.logService.error('Error copying file:', e) errAcc += `Error copying ${from.toString()}: ${e}\n` } } @@ -145,7 +142,7 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS if (child.isDirectory) { // if is blacklisted if (isBlacklisted(child.resource.fsPath)) { - console.log('Deleting extension', child.resource.fsPath) + this.logService.debug('Deleting extension', child.resource.fsPath) await fileService.del(child.resource, { recursive: true, useTrash: true }) } } @@ -153,7 +150,7 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS // if is extensions.json if (child.name === 'extensions.json') { - console.log('Updating extensions.json', child.resource.fsPath) + this.logService.debug('Updating extensions.json', child.resource.fsPath) try { const contentsStr = await fileService.readFile(child.resource) const json: any = JSON.parse(contentsStr.value.toString()) @@ -162,13 +159,13 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS await fileService.writeFile(child.resource, VSBuffer.fromString(jsonStr)) } catch { - console.log('Error copying extensions.json, skipping') + this.logService.debug('Error copying extensions.json, skipping') } } } } catch (e) { - console.error('Could not delete extension', child.resource.fsPath, e) + this.logService.error('Could not delete extension', child.resource.fsPath, e) } } } @@ -177,14 +174,6 @@ class ExtensionTransferService extends Disposable implements IExtensionTransferS registerSingleton(IExtensionTransferService, ExtensionTransferService, InstantiationType.Eager); // lazily loaded, even if Eager - - - - - - - - const transferTheseFilesOfOS = (os: 'mac' | 'windows' | 'linux' | null, fromEditor: TransferEditorType = 'VS Code'): TransferFilesInfo => { if (os === null) throw new Error(`One-click switch is not possible in this environment.`) diff --git a/src/vs/workbench/contrib/void/browser/fileService.ts b/src/vs/workbench/contrib/void/browser/fileService.ts index 93da1b1e2cf..50a1fec5260 100644 --- a/src/vs/workbench/contrib/void/browser/fileService.ts +++ b/src/vs/workbench/contrib/void/browser/fileService.ts @@ -5,12 +5,11 @@ import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; -import { IDirectoryStrService } from '../common/directoryStrService.js'; +import { IDirectoryStrService } from '../../../../platform/void/common/directoryStrService.js'; import { messageOfSelection } from '../common/prompt/prompts.js'; import { IVoidModelService } from '../common/voidModelService.js'; - class FilePromptActionService extends Action2 { private static readonly VOID_COPY_FILE_PROMPT_ID = 'void.copyfileprompt' diff --git a/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts b/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts index ba906ff5978..a8cca0a3d96 100644 --- a/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts +++ b/src/vs/workbench/contrib/void/browser/helperServices/consistentItemService.ts @@ -94,8 +94,6 @@ export class ConsistentItemService extends Disposable implements IConsistentItem this._register(this._editorService.onCodeEditorRemove(editor => { removeItemsFromEditor(editor) })) } - - _putItemOnEditor(editor: ICodeEditor, consistentItemId: string) { const { fn } = this.infoOfConsistentItemId[consistentItemId] @@ -113,7 +111,6 @@ export class ConsistentItemService extends Disposable implements IConsistentItem this.consistentItemIdOfItemId[itemId] = consistentItemId this.disposeFnOfItemId[itemId] = () => { - // console.log('calling remove for', itemId) dispose?.() } diff --git a/src/vs/workbench/contrib/void/browser/helpers/findDiffs.ts b/src/vs/workbench/contrib/void/browser/helpers/findDiffs.ts index 703b2775be6..4aac7bf9d65 100644 --- a/src/vs/workbench/contrib/void/browser/helpers/findDiffs.ts +++ b/src/vs/workbench/contrib/void/browser/helpers/findDiffs.ts @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { ComputedDiff } from '../../common/editCodeServiceTypes.js'; +import { ComputedDiff } from '../../../../../platform/void/common/editCodeServiceTypes.js'; import { diffLines } from '../react/out/diff/index.js' export function findDiffs(oldStr: string, newStr: string) { @@ -100,152 +100,5 @@ export function findDiffs(oldStr: string, newStr: string) { } } // end for - // console.log('DIFF', { oldStr, newStr, replacements }) return replacements } - - - - - - - - - - - - - - - - - - - - -// // uncomment this to test -// let name_ = '' -// let testsFailed = 0 -// const assertEqual = (a: { [s: string]: any }, b: { [s: string]: any }) => { -// let keys = new Set([...Object.keys(a), ...Object.keys(b)]) -// for (let k of keys) { -// if (a[k] !== b[k]) { -// console.error('Void Test Error:', name_, '\n', `${k}=`, `${JSON.stringify(a[k])}, ${JSON.stringify(b[k])}`) -// // console.error(JSON.stringify(a, null, 4)) -// // console.error(JSON.stringify(b, null, 4)) -// testsFailed += 1 -// } -// } -// } -// const test = (name: string, fn: () => void) => { -// name_ = name -// fn() -// } - -// const originalCode = `\ -// A -// B -// C -// D -// E` - -// const insertedCode = `\ -// A -// B -// C -// F -// D -// E` - -// const modifiedCode = `\ -// A -// B -// C -// F -// E` - -// const modifiedCode2 = `\ -// A -// B -// C -// D -// E -// ` - - -// test('Diffs Insertion', () => { -// const diffs = findDiffs(originalCode, insertedCode) - -// const expected: BaseDiff = { -// type: 'insertion', -// originalCode: '', -// originalStartLine: 4, // empty range where the insertion happened -// originalEndLine: 4, - -// startLine: 4, -// startCol: 1, -// endLine: 4, -// endCol: Number.MAX_SAFE_INTEGER, -// } -// assertEqual(diffs[0], expected) -// }) - -// test('Diffs Deletion', () => { -// const diffs = findDiffs(insertedCode, originalCode) -// assertEqual({ length: diffs.length }, { length: 1 }) -// const expected: BaseDiff = { -// type: 'deletion', -// originalCode: 'F', -// originalStartLine: 4, -// originalEndLine: 4, - -// startLine: 4, -// startCol: 1, // empty range where the deletion happened -// endLine: 4, -// endCol: 1, -// } -// assertEqual(diffs[0], expected) -// }) - -// test('Diffs Modification', () => { -// const diffs = findDiffs(originalCode, modifiedCode) -// assertEqual({ length: diffs.length }, { length: 1 }) -// const expected: BaseDiff = { -// type: 'edit', -// originalCode: 'D', -// originalStartLine: 4, -// originalEndLine: 4, - -// startLine: 4, -// startCol: 1, -// endLine: 4, -// endCol: Number.MAX_SAFE_INTEGER, -// } -// assertEqual(diffs[0], expected) -// }) - -// test('Diffs Modification 2', () => { -// const diffs = findDiffs(originalCode, modifiedCode2) -// assertEqual({ length: diffs.length }, { length: 1 }) -// const expected: BaseDiff = { -// type: 'insertion', -// originalCode: '', -// originalStartLine: 6, -// originalEndLine: 6, - -// startLine: 6, -// startCol: 1, -// endLine: 6, -// endCol: Number.MAX_SAFE_INTEGER, -// } -// assertEqual(diffs[0], expected) -// }) - - - -// if (testsFailed === 0) { -// console.log('✅ Void - All tests passed') -// } -// else { -// console.log('❌ Void - At least one test failed') -// } diff --git a/src/vs/workbench/contrib/void/browser/lib/diff-match-patch.d.ts b/src/vs/workbench/contrib/void/browser/lib/diff-match-patch.d.ts new file mode 100644 index 00000000000..89dc8751f33 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/lib/diff-match-patch.d.ts @@ -0,0 +1,7 @@ +declare const diff_match_patch: any; +declare const DIFF_DELETE: -1; +declare const DIFF_INSERT: 1; +declare const DIFF_EQUAL: 0; + +export default diff_match_patch; +export { diff_match_patch, DIFF_DELETE, DIFF_INSERT, DIFF_EQUAL }; diff --git a/src/vs/workbench/contrib/void/browser/lib/diff-match-patch.js b/src/vs/workbench/contrib/void/browser/lib/diff-match-patch.js new file mode 100644 index 00000000000..74139e1235a --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/lib/diff-match-patch.js @@ -0,0 +1,2220 @@ +/** + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * 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. + */ + +/** + * @fileoverview Computes the difference between two texts to create a patch. + * Applies the patch onto another text, allowing for errors. + * @author fraser@google.com (Neil Fraser) + */ + +/** + * Class containing the diff, match and patch methods. + * @constructor + */ +let diff_match_patch = function () { + + // Defaults. + // Redefine these in your program to override the defaults. + + // Number of seconds to map a diff before giving up (0 for infinity). + this.Diff_Timeout = 1.0; + // Cost of an empty edit operation in terms of edit characters. + this.Diff_EditCost = 4; + // At what point is no match declared (0.0 = perfection, 1.0 = very loose). + this.Match_Threshold = 0.5; + // How far to search for a match (0 = exact location, 1000+ = broad match). + // A match this many characters away from the expected location will add + // 1.0 to the score (0.0 is a perfect match). + this.Match_Distance = 1000; + // When deleting a large block of text (over ~64 characters), how close do + // the contents have to be to match the expected contents. (0.0 = perfection, + // 1.0 = very loose). Note that Match_Threshold controls how closely the + // end points of a delete need to match. + this.Patch_DeleteThreshold = 0.5; + // Chunk size for context length. + this.Patch_Margin = 4; + + // The number of bits in an int. + this.Match_MaxBits = 32; +}; + + +// DIFF FUNCTIONS + + +/** + * The data structure representing a diff is an array of tuples: + * [[DIFF_DELETE, 'Hello'], [DIFF_INSERT, 'Goodbye'], [DIFF_EQUAL, ' world.']] + * which means: delete 'Hello', add 'Goodbye' and keep ' world.' + */ +let DIFF_DELETE = -1; +let DIFF_INSERT = 1; +let DIFF_EQUAL = 0; + +/** + * Class representing one diff tuple. + * ~Attempts to look like a two-element array (which is what this used to be).~ + * Constructor returns an actual two-element array, to allow destructing @JackuB + * See https://github.com/JackuB/diff-match-patch/issues/14 for details + * @param {number} op Operation, one of: DIFF_DELETE, DIFF_INSERT, DIFF_EQUAL. + * @param {string} text Text to be deleted, inserted, or retained. + * @constructor + */ +diff_match_patch.Diff = function (op, text) { + return [op, text]; +}; + +/** + * Find the differences between two texts. Simplifies the problem by stripping + * any common prefix or suffix off the texts before diffing. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {boolean=} opt_checklines Optional speedup flag. If present and false, + * then don't run a line-level diff first to identify the changed areas. + * Defaults to true, which does a faster, slightly less optimal diff. + * @param {number=} opt_deadline Optional time when the diff should be complete + * by. Used internally for recursive calls. Users should set DiffTimeout + * instead. + * @return {!Array.} Array of diff tuples. + */ +diff_match_patch.prototype.diff_main = function (text1, text2, opt_checklines, + opt_deadline) { + // Set a deadline by which time the diff must be complete. + if (typeof opt_deadline === 'undefined') { + if (this.Diff_Timeout <= 0) { + opt_deadline = Number.MAX_VALUE; + } else { + opt_deadline = (new Date).getTime() + this.Diff_Timeout * 1000; + } + } + let deadline = opt_deadline; + + // Check for null inputs. + if (text1 === null || text2 === null) { + throw new Error('Null input. (diff_main)'); + } + + // Check for equality (speedup). + if (text1 === text2) { + if (text1) { + return [new diff_match_patch.Diff(DIFF_EQUAL, text1)]; + } + return []; + } + + if (typeof opt_checklines === 'undefined') { + opt_checklines = true; + } + let checklines = opt_checklines; + + // Trim off common prefix (speedup). + let commonlength = this.diff_commonPrefix(text1, text2); + let commonprefix = text1.substring(0, commonlength); + text1 = text1.substring(commonlength); + text2 = text2.substring(commonlength); + + // Trim off common suffix (speedup). + commonlength = this.diff_commonSuffix(text1, text2); + let commonsuffix = text1.substring(text1.length - commonlength); + text1 = text1.substring(0, text1.length - commonlength); + text2 = text2.substring(0, text2.length - commonlength); + + // Compute the diff on the middle block. + let diffs = this.diff_compute_(text1, text2, checklines, deadline); + + // Restore the prefix and suffix. + if (commonprefix) { + diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, commonprefix)); + } + if (commonsuffix) { + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, commonsuffix)); + } + this.diff_cleanupMerge(diffs); + return diffs; +}; + + +/** + * Find the differences between two texts. Assumes that the texts do not + * have any common prefix or suffix. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {boolean} checklines Speedup flag. If false, then don't run a + * line-level diff first to identify the changed areas. + * If true, then run a faster, slightly less optimal diff. + * @param {number} deadline Time when the diff should be complete by. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_compute_ = function (text1, text2, checklines, + deadline) { + let diffs; + + if (!text1) { + // Just add some text (speedup). + return [new diff_match_patch.Diff(DIFF_INSERT, text2)]; + } + + if (!text2) { + // Just delete some text (speedup). + return [new diff_match_patch.Diff(DIFF_DELETE, text1)]; + } + + let longtext = text1.length > text2.length ? text1 : text2; + let shorttext = text1.length > text2.length ? text2 : text1; + let i = longtext.indexOf(shorttext); + if (i !== -1) { + // Shorter text is inside the longer text (speedup). + diffs = [new diff_match_patch.Diff(DIFF_INSERT, longtext.substring(0, i)), + new diff_match_patch.Diff(DIFF_EQUAL, shorttext), + new diff_match_patch.Diff(DIFF_INSERT, + longtext.substring(i + shorttext.length))]; + // Swap insertions for deletions if diff is reversed. + if (text1.length > text2.length) { + diffs[0][0] = diffs[2][0] = DIFF_DELETE; + } + return diffs; + } + + if (shorttext.length === 1) { + // Single character string. + // After the previous speedup, the character can't be an equality. + return [new diff_match_patch.Diff(DIFF_DELETE, text1), + new diff_match_patch.Diff(DIFF_INSERT, text2)]; + } + + // Check to see if the problem can be split in two. + let hm = this.diff_halfMatch_(text1, text2); + if (hm) { + // A half-match was found, sort out the return data. + let text1_a = hm[0]; + let text1_b = hm[1]; + let text2_a = hm[2]; + let text2_b = hm[3]; + let mid_common = hm[4]; + // Send both pairs off for separate processing. + let diffs_a = this.diff_main(text1_a, text2_a, checklines, deadline); + let diffs_b = this.diff_main(text1_b, text2_b, checklines, deadline); + // Merge the results. + return diffs_a.concat([new diff_match_patch.Diff(DIFF_EQUAL, mid_common)], + diffs_b); + } + + if (checklines && text1.length > 100 && text2.length > 100) { + return this.diff_lineMode_(text1, text2, deadline); + } + + return this.diff_bisect_(text1, text2, deadline); +}; + + +/** + * Do a quick line-level diff on both strings, then rediff the parts for + * greater accuracy. + * This speedup can produce non-minimal diffs. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} deadline Time when the diff should be complete by. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_lineMode_ = function (text1, text2, deadline) { + // Scan the text on a line-by-line basis first. + let a = this.diff_linesToChars_(text1, text2); + text1 = a.chars1; + text2 = a.chars2; + let linearray = a.lineArray; + + let diffs = this.diff_main(text1, text2, false, deadline); + + // Convert the diff back to original text. + this.diff_charsToLines_(diffs, linearray); + // Eliminate freak matches (e.g. blank lines) + this.diff_cleanupSemantic(diffs); + + // Rediff any replacement blocks, this time character-by-character. + // Add a dummy entry at the end. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); + let pointer = 0; + let count_delete = 0; + let count_insert = 0; + let text_delete = ''; + let text_insert = ''; + while (pointer < diffs.length) { + switch (diffs[pointer][0]) { + case DIFF_INSERT: + count_insert++; + text_insert += diffs[pointer][1]; + break; + case DIFF_DELETE: + count_delete++; + text_delete += diffs[pointer][1]; + break; + case DIFF_EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete >= 1 && count_insert >= 1) { + // Delete the offending records and add the merged ones. + diffs.splice(pointer - count_delete - count_insert, + count_delete + count_insert); + pointer = pointer - count_delete - count_insert; + let subDiff = + this.diff_main(text_delete, text_insert, false, deadline); + for (let j = subDiff.length - 1; j >= 0; j--) { + diffs.splice(pointer, 0, subDiff[j]); + } + pointer = pointer + subDiff.length; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + pointer++; + } + diffs.pop(); // Remove the dummy entry at the end. + + return diffs; +}; + + +/** + * Find the 'middle snake' of a diff, split the problem in two + * and return the recursively constructed diff. + * See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} deadline Time at which to bail if not yet complete. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_bisect_ = function (text1, text2, deadline) { + + // Cache the text lengths to prevent multiple calls. + let text1_length = text1.length; + let text2_length = text2.length; + let max_d = Math.ceil((text1_length + text2_length) / 2); + let v_offset = max_d; + let v_length = 2 * max_d; + let v1 = new Array(v_length); + let v2 = new Array(v_length); + // Setting all elements to -1 is faster in Chrome & Firefox than mixing + // integers and undefined. + for (let x = 0; x < v_length; x++) { + v1[x] = -1; + v2[x] = -1; + } + v1[v_offset + 1] = 0; + v2[v_offset + 1] = 0; + let delta = text1_length - text2_length; + // If the total number of characters is odd, then the front path will collide + // with the reverse path. + let front = (delta % 2 !== 0); + // Offsets for start and end of k loop. + // Prevents mapping of space beyond the grid. + let k1start = 0; + let k1end = 0; + let k2start = 0; + let k2end = 0; + for (let d = 0; d < max_d; d++) { + // Bail out if deadline is reached. + if ((new Date()).getTime() > deadline) { + break; + } + + // Walk the front path one step. + for (let k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { + let k1_offset = v_offset + k1; + let x1; + if (k1 === -d || (k1 !== d && v1[k1_offset - 1] < v1[k1_offset + 1])) { + x1 = v1[k1_offset + 1]; + } else { + x1 = v1[k1_offset - 1] + 1; + } + let y1 = x1 - k1; + while (x1 < text1_length && y1 < text2_length && + text1.charAt(x1) === text2.charAt(y1)) { + x1++; + y1++; + } + v1[k1_offset] = x1; + if (x1 > text1_length) { + // Ran off the right of the graph. + k1end += 2; + } else if (y1 > text2_length) { + // Ran off the bottom of the graph. + k1start += 2; + } else if (front) { + let k2_offset = v_offset + delta - k1; + if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] !== -1) { + // Mirror x2 onto top-left coordinate system. + let x2 = text1_length - v2[k2_offset]; + if (x1 >= x2) { + // Overlap detected. + return this.diff_bisectSplit_(text1, text2, x1, y1, deadline); + } + } + } + } + + // Walk the reverse path one step. + for (let k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { + let k2_offset = v_offset + k2; + let x2; + if (k2 === -d || (k2 !== d && v2[k2_offset - 1] < v2[k2_offset + 1])) { + x2 = v2[k2_offset + 1]; + } else { + x2 = v2[k2_offset - 1] + 1; + } + let y2 = x2 - k2; + while (x2 < text1_length && y2 < text2_length && + text1.charAt(text1_length - x2 - 1) === + text2.charAt(text2_length - y2 - 1)) { + x2++; + y2++; + } + v2[k2_offset] = x2; + if (x2 > text1_length) { + // Ran off the left of the graph. + k2end += 2; + } else if (y2 > text2_length) { + // Ran off the top of the graph. + k2start += 2; + } else if (!front) { + let k1_offset = v_offset + delta - k2; + if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] !== -1) { + let x1 = v1[k1_offset]; + let y1 = v_offset + x1 - k1_offset; + // Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2; + if (x1 >= x2) { + // Overlap detected. + return this.diff_bisectSplit_(text1, text2, x1, y1, deadline); + } + } + } + } + } + // Diff took too long and hit the deadline or + // number of diffs equals number of characters, no commonality at all. + return [new diff_match_patch.Diff(DIFF_DELETE, text1), + new diff_match_patch.Diff(DIFF_INSERT, text2)]; +}; + + +/** + * Given the location of the 'middle snake', split the diff in two parts + * and recurse. + * @param {string} text1 Old string to be diffed. + * @param {string} text2 New string to be diffed. + * @param {number} x Index of split point in text1. + * @param {number} y Index of split point in text2. + * @param {number} deadline Time at which to bail if not yet complete. + * @return {!Array.} Array of diff tuples. + * @private + */ +diff_match_patch.prototype.diff_bisectSplit_ = function (text1, text2, x, y, + deadline) { + let text1a = text1.substring(0, x); + let text2a = text2.substring(0, y); + let text1b = text1.substring(x); + let text2b = text2.substring(y); + + // Compute both diffs serially. + let diffs = this.diff_main(text1a, text2a, false, deadline); + let diffsb = this.diff_main(text1b, text2b, false, deadline); + + return diffs.concat(diffsb); +}; + + +/** + * Split two texts into an array of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {{chars1: string, chars2: string, lineArray: !Array.}} + * An object containing the encoded text1, the encoded text2 and + * the array of unique strings. + * The zeroth element of the array of unique strings is intentionally blank. + * @private + */ +diff_match_patch.prototype.diff_linesToChars_ = function (text1, text2) { + let lineArray = []; // e.g. lineArray[4] == 'Hello\n' + let lineHash = {}; // e.g. lineHash['Hello\n'] == 4 + + // '\x00' is a valid character, but various debuggers don't like it. + // So we'll insert a junk entry to avoid generating a null character. + lineArray[0] = ''; + + /** + * Split a text into an array of strings. Reduce the texts to a string of + * hashes where each Unicode character represents one line. + * Modifies linearray and linehash through being a closure. + * @param {string} text String to encode. + * @return {string} Encoded string. + * @private + */ + function diff_linesToCharsMunge_(text) { + let chars = ''; + // Walk the text, pulling out a substring for each line. + // text.split('\n') would would temporarily double our memory footprint. + // Modifying text would create many large strings to garbage collect. + let lineStart = 0; + let lineEnd = -1; + // Keeping our own length letiable is faster than looking it up. + let lineArrayLength = lineArray.length; + while (lineEnd < text.length - 1) { + lineEnd = text.indexOf('\n', lineStart); + if (lineEnd === -1) { + lineEnd = text.length - 1; + } + let line = text.substring(lineStart, lineEnd + 1); + + if (lineHash.hasOwnProperty ? lineHash.hasOwnProperty(line) : + (lineHash[line] !== undefined)) { + chars += String.fromCharCode(lineHash[line]); + } else { + if (lineArrayLength === maxLines) { + // Bail out at 65535 because + // String.fromCharCode(65536) == String.fromCharCode(0) + line = text.substring(lineStart); + lineEnd = text.length; + } + chars += String.fromCharCode(lineArrayLength); + lineHash[line] = lineArrayLength; + lineArray[lineArrayLength++] = line; + } + lineStart = lineEnd + 1; + } + return chars; + } + // Allocate 2/3rds of the space for text1, the rest for text2. + let maxLines = 40000; + let chars1 = diff_linesToCharsMunge_(text1); + maxLines = 65535; + let chars2 = diff_linesToCharsMunge_(text2); + return { chars1: chars1, chars2: chars2, lineArray: lineArray }; +}; + + +/** + * Rehydrate the text in a diff from a string of line hashes to real lines of + * text. + * @param {!Array.} diffs Array of diff tuples. + * @param {!Array.} lineArray Array of unique strings. + * @private + */ +diff_match_patch.prototype.diff_charsToLines_ = function (diffs, lineArray) { + for (let i = 0; i < diffs.length; i++) { + let chars = diffs[i][1]; + let text = []; + for (let j = 0; j < chars.length; j++) { + text[j] = lineArray[chars.charCodeAt(j)]; + } + diffs[i][1] = text.join(''); + } +}; + + +/** + * Determine the common prefix of two strings. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the start of each + * string. + */ +diff_match_patch.prototype.diff_commonPrefix = function (text1, text2) { + // Quick check for common null cases. + if (!text1 || !text2 || text1.charAt(0) !== text2.charAt(0)) { + return 0; + } + // Binary search. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + let pointermin = 0; + let pointermax = Math.min(text1.length, text2.length); + let pointermid = pointermax; + let pointerstart = 0; + while (pointermin < pointermid) { + if (text1.substring(pointerstart, pointermid) === + text2.substring(pointerstart, pointermid)) { + pointermin = pointermid; + pointerstart = pointermin; + } else { + pointermax = pointermid; + } + pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); + } + return pointermid; +}; + + +/** + * Determine the common suffix of two strings. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the end of each string. + */ +diff_match_patch.prototype.diff_commonSuffix = function (text1, text2) { + // Quick check for common null cases. + if (!text1 || !text2 || + text1.charAt(text1.length - 1) !== text2.charAt(text2.length - 1)) { + return 0; + } + // Binary search. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + let pointermin = 0; + let pointermax = Math.min(text1.length, text2.length); + let pointermid = pointermax; + let pointerend = 0; + while (pointermin < pointermid) { + if (text1.substring(text1.length - pointermid, text1.length - pointerend) === + text2.substring(text2.length - pointermid, text2.length - pointerend)) { + pointermin = pointermid; + pointerend = pointermin; + } else { + pointermax = pointermid; + } + pointermid = Math.floor((pointermax - pointermin) / 2 + pointermin); + } + return pointermid; +}; + + +/** + * Determine if the suffix of one string is the prefix of another. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {number} The number of characters common to the end of the first + * string and the start of the second string. + * @private + */ +diff_match_patch.prototype.diff_commonOverlap_ = function (text1, text2) { + // Cache the text lengths to prevent multiple calls. + let text1_length = text1.length; + let text2_length = text2.length; + // Eliminate the null case. + if (text1_length === 0 || text2_length === 0) { + return 0; + } + // Truncate the longer string. + if (text1_length > text2_length) { + text1 = text1.substring(text1_length - text2_length); + } else if (text1_length < text2_length) { + text2 = text2.substring(0, text1_length); + } + let text_length = Math.min(text1_length, text2_length); + // Quick check for the worst case. + if (text1 === text2) { + return text_length; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + let best = 0; + let length = 1; + while (true) { + let pattern = text1.substring(text_length - length); + let found = text2.indexOf(pattern); + if (found === -1) { + return best; + } + length += found; + if (found === 0 || text1.substring(text_length - length) === + text2.substring(0, length)) { + best = length; + length++; + } + } +}; + + +/** + * Do the two texts share a substring which is at least half the length of the + * longer text? + * This speedup can produce non-minimal diffs. + * @param {string} text1 First string. + * @param {string} text2 Second string. + * @return {Array.} Five element Array, containing the prefix of + * text1, the suffix of text1, the prefix of text2, the suffix of + * text2 and the common middle. Or null if there was no match. + * @private + */ +diff_match_patch.prototype.diff_halfMatch_ = function (text1, text2) { + if (this.Diff_Timeout <= 0) { + // Don't risk returning a non-optimal diff if we have unlimited time. + return null; + } + let longtext = text1.length > text2.length ? text1 : text2; + let shorttext = text1.length > text2.length ? text2 : text1; + if (longtext.length < 4 || shorttext.length * 2 < longtext.length) { + return null; // Pointless. + } + let dmp = this; // 'this' becomes 'window' in a closure. + + /** + * Does a substring of shorttext exist within longtext such that the substring + * is at least half the length of longtext? + * Closure, but does not reference any external variables. + * @param {string} longtext Longer string. + * @param {string} shorttext Shorter string. + * @param {number} i Start index of quarter length substring within longtext. + * @return {Array.} Five element Array, containing the prefix of + * longtext, the suffix of longtext, the prefix of shorttext, the suffix + * of shorttext and the common middle. Or null if there was no match. + * @private + */ + function diff_halfMatchI_(longtext, shorttext, i) { + // Start with a 1/4 length substring at position i as a seed. + let seed = longtext.substring(i, i + Math.floor(longtext.length / 4)); + let j = -1; + let best_common = ''; + let best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b; + while ((j = shorttext.indexOf(seed, j + 1)) !== -1) { + let prefixLength = dmp.diff_commonPrefix(longtext.substring(i), + shorttext.substring(j)); + let suffixLength = dmp.diff_commonSuffix(longtext.substring(0, i), + shorttext.substring(0, j)); + if (best_common.length < suffixLength + prefixLength) { + best_common = shorttext.substring(j - suffixLength, j) + + shorttext.substring(j, j + prefixLength); + best_longtext_a = longtext.substring(0, i - suffixLength); + best_longtext_b = longtext.substring(i + prefixLength); + best_shorttext_a = shorttext.substring(0, j - suffixLength); + best_shorttext_b = shorttext.substring(j + prefixLength); + } + } + if (best_common.length * 2 >= longtext.length) { + return [best_longtext_a, best_longtext_b, + best_shorttext_a, best_shorttext_b, best_common]; + } else { + return null; + } + } + + // First check if the second quarter is the seed for a half-match. + let hm1 = diff_halfMatchI_(longtext, shorttext, + Math.ceil(longtext.length / 4)); + // Check again based on the third quarter. + let hm2 = diff_halfMatchI_(longtext, shorttext, + Math.ceil(longtext.length / 2)); + let hm; + if (!hm1 && !hm2) { + return null; + } else if (!hm2) { + hm = hm1; + } else if (!hm1) { + hm = hm2; + } else { + // Both matched. Select the longest. + hm = hm1[4].length > hm2[4].length ? hm1 : hm2; + } + + // A half-match was found, sort out the return data. + let text1_a, text1_b, text2_a, text2_b; + if (text1.length > text2.length) { + text1_a = hm[0]; + text1_b = hm[1]; + text2_a = hm[2]; + text2_b = hm[3]; + } else { + text2_a = hm[0]; + text2_b = hm[1]; + text1_a = hm[2]; + text1_b = hm[3]; + } + let mid_common = hm[4]; + return [text1_a, text1_b, text2_a, text2_b, mid_common]; +}; + + +/** + * Reduce the number of edits by eliminating semantically trivial equalities. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupSemantic = function (diffs) { + let changes = false; + let equalities = []; // Stack of indices where equalities are found. + let equalitiesLength = 0; // Keeping our own length let is faster in JS. + /** @type {?string} */ + let lastEquality = null; + // Always equal to diffs[equalities[equalitiesLength - 1]][1] + let pointer = 0; // Index of current position. + // Number of characters that changed prior to the equality. + let length_insertions1 = 0; + let length_deletions1 = 0; + // Number of characters that changed after the equality. + let length_insertions2 = 0; + let length_deletions2 = 0; + while (pointer < diffs.length) { + if (diffs[pointer][0] === DIFF_EQUAL) { // Equality found. + equalities[equalitiesLength++] = pointer; + length_insertions1 = length_insertions2; + length_deletions1 = length_deletions2; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = diffs[pointer][1]; + } else { // An insertion or deletion. + if (diffs[pointer][0] === DIFF_INSERT) { + length_insertions2 += diffs[pointer][1].length; + } else { + length_deletions2 += diffs[pointer][1].length; + } + // Eliminate an equality that is smaller or equal to the edits on both + // sides of it. + if (lastEquality && (lastEquality.length <= + Math.max(length_insertions1, length_deletions1)) && + (lastEquality.length <= Math.max(length_insertions2, + length_deletions2))) { + // Duplicate record. + diffs.splice(equalities[equalitiesLength - 1], 0, + new diff_match_patch.Diff(DIFF_DELETE, lastEquality)); + // Change second copy to insert. + diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; + // Throw away the equality we just deleted. + equalitiesLength--; + // Throw away the previous equality (it needs to be reevaluated). + equalitiesLength--; + pointer = equalitiesLength > 0 ? equalities[equalitiesLength - 1] : -1; + length_insertions1 = 0; // Reset the counters. + length_deletions1 = 0; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = null; + changes = true; + } + } + pointer++; + } + + // Normalize the diff. + if (changes) { + this.diff_cleanupMerge(diffs); + } + this.diff_cleanupSemanticLossless(diffs); + + // Find any overlaps between deletions and insertions. + // e.g: abcxxxxxxdef + // -> abcxxxdef + // e.g: xxxabcdefxxx + // -> defxxxabc + // Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = 1; + while (pointer < diffs.length) { + if (diffs[pointer - 1][0] === DIFF_DELETE && + diffs[pointer][0] === DIFF_INSERT) { + let deletion = diffs[pointer - 1][1]; + let insertion = diffs[pointer][1]; + let overlap_length1 = this.diff_commonOverlap_(deletion, insertion); + let overlap_length2 = this.diff_commonOverlap_(insertion, deletion); + if (overlap_length1 >= overlap_length2) { + if (overlap_length1 >= deletion.length / 2 || + overlap_length1 >= insertion.length / 2) { + // Overlap found. Insert an equality and trim the surrounding edits. + diffs.splice(pointer, 0, new diff_match_patch.Diff(DIFF_EQUAL, + insertion.substring(0, overlap_length1))); + diffs[pointer - 1][1] = + deletion.substring(0, deletion.length - overlap_length1); + diffs[pointer + 1][1] = insertion.substring(overlap_length1); + pointer++; + } + } else { + if (overlap_length2 >= deletion.length / 2 || + overlap_length2 >= insertion.length / 2) { + // Reverse overlap found. + // Insert an equality and swap and trim the surrounding edits. + diffs.splice(pointer, 0, new diff_match_patch.Diff(DIFF_EQUAL, + deletion.substring(0, overlap_length2))); + diffs[pointer - 1][0] = DIFF_INSERT; + diffs[pointer - 1][1] = + insertion.substring(0, insertion.length - overlap_length2); + diffs[pointer + 1][0] = DIFF_DELETE; + diffs[pointer + 1][1] = + deletion.substring(overlap_length2); + pointer++; + } + } + pointer++; + } + pointer++; + } +}; + + +/** + * Look for single edits surrounded on both sides by equalities + * which can be shifted sideways to align the edit to a word boundary. + * e.g: The cat came. -> The cat came. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupSemanticLossless = function (diffs) { + /** + * Given two strings, compute a score representing whether the internal + * boundary falls on logical boundaries. + * Scores range from 6 (best) to 0 (worst). + * Closure, but does not reference any external variables. + * @param {string} one First string. + * @param {string} two Second string. + * @return {number} The score. + * @private + */ + function diff_cleanupSemanticScore_(one, two) { + if (!one || !two) { + // Edges are the best. + return 6; + } + + // Each port of this function behaves slightly differently due to + // subtle differences in each language's definition of things like + // 'whitespace'. Since this function's purpose is largely cosmetic, + // the choice has been made to use each language's native features + // rather than force total conformity. + let char1 = one.charAt(one.length - 1); + let char2 = two.charAt(0); + let nonAlphaNumeric1 = char1.match(diff_match_patch.nonAlphaNumericRegex_); + let nonAlphaNumeric2 = char2.match(diff_match_patch.nonAlphaNumericRegex_); + let whitespace1 = nonAlphaNumeric1 && + char1.match(diff_match_patch.whitespaceRegex_); + let whitespace2 = nonAlphaNumeric2 && + char2.match(diff_match_patch.whitespaceRegex_); + let lineBreak1 = whitespace1 && + char1.match(diff_match_patch.linebreakRegex_); + let lineBreak2 = whitespace2 && + char2.match(diff_match_patch.linebreakRegex_); + let blankLine1 = lineBreak1 && + one.match(diff_match_patch.blanklineEndRegex_); + let blankLine2 = lineBreak2 && + two.match(diff_match_patch.blanklineStartRegex_); + + if (blankLine1 || blankLine2) { + // Five points for blank lines. + return 5; + } else if (lineBreak1 || lineBreak2) { + // Four points for line breaks. + return 4; + } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { + // Three points for end of sentences. + return 3; + } else if (whitespace1 || whitespace2) { + // Two points for whitespace. + return 2; + } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { + // One point for non-alphanumeric. + return 1; + } + return 0; + } + + let pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if (diffs[pointer - 1][0] === DIFF_EQUAL && + diffs[pointer + 1][0] === DIFF_EQUAL) { + // This is a single edit surrounded by equalities. + let equality1 = diffs[pointer - 1][1]; + let edit = diffs[pointer][1]; + let equality2 = diffs[pointer + 1][1]; + + // First, shift the edit as far left as possible. + let commonOffset = this.diff_commonSuffix(equality1, edit); + if (commonOffset) { + let commonString = edit.substring(edit.length - commonOffset); + equality1 = equality1.substring(0, equality1.length - commonOffset); + edit = commonString + edit.substring(0, edit.length - commonOffset); + equality2 = commonString + equality2; + } + + // Second, step character by character right, looking for the best fit. + let bestEquality1 = equality1; + let bestEdit = edit; + let bestEquality2 = equality2; + let bestScore = diff_cleanupSemanticScore_(equality1, edit) + + diff_cleanupSemanticScore_(edit, equality2); + while (edit.charAt(0) === equality2.charAt(0)) { + equality1 += edit.charAt(0); + edit = edit.substring(1) + equality2.charAt(0); + equality2 = equality2.substring(1); + let score = diff_cleanupSemanticScore_(equality1, edit) + + diff_cleanupSemanticScore_(edit, equality2); + // The >= encourages trailing rather than leading whitespace on edits. + if (score >= bestScore) { + bestScore = score; + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + } + } + + if (diffs[pointer - 1][1] !== bestEquality1) { + // We have an improvement, save it back to the diff. + if (bestEquality1) { + diffs[pointer - 1][1] = bestEquality1; + } else { + diffs.splice(pointer - 1, 1); + pointer--; + } + diffs[pointer][1] = bestEdit; + if (bestEquality2) { + diffs[pointer + 1][1] = bestEquality2; + } else { + diffs.splice(pointer + 1, 1); + pointer--; + } + } + } + pointer++; + } +}; + +// Define some regex patterns for matching boundaries. +diff_match_patch.nonAlphaNumericRegex_ = /[^a-zA-Z0-9]/; +diff_match_patch.whitespaceRegex_ = /\s/; +diff_match_patch.linebreakRegex_ = /[\r\n]/; +diff_match_patch.blanklineEndRegex_ = /\n\r?\n$/; +diff_match_patch.blanklineStartRegex_ = /^\r?\n\r?\n/; + +/** + * Reduce the number of edits by eliminating operationally trivial equalities. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupEfficiency = function (diffs) { + let changes = false; + let equalities = []; // Stack of indices where equalities are found. + let equalitiesLength = 0; // Keeping our own length let is faster in JS. + /** @type {?string} */ + let lastEquality = null; + // Always equal to diffs[equalities[equalitiesLength - 1]][1] + let pointer = 0; // Index of current position. + // Is there an insertion operation before the last equality. + let pre_ins = false; + // Is there a deletion operation before the last equality. + let pre_del = false; + // Is there an insertion operation after the last equality. + let post_ins = false; + // Is there a deletion operation after the last equality. + let post_del = false; + while (pointer < diffs.length) { + if (diffs[pointer][0] === DIFF_EQUAL) { // Equality found. + if (diffs[pointer][1].length < this.Diff_EditCost && + (post_ins || post_del)) { + // Candidate found. + equalities[equalitiesLength++] = pointer; + pre_ins = post_ins; + pre_del = post_del; + lastEquality = diffs[pointer][1]; + } else { + // Not a candidate, and can never become one. + equalitiesLength = 0; + lastEquality = null; + } + post_ins = post_del = false; + } else { // An insertion or deletion. + if (diffs[pointer][0] === DIFF_DELETE) { + post_del = true; + } else { + post_ins = true; + } + /* + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + */ + if (lastEquality && ((pre_ins && pre_del && post_ins && post_del) || + ((lastEquality.length < this.Diff_EditCost / 2) && + (pre_ins + pre_del + post_ins + post_del) === 3))) { + // Duplicate record. + diffs.splice(equalities[equalitiesLength - 1], 0, + new diff_match_patch.Diff(DIFF_DELETE, lastEquality)); + // Change second copy to insert. + diffs[equalities[equalitiesLength - 1] + 1][0] = DIFF_INSERT; + equalitiesLength--; // Throw away the equality we just deleted; + lastEquality = null; + if (pre_ins && pre_del) { + // No changes made which could affect previous entry, keep going. + post_ins = post_del = true; + equalitiesLength = 0; + } else { + equalitiesLength--; // Throw away the previous equality. + pointer = equalitiesLength > 0 ? + equalities[equalitiesLength - 1] : -1; + post_ins = post_del = false; + } + changes = true; + } + } + pointer++; + } + + if (changes) { + this.diff_cleanupMerge(diffs); + } +}; + + +/** + * Reorder and merge like edit sections. Merge equalities. + * Any edit section can move as long as it doesn't cross an equality. + * @param {!Array.} diffs Array of diff tuples. + */ +diff_match_patch.prototype.diff_cleanupMerge = function (diffs) { + // Add a dummy entry at the end. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, '')); + let pointer = 0; + let count_delete = 0; + let count_insert = 0; + let text_delete = ''; + let text_insert = ''; + let commonlength; + while (pointer < diffs.length) { + switch (diffs[pointer][0]) { + case DIFF_INSERT: + count_insert++; + text_insert += diffs[pointer][1]; + pointer++; + break; + case DIFF_DELETE: + count_delete++; + text_delete += diffs[pointer][1]; + pointer++; + break; + case DIFF_EQUAL: + // Upon reaching an equality, check for prior redundancies. + if (count_delete + count_insert > 1) { + if (count_delete !== 0 && count_insert !== 0) { + // Factor out any common prefixies. + commonlength = this.diff_commonPrefix(text_insert, text_delete); + if (commonlength !== 0) { + if ((pointer - count_delete - count_insert) > 0 && + diffs[pointer - count_delete - count_insert - 1][0] === + DIFF_EQUAL) { + diffs[pointer - count_delete - count_insert - 1][1] += + text_insert.substring(0, commonlength); + } else { + diffs.splice(0, 0, new diff_match_patch.Diff(DIFF_EQUAL, + text_insert.substring(0, commonlength))); + pointer++; + } + text_insert = text_insert.substring(commonlength); + text_delete = text_delete.substring(commonlength); + } + // Factor out any common suffixies. + commonlength = this.diff_commonSuffix(text_insert, text_delete); + if (commonlength !== 0) { + diffs[pointer][1] = text_insert.substring(text_insert.length - + commonlength) + diffs[pointer][1]; + text_insert = text_insert.substring(0, text_insert.length - + commonlength); + text_delete = text_delete.substring(0, text_delete.length - + commonlength); + } + } + // Delete the offending records and add the merged ones. + pointer -= count_delete + count_insert; + diffs.splice(pointer, count_delete + count_insert); + if (text_delete.length) { + diffs.splice(pointer, 0, + new diff_match_patch.Diff(DIFF_DELETE, text_delete)); + pointer++; + } + if (text_insert.length) { + diffs.splice(pointer, 0, + new diff_match_patch.Diff(DIFF_INSERT, text_insert)); + pointer++; + } + pointer++; + } else if (pointer !== 0 && diffs[pointer - 1][0] === DIFF_EQUAL) { + // Merge this equality with the previous one. + diffs[pointer - 1][1] += diffs[pointer][1]; + diffs.splice(pointer, 1); + } else { + pointer++; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + } + if (diffs[diffs.length - 1][1] === '') { + diffs.pop(); // Remove the dummy entry at the end. + } + + // Second pass: look for single edits surrounded on both sides by equalities + // which can be shifted sideways to eliminate an equality. + // e.g: ABAC -> ABAC + let changes = false; + pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if (diffs[pointer - 1][0] === DIFF_EQUAL && + diffs[pointer + 1][0] === DIFF_EQUAL) { + // This is a single edit surrounded by equalities. + if (diffs[pointer][1].substring(diffs[pointer][1].length - + diffs[pointer - 1][1].length) === diffs[pointer - 1][1]) { + // Shift the edit over the previous equality. + diffs[pointer][1] = diffs[pointer - 1][1] + + diffs[pointer][1].substring(0, diffs[pointer][1].length - + diffs[pointer - 1][1].length); + diffs[pointer + 1][1] = diffs[pointer - 1][1] + diffs[pointer + 1][1]; + diffs.splice(pointer - 1, 1); + changes = true; + } else if (diffs[pointer][1].substring(0, diffs[pointer + 1][1].length) === + diffs[pointer + 1][1]) { + // Shift the edit over the next equality. + diffs[pointer - 1][1] += diffs[pointer + 1][1]; + diffs[pointer][1] = + diffs[pointer][1].substring(diffs[pointer + 1][1].length) + + diffs[pointer + 1][1]; + diffs.splice(pointer + 1, 1); + changes = true; + } + } + pointer++; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) { + this.diff_cleanupMerge(diffs); + } +}; + + +/** + * loc is a location in text1, compute and return the equivalent location in + * text2. + * e.g. 'The cat' vs 'The big cat', 1->1, 5->8 + * @param {!Array.} diffs Array of diff tuples. + * @param {number} loc Location within text1. + * @return {number} Location within text2. + */ +diff_match_patch.prototype.diff_xIndex = function (diffs, loc) { + let chars1 = 0; + let chars2 = 0; + let last_chars1 = 0; + let last_chars2 = 0; + let x; + for (x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_INSERT) { // Equality or deletion. + chars1 += diffs[x][1].length; + } + if (diffs[x][0] !== DIFF_DELETE) { // Equality or insertion. + chars2 += diffs[x][1].length; + } + if (chars1 > loc) { // Overshot the location. + break; + } + last_chars1 = chars1; + last_chars2 = chars2; + } + // Was the location was deleted? + if (diffs.length !== x && diffs[x][0] === DIFF_DELETE) { + return last_chars2; + } + // Add the remaining character length. + return last_chars2 + (loc - last_chars1); +}; + + +/** + * Convert a diff array into a pretty HTML report. + * @param {!Array.} diffs Array of diff tuples. + * @return {string} HTML representation. + */ +diff_match_patch.prototype.diff_prettyHtml = function (diffs) { + let html = []; + let pattern_amp = /&/g; + let pattern_lt = //g; + let pattern_para = /\n/g; + for (let x = 0; x < diffs.length; x++) { + let op = diffs[x][0]; // Operation (insert, delete, equal) + let data = diffs[x][1]; // Text of change. + let text = data.replace(pattern_amp, '&').replace(pattern_lt, '<') + .replace(pattern_gt, '>').replace(pattern_para, '¶
'); + switch (op) { + case DIFF_INSERT: + html[x] = '' + text + ''; + break; + case DIFF_DELETE: + html[x] = '' + text + ''; + break; + case DIFF_EQUAL: + html[x] = '' + text + ''; + break; + } + } + return html.join(''); +}; + + +/** + * Compute and return the source text (all equalities and deletions). + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Source text. + */ +diff_match_patch.prototype.diff_text1 = function (diffs) { + let text = []; + for (let x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_INSERT) { + text[x] = diffs[x][1]; + } + } + return text.join(''); +}; + + +/** + * Compute and return the destination text (all equalities and insertions). + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Destination text. + */ +diff_match_patch.prototype.diff_text2 = function (diffs) { + let text = []; + for (let x = 0; x < diffs.length; x++) { + if (diffs[x][0] !== DIFF_DELETE) { + text[x] = diffs[x][1]; + } + } + return text.join(''); +}; + + +/** + * Compute the Levenshtein distance; the number of inserted, deleted or + * substituted characters. + * @param {!Array.} diffs Array of diff tuples. + * @return {number} Number of changes. + */ +diff_match_patch.prototype.diff_levenshtein = function (diffs) { + let levenshtein = 0; + let insertions = 0; + let deletions = 0; + for (let x = 0; x < diffs.length; x++) { + let op = diffs[x][0]; + let data = diffs[x][1]; + switch (op) { + case DIFF_INSERT: + insertions += data.length; + break; + case DIFF_DELETE: + deletions += data.length; + break; + case DIFF_EQUAL: + // A deletion and an insertion is one substitution. + levenshtein += Math.max(insertions, deletions); + insertions = 0; + deletions = 0; + break; + } + } + levenshtein += Math.max(insertions, deletions); + return levenshtein; +}; + + +/** + * Crush the diff into an encoded string which describes the operations + * required to transform text1 into text2. + * E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. + * Operations are tab-separated. Inserted text is escaped using %xx notation. + * @param {!Array.} diffs Array of diff tuples. + * @return {string} Delta text. + */ +diff_match_patch.prototype.diff_toDelta = function (diffs) { + let text = []; + for (let x = 0; x < diffs.length; x++) { + switch (diffs[x][0]) { + case DIFF_INSERT: + text[x] = '+' + encodeURI(diffs[x][1]); + break; + case DIFF_DELETE: + text[x] = '-' + diffs[x][1].length; + break; + case DIFF_EQUAL: + text[x] = '=' + diffs[x][1].length; + break; + } + } + return text.join('\t').replace(/%20/g, ' '); +}; + + +/** + * Given the original text1, and an encoded string which describes the + * operations required to transform text1 into text2, compute the full diff. + * @param {string} text1 Source string for the diff. + * @param {string} delta Delta text. + * @return {!Array.} Array of diff tuples. + * @throws {!Error} If invalid input. + */ +diff_match_patch.prototype.diff_fromDelta = function (text1, delta) { + let diffs = []; + let diffsLength = 0; // Keeping our own length let is faster in JS. + let pointer = 0; // Cursor in text1 + let tokens = delta.split(/\t/g); + for (let x = 0; x < tokens.length; x++) { + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + let param = tokens[x].substring(1); + switch (tokens[x].charAt(0)) { + case '+': + try { + diffs[diffsLength++] = + new diff_match_patch.Diff(DIFF_INSERT, decodeURI(param)); + } catch (ex) { + // Malformed URI sequence. + throw new Error('Illegal escape in diff_fromDelta: ' + param); + } + break; + case '-': + // Fall through. + case '=': { + let n = parseInt(param, 10); + if (isNaN(n) || n < 0) { + throw new Error('Invalid number in diff_fromDelta: ' + param); + } + let text = text1.substring(pointer, pointer += n); + if (tokens[x].charAt(0) === '=') { + diffs[diffsLength++] = new diff_match_patch.Diff(DIFF_EQUAL, text); + } else { + diffs[diffsLength++] = new diff_match_patch.Diff(DIFF_DELETE, text); + } + break; + } + + } + } + if (pointer !== text1.length) { + throw new Error('Delta length (' + pointer + + ') does not equal source text length (' + text1.length + ').'); + } + return diffs; +}; + + +// MATCH FUNCTIONS + + +/** + * Locate the best instance of 'pattern' in 'text' near 'loc'. + * @param {string} text The text to search. + * @param {string} pattern The pattern to search for. + * @param {number} loc The location to search around. + * @return {number} Best match index or -1. + */ +diff_match_patch.prototype.match_main = function (text, pattern, loc) { + // Check for null inputs. + if (text === null || pattern === null || loc === null) { + throw new Error('Null input. (match_main)'); + } + + loc = Math.max(0, Math.min(loc, text.length)); + if (text === pattern) { + // Shortcut (potentially not guaranteed by the algorithm) + return 0; + } else if (!text.length) { + // Nothing to match. + return -1; + } else if (text.substring(loc, loc + pattern.length) === pattern) { + // Perfect match at the perfect spot! (Includes case of null pattern) + return loc; + } else { + // Do a fuzzy compare. + return this.match_bitap_(text, pattern, loc); + } +}; + + +/** + * Locate the best instance of 'pattern' in 'text' near 'loc' using the + * Bitap algorithm. + * @param {string} text The text to search. + * @param {string} pattern The pattern to search for. + * @param {number} loc The location to search around. + * @return {number} Best match index or -1. + * @private + */ +diff_match_patch.prototype.match_bitap_ = function (text, pattern, loc) { + if (pattern.length > this.Match_MaxBits) { + throw new Error('Pattern too long for this browser.'); + } + + // Initialise the alphabet. + let s = this.match_alphabet_(pattern); + + let dmp = this; // 'this' becomes 'window' in a closure. + + /** + * Compute and return the score for a match with e errors and x location. + * Accesses loc and pattern through being a closure. + * @param {number} e Number of errors in match. + * @param {number} x Location of match. + * @return {number} Overall score for match (0.0 = good, 1.0 = bad). + * @private + */ + function match_bitapScore_(e, x) { + let accuracy = e / pattern.length; + let proximity = Math.abs(loc - x); + if (!dmp.Match_Distance) { + // Dodge divide by zero error. + return proximity ? 1.0 : accuracy; + } + return accuracy + (proximity / dmp.Match_Distance); + } + + // Highest score beyond which we give up. + let score_threshold = this.Match_Threshold; + // Is there a nearby exact match? (speedup) + let best_loc = text.indexOf(pattern, loc); + if (best_loc !== -1) { + score_threshold = Math.min(match_bitapScore_(0, best_loc), score_threshold); + // What about in the other direction? (speedup) + best_loc = text.lastIndexOf(pattern, loc + pattern.length); + if (best_loc !== -1) { + score_threshold = + Math.min(match_bitapScore_(0, best_loc), score_threshold); + } + } + + // Initialise the bit arrays. + let matchmask = 1 << (pattern.length - 1); + best_loc = -1; + + let bin_min, bin_mid; + let bin_max = pattern.length + text.length; + let last_rd; + for (let d = 0; d < pattern.length; d++) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from 'loc' we can stray at this + // error level. + bin_min = 0; + bin_mid = bin_max; + while (bin_min < bin_mid) { + if (match_bitapScore_(d, loc + bin_mid) <= score_threshold) { + bin_min = bin_mid; + } else { + bin_max = bin_mid; + } + bin_mid = Math.floor((bin_max - bin_min) / 2 + bin_min); + } + // Use the result from this iteration as the maximum for the next. + bin_max = bin_mid; + let start = Math.max(1, loc - bin_mid + 1); + let finish = Math.min(loc + bin_mid, text.length) + pattern.length; + + let rd = Array(finish + 2); + rd[finish + 1] = (1 << d) - 1; + for (let j = finish; j >= start; j--) { + // The alphabet (s) is a sparse hash, so the following line generates + // warnings. + let charMatch = s[text.charAt(j - 1)]; + if (d === 0) { // First pass: exact match. + rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; + } else { // Subsequent passes: fuzzy match. + rd[j] = (((rd[j + 1] << 1) | 1) & charMatch) | + (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | + last_rd[j + 1]; + } + if (rd[j] & matchmask) { + let score = match_bitapScore_(d, j - 1); + // This match will almost certainly be better than any existing match. + // But check anyway. + if (score <= score_threshold) { + // Told you so. + score_threshold = score; + best_loc = j - 1; + if (best_loc > loc) { + // When passing loc, don't exceed our current distance from loc. + start = Math.max(1, 2 * loc - best_loc); + } else { + // Already passed loc, downhill from here on in. + break; + } + } + } + } + // No hope for a (better) match at greater error levels. + if (match_bitapScore_(d + 1, loc) > score_threshold) { + break; + } + last_rd = rd; + } + return best_loc; +}; + + +/** + * Initialise the alphabet for the Bitap algorithm. + * @param {string} pattern The text to encode. + * @return {!Object} Hash of character locations. + * @private + */ +diff_match_patch.prototype.match_alphabet_ = function (pattern) { + let s = {}; + for (let i = 0; i < pattern.length; i++) { + s[pattern.charAt(i)] = 0; + } + for (let i = 0; i < pattern.length; i++) { + s[pattern.charAt(i)] |= 1 << (pattern.length - i - 1); + } + return s; +}; + + +// PATCH FUNCTIONS + + +/** + * Increase the context until it is unique, + * but don't let the pattern expand beyond Match_MaxBits. + * @param {!diff_match_patch.patch_obj} patch The patch to grow. + * @param {string} text Source text. + * @private + */ +diff_match_patch.prototype.patch_addContext_ = function (patch, text) { + if (text.length === 0) { + return; + } + if (patch.start2 === null) { + throw Error('patch not initialized'); + } + let pattern = text.substring(patch.start2, patch.start2 + patch.length1); + let padding = 0; + + // Look for the first and last matches of pattern in text. If two different + // matches are found, increase the pattern length. + while (text.indexOf(pattern) !== text.lastIndexOf(pattern) && + pattern.length < this.Match_MaxBits - this.Patch_Margin - + this.Patch_Margin) { + padding += this.Patch_Margin; + pattern = text.substring(patch.start2 - padding, + patch.start2 + patch.length1 + padding); + } + // Add one chunk for good luck. + padding += this.Patch_Margin; + + // Add the prefix. + let prefix = text.substring(patch.start2 - padding, patch.start2); + if (prefix) { + patch.diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, prefix)); + } + // Add the suffix. + let suffix = text.substring(patch.start2 + patch.length1, + patch.start2 + patch.length1 + padding); + if (suffix) { + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, suffix)); + } + + // Roll back the start points. + patch.start1 -= prefix.length; + patch.start2 -= prefix.length; + // Extend the lengths. + patch.length1 += prefix.length + suffix.length; + patch.length2 += prefix.length + suffix.length; +}; + + +/** + * Compute a list of patches to turn text1 into text2. + * Use diffs if provided, otherwise compute it ourselves. + * There are four ways to call this function, depending on what data is + * available to the caller: + * Method 1: + * a = text1, b = text2 + * Method 2: + * a = diffs + * Method 3 (optimal): + * a = text1, b = diffs + * Method 4 (deprecated, use method 3): + * a = text1, b = text2, c = diffs + * + * @param {string|!Array.} a text1 (methods 1,3,4) or + * Array of diff tuples for text1 to text2 (method 2). + * @param {string|!Array.=} opt_b text2 (methods 1,4) or + * Array of diff tuples for text1 to text2 (method 3) or undefined (method 2). + * @param {string|!Array.=} opt_c Array of diff tuples + * for text1 to text2 (method 4) or undefined (methods 1,2,3). + * @return {!Array.} Array of Patch objects. + */ +diff_match_patch.prototype.patch_make = function (a, opt_b, opt_c) { + let text1, diffs; + if (typeof a === 'string' && typeof opt_b === 'string' && + typeof opt_c === 'undefined') { + // Method 1: text1, text2 + // Compute diffs from text1 and text2. + text1 = /** @type {string} */(a); + diffs = this.diff_main(text1, /** @type {string} */(opt_b), true); + if (diffs.length > 2) { + this.diff_cleanupSemantic(diffs); + this.diff_cleanupEfficiency(diffs); + } + } else if (a && typeof a === 'object' && typeof opt_b === 'undefined' && + typeof opt_c === 'undefined') { + // Method 2: diffs + // Compute text1 from diffs. + diffs = /** @type {!Array.} */(a); + text1 = this.diff_text1(diffs); + } else if (typeof a === 'string' && opt_b && typeof opt_b === 'object' && + typeof opt_c === 'undefined') { + // Method 3: text1, diffs + text1 = /** @type {string} */(a); + diffs = /** @type {!Array.} */(opt_b); + } else if (typeof a === 'string' && typeof opt_b === 'string' && + opt_c && typeof opt_c === 'object') { + // Method 4: text1, text2, diffs + // text2 is not used. + text1 = /** @type {string} */(a); + diffs = /** @type {!Array.} */(opt_c); + } else { + throw new Error('Unknown call format to patch_make.'); + } + + if (diffs.length === 0) { + return []; // Get rid of the null case. + } + let patches = []; + let patch = new diff_match_patch.patch_obj(); + let patchDiffLength = 0; // Keeping our own length let is faster in JS. + let char_count1 = 0; // Number of characters into the text1 string. + let char_count2 = 0; // Number of characters into the text2 string. + // Start with text1 (prepatch_text) and apply the diffs until we arrive at + // text2 (postpatch_text). We recreate the patches one by one to determine + // context info. + let prepatch_text = text1; + let postpatch_text = text1; + for (let x = 0; x < diffs.length; x++) { + let diff_type = diffs[x][0]; + let diff_text = diffs[x][1]; + + if (!patchDiffLength && diff_type !== DIFF_EQUAL) { + // A new patch starts here. + patch.start1 = char_count1; + patch.start2 = char_count2; + } + + switch (diff_type) { + case DIFF_INSERT: + patch.diffs[patchDiffLength++] = diffs[x]; + patch.length2 += diff_text.length; + postpatch_text = postpatch_text.substring(0, char_count2) + diff_text + + postpatch_text.substring(char_count2); + break; + case DIFF_DELETE: + patch.length1 += diff_text.length; + patch.diffs[patchDiffLength++] = diffs[x]; + postpatch_text = postpatch_text.substring(0, char_count2) + + postpatch_text.substring(char_count2 + + diff_text.length); + break; + case DIFF_EQUAL: + if (diff_text.length <= 2 * this.Patch_Margin && + patchDiffLength && diffs.length !== x + 1) { + // Small equality inside a patch. + patch.diffs[patchDiffLength++] = diffs[x]; + patch.length1 += diff_text.length; + patch.length2 += diff_text.length; + } else if (diff_text.length >= 2 * this.Patch_Margin) { + // Time for a new patch. + if (patchDiffLength) { + this.patch_addContext_(patch, prepatch_text); + patches.push(patch); + patch = new diff_match_patch.patch_obj(); + patchDiffLength = 0; + // Unlike Unidiff, our patch lists have a rolling context. + // https://github.com/google/diff-match-patch/wiki/Unidiff + // Update prepatch text & pos to reflect the application of the + // just completed patch. + prepatch_text = postpatch_text; + char_count1 = char_count2; + } + } + break; + } + + // Update the current character count. + if (diff_type !== DIFF_INSERT) { + char_count1 += diff_text.length; + } + if (diff_type !== DIFF_DELETE) { + char_count2 += diff_text.length; + } + } + // Pick up the leftover patch if not empty. + if (patchDiffLength) { + this.patch_addContext_(patch, prepatch_text); + patches.push(patch); + } + + return patches; +}; + + +/** + * Given an array of patches, return another array that is identical. + * @param {!Array.} patches Array of Patch objects. + * @return {!Array.} Array of Patch objects. + */ +diff_match_patch.prototype.patch_deepCopy = function (patches) { + // Making deep copies is hard in JavaScript. + let patchesCopy = []; + for (let x = 0; x < patches.length; x++) { + let patch = patches[x]; + let patchCopy = new diff_match_patch.patch_obj(); + patchCopy.diffs = []; + for (let y = 0; y < patch.diffs.length; y++) { + patchCopy.diffs[y] = + new diff_match_patch.Diff(patch.diffs[y][0], patch.diffs[y][1]); + } + patchCopy.start1 = patch.start1; + patchCopy.start2 = patch.start2; + patchCopy.length1 = patch.length1; + patchCopy.length2 = patch.length2; + patchesCopy[x] = patchCopy; + } + return patchesCopy; +}; + + +/** + * Merge a set of patches onto the text. Return a patched text, as well + * as a list of true/false values indicating which patches were applied. + * @param {!Array.} patches Array of Patch objects. + * @param {string} text Old text. + * @return {!Array.>} Two element Array, containing the + * new text and an array of boolean values. + */ +diff_match_patch.prototype.patch_apply = function (patches, text) { + if (patches.length === 0) { + return [text, []]; + } + + // Deep copy the patches so that no changes are made to originals. + patches = this.patch_deepCopy(patches); + + let nullPadding = this.patch_addPadding(patches); + text = nullPadding + text + nullPadding; + + this.patch_splitMax(patches); + // delta keeps track of the offset between the expected and actual location + // of the previous patch. If there are patches expected at positions 10 and + // 20, but the first patch was found at 12, delta is 2 and the second patch + // has an effective expected position of 22. + let delta = 0; + let results = []; + for (let x = 0; x < patches.length; x++) { + let expected_loc = patches[x].start2 + delta; + let text1 = this.diff_text1(patches[x].diffs); + let start_loc; + let end_loc = -1; + if (text1.length > this.Match_MaxBits) { + // patch_splitMax will only provide an oversized pattern in the case of + // a monster delete. + start_loc = this.match_main(text, text1.substring(0, this.Match_MaxBits), + expected_loc); + if (start_loc !== -1) { + end_loc = this.match_main(text, + text1.substring(text1.length - this.Match_MaxBits), + expected_loc + text1.length - this.Match_MaxBits); + if (end_loc === -1 || start_loc >= end_loc) { + // Can't find valid trailing context. Drop this patch. + start_loc = -1; + } + } + } else { + start_loc = this.match_main(text, text1, expected_loc); + } + if (start_loc === -1) { + // No match found. :( + results[x] = false; + // Subtract the delta for this failed patch from subsequent patches. + delta -= patches[x].length2 - patches[x].length1; + } else { + // Found a match. :) + results[x] = true; + delta = start_loc - expected_loc; + let text2; + if (end_loc === -1) { + text2 = text.substring(start_loc, start_loc + text1.length); + } else { + text2 = text.substring(start_loc, end_loc + this.Match_MaxBits); + } + if (text1 === text2) { + // Perfect match, just shove the replacement text in. + text = text.substring(0, start_loc) + + this.diff_text2(patches[x].diffs) + + text.substring(start_loc + text1.length); + } else { + // Imperfect match. Run a diff to get a framework of equivalent + // indices. + let diffs = this.diff_main(text1, text2, false); + if (text1.length > this.Match_MaxBits && + this.diff_levenshtein(diffs) / text1.length > + this.Patch_DeleteThreshold) { + // The end points match, but the content is unacceptably bad. + results[x] = false; + } else { + this.diff_cleanupSemanticLossless(diffs); + let index1 = 0; + let index2; + for (let y = 0; y < patches[x].diffs.length; y++) { + let mod = patches[x].diffs[y]; + if (mod[0] !== DIFF_EQUAL) { + index2 = this.diff_xIndex(diffs, index1); + } + if (mod[0] === DIFF_INSERT) { // Insertion + text = text.substring(0, start_loc + index2) + mod[1] + + text.substring(start_loc + index2); + } else if (mod[0] === DIFF_DELETE) { // Deletion + text = text.substring(0, start_loc + index2) + + text.substring(start_loc + this.diff_xIndex(diffs, + index1 + mod[1].length)); + } + if (mod[0] !== DIFF_DELETE) { + index1 += mod[1].length; + } + } + } + } + } + } + // Strip the padding off. + text = text.substring(nullPadding.length, text.length - nullPadding.length); + return [text, results]; +}; + + +/** + * Add some padding on text start and end so that edges can match something. + * Intended to be called only from within patch_apply. + * @param {!Array.} patches Array of Patch objects. + * @return {string} The padding string added to each side. + */ +diff_match_patch.prototype.patch_addPadding = function (patches) { + let paddingLength = this.Patch_Margin; + let nullPadding = ''; + for (let x = 1; x <= paddingLength; x++) { + nullPadding += String.fromCharCode(x); + } + + // Bump all the patches forward. + for (let x = 0; x < patches.length; x++) { + patches[x].start1 += paddingLength; + patches[x].start2 += paddingLength; + } + + // Add some padding on start of first diff. + let patch = patches[0]; + let diffs = patch.diffs; + if (diffs.length === 0 || diffs[0][0] !== DIFF_EQUAL) { + // Add nullPadding equality. + diffs.unshift(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); + patch.start1 -= paddingLength; // Should be 0. + patch.start2 -= paddingLength; // Should be 0. + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs[0][1].length) { + // Grow first equality. + let extraLength = paddingLength - diffs[0][1].length; + diffs[0][1] = nullPadding.substring(diffs[0][1].length) + diffs[0][1]; + patch.start1 -= extraLength; + patch.start2 -= extraLength; + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + // Add some padding on end of last diff. + patch = patches[patches.length - 1]; + diffs = patch.diffs; + if (diffs.length === 0 || diffs[diffs.length - 1][0] !== DIFF_EQUAL) { + // Add nullPadding equality. + diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, nullPadding)); + patch.length1 += paddingLength; + patch.length2 += paddingLength; + } else if (paddingLength > diffs[diffs.length - 1][1].length) { + // Grow last equality. + let extraLength = paddingLength - diffs[diffs.length - 1][1].length; + diffs[diffs.length - 1][1] += nullPadding.substring(0, extraLength); + patch.length1 += extraLength; + patch.length2 += extraLength; + } + + return nullPadding; +}; + + +/** + * Look through the patches and break up any which are longer than the maximum + * limit of the match algorithm. + * Intended to be called only from within patch_apply. + * @param {!Array.} patches Array of Patch objects. + */ +diff_match_patch.prototype.patch_splitMax = function (patches) { + let patch_size = this.Match_MaxBits; + for (let x = 0; x < patches.length; x++) { + if (patches[x].length1 <= patch_size) { + continue; + } + let bigpatch = patches[x]; + // Remove the big old patch. + patches.splice(x--, 1); + let start1 = bigpatch.start1; + let start2 = bigpatch.start2; + let precontext = ''; + while (bigpatch.diffs.length !== 0) { + // Create one of several smaller patches. + let patch = new diff_match_patch.patch_obj(); + let empty = true; + patch.start1 = start1 - precontext.length; + patch.start2 = start2 - precontext.length; + if (precontext !== '') { + patch.length1 = patch.length2 = precontext.length; + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, precontext)); + } + while (bigpatch.diffs.length !== 0 && + patch.length1 < patch_size - this.Patch_Margin) { + let diff_type = bigpatch.diffs[0][0]; + let diff_text = bigpatch.diffs[0][1]; + if (diff_type === DIFF_INSERT) { + // Insertions are harmless. + patch.length2 += diff_text.length; + start2 += diff_text.length; + patch.diffs.push(bigpatch.diffs.shift()); + empty = false; + } else if (diff_type === DIFF_DELETE && patch.diffs.length === 1 && + patch.diffs[0][0] === DIFF_EQUAL && + diff_text.length > 2 * patch_size) { + // This is a large deletion. Let it pass in one chunk. + patch.length1 += diff_text.length; + start1 += diff_text.length; + empty = false; + patch.diffs.push(new diff_match_patch.Diff(diff_type, diff_text)); + bigpatch.diffs.shift(); + } else { + // Deletion or equality. Only take as much as we can stomach. + diff_text = diff_text.substring(0, + patch_size - patch.length1 - this.Patch_Margin); + patch.length1 += diff_text.length; + start1 += diff_text.length; + if (diff_type === DIFF_EQUAL) { + patch.length2 += diff_text.length; + start2 += diff_text.length; + } else { + empty = false; + } + patch.diffs.push(new diff_match_patch.Diff(diff_type, diff_text)); + if (diff_text === bigpatch.diffs[0][1]) { + bigpatch.diffs.shift(); + } else { + bigpatch.diffs[0][1] = + bigpatch.diffs[0][1].substring(diff_text.length); + } + } + } + // Compute the head context for the next patch. + precontext = this.diff_text2(patch.diffs); + precontext = + precontext.substring(precontext.length - this.Patch_Margin); + // Append the end context for this patch. + let postcontext = this.diff_text1(bigpatch.diffs) + .substring(0, this.Patch_Margin); + if (postcontext !== '') { + patch.length1 += postcontext.length; + patch.length2 += postcontext.length; + if (patch.diffs.length !== 0 && + patch.diffs[patch.diffs.length - 1][0] === DIFF_EQUAL) { + patch.diffs[patch.diffs.length - 1][1] += postcontext; + } else { + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, postcontext)); + } + } + if (!empty) { + patches.splice(++x, 0, patch); + } + } + } +}; + + +/** + * Take a list of patches and return a textual representation. + * @param {!Array.} patches Array of Patch objects. + * @return {string} Text representation of patches. + */ +diff_match_patch.prototype.patch_toText = function (patches) { + let text = []; + for (let x = 0; x < patches.length; x++) { + text[x] = patches[x]; + } + return text.join(''); +}; + + +/** + * Parse a textual representation of patches and return a list of Patch objects. + * @param {string} textline Text representation of patches. + * @return {!Array.} Array of Patch objects. + * @throws {!Error} If invalid input. + */ +diff_match_patch.prototype.patch_fromText = function (textline) { + let patches = []; + if (!textline) { + return patches; + } + let text = textline.split('\n'); + let textPointer = 0; + let patchHeader = /^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@$/; + while (textPointer < text.length) { + let m = text[textPointer].match(patchHeader); + if (!m) { + throw new Error('Invalid patch string: ' + text[textPointer]); + } + let patch = new diff_match_patch.patch_obj(); + patches.push(patch); + patch.start1 = parseInt(m[1], 10); + if (m[2] === '') { + patch.start1--; + patch.length1 = 1; + } else if (m[2] === '0') { + patch.length1 = 0; + } else { + patch.start1--; + patch.length1 = parseInt(m[2], 10); + } + + patch.start2 = parseInt(m[3], 10); + if (m[4] === '') { + patch.start2--; + patch.length2 = 1; + } else if (m[4] === '0') { + patch.length2 = 0; + } else { + patch.start2--; + patch.length2 = parseInt(m[4], 10); + } + textPointer++; + + while (textPointer < text.length) { + let sign = text[textPointer].charAt(0); + try { + let line = decodeURI(text[textPointer].substring(1)); + } catch (ex) { + // Malformed URI sequence. + throw new Error('Illegal escape in patch_fromText: ' + line); + } + if (sign === '-') { + // Deletion. + patch.diffs.push(new diff_match_patch.Diff(DIFF_DELETE, line)); + } else if (sign === '+') { + // Insertion. + patch.diffs.push(new diff_match_patch.Diff(DIFF_INSERT, line)); + } else if (sign === ' ') { + // Minor equality. + patch.diffs.push(new diff_match_patch.Diff(DIFF_EQUAL, line)); + } else if (sign === '@') { + // Start of next patch. + break; + } else if (sign === '') { + // Blank line? Whatever. + } else { + // WTF? + throw new Error('Invalid patch mode "' + sign + '" in: ' + line); + } + textPointer++; + } + } + return patches; +}; + + +/** + * Class representing one patch operation. + * @constructor + */ +diff_match_patch.patch_obj = function () { + /** @type {!Array.} */ + this.diffs = []; + /** @type {?number} */ + this.start1 = null; + /** @type {?number} */ + this.start2 = null; + /** @type {number} */ + this.length1 = 0; + /** @type {number} */ + this.length2 = 0; +}; + + +/** + * Emulate GNU diff's format. + * Header: @@ -382,8 +481,9 @@ + * Indices are printed as 1-based, not 0-based. + * @return {string} The GNU diff string. + */ +diff_match_patch.patch_obj.prototype.toString = function () { + let coords1, coords2; + if (this.length1 === 0) { + coords1 = this.start1 + ',0'; + } else if (this.length1 === 1) { + coords1 = this.start1 + 1; + } else { + coords1 = (this.start1 + 1) + ',' + this.length1; + } + if (this.length2 === 0) { + coords2 = this.start2 + ',0'; + } else if (this.length2 === 1) { + coords2 = this.start2 + 1; + } else { + coords2 = (this.start2 + 1) + ',' + this.length2; + } + let text = ['@@ -' + coords1 + ' +' + coords2 + ' @@\n']; + let op; + // Escape the body of the patch with %xx notation. + for (let x = 0; x < this.diffs.length; x++) { + switch (this.diffs[x][0]) { + case DIFF_INSERT: + op = '+'; + break; + case DIFF_DELETE: + op = '-'; + break; + case DIFF_EQUAL: + op = ' '; + break; + } + text[x + 1] = op + encodeURI(this.diffs[x][1]) + '\n'; + } + return text.join('').replace(/%20/g, ' '); +}; + + +// CommonJS exports (guarded) +if (typeof module !== 'undefined' && module.exports) { + module.exports = diff_match_patch; + module.exports['diff_match_patch'] = diff_match_patch; + module.exports['DIFF_DELETE'] = DIFF_DELETE; + module.exports['DIFF_INSERT'] = DIFF_INSERT; + module.exports['DIFF_EQUAL'] = DIFF_EQUAL; +} + +// ESM exports for static import compatibility +export default diff_match_patch; +export { diff_match_patch, DIFF_DELETE, DIFF_INSERT, DIFF_EQUAL }; diff --git a/src/vs/workbench/contrib/void/browser/media/void.css b/src/vs/workbench/contrib/void/browser/media/void.css index d732c4bf08a..f19841bb1b8 100644 --- a/src/vs/workbench/contrib/void/browser/media/void.css +++ b/src/vs/workbench/contrib/void/browser/media/void.css @@ -17,10 +17,12 @@ .void-greenBG { background-color: var(--vscode-void-greenBG); + box-shadow: inset 2px 0 0 var(--vscode-void-greenBorder); } .void-redBG { background-color: var(--vscode-void-redBG); + box-shadow: inset 2px 0 0 var(--vscode-void-redBorder); } diff --git a/src/vs/workbench/contrib/void/browser/metricsPollService.ts b/src/vs/workbench/contrib/void/browser/metricsPollService.ts index 493c7edaccd..329dc5553e2 100644 --- a/src/vs/workbench/contrib/void/browser/metricsPollService.ts +++ b/src/vs/workbench/contrib/void/browser/metricsPollService.ts @@ -8,7 +8,7 @@ import { createDecorator } from '../../../../platform/instantiation/common/insta import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import * as dom from '../../../../base/browser/dom.js'; -import { IMetricsService } from '../common/metricsService.js'; +import { IMetricsService } from '../../../../platform/void/common/metricsService.js'; diff --git a/src/vs/workbench/contrib/void/browser/miscWokrbenchContrib.ts b/src/vs/workbench/contrib/void/browser/miscWokrbenchContrib.ts index 83b3ed7b7fd..e4ca7a21b26 100644 --- a/src/vs/workbench/contrib/void/browser/miscWokrbenchContrib.ts +++ b/src/vs/workbench/contrib/void/browser/miscWokrbenchContrib.ts @@ -6,10 +6,8 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; import { IExtensionTransferService } from './extensionTransferService.js'; -import { os } from '../common/helpers/systemInfo.js'; +import { os } from '../../../../platform/void/common/helpers/systemInfo.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { timeout } from '../../../../base/common/async.js'; -import { getActiveWindow } from '../../../../base/browser/dom.js'; // Onboarding contribution that mounts the component at startup export class MiscWorkbenchContribs extends Disposable implements IWorkbenchContribution { @@ -33,16 +31,6 @@ export class MiscWorkbenchContribs extends Disposable implements IWorkbenchContr this.extensionTransferService.deleteBlacklistExtensions(os) } - - // after some time, trigger a resize event for the blank screen error - timeout(5_000).then(() => { - // Get the active window reference for multi-window support - const targetWindow = getActiveWindow(); - // Trigger a window resize event to ensure proper layout calculations - targetWindow.dispatchEvent(new Event('resize')) - - }) - } } diff --git a/src/vs/workbench/contrib/void/browser/quickEditActions.ts b/src/vs/workbench/contrib/void/browser/quickEditActions.ts index 63deba31de8..22bef713aef 100644 --- a/src/vs/workbench/contrib/void/browser/quickEditActions.ts +++ b/src/vs/workbench/contrib/void/browser/quickEditActions.ts @@ -12,8 +12,8 @@ import { IEditCodeService } from './editCodeServiceInterface.js'; import { roundRangeToLines } from './sidebarActions.js'; import { VOID_CTRL_K_ACTION_ID } from './actionIDs.js'; import { localize2 } from '../../../../nls.js'; -import { IMetricsService } from '../common/metricsService.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IMetricsService } from '../../../../platform/void/common/metricsService.js'; + export type QuickEditPropsType = { diffareaid: number, @@ -42,7 +42,6 @@ registerAction2(class extends Action2 { keybinding: { primary: KeyMod.CtrlCmd | KeyCode.KeyK, weight: KeybindingWeight.VoidExtension, - when: ContextKeyExpr.deserialize('editorFocus && !terminalFocus'), } }); } diff --git a/src/vs/workbench/contrib/void/browser/react/build.js b/src/vs/workbench/contrib/void/browser/react/build.js index 9507aa59f26..4d21f840e2d 100755 --- a/src/vs/workbench/contrib/void/browser/react/build.js +++ b/src/vs/workbench/contrib/void/browser/react/build.js @@ -3,10 +3,11 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { execSync } from 'child_process'; +import { execFileSync } from 'child_process'; import { spawn } from 'cross-spawn' // Added lines below import fs from 'fs'; +import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -56,6 +57,44 @@ function findDesiredPathFromLocalPath(localDesiredPath, currentPath) { return globalDesiredPath; } +function requireRepoPath(localDesiredPath) { + const desiredPath = findDesiredPathFromLocalPath(localDesiredPath, __dirname); + if (!desiredPath) { + throw new Error(`Could not resolve required path: ${localDesiredPath}`); + } + return desiredPath; +} + +function createTailwindShim(tailwindCliPath) { + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'void-tailwind-')); + const shimPath = path.join(shimDir, 'tailwindcss'); + const script = `#!/usr/bin/env sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(tailwindCliPath)} "$@"\n`; + fs.writeFileSync(shimPath, script, { mode: 0o755 }); + + return { + shimDir, + cleanup: () => { + try { + fs.rmSync(shimDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } + }; +} + +const scopeTailwindCliPath = requireRepoPath('./node_modules/scope-tailwind/dist/main.js'); +const nodemonCliPath = requireRepoPath('./node_modules/nodemon/bin/nodemon.js'); +const tsupCliPath = requireRepoPath('./node_modules/tsup/dist/cli-default.js'); +const tailwindCliPath = requireRepoPath('./node_modules/tailwindcss/lib/cli.js'); + +const { shimDir: tailwindShimDir, cleanup: cleanupTailwindShim } = createTailwindShim(tailwindCliPath); +const buildEnv = { + ...process.env, + PATH: `${tailwindShimDir}${path.delimiter}${process.env.PATH ?? ''}`, +}; +process.on('exit', cleanupTailwindShim); + // hack to refresh styles automatically function saveStylesFile() { setTimeout(() => { @@ -86,10 +125,14 @@ if (isWatch) { if (!fs.existsSync('src2')) { try { console.log('🔨 Running initial scope-tailwind build to create src2 folder...'); - execSync( - 'npx scope-tailwind ./src -o src2/ -s void-scope -c styles.css -p "void-"', - { stdio: 'inherit' } - ); + execFileSync(process.execPath, [ + scopeTailwindCliPath, + './src', + '-o', 'src2/', + '-s', 'void-scope', + '-c', 'styles.css', + '-p', 'void-', + ], { stdio: 'inherit', env: buildEnv }); console.log('✅ src2/ created successfully.'); } catch (err) { console.error('❌ Error running initial scope-tailwind build:', err); @@ -98,18 +141,18 @@ if (isWatch) { } // Watch mode - const scopeTailwindWatcher = spawn('npx', [ - 'nodemon', + const scopeTailwindWatcher = spawn(process.execPath, [ + nodemonCliPath, '--watch', 'src', '--ext', 'ts,tsx,css', '--exec', - 'npx scope-tailwind ./src -o src2/ -s void-scope -c styles.css -p "void-"' - ]); + `${JSON.stringify(process.execPath)} ${JSON.stringify(scopeTailwindCliPath)} ./src -o src2/ -s void-scope -c styles.css -p void-` + ], { env: buildEnv }); - const tsupWatcher = spawn('npx', [ - 'tsup', + const tsupWatcher = spawn(process.execPath, [ + tsupCliPath, '--watch' - ]); + ], { env: buildEnv }); scopeTailwindWatcher.stdout.on('data', (data) => { console.log(`[scope-tailwind] ${data}`); @@ -136,6 +179,7 @@ if (isWatch) { process.on('SIGINT', () => { scopeTailwindWatcher.kill(); tsupWatcher.kill(); + cleanupTailwindShim(); process.exit(); }); @@ -145,10 +189,18 @@ if (isWatch) { console.log('📦 Building...'); // Run scope-tailwind once - execSync('npx scope-tailwind ./src -o src2/ -s void-scope -c styles.css -p "void-"', { stdio: 'inherit' }); + execFileSync(process.execPath, [ + scopeTailwindCliPath, + './src', + '-o', 'src2/', + '-s', 'void-scope', + '-c', 'styles.css', + '-p', 'void-', + ], { stdio: 'inherit', env: buildEnv }); // Run tsup once - execSync('npx tsup', { stdio: 'inherit' }); + execFileSync(process.execPath, [tsupCliPath], { stdio: 'inherit', env: buildEnv }); console.log('✅ Build complete!'); + cleanupTailwindShim(); } diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx index 93e26b0d7b4..10135b9494d 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ApplyBlockHoverButtons.tsx @@ -3,15 +3,23 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { useState, useEffect, useCallback, useRef, Fragment } from 'react' -import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useCommandBarState, useCommandBarURIListener, useSettingsState } from '../util/services.js' -import { usePromise, useRefState } from '../util/helpers.js' -import { isFeatureNameDisabled } from '../../../../common/voidSettingsTypes.js' +import { useState, useEffect, useCallback, Fragment, } from 'react' +import { useAccessor, useChatThreadsStreamState, useCommandBarURIListener, useSettingsState } from '../util/services.js' +import { useRefState } from '../util/helpers.js' +import { isFeatureNameDisabled } from '../../../../../../../platform/void/common/voidSettingsTypes.js' import { URI } from '../../../../../../../base/common/uri.js' -import { FileSymlink, LucideIcon, RotateCw, Terminal } from 'lucide-react' +import { FileSymlink, LucideIcon, Terminal } from 'lucide-react' import { Check, X, Square, Copy, Play, } from 'lucide-react' -import { getBasename, ListableToolItem, voidOpenFileFn, ToolChildrenWrapper } from '../sidebar-tsx/SidebarChat.js' -import { PlacesType, VariantType } from 'react-tooltip' +import { getBasename, getRelative, voidOpenFileFn } from '../sidebar-tsx/SidebarChatShared.js' +import { ListableToolItem, ToolChildrenWrapper } from '../sidebar-tsx/SidebarChatUI.js' +import { IChatThreadService } from '../../../chatThreadService.js' +import { IModelService } from '../../../../../../../editor/common/services/model.js' +import { EndOfLinePreference } from '../../../../../../../editor/common/model.js' +import { StagingSelectionItem } from '../../../../../../../platform/void/common/chatThreadServiceTypes.js' +import { PlacesType } from 'react-tooltip' +import { QueryType } from '../../../../../../services/search/common/search.js' +import { ToolName } from '../../../../common/prompt/prompts.js' +import type { IEditCodeService } from '../../../editCodeServiceInterface.js' enum CopyButtonText { Idle = 'Copy', @@ -49,25 +57,6 @@ export const IconShell1 = ({ onClick, Icon, disabled, className, ...props }: Ico } - -// export const IconShell2 = ({ onClick, title, Icon, disabled, className }: IconButtonProps) => ( -// -// ) - const COPY_FEEDBACK_TIMEOUT = 1500 // amount of time to say 'Copied!' export const CopyButton = ({ codeStr, toolTipName }: { codeStr: string | (() => Promise | string), toolTipName: string }) => { @@ -88,7 +77,7 @@ export const CopyButton = ({ codeStr, toolTipName }: { codeStr: string | (() => clipboardService.writeText(typeof codeStr === 'string' ? codeStr : await codeStr()) .then(() => { setCopyButtonText(CopyButtonText.Copied) }) .catch(() => { setCopyButtonText(CopyButtonText.Error) }) - metricsService.capture('Copy Code', { length: codeStr.length }) // capture the length only + metricsService.capture('Copy Code', { length: codeStr.length }) }, [metricsService, clipboardService, codeStr, setCopyButtonText]) return /> } - - - export const JumpToFileButton = ({ uri, ...props }: { uri: URI | 'current' } & React.ButtonHTMLAttributes) => { const accessor = useAccessor() const commandService = accessor.get('ICommandService') @@ -133,22 +119,28 @@ export const JumpToTerminalButton = ({ onClick }: { onClick: () => void }) => { // state persisted for duration of react only // TODO change this to use type `ChatThreads.applyBoxState[applyBoxId]` const _applyingURIOfApplyBoxIdRef: { current: { [applyBoxId: string]: URI | undefined } } = { current: {} } +const _fileOrdinalOfApplyBoxIdRef: { current: { [applyBoxId: string]: number | undefined } } = { current: {} } const getUriBeingApplied = (applyBoxId: string) => { return _applyingURIOfApplyBoxIdRef.current[applyBoxId] ?? null } - -export const useApplyStreamState = ({ applyBoxId }: { applyBoxId: string }) => { +export function useApplyStreamState({ applyBoxId, boundUri }: { applyBoxId: string; boundUri?: URI }) { const accessor = useAccessor() const voidCommandBarService = accessor.get('IVoidCommandBarService') + const editCodeService = accessor.get('IEditCodeService') as IEditCodeService const getStreamState = useCallback(() => { - const uri = getUriBeingApplied(applyBoxId) - if (!uri) return 'idle-no-changes' - return voidCommandBarService.getStreamState(uri) - }, [voidCommandBarService, applyBoxId]) + const effective = boundUri ?? getUriBeingApplied(applyBoxId) + if (!effective) return 'idle-no-changes' as const + const fromSvc = voidCommandBarService.getStreamState(effective) + if (fromSvc === 'streaming') return fromSvc + + return editCodeService.hasIdleDiffZoneForApplyBox(effective, applyBoxId) + ? ('idle-has-changes' as const) + : ('idle-no-changes' as const) + }, [voidCommandBarService, editCodeService, applyBoxId, boundUri]) const [currStreamStateRef, setStreamState] = useRefState(getStreamState()) @@ -157,19 +149,30 @@ export const useApplyStreamState = ({ applyBoxId }: { applyBoxId: string }) => { setStreamState(getStreamState()) }, [setStreamState, getStreamState, applyBoxId]) - // listen for stream updates on this box - useCommandBarURIListener(useCallback((uri_) => { - const uri = getUriBeingApplied(applyBoxId) - if (uri?.fsPath === uri_.fsPath) { - setStreamState(getStreamState()) - } - }, [setStreamState, applyBoxId, getStreamState])) + useCommandBarURIListener(useCallback((uri_: URI) => { + const effective = boundUri ?? getUriBeingApplied(applyBoxId) + if (effective?.fsPath === uri_.fsPath) setStreamState(getStreamState()) + }, [setStreamState, applyBoxId, getStreamState, boundUri])) + useEffect(() => { + const d1 = editCodeService.onDidAddOrDeleteDiffZones((event) => { + const eff = boundUri ?? getUriBeingApplied(applyBoxId) + if (eff && event.uri && eff.fsPath === event.uri.fsPath) setStreamState(getStreamState()) + }) + const d2 = editCodeService.onDidChangeDiffsInDiffZoneNotStreaming((event) => { + const eff = boundUri ?? getUriBeingApplied(applyBoxId) + if (eff && event.uri && eff.fsPath === event.uri.fsPath) setStreamState(getStreamState()) + }) + const d3 = editCodeService.onDidChangeStreamingInDiffZone((event) => { + const eff = boundUri ?? getUriBeingApplied(applyBoxId) + if (eff && event.uri && eff.fsPath === event.uri.fsPath) setStreamState(getStreamState()) + }) + return () => { d1?.dispose?.(); d2?.dispose?.(); d3?.dispose?.(); } + }, [editCodeService, setStreamState, getStreamState, applyBoxId, boundUri]) return { currStreamStateRef, setApplying } } - type IndicatorColor = 'green' | 'orange' | 'dark' | 'yellow' | null export const StatusIndicator = ({ indicatorColor, title, className, ...props }: { indicatorColor: IndicatorColor, title?: React.ReactNode, className?: string } & React.HTMLAttributes) => { return ( @@ -199,19 +202,42 @@ const tooltipPropsForApplyBlock = ({ tooltipName, color = undefined, position = export const useEditToolStreamState = ({ applyBoxId, uri }: { applyBoxId: string, uri: URI }) => { const accessor = useAccessor() const voidCommandBarService = accessor.get('IVoidCommandBarService') - const [streamState, setStreamState] = useState(voidCommandBarService.getStreamState(uri)) - // listen for stream updates on this box + const editCodeService = accessor.get('IEditCodeService') as IEditCodeService + + const compute = useCallback(() => { + const fromSvc = voidCommandBarService.getStreamState(uri) + if (fromSvc === 'streaming') return fromSvc + return editCodeService.hasIdleDiffZoneForApplyBox(uri, applyBoxId) + ? ('idle-has-changes' as const) + : ('idle-no-changes' as const) + }, [voidCommandBarService, editCodeService, uri, applyBoxId]) + + const [streamState, setStreamState] = useState(compute()) + useCommandBarURIListener(useCallback((uri_) => { - const shouldUpdate = uri.fsPath === uri_.fsPath - if (shouldUpdate) { setStreamState(voidCommandBarService.getStreamState(uri)) } - }, [voidCommandBarService, applyBoxId, uri])) + if (uri.fsPath === uri_.fsPath) setStreamState(compute()) + }, [compute, uri])) + + useEffect(() => { + const d1 = editCodeService.onDidAddOrDeleteDiffZones((e) => { if (e?.uri?.fsPath === uri.fsPath) setStreamState(compute()) }) + const d2 = editCodeService.onDidChangeDiffsInDiffZoneNotStreaming((e) => { if (e?.uri?.fsPath === uri.fsPath) setStreamState(compute()) }) + const d3 = editCodeService.onDidChangeStreamingInDiffZone((e) => { if (e?.uri?.fsPath === uri.fsPath) setStreamState(compute()) }) + return () => { d1?.dispose?.(); d2?.dispose?.(); d3?.dispose?.(); } + }, [editCodeService, compute, uri]) - return { streamState, } + return { streamState } } export const StatusIndicatorForApplyButton = ({ applyBoxId, uri }: { applyBoxId: string, uri: URI | 'current' } & React.HTMLAttributes) => { - const { currStreamStateRef } = useApplyStreamState({ applyBoxId }) + const accessor = useAccessor() + const editCodeService = accessor.get('IEditCodeService') as IEditCodeService + const { currStreamStateRef, setApplying } = useApplyStreamState({ applyBoxId, boundUri: uri !== 'current' ? uri : undefined }) + useEffect(() => { + if (uri !== 'current') { + editCodeService.bindApplyBoxUri?.(applyBoxId, uri) + } + }, [uri, applyBoxId, editCodeService]) const currStreamState = currStreamStateRef.current @@ -239,131 +265,215 @@ export const StatusIndicatorForApplyButton = ({ applyBoxId, uri }: { applyBoxId: } -const terminalLanguages = new Set([ - 'bash', - 'shellscript', - 'shell', - 'powershell', - 'bat', - 'zsh', - 'sh', - 'fish', - 'nushell', - 'ksh', - 'xonsh', - 'elvish', -]) - -const ApplyButtonsForTerminal = ({ +export const ApplyButtonsHTML = ({ codeStr, applyBoxId, uri, - language, }: { codeStr: string, applyBoxId: string, - language?: string, +} & ({ uri: URI | 'current'; -}) => { +}) +) => { const accessor = useAccessor() + const editCodeService = accessor.get('IEditCodeService') as IEditCodeService const metricsService = accessor.get('IMetricsService') - const terminalToolService = accessor.get('ITerminalToolService') + const notificationService = accessor.get('INotificationService') + const chatThreadsService = accessor.get('IChatThreadService') as IChatThreadService + const modelService = accessor.get('IModelService') as IModelService + const fileService = accessor.get('IFileService') as any const settingsState = useSettingsState() + const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId - const [isShellRunning, setIsShellRunning] = useState(false) - const interruptToolRef = useRef<(() => void) | null>(null) - const isDisabled = isShellRunning - - const onClickSubmit = useCallback(async () => { - if (isShellRunning) return - try { - setIsShellRunning(true) - const terminalId = await terminalToolService.createPersistentTerminal({ cwd: null }) - const { interrupt } = await terminalToolService.runCommand( - codeStr, - { type: 'persistent', persistentTerminalId: terminalId } - ); - interruptToolRef.current = interrupt - metricsService.capture('Execute Shell', { length: codeStr.length }) - } catch (e) { - setIsShellRunning(false) - console.error('Failed to execute in terminal:', e) + const { currStreamStateRef, setApplying } = useApplyStreamState({ applyBoxId, boundUri: uri !== 'current' ? uri : undefined }) + useEffect(() => { + if (uri !== 'current') { + editCodeService.bindApplyBoxUri?.(applyBoxId, uri) } - }, [codeStr, uri, applyBoxId, metricsService, terminalToolService, isShellRunning]) - - if (isShellRunning) { - return ( - { - interruptToolRef.current?.(); - setIsShellRunning(false); - }} - {...tooltipPropsForApplyBlock({ tooltipName: 'Stop' })} - /> - ); - } - if (isDisabled) { - return null - } - return -} - - - -const ApplyButtonsForEdit = ({ - codeStr, - applyBoxId, - uri, - language, -}: { - codeStr: string, - applyBoxId: string, - language?: string, - uri: URI | 'current'; -}) => { - const accessor = useAccessor() - const editCodeService = accessor.get('IEditCodeService') - const metricsService = accessor.get('IMetricsService') - const notificationService = accessor.get('INotificationService') + }, [uri, applyBoxId, editCodeService]) - const settingsState = useSettingsState() - const isDisabled = !!isFeatureNameDisabled('Apply', settingsState) || !applyBoxId + // Detect if provided code snippet is already present in the target file; disable Apply if so + const [srStatus, setSrStatus] = useState<'already' | 'notpresent' | 'unknown'>('unknown') + useEffect(() => { + const effective = (uri !== 'current' ? uri : (getUriBeingApplied(applyBoxId) ?? null)) as URI | null + const normalize = (s: string) => s.replace(/\r/g, '').trim() + const collapse = (s: string) => normalize(s).replace(/[\t ]+/g, ' ').replace(/\n+/g, '\n') + const run = async () => { + try { + if (!effective) { setSrStatus('unknown'); return } + let snippet = codeStr || '' + snippet = normalize(snippet) + if (!snippet) { setSrStatus('unknown'); return } + const model = modelService.getModel(effective) + let fileText = model ? model.getValue(EndOfLinePreference.LF) : '' + if (!fileText) { + try { + const data = await (fileService as any).readFile(effective) + fileText = (data?.value?.toString ? data.value.toString() : new TextDecoder('utf-8').decode(data?.value)) || '' + } catch { fileText = '' } + } + if (!fileText) { setSrStatus('unknown'); return } + const textNorm = normalize(fileText) + const textCollapsed = collapse(fileText) + const snippetCollapsed = collapse(snippet) + const present = textNorm.includes(snippet) || textCollapsed.includes(snippetCollapsed) + setSrStatus(present ? 'already' : 'notpresent') + } catch { setSrStatus('unknown') } + } + run() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [codeStr, uri, applyBoxId]) - const { currStreamStateRef, setApplying } = useApplyStreamState({ applyBoxId }) const onClickSubmit = useCallback(async () => { if (currStreamStateRef.current === 'streaming') return - await editCodeService.callBeforeApplyOrEdit(uri) + // Prefer explicit uri; if 'current', try a pre-linked URI for this box (future UI could set it) + const maybeLinkedUri = getUriBeingApplied(applyBoxId) + const effectiveUri = uri !== 'current' ? uri : (maybeLinkedUri ?? 'current') + await editCodeService.callBeforeApplyOrEdit(effectiveUri) + + // Build SELECTIONS from the previous user message selections for this card + const buildSelectionsForApply = async (targetUri?: URI): Promise => { + try { + // parse applyBoxId: threadId-messageIdx-tokenIdx; threadId may include '-' + const parts = applyBoxId.split('-') + if (parts.length < 3) return [] + const tokenIdxStr = parts.pop()! + const tokenIdx = parseInt(tokenIdxStr, 10) + const messageIdxStr = parts.pop()! + const threadId = parts.join('-') + const messageIdx = parseInt(messageIdxStr, 10) + if (Number.isNaN(messageIdx)) return [] + const thread = chatThreadsService.state.allThreads[threadId] + if (!thread) return [] + const fileTextCache = new Map() + const getFileTextCached = async (fileUri: URI): Promise<{ text: string; lines: string[] } | null> => { + const cached = fileTextCache.get(fileUri.fsPath) + if (cached) return cached + + const model = modelService.getModel(fileUri) + let fileText = model ? model.getValue(EndOfLinePreference.LF) : '' + if (!fileText) { + try { + const data = await fileService.readFile(fileUri) + fileText = (data?.value?.toString ? data.value.toString() : new TextDecoder('utf-8').decode(data?.value)) || '' + } catch { fileText = '' } + } + if (!fileText) return null + + const result = { text: fileText, lines: fileText.split('\n') } + fileTextCache.set(fileUri.fsPath, result) + return result + } + // find nearest previous user message + let prevUserSelections: StagingSelectionItem[] | null = null + for (let i = messageIdx - 1; i >= 0; i -= 1) { + const m = thread.messages[i] + if (m?.role === 'user') { prevUserSelections = m.selections ?? null; break } + } + if (!prevUserSelections || prevUserSelections.length === 0) { + if (!targetUri) return [] + const fileState = await getFileTextCached(targetUri) + if (!fileState) return [] + const inferred = await editCodeService.inferSelectionForApply({ uri: targetUri, codeStr, fileText: fileState.text }) + return inferred ? [inferred.text] : [] + } + // if no explicit target, infer unique file from selections + if (!targetUri) { + const fileSet = new Set(prevUserSelections.filter(s => s.type !== 'Folder').map(s => s.uri.fsPath)) + if (fileSet.size !== 1) return [] + const onlyFsPath = Array.from(fileSet)[0] + targetUri = prevUserSelections.find(s => s.type !== 'Folder' && s.uri.fsPath === onlyFsPath)!.uri + } + const candidates: string[] = [] + const seenCandidateTexts = new Set() + const addCandidate = (text: string) => { + if (seenCandidateTexts.has(text)) return + seenCandidateTexts.add(text) + candidates.push(text) + } + const ctx = 4 + for (const sel of prevUserSelections) { + if (sel.type === 'Folder') continue + if (sel.uri.fsPath !== targetUri.fsPath) continue + const fileState = await getFileTextCached(sel.uri) + if (!fileState) continue + const { text: fileText, lines } = fileState + if (sel.type === 'CodeSelection') { + const [startLine, endLine] = sel.range + const start = Math.max(1, startLine - ctx) + const end = Math.min(lines.length, endLine + ctx) + addCandidate(lines.slice(start - 1, end).join('\n')) + } + else if (sel.type === 'File') { + addCandidate(fileText) + } + } + if (candidates.length === 0) return [] + const normalize = (s: string) => s.split('\n').map(l => l.trim().toLowerCase()).filter(l => l.length > 0) + const codeLines = new Set(normalize(codeStr)) + let bestIdx = -1, bestScore = -1 + for (let i = 0; i < candidates.length; i += 1) { + const candLines = new Set(normalize(candidates[i])) + let score = 0 + for (const ln of candLines) { if (codeLines.has(ln)) score += 1 } + if (score > bestScore) { bestScore = score; bestIdx = i } + } + if (bestScore <= 0) { + const fallbackIdx = Number.isFinite(tokenIdx) ? (Math.abs(tokenIdx) % candidates.length) : 0 + bestIdx = fallbackIdx + } + return [candidates[bestIdx]] + } + catch { return [] } + } + const selectionsForApply = await buildSelectionsForApply(effectiveUri === 'current' ? undefined : effectiveUri) + + if (effectiveUri && effectiveUri !== 'current') { + setApplying(effectiveUri) + try { + const applied = await editCodeService.applyEditFileSimpleForApplyBox({ uri: effectiveUri, applyBoxId }) + if (applied) { + metricsService.capture('Apply Code', { length: codeStr.length }) + return + } + } catch (e: any) { + notificationService.warn?.(`Apply failed: ${e?.message ?? String(e)}`) + console.error('applyEditFileSimpleForApplyBox error:', e) + } finally { + setApplying(undefined) + } + } + + // Fallback: use existing startApplying flow const [newApplyingUri, applyDonePromise] = editCodeService.startApplying({ from: 'ClickApply', applyStr: codeStr, - uri: uri, + selections: selectionsForApply, + uri: effectiveUri, startBehavior: 'reject-conflicts', + applyBoxId: applyBoxId, }) ?? [] setApplying(newApplyingUri) if (!applyDonePromise) { - notificationService.info(`Void Error: We couldn't run Apply here. ${uri === 'current' ? 'This Apply block wants to run on the current file, but you might not have a file open.' : `This Apply block wants to run on ${uri.fsPath}, but it might not exist.`}`) + notificationService.info(`Void Error: We couldn't run Apply here. ${effectiveUri === 'current' ? 'Specify the target file path as the first line of the code block (absolute path), then try again.' : `This Apply block wants to run on ${effectiveUri.fsPath}, but it might not exist.`}`) } // catch any errors by interrupting the stream - applyDonePromise?.catch(e => { + applyDonePromise?.then(() => { + }).catch(e => { const uri = getUriBeingApplied(applyBoxId) if (uri) editCodeService.interruptURIStreaming({ uri: uri }) notificationService.info(`Void Error: There was a problem running Apply: ${e}.`) - }) metricsService.capture('Apply Code', { length: codeStr.length }) // capture the length only - }, [setApplying, currStreamStateRef, editCodeService, codeStr, uri, applyBoxId, metricsService, notificationService]) + }, [setApplying, currStreamStateRef, editCodeService, codeStr, uri, applyBoxId, metricsService]) const onClickStop = useCallback(() => { @@ -375,17 +485,23 @@ const ApplyButtonsForEdit = ({ metricsService.capture('Stop Apply', {}) }, [currStreamStateRef, applyBoxId, editCodeService, metricsService]) - const onAccept = useCallback(() => { - const uri = getUriBeingApplied(applyBoxId) - if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri: uri, behavior: 'accept', removeCtrlKs: false }) + const onAccept = useCallback(async () => { + const target = getUriBeingApplied(applyBoxId) ?? (uri !== 'current' ? uri : undefined) + if (target) { + await editCodeService.acceptOrRejectDiffAreasByApplyBox({ uri: target, applyBoxId, behavior: 'accept' }) + } }, [uri, applyBoxId, editCodeService]) - const onReject = useCallback(() => { - const uri = getUriBeingApplied(applyBoxId) - if (uri) editCodeService.acceptOrRejectAllDiffAreas({ uri: uri, behavior: 'reject', removeCtrlKs: false }) + const onReject = useCallback(async () => { + const target = getUriBeingApplied(applyBoxId) ?? (uri !== 'current' ? uri : undefined) + if (target) { + await editCodeService.acceptOrRejectDiffAreasByApplyBox({ uri: target, applyBoxId, behavior: 'reject' }) + } }, [uri, applyBoxId, editCodeService]) + const currStreamState = currStreamStateRef.current + if (currStreamState === 'streaming') { return } + if (isDisabled) { return null } + + if (currStreamState === 'idle-no-changes') { - return + return + + {srStatus === 'already' && Already applied — no changes detected} + } - if (currStreamState === 'idle-has-changes') { + + if (currStreamState === 'idle-has-changes') { return } -} - - - - -export const ApplyButtonsHTML = (params: { - codeStr: string, - applyBoxId: string, - language?: string, - uri: URI | 'current'; -}) => { - const { language } = params - const isShellLanguage = !!language && terminalLanguages.has(language) - - if (isShellLanguage) { - return - } - else { - return - } + return null } - - - export const EditToolAcceptRejectButtonsHTML = ({ codeStr, applyBoxId, @@ -455,7 +557,7 @@ export const EditToolAcceptRejectButtonsHTML = ({ applyBoxId: string, } & ({ uri: URI, - type: 'edit_file' | 'rewrite_file', + type: ToolName, threadId: string, }) ) => { @@ -485,7 +587,7 @@ export const EditToolAcceptRejectButtonsHTML = ({ return null } - if (streamState === 'idle-has-changes') { + if (streamState === 'idle-has-changes') { if (isRunning === 'LLM' || isRunning === 'tool') return null return <> @@ -501,7 +603,7 @@ export const EditToolAcceptRejectButtonsHTML = ({ /> } - + return null } export const BlockCodeApplyWrapper = ({ @@ -511,6 +613,7 @@ export const BlockCodeApplyWrapper = ({ language, canApply, uri, + fileOrdinalIdx, }: { codeStr: string; children: React.ReactNode; @@ -518,39 +621,258 @@ export const BlockCodeApplyWrapper = ({ canApply: boolean; language: string; uri: URI | 'current', + fileOrdinalIdx?: number, }) => { const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const { currStreamStateRef } = useApplyStreamState({ applyBoxId }) + const chatThreadsService = accessor.get('IChatThreadService') as IChatThreadService + const modelService = accessor.get('IModelService') as IModelService + const editCodeService = accessor.get('IEditCodeService') as IEditCodeService + const { currStreamStateRef, setApplying } = useApplyStreamState({ applyBoxId, boundUri: uri !== 'current' ? uri : undefined }) + useEffect(() => { + if (uri !== 'current') { + editCodeService.bindApplyBoxUri?.(applyBoxId, uri) + } + }, [uri, applyBoxId, editCodeService]) const currStreamState = currStreamStateRef.current + const [showPicker, setShowPicker] = useState(false) + const [manualPath, setManualPath] = useState('') + const [suggestions, setSuggestions] = useState([]) + const [isSearching, setIsSearching] = useState(false) + + const workspaceService = accessor.get('IWorkspaceContextService') + const fileService = accessor.get('IFileService') + const searchService = accessor.get('ISearchService') as any + const commandBarService = accessor.get('IVoidCommandBarService') - const name = uri !== 'current' ? + useEffect(() => { + if (!(uri === 'current' && showPicker)) return + const recent = commandBarService.sortedURIs ?? [] + if (recent.length > 0) { + setSuggestions(recent.slice(0, 200)) + return + } + setSuggestions([]) + }, [uri, showPicker]) + + useEffect(() => { + if (!(uri === 'current' && showPicker)) return + const q = manualPath.trim() + if (q.length === 0) return + let didCancel = false + const h = setTimeout(async () => { + try { + setIsSearching(true) + const folders = workspaceService.getWorkspace()?.folders ?? [] + if (folders.length === 0) { setSuggestions([]); return } + const folderQueries = folders.map((f: any) => ({ folder: f.uri })) + const res = await searchService.fileSearch({ + type: QueryType.File, + folderQueries, + filePattern: q, + sortByScore: true, + onlyFileScheme: true, + excludePattern: { + '**/node_modules/**': true, + '**/bower_components/**': true, + '**/.yarn/**': true, + '**/.pnp/**': true, + '**/.parcel-cache/**': true, + '**/.turbo/**': true, + '**/.cache/**': true, + '**/.next/**': true, + '**/.nuxt/**': true, + '**/.svelte-kit/**': true, + '**/dist/**': true, + '**/build/**': true, + '**/out/**': true, + '**/coverage/**': true, + '**/target/**': true, + '**/.git/**': true, + // Python + '**/.venv/**': true, + '**/venv/**': true, + '**/__pycache__/**': true, + '**/.mypy_cache/**': true, + '**/.pytest_cache/**': true, + '**/.tox/**': true, + '**/.ruff_cache/**': true, + // Java / Kotlin / Android + '**/.gradle/**': true, + '**/.idea/**': true, + '**/.settings/**': true, + '**/Pods/**': true, + // .NET / C# + '**/bin/**': true, + '**/obj/**': true, + // Go / PHP / Ruby + '**/vendor/**': true, + '**/pkg/**': true, + '**/.bundle/**': true, + '**/vendor/bundle/**': true, + // Haskell / Stack + '**/dist-newstyle/**': true, + '**/.stack-work/**': true, + // Elixir / Erlang + '**/_build/**': true, + '**/deps/**': true, + '**/ebin/**': true, + // C/C++ / CMake + '**/CMakeFiles/**': true, + '**/cmake-build-*/**': true, + }, + maxResults: 200, + }) + if (didCancel) return + const items: URI[] = (res?.results || []).map((r: any) => r.resource).filter(Boolean) + setSuggestions(items) + } + catch { + if (!didCancel) setSuggestions([]) + } + finally { if (!didCancel) setIsSearching(false) } + }, 200) + return () => { didCancel = true; clearTimeout(h) } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [manualPath, uri, showPicker]) + + const resolvePathToUri = useCallback((pathStr: string): URI | null => { + const trimmed = pathStr.trim() + if (!trimmed) return null + const isWindowsAbs = /^[a-zA-Z]:[\\\/]/.test(trimmed) + if (isWindowsAbs || trimmed.startsWith('/')) { + try { return URI.file(trimmed) } catch { return null } + } + const folders = workspaceService.getWorkspace()?.folders ?? [] + if (folders.length === 0) return null + const normalized = trimmed.replace(/^\.\/[\\/]?/, '') + return URI.joinPath(folders[0].uri, normalized) + }, [workspaceService]) + + const highlightName = useCallback((name: string, query: string) => { + const q = query.trim() + if (!q) return <>{name} + const lowerName = name.toLowerCase() + const lowerQ = q.toLowerCase() + let i = 0, j = 0 + const matchedIdxs: number[] = [] + while (i < lowerName.length && j < lowerQ.length) { + if (lowerName[i] === lowerQ[j]) { matchedIdxs.push(i); j += 1 } + i += 1 + } + if (matchedIdxs.length === 0) return <>{name} + const parts: React.ReactNode[] = [] + for (let k = 0; k < name.length; k++) { + const ch = name[k] + const isMatch = matchedIdxs.includes(k) + parts.push(isMatch ? {ch} : {ch}) + } + return <>{parts} + }, []) + + const onPick = useCallback((picked: URI) => { + setApplying(picked) + setShowPicker(false) + }, [setApplying]) + + const onSubmitManual = useCallback(() => { + const u = resolvePathToUri(manualPath) + if (u) { + setApplying(u) + setShowPicker(false) + } + }, [manualPath, resolvePathToUri, setApplying]) + + const selectedUri = getUriBeingApplied(applyBoxId) + + const name = (uri !== 'current' ? uri : selectedUri) ? {getBasename(uri.fsPath)}} + name={{getBasename(((uri !== 'current' ? uri : selectedUri) as URI).fsPath)}} isSmall={true} showDot={false} - onClick={() => { voidOpenFileFn(uri, accessor) }} + onClick={() => { const target = (uri !== 'current' ? uri : selectedUri) as URI; if (target) voidOpenFileFn(target, accessor) }} /> : {language} + const canRunApply = canApply || !!selectedUri + + // remember per-block ordinal to disambiguate selections per file across multiple code blocks + useEffect(() => { + if (typeof fileOrdinalIdx === 'number' && Number.isFinite(fileOrdinalIdx)) { + _fileOrdinalOfApplyBoxIdRef.current[applyBoxId] = fileOrdinalIdx + } + return () => { + // do not clear on unmount to keep stable mapping during interactions + } + }, [applyBoxId, fileOrdinalIdx]) return
{/* header */}
- + {name}
-
- +
+ {(uri === 'current') && ( + <> + setShowPicker(v => !v)} + {...tooltipPropsForApplyBlock({ tooltipName: selectedUri ? 'Change target file' : 'Select target file' })} + /> + {selectedUri && ( + setApplying(undefined)} + {...tooltipPropsForApplyBlock({ tooltipName: 'Clear selection' })} + /> + )} + + )} + {(uri !== 'current' || !!selectedUri) && } {currStreamState === 'idle-no-changes' && } - + {canRunApply && }
+ {/* selections spoiler (temporarily hidden — can re-enable if fallback needed) */} + {/* */} + + {/* picker */} + {uri === 'current' && showPicker && ( +
+
+ setManualPath(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') onSubmitManual() }} /> + +
+
+ {isSearching &&
Searching…
} + {suggestions.map((u, i) => { + const name = getBasename(u.fsPath) + let rel = getRelative(u, accessor) || '' + // normalize slashes and drop leading slashes + rel = rel.replace(/[/\\]+/g, '/').replace(/^\/+/, '') + // remove filename from the tail of relative path + const baseUnix = name.replace(/[/\\]+/g, '/') + if (rel.endsWith('/' + baseUnix)) { + rel = rel.slice(0, -('/' + baseUnix).length) + } + return ( +
onPick(u)} title={u.fsPath}> +
+ {highlightName(name, manualPath)}{rel ? {rel} : null} +
+
+ ) + })} + {!isSearching && suggestions.length === 0 &&
No results
} +
+
+ )} + {/* contents */} {children} diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx index 97214330b2f..7731f6df41a 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/ChatMarkdownRender.tsx @@ -3,7 +3,7 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { JSX, useMemo, useState } from 'react' +import React, { JSX, useState } from 'react' import { marked, MarkedToken, Token } from 'marked' import { convertToVscodeLang, detectLanguage } from '../../../../common/helpers/languageHelpers.js' @@ -11,10 +11,10 @@ import { BlockCodeApplyWrapper } from './ApplyBlockHoverButtons.js' import { useAccessor } from '../util/services.js' import { URI } from '../../../../../../../base/common/uri.js' import { isAbsolute } from '../../../../../../../base/common/path.js' -import { separateOutFirstLine } from '../../../../common/helpers/util.js' +import { separateOutFirstLine } from '../../../../../../../platform/void/common/helpers/util.js' import { BlockCode } from '../util/inputs.js' -import { CodespanLocationLink } from '../../../../common/chatThreadServiceTypes.js' -import { getBasename, getRelative, voidOpenFileFn } from '../sidebar-tsx/SidebarChat.js' +import { CodespanLocationLink } from '../../../../../../../platform/void/common/chatThreadServiceTypes.js' +import { getBasename, getRelative, voidOpenFileFn } from '../sidebar-tsx/SidebarChatShared.js' export type ChatMessageLocation = { @@ -29,64 +29,16 @@ export const getApplyBoxId = ({ threadId, messageIdx, tokenIdx }: ApplyBoxLocati } function isValidUri(s: string): boolean { - return s.length > 5 && isAbsolute(s) && !s.includes('//') && !s.includes('/*') // common case that is a false positive is comments like // + const trimmed = s.trim() + if (!trimmed) return false + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) return false + if (trimmed.includes('/*')) return false + return isAbsolute(trimmed) } // renders contiguous string of latex eg $e^{i\pi}$ const LatexRender = ({ latex }: { latex: string }) => { return {latex} - // try { - // let formula = latex; - // let displayMode = false; - - // // Extract the formula from delimiters - // if (latex.startsWith('$') && latex.endsWith('$')) { - // // Check if it's display math $$...$$ - // if (latex.startsWith('$$') && latex.endsWith('$$')) { - // formula = latex.slice(2, -2); - // displayMode = true; - // } else { - // formula = latex.slice(1, -1); - // } - // } else if (latex.startsWith('\\(') && latex.endsWith('\\)')) { - // formula = latex.slice(2, -2); - // } else if (latex.startsWith('\\[') && latex.endsWith('\\]')) { - // formula = latex.slice(2, -2); - // displayMode = true; - // } - - // // Render LaTeX - // const html = katex.renderToString(formula, { - // displayMode: displayMode, - // throwOnError: false, - // output: 'html' - // }); - - // // Sanitize the HTML output with DOMPurify - // const sanitizedHtml = dompurify.sanitize(html, { - // RETURN_TRUSTED_TYPE: true, - // USE_PROFILES: { html: true, svg: true, mathMl: true } - // }); - - // // Add proper styling based on mode - // const className = displayMode - // ? 'katex-block my-2 text-center' - // : 'katex-inline'; - - // // Use the ref approach to avoid dangerouslySetInnerHTML - // const mathRef = React.useRef(null); - - // React.useEffect(() => { - // if (mathRef.current) { - // mathRef.current.innerHTML = sanitizedHtml as unknown as string; - // } - // }, [sanitizedHtml]); - - // return ; - // } catch (error) { - // console.error('KaTeX rendering error:', error); - // return {latex}; - // } } const Codespan = ({ text, className, onClick, tooltip }: { text: string, className?: string, onClick?: () => void, tooltip?: string }) => { @@ -129,12 +81,18 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string link = chatThreadService.getCodespanLink({ codespanStr: text, messageIdx, threadId }) if (link === undefined) { - // if no link, generate link and add to cache chatThreadService.generateCodespanLink({ codespanStr: text, threadId }) - .then(link => { - chatThreadService.addCodespanLink({ newLinkText: text, newLinkLocation: link, messageIdx, threadId }) - setDidComputeCodespanLink(true) // rerender - }) + .then(newLink => { + if (newLink) { + chatThreadService.addCodespanLink({ + newLinkText: text, + newLinkLocation: newLink, + messageIdx, + threadId + }) + setDidComputeCodespanLink(true) + } + }) } if (link?.displayText) { @@ -142,7 +100,7 @@ const CodespanWithLink = ({ text, rawText, chatMessageLocation }: { text: string } if (isValidUri(displayText)) { - tooltip = getRelative(URI.file(displayText), accessor) // Full path as tooltip + tooltip = getRelative(URI.file(displayText), accessor) displayText = getBasename(displayText) } } @@ -252,15 +210,10 @@ const paragraphToLatexSegments = (paragraphText: string) => { ); } - segments.push(...inlineSegments); } - - } } - - return segments } @@ -283,57 +236,89 @@ const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, .. if (t.type === 'code') { const [firstLine, remainingContents] = separateOutFirstLine(t.text) - const firstLineIsURI = isValidUri(firstLine) && !codeURI - const contents = firstLineIsURI ? (remainingContents?.trimStart() || '') : t.text // exclude first-line URI from contents - if (!contents) return null - // figure out langauge and URI - let uri: URI | null - let language: string - if (codeURI) { - uri = codeURI - } - else if (firstLineIsURI) { // get lang from the uri in the first line of the markdown - uri = URI.file(firstLine) - } - else { - uri = null + const looksLikeFilePath = (s: string) => { + const fl = (s || '').trim() + if (!fl) return false + + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(fl)) return false + // Windows absolute + if (/^[a-zA-Z]:[\\\/]/.test(fl)) return true + // workspace-relative + if (fl.startsWith('./') || fl.startsWith('../')) return true + // POSIX absolute + if (isAbsolute(fl)) return true + return false } - if (t.lang) { // a language was provided. empty string is common so check truthy, not just undefined - language = convertToVscodeLang(languageService, t.lang) // convert markdown language to language that vscode recognizes (eg markdown doesn't know bash but it does know shell) + + const tryResolveUriFromFirstLine = (): URI | null => { + if (codeURI) return codeURI + let fl = (firstLine || '').trim() + fl = fl.replace(/\s*\([\s\S]*?\)\s*:?\s*$/, '') + const isWindowsAbs = /^[a-zA-Z]:[\\\/]/.test(fl) + if (isWindowsAbs || isAbsolute(fl)) { + try { return URI.file(fl) } catch { } + } + // workspace-relative heuristic + try { + const workspaceService = accessor.get('IWorkspaceContextService') as any + const folders = workspaceService?.getWorkspace?.()?.folders ?? [] + if (folders.length > 0) { + const looksLikePath = (fl.startsWith('./') || fl.startsWith('../')) + if (looksLikePath) { + const normalized = fl.replace(/^\.\/[\\\/]?/, '') + return URI.joinPath(folders[0].uri, normalized) + } + } + } catch { } + return null } - else { // no language provided - fallback - get lang from the uri and contents + + + const shouldStripFirst = looksLikeFilePath(firstLine) + const uriFromFirstLine = shouldStripFirst ? tryResolveUriFromFirstLine() : null + + + const uri: URI | null = codeURI ?? uriFromFirstLine ?? null + + + const contents = shouldStripFirst ? (remainingContents?.trimStart() || '') : t.text + if (!contents) return null + + + let language: string + if (t.lang) { + language = convertToVscodeLang(languageService, t.lang) + } else { language = detectLanguage(languageService, { uri, fileContents: contents }) } if (options.isApplyEnabled && chatMessageLocation) { - const isCodeblockClosed = t.raw.trimEnd().endsWith('```') // user should only be able to Apply when the code has been closed (t.raw ends with '```') - + const isCodeblockClosed = t.raw.trimEnd().endsWith('```') const applyBoxId = getApplyBoxId({ threadId: chatMessageLocation.threadId, messageIdx: chatMessageLocation.messageIdx, tokenIdx: tokenIdx, }) + const hasTargetUri = !!uri + return } - return + return } if (t.type === 'heading') { @@ -373,40 +358,6 @@ const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, ..
) - // return ( - //
- // - // - // - // {t.header.map((cell: any, index: number) => ( - // - // ))} - // - // - // - // {t.rows.map((row: any[], rowIndex: number) => ( - // - // {row.map((cell: any, cellIndex: number) => ( - // - // ))} - // - // ))} - // - //
- // {cell.raw} - //
- // {cell.raw} - //
- //
- // ) } if (t.type === 'hr') { @@ -546,11 +497,98 @@ const RenderToken = ({ token, inPTag, codeURI, chatMessageLocation, tokenIdx, .. export const ChatMarkdownRender = ({ string, inPTag = false, chatMessageLocation, ...options }: { string: string, inPTag?: boolean, codeURI?: URI, chatMessageLocation: ChatMessageLocation | undefined } & RenderTokenOptions) => { string = string.replaceAll('\n•', '\n\n•') const tokens = marked.lexer(string); // https://marked.js.org/using_pro#renderer + + // Infer codeURI for the next code block from preceding text tokens + const accessor = useAccessor() + const modelService: any = accessor.get('IModelService') + const commandBarService: any = accessor.get('IVoidCommandBarService') + const workspaceService: any = accessor.get('IWorkspaceContextService') + + const getBase = (p: string) => p.split(/[\\\/]/).pop() || p + const sanitizeHint = (s: string) => { + let out = (s || '').trim() + // strip surrounding backticks/quotes + out = out.replace(/^\s*[`'\"]/, '').replace(/[`'\"]\s*$/, '') + // strip list bullets like "- ", "• ", "1. " + out = out.replace(/^\s*(?:[-•]|\d+\.)\s+/, '') + out = out.replace(/\(.*?\)\s*:?$/, '').replace(/[:;,]+$/, '') + return out.trim() + } + const resolveUriFromHint = (raw: string): URI | null => { + const hint = sanitizeHint(raw) + if (!hint) return null + // absolute (unix/win) + const isWinAbs = /^[a-zA-Z]:[\\\/]/.test(hint) + if (isWinAbs || isAbsolute(hint)) { + try { return URI.file(hint) } catch { /* noop */ } + } + // contains path separators → workspace-relative join + if (/[\\\/]/.test(hint)) { + try { + const folders = workspaceService?.getWorkspace?.()?.folders ?? [] + if (folders.length > 0) { + const normalized = hint.replace(/^\.\/[\\\/]?/, '') + return URI.joinPath(folders[0].uri, normalized) + } + } catch { /* noop */ } + } + // bare filename → try open models then recent URIs + if (/^[\w.-]+\.[\w0-9.-]+$/.test(hint)) { + try { + const models: any[] = modelService?.getModels?.() ?? [] + const modelMatches = models.map(m => m?.uri).filter((u: any) => u?.fsPath && getBase(u.fsPath) === hint) + if (modelMatches.length === 1) return modelMatches[0] + const recent: any[] = commandBarService?.sortedURIs ?? [] + const recentMatches = recent.filter((u: any) => u?.fsPath && getBase(u.fsPath) === hint) + if (recentMatches.length === 1) return recentMatches[0] + } catch { /* noop */ } + } + return null + } + + const elements: React.ReactNode[] = [] + let pendingUri: URI | null = options.codeURI ?? null + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] as any + let codeURIForThisToken: URI | undefined = undefined + + // If this token is a code block, pass the pendingUri once, then clear it + if (token.type === 'code') { + codeURIForThisToken = pendingUri ?? undefined + pendingUri = null + } + else { + // Try to infer URI from text-like tokens to apply to the next code block + let rawText: string | null = null + if (token.type === 'paragraph' || token.type === 'heading') rawText = token.text || token.raw || '' + else if (token.type === 'text') rawText = token.raw || token.text || '' + else if (token.type === 'list_item') rawText = token.text || '' + if (rawText) { + // find first plausible path/filename in the text + const match = rawText.match(/[`'\"]?([A-Za-z]:[\\\/][^\s:()]+|\/[^^\s:()]+|\.{0,2}\/[^^\s:()]+|(?:[\w.-]+[\\\/])+[\w.-]+\.[A-Za-z0-9.-]+|[\w.-]+\.[A-Za-z0-9.-]+)[`'\"]?/) + if (match && match[1]) { + const uri = resolveUriFromHint(match[1]) + if (uri) pendingUri = uri + } + } + } + + elements.push( + + ) + } + return ( <> - {tokens.map((token, index) => ( - - ))} + {elements} ) } diff --git a/src/vs/workbench/contrib/void/browser/react/src/markdown/inferSelection.ts b/src/vs/workbench/contrib/void/browser/react/src/markdown/inferSelection.ts new file mode 100644 index 00000000000..b47c8fdcc3d --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/markdown/inferSelection.ts @@ -0,0 +1,381 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +export type InferredSelection = { text: string; range: [number, number] }; +export type AstCandidateRange = { + startOffset: number; + endOffset: number; + nodeType?: string; +}; +export type InferenceAstContext = { + candidates: AstCandidateRange[]; + languageId?: string; + source?: 'service' | 'bundled'; +}; + +const isInformativeLine = (line: string): boolean => { + const s = line.trim(); + if (s.length < 8) return false; + if (/^\s*(\/\/|#|'|\*\s)/.test(line)) return false; // comments + if (/^[{}()[\];,:]*$/.test(s)) return false; // only punctuation + return /[A-Za-z0-9]/.test(s); +}; + +const normalizeLine = (line: string): string => line.trim().toLowerCase(); + +const buildLineMatchPrefix = (lines: string[], codeSet: Set): number[] => { + const prefix = new Array(lines.length + 1); + prefix[0] = 0; + + for (let i = 0; i < lines.length; i += 1) { + prefix[i + 1] = prefix[i] + (codeSet.has(normalizeLine(lines[i])) ? 1 : 0); + } + + return prefix; +}; + +const countLineMatchesInRange = (prefix: number[], startLine: number, endLine: number): number => { + const startIdx = Math.max(0, startLine - 1); + const endIdxExclusive = Math.min(prefix.length - 1, endLine); + if (endIdxExclusive <= startIdx) return 0; + return prefix[endIdxExclusive] - prefix[startIdx]; +}; + +const pickBestAstCandidate = ({ + codeStr, + fileText, + astContext +}: { + codeStr: string; + fileText: string; + astContext?: InferenceAstContext; +}): { startOffset: number; endOffset: number; score: number; overlap: number; anchorHits: number } | null => { + const candidates = astContext?.candidates; + if (!candidates || candidates.length === 0) return null; + + const codeLinesRaw = codeStr.split('\n'); + const codeSet = new Set(codeLinesRaw.map(normalizeLine).filter(s => s.length >= 4)); + const anchors = codeLinesRaw + .map(l => l.trim()) + .filter(isInformativeLine) + .sort((a, b) => b.length - a.length) + .slice(0, 8); + const lowerAnchors = anchors.map(anchor => anchor.toLowerCase()); + + let best: { startOffset: number; endOffset: number; score: number; overlap: number; anchorHits: number } | null = null; + const maxCandidates = Math.min(1500, candidates.length); + + for (let i = 0; i < maxCandidates; i += 1) { + const rawStart = Math.max(0, Math.min(fileText.length, Math.floor(candidates[i].startOffset))); + const rawEnd = Math.max(0, Math.min(fileText.length, Math.floor(candidates[i].endOffset))); + const startOffset = Math.min(rawStart, rawEnd); + const endOffset = Math.max(rawStart, rawEnd); + if (endOffset - startOffset < 8) continue; + + const candidateText = fileText.slice(startOffset, endOffset); + const candidateLines = candidateText.split('\n'); + + let overlap = 0; + for (const ln of candidateLines) { + if (codeSet.has(normalizeLine(ln))) overlap += 1; + } + + const candidateTextLower = candidateText.toLowerCase(); + let anchorHits = 0; + for (const lowerAnchor of lowerAnchors) { + if (candidateTextLower.includes(lowerAnchor)) anchorHits += 1; + } + + const expectedLines = Math.max(1, codeLinesRaw.length); + const lineSpan = Math.max(1, candidateLines.length); + const sizePenalty = lineSpan > expectedLines * 10 + ? Math.min(6, Math.floor(lineSpan / Math.max(1, expectedLines * 3))) + : 0; + + const score = overlap * 5 + anchorHits * 9 - sizePenalty; + if (!best || score > best.score) { + best = { startOffset, endOffset, score, overlap, anchorHits }; + } + } + + if (!best) return null; + + const minOverlap = Math.max(1, Math.floor(codeLinesRaw.length * 0.12)); + if (best.overlap < minOverlap && best.anchorHits === 0) return null; + if (best.score < 4) return null; + + return best; +}; + +export const inferSelectionFromCode = ({ + codeStr, + fileText, + astContext +}: { + codeStr: string; + fileText: string; + astContext?: InferenceAstContext; +}): InferredSelection | null => { + if (!codeStr || !fileText) return null; + + const astBest = pickBestAstCandidate({ codeStr, fileText, astContext }); + if (astBest) { + const fileLineOffsets = buildLineOffsets(fileText); + const startLine = charIdxToLine(astBest.startOffset, fileLineOffsets); + const endLine = charIdxToLine(astBest.endOffset - 1, fileLineOffsets); + const text = fileText.slice(astBest.startOffset, astBest.endOffset); + return { text, range: [startLine, endLine] }; + } + + const fileLines = fileText.split('\n'); + const codeLinesRaw = codeStr.split('\n'); + const codeLinesNorm = codeLinesRaw.map(normalizeLine); + + const anchors: { text: string; codeIdx: number }[] = []; + for (let i = 0; i < codeLinesRaw.length; i += 1) { + const ln = codeLinesRaw[i]; + if (!isInformativeLine(ln)) continue; + anchors.push({ text: ln.trim(), codeIdx: i }); + } + // prefer longer anchors; cap to 5 + anchors.sort((a, b) => b.text.length - a.text.length); + const topAnchors = anchors.slice(0, 5); + if (topAnchors.length === 0) return null; + + const fileLineOffsets = buildLineOffsets(fileText); + + const codeSet = new Set(codeLinesNorm.filter(s => s.length >= 5)); + const lineMatchPrefix = buildLineMatchPrefix(fileLines, codeSet); + + let best: { start: number; end: number; score: number } | null = null; + + for (const a of topAnchors) { + const anchor = a.text; + let fromIdx = 0; + while (fromIdx <= fileText.length) { + const hit = fileText.indexOf(anchor, fromIdx); + if (hit === -1) break; + const hitLine = charIdxToLine(hit, fileLineOffsets); // 1-indexed + + // align window by anchor's position in code + const startLine = Math.max(1, hitLine - a.codeIdx); + const endLine = Math.min(fileLines.length, startLine + codeLinesRaw.length - 1); + + // score overlap in O(1) using prefix sums + const score = countLineMatchesInRange(lineMatchPrefix, startLine, endLine); + + if (!best || score > best.score) { + best = { start: startLine, end: endLine, score }; + } + + fromIdx = hit + Math.max(1, Math.floor(anchor.length / 2)); + } + } + + if (!best) return null; + + // require minimal confidence: at least 2 overlapping lines or 15% of code lines + const minOverlap = Math.max(2, Math.floor(codeLinesRaw.length * 0.15)); + if (best.score < minOverlap) return null; + + const text = fileLines.slice(best.start - 1, best.end).join('\n'); + return { text, range: [best.start, best.end] }; +}; + +export type InferredBlock = { + text: string + range: [number, number] + offsets: [number, number] // [startOffset, endOffsetExclusive] + occurrence: number // 1-based +} + +const buildLineOffsets = (text: string): number[] => { + const offs: number[] = [0] + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10 /* \n */) offs.push(i + 1) + } + return offs +} + +const charIdxToLine = (charIdx: number, lineOffsets: number[]): number => { + let lo = 0, hi = lineOffsets.length - 1 + while (lo <= hi) { + const mid = (lo + hi) >> 1 + if (lineOffsets[mid] <= charIdx) lo = mid + 1; else hi = mid - 1 + } + return Math.max(1, Math.min(lineOffsets.length, hi + 1)) +} + +const countOccurrencesBefore = (haystack: string, needle: string, endOffsetExclusive: number): number => { + if (!needle) return 0 + let count = 0 + let idx = 0 + while (idx < endOffsetExclusive) { + const hit = haystack.indexOf(needle, idx) + if (hit === -1 || hit >= endOffsetExclusive) break + count++ + idx = hit + Math.max(1, Math.floor(needle.length / 2)) + } + return count +} + +export const inferExactBlockFromCode = ({ + codeStr, + fileText, + astContext +}: { + codeStr: string; + fileText: string; + astContext?: InferenceAstContext; +}): InferredBlock | null => { + if (!codeStr || !fileText) return null; + + const fileLF = fileText.replace(/\r\n/g, '\n'); + + const astBest = pickBestAstCandidate({ codeStr, fileText, astContext }); + if (astBest) { + const text = fileText.substring(astBest.startOffset, astBest.endOffset); + const lineOffsets = buildLineOffsets(fileLF); + const startLine = charIdxToLine(astBest.startOffset, lineOffsets); + const endLine = charIdxToLine(astBest.endOffset - 1, lineOffsets); + const occurrence = countOccurrencesBefore(fileText, text, astBest.startOffset) + 1; + return { + text, + range: [startLine, endLine], + offsets: [astBest.startOffset, astBest.endOffset], + occurrence + }; + } + + + const codeLines = codeStr.split('\n'); + let anchor = ''; + for (const line of codeLines) { + const t = line.trim(); + if (t.length < 5) continue; + const patterns = [ + /^(function|class|const|let|var|export|async|interface|type|enum|namespace|declare|abstract)\s/, + /^(public|private|protected|static|readonly|override)\s/, + /^(def|class|async def|@\w+)[\s(]/, + /^(public|private|protected|internal|static|final|abstract|sealed|virtual|override|partial)\s/, + /^(class|interface|enum|struct|record)\s/, + /^(void|int|char|float|double|bool|auto|const|static|extern|inline|virtual|template|typename)\s/, + /^(class|struct|enum|union|namespace|using)\s/, + /^(func|type|interface|struct|package|var|const)\s/, + /^(fn|pub|impl|trait|struct|enum|mod|use|const|static|async|unsafe|extern)\s/, + /^(def|class|module|begin|if|unless|case|while|until|for)\s/, + /^(function|class|interface|trait|namespace|use|public|private|protected|static|abstract|final)\s/, + /^<\?php/, + /^(func|class|struct|enum|protocol|extension|var|let|init|deinit|typealias)\s/, + /^(public|private|internal|fileprivate|open|static|final|lazy|weak|unowned)\s/, + /^(fun|class|interface|object|enum|data class|sealed class|companion object|val|var)\s/, + /^(public|private|protected|internal|override|abstract|final|open|lateinit|inline)\s/, + /^(def|class|object|trait|case class|sealed|abstract|override|implicit|lazy)\s/, + + /^\w+\s*(<[^>]+>)?\s*\([^)]*\)\s*\{/, + + /^\w+\s*(<[^>]+>)?\s*=\s*(?:\([^)]*\)|[A-Za-z0-9_$]+)\s*=>/ + ]; + if (patterns.some(rx => rx.test(t)) || t.length > 10) { anchor = t; break; } + } + if (!anchor) { + anchor = codeLines.find(l => { + const t = l.trim(); + return t.length > 5 && !/^(\/\/|#|\*|\/\*)/.test(t); + })?.trim() || ''; + } + if (!anchor) return null; + + + const startIdx = fileText.indexOf(anchor); + if (startIdx === -1) return null; + + let startOffset = startIdx; + let endOffsetExclusive = startIdx; + + + const anchorEnd = startIdx + anchor.length; + const afterAnchorRaw = fileText.slice(anchorEnd); + const anchorHasBrace = anchor.includes('{'); + const nextNonWsIsBrace = /^\s*\{/.test(afterAnchorRaw); + const arrowNear = anchor.includes('=>') || /^\s*=>/.test(afterAnchorRaw); + const usesArrowWithoutBrace = arrowNear && !anchorHasBrace && !nextNonWsIsBrace; + + if (usesArrowWithoutBrace) { + + const scanFrom = anchorEnd; + const semi = fileText.indexOf(';', scanFrom); + const nl = fileText.indexOf('\n', scanFrom); + if (semi !== -1 && (nl === -1 || semi < nl)) endOffsetExclusive = semi + 1; + else if (nl !== -1) endOffsetExclusive = nl; + else endOffsetExclusive = fileText.length; + } else { + + let inS = false, inD = false, inT = false, inSL = false, inML = false; + let openPos = -1; + for (let pos = startIdx; pos < fileText.length; pos++) { + const c = fileText[pos]; + const next = pos + 1 < fileText.length ? fileText[pos + 1] : ''; + + if (!inS && !inD && !inT) { + if (!inML && !inSL && c === '/' && next === '/') { inSL = true; pos++; continue; } + if (!inML && !inSL && c === '/' && next === '*') { inML = true; pos++; continue; } + if (inSL && c === '\n') { inSL = false; continue; } + if (inML && c === '*' && next === '/') { inML = false; pos++; continue; } + if (inSL || inML) continue; + } + if (!inML && !inSL) { + if (!inD && !inT && c === '\'') { inS = !inS; continue; } + if (!inS && !inT && c === '"') { inD = !inD; continue; } + if (!inS && !inD && c === '`') { inT = !inT; continue; } + } + if (inS || inD || inT) continue; + + if (c === '{') { openPos = pos; break; } + } + if (openPos === -1) return null; + + + let depth = 0; + inS = inD = inT = inSL = inML = false; + for (let pos = openPos; pos < fileText.length; pos++) { + const c = fileText[pos]; + const next = pos + 1 < fileText.length ? fileText[pos + 1] : ''; + + if (!inS && !inD && !inT) { + if (!inML && !inSL && c === '/' && next === '/') { inSL = true; pos++; continue; } + if (!inML && !inSL && c === '/' && next === '*') { inML = true; pos++; continue; } + if (inSL && c === '\n') { inSL = false; continue; } + if (inML && c === '*' && next === '/') { inML = false; pos++; continue; } + if (inSL || inML) continue; + } + if (!inML && !inSL) { + if (!inD && !inT && c === '\'') { inS = !inS; continue; } + if (!inS && !inT && c === '"') { inD = !inD; continue; } + if (!inS && !inD && c === '`') { inT = !inT; continue; } + } + if (inS || inD || inT) continue; + + if (c === '{') { + if (depth === 0) startOffset = startIdx; + depth++; + } else if (c === '}') { + depth--; + if (depth === 0) { endOffsetExclusive = pos + 1; break; } + } + } + if (endOffsetExclusive <= startOffset) return null; + } + + const text = fileText.substring(startOffset, endOffsetExclusive); + + const lineOffsets = buildLineOffsets(fileLF); + const startLine = charIdxToLine(startOffset, lineOffsets); + const endLine = charIdxToLine(endOffsetExclusive - 1, lineOffsets); + + const occurrence = countOccurrencesBefore(fileText, text, startOffset) + 1; + + return { text, range: [startLine, endLine], offsets: [startOffset, endOffsetExclusive], occurrence }; +}; diff --git a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx index 30762d89d0c..69696277bc7 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/quick-edit-tsx/QuickEditChat.tsx @@ -3,16 +3,14 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useSettingsState, useAccessor, useCtrlKZoneStreamingState } from '../util/services.js'; import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js'; import { QuickEditPropsType } from '../../../quickEditActions.js'; -import { ButtonStop, ButtonSubmit, IconX, VoidChatArea } from '../sidebar-tsx/SidebarChat.js'; +import { VoidChatArea } from '../sidebar-tsx/SidebarChatUI.js'; import { VOID_CTRL_K_ACTION_ID } from '../../../actionIDs.js'; import { useRefState } from '../util/helpers.js'; -import { isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'; - - +import { isFeatureNameDisabled } from '../../../../../../../platform/void/common/voidSettingsTypes.js'; export const QuickEditChat = ({ @@ -133,6 +131,4 @@ export const QuickEditChat = ({ />
- - } diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorBoundary.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorBoundary.tsx index 9e882240dfa..32fcaee8fad 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorBoundary.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorBoundary.tsx @@ -18,7 +18,7 @@ interface State { errorInfo: ErrorInfo | null; } -class ErrorBoundary extends Component { +export class ErrorBoundary extends Component { constructor(props: Props) { super(props); this.state = { @@ -28,38 +28,20 @@ class ErrorBoundary extends Component { }; } - static getDerivedStateFromError(error: Error): Partial { - return { - hasError: true, - error - }; - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + override componentDidCatch(error: Error, errorInfo: ErrorInfo): void { this.setState({ error, errorInfo }); } - render(): ReactNode { + override render(): ReactNode { if (this.state.hasError && this.state.error) { - // If a custom fallback is provided, use it if (this.props.fallback) { return this.props.fallback; } - - // Use ErrorDisplay component as the default error UI - return ( - - // - ); + return ; } - return this.props.children; } } diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx index e8aec93777d..50aac396a62 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/ErrorDisplay.tsx @@ -3,10 +3,9 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { useEffect, useState } from 'react'; +import { useState } from 'react'; import { AlertCircle, ChevronDown, ChevronUp, X } from 'lucide-react'; -import { useSettingsState } from '../util/services.js'; -import { errorDetails } from '../../../../common/sendLLMMessageTypes.js'; +import { errorDetails } from '../../../../../../../platform/void/common/sendLLMMessageTypes.js'; export const ErrorDisplay = ({ diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/Sidebar.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/Sidebar.tsx index 44df32b0ac1..b9d0f9a75c1 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/Sidebar.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/Sidebar.tsx @@ -2,11 +2,7 @@ * Copyright 2025 Glass Devtools, Inc. All rights reserved. * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ - import { useIsDark } from '../util/services.js'; -// import { SidebarThreadSelector } from './SidebarThreadSelector.js'; -// import { SidebarChat } from './SidebarChat.js'; - import '../styles.css' import { SidebarChat } from './SidebarChat.js'; import ErrorBoundary from './ErrorBoundary.js'; @@ -19,14 +15,12 @@ export const Sidebar = ({ className }: { className: string }) => { style={{ width: '100%', height: '100%' }} >
-
@@ -35,7 +29,5 @@ export const Sidebar = ({ className }: { className: string }) => {
- - } diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx index 97add942891..5b849407b55 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChat.tsx @@ -3,2882 +3,190 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { ButtonHTMLAttributes, FormEvent, FormHTMLAttributes, Fragment, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { Fragment, KeyboardEvent, useCallback, useLayoutEffect, useEffect, useMemo, useRef, useState } from 'react'; +import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useSettingsState, } from '../util/services.js'; - -import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useSettingsState, useActiveURI, useCommandBarState, useFullChatThreadsStreamState } from '../util/services.js'; -import { ScrollType } from '../../../../../../../editor/common/editorCommon.js'; - -import { ChatMarkdownRender, ChatMessageLocation, getApplyBoxId } from '../markdown/ChatMarkdownRender.js'; import { URI } from '../../../../../../../base/common/uri.js'; -import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; -import { ErrorDisplay } from './ErrorDisplay.js'; -import { BlockCode, TextAreaFns, VoidCustomDropdownBox, VoidInputBox2, VoidSlider, VoidSwitch, VoidDiffEditor } from '../util/inputs.js'; -import { ModelDropdown, } from '../void-settings-tsx/ModelDropdown.js'; -import { PastThreadsList } from './SidebarThreadSelector.js'; -import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js'; -import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js'; -import { ChatMode, displayInfoOfProviderName, FeatureName, isFeatureNameDisabled } from '../../../../../../../workbench/contrib/void/common/voidSettingsTypes.js'; -import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; -import { WarningBox } from '../void-settings-tsx/WarningBox.js'; -import { getModelCapabilities, getIsReasoningEnabledState } from '../../../../common/modelCapabilities.js'; -import { AlertTriangle, File, Ban, Check, ChevronRight, Dot, FileIcon, Pencil, Undo, Undo2, X, Flag, Copy as CopyIcon, Info, CirclePlus, Ellipsis, CircleEllipsis, Folder, ALargeSmall, TypeOutline, Text } from 'lucide-react'; -import { ChatMessage, CheckpointEntry, StagingSelectionItem, ToolMessage } from '../../../../common/chatThreadServiceTypes.js'; -import { approvalTypeOfBuiltinToolName, BuiltinToolCallParams, BuiltinToolName, ToolName, LintErrorItem, ToolApprovalType, toolApprovalTypes } from '../../../../common/toolsServiceTypes.js'; -import { CopyButton, EditToolAcceptRejectButtonsHTML, IconShell1, JumpToFileButton, JumpToTerminalButton, StatusIndicator, StatusIndicatorForApplyButton, useApplyStreamState, useEditToolStreamState } from '../markdown/ApplyBlockHoverButtons.js'; -import { IsRunningType } from '../../../chatThreadService.js'; -import { acceptAllBg, acceptBorder, buttonFontSize, buttonTextColor, rejectAllBg, rejectBg, rejectBorder } from '../../../../common/helpers/colors.js'; -import { builtinToolNames, isABuiltinToolName, MAX_FILE_CHARS_PAGE, MAX_TERMINAL_INACTIVE_TIME } from '../../../../common/prompt/prompts.js'; -import { RawToolCallObj } from '../../../../common/sendLLMMessageTypes.js'; -import ErrorBoundary from './ErrorBoundary.js'; -import { ToolApprovalTypeSwitch } from '../void-settings-tsx/Settings.js'; - -import { persistentTerminalNameOfId } from '../../../terminalToolService.js'; -import { removeMCPToolNamePrefix } from '../../../../common/mcpServiceTypes.js'; - - - -export const IconX = ({ size, className = '', ...props }: { size: number, className?: string } & React.SVGProps) => { - return ( - - - - ); -}; - -const IconArrowUp = ({ size, className = '' }: { size: number, className?: string }) => { - return ( - - - - ); -}; - - -const IconSquare = ({ size, className = '' }: { size: number, className?: string }) => { - return ( - - - - ); -}; - - -export const IconWarning = ({ size, className = '' }: { size: number, className?: string }) => { - return ( - - - - ); -}; - - -export const IconLoading = ({ className = '' }: { className?: string }) => { - - const [loadingText, setLoadingText] = useState('.'); - - useEffect(() => { - let intervalId; - - // Function to handle the animation - const toggleLoadingText = () => { - if (loadingText === '...') { - setLoadingText('.'); - } else { - setLoadingText(loadingText + '.'); - } - }; - - // Start the animation loop - intervalId = setInterval(toggleLoadingText, 300); - - // Cleanup function to clear the interval when component unmounts - return () => clearInterval(intervalId); - }, [loadingText, setLoadingText]); - - return
{loadingText}
; - -} - - - -// SLIDER ONLY: -const ReasoningOptionSlider = ({ featureName }: { featureName: FeatureName }) => { - const accessor = useAccessor() - - const voidSettingsService = accessor.get('IVoidSettingsService') - const voidSettingsState = useSettingsState() - - const modelSelection = voidSettingsState.modelSelectionOfFeature[featureName] - const overridesOfModel = voidSettingsState.overridesOfModel - - if (!modelSelection) return null - - const { modelName, providerName } = modelSelection - const { reasoningCapabilities } = getModelCapabilities(providerName, modelName, overridesOfModel) - const { canTurnOffReasoning, reasoningSlider: reasoningBudgetSlider } = reasoningCapabilities || {} - - const modelSelectionOptions = voidSettingsState.optionsOfModelSelection[featureName][providerName]?.[modelName] - const isReasoningEnabled = getIsReasoningEnabledState(featureName, providerName, modelName, modelSelectionOptions, overridesOfModel) - - if (canTurnOffReasoning && !reasoningBudgetSlider) { // if it's just a on/off toggle without a power slider - return
- Thinking - { - const isOff = canTurnOffReasoning && !newVal - voidSettingsService.setOptionsOfModelSelection(featureName, modelSelection.providerName, modelSelection.modelName, { reasoningEnabled: !isOff }) - }} - /> -
- } - - if (reasoningBudgetSlider?.type === 'budget_slider') { // if it's a slider - const { min: min_, max, default: defaultVal } = reasoningBudgetSlider - - const nSteps = 8 // only used in calculating stepSize, stepSize is what actually matters - const stepSize = Math.round((max - min_) / nSteps) - - const valueIfOff = min_ - stepSize - const min = canTurnOffReasoning ? valueIfOff : min_ - const value = isReasoningEnabled ? voidSettingsState.optionsOfModelSelection[featureName][modelSelection.providerName]?.[modelSelection.modelName]?.reasoningBudget ?? defaultVal - : valueIfOff - - return
- Thinking - { - const isOff = canTurnOffReasoning && newVal === valueIfOff - voidSettingsService.setOptionsOfModelSelection(featureName, modelSelection.providerName, modelSelection.modelName, { reasoningEnabled: !isOff, reasoningBudget: newVal }) - }} - /> - {isReasoningEnabled ? `${value} tokens` : 'Thinking disabled'} -
- } - - if (reasoningBudgetSlider?.type === 'effort_slider') { - - const { values, default: defaultVal } = reasoningBudgetSlider - - const min = canTurnOffReasoning ? -1 : 0 - const max = values.length - 1 - - const currentEffort = voidSettingsState.optionsOfModelSelection[featureName][modelSelection.providerName]?.[modelSelection.modelName]?.reasoningEffort ?? defaultVal - const valueIfOff = -1 - const value = isReasoningEnabled && currentEffort ? values.indexOf(currentEffort) : valueIfOff - - const currentEffortCapitalized = currentEffort.charAt(0).toUpperCase() + currentEffort.slice(1, Infinity) - - return
- Thinking - { - const isOff = canTurnOffReasoning && newVal === valueIfOff - voidSettingsService.setOptionsOfModelSelection(featureName, modelSelection.providerName, modelSelection.modelName, { reasoningEnabled: !isOff, reasoningEffort: values[newVal] ?? undefined }) - }} - /> - {isReasoningEnabled ? `${currentEffortCapitalized}` : 'Thinking disabled'} -
- } - - return null -} - - - -const nameOfChatMode = { - 'normal': 'Chat', - 'gather': 'Gather', - 'agent': 'Agent', -} - -const detailOfChatMode = { - 'normal': 'Normal chat', - 'gather': 'Reads files, but can\'t edit', - 'agent': 'Edits files and uses tools', -} - - -const ChatModeDropdown = ({ className }: { className: string }) => { - const accessor = useAccessor() - - const voidSettingsService = accessor.get('IVoidSettingsService') - const settingsState = useSettingsState() - - const options: ChatMode[] = useMemo(() => ['normal', 'gather', 'agent'], []) - - const onChangeOption = useCallback((newVal: ChatMode) => { - voidSettingsService.setGlobalSetting('chatMode', newVal) - }, [voidSettingsService]) - - return nameOfChatMode[val]} - getOptionDropdownName={(val) => nameOfChatMode[val]} - getOptionDropdownDetail={(val) => detailOfChatMode[val]} - getOptionsEqual={(a, b) => a === b} - /> - -} - - - - - -interface VoidChatAreaProps { - // Required - children: React.ReactNode; // This will be the input component - - // Form controls - onSubmit: () => void; - onAbort: () => void; - isStreaming: boolean; - isDisabled?: boolean; - divRef?: React.RefObject; - - // UI customization - className?: string; - showModelDropdown?: boolean; - showSelections?: boolean; - showProspectiveSelections?: boolean; - loadingIcon?: React.ReactNode; - - selections?: StagingSelectionItem[] - setSelections?: (s: StagingSelectionItem[]) => void - // selections?: any[]; - // onSelectionsChange?: (selections: any[]) => void; - - onClickAnywhere?: () => void; - // Optional close button - onClose?: () => void; - - featureName: FeatureName; -} - -export const VoidChatArea: React.FC = ({ - children, - onSubmit, - onAbort, - onClose, - onClickAnywhere, - divRef, - isStreaming = false, - isDisabled = false, - className = '', - showModelDropdown = true, - showSelections = false, - showProspectiveSelections = false, - selections, - setSelections, - featureName, - loadingIcon, -}) => { - return ( -
{ - onClickAnywhere?.() - }} - > - {/* Selections section */} - {showSelections && selections && setSelections && ( - - )} - - {/* Input section */} -
- {children} - - {/* Close button (X) if onClose is provided */} - {onClose && ( -
- -
- )} -
- - {/* Bottom row */} -
- {showModelDropdown && ( -
- - -
- {featureName === 'Chat' && } - -
-
- )} - -
- - {isStreaming && loadingIcon} - - {isStreaming ? ( - - ) : ( - - )} -
- -
-
- ); -}; - - - - -type ButtonProps = ButtonHTMLAttributes -const DEFAULT_BUTTON_SIZE = 22; -export const ButtonSubmit = ({ className, disabled, ...props }: ButtonProps & Required>) => { - - return -} - -export const ButtonStop = ({ className, ...props }: ButtonHTMLAttributes) => { - return -} - - - -const scrollToBottom = (divRef: { current: HTMLElement | null }) => { - if (divRef.current) { - divRef.current.scrollTop = divRef.current.scrollHeight; - } -}; - - - -const ScrollToBottomContainer = ({ children, className, style, scrollContainerRef }: { children: React.ReactNode, className?: string, style?: React.CSSProperties, scrollContainerRef: React.MutableRefObject }) => { - const [isAtBottom, setIsAtBottom] = useState(true); // Start at bottom - - const divRef = scrollContainerRef - - const onScroll = () => { - const div = divRef.current; - if (!div) return; - - const isBottom = Math.abs( - div.scrollHeight - div.clientHeight - div.scrollTop - ) < 4; - - setIsAtBottom(isBottom); - }; - - // When children change (new messages added) - useEffect(() => { - if (isAtBottom) { - scrollToBottom(divRef); - } - }, [children, isAtBottom]); // Dependency on children to detect new messages - - // Initial scroll to bottom - useEffect(() => { - scrollToBottom(divRef); - }, []); - - return ( -
- {children} -
- ); -}; - -export const getRelative = (uri: URI, accessor: ReturnType) => { - const workspaceContextService = accessor.get('IWorkspaceContextService') - let path: string - const isInside = workspaceContextService.isInsideWorkspace(uri) - if (isInside) { - const f = workspaceContextService.getWorkspace().folders.find(f => uri.fsPath?.startsWith(f.uri.fsPath)) - if (f) { path = uri.fsPath.replace(f.uri.fsPath, '') } - else { path = uri.fsPath } - } - else { - path = uri.fsPath - } - return path || undefined -} - -export const getFolderName = (pathStr: string) => { - // 'unixify' path - pathStr = pathStr.replace(/[/\\]+/g, '/') // replace any / or \ or \\ with / - const parts = pathStr.split('/') // split on / - // Filter out empty parts (the last element will be empty if path ends with /) - const nonEmptyParts = parts.filter(part => part.length > 0) - if (nonEmptyParts.length === 0) return '/' // Root directory - if (nonEmptyParts.length === 1) return nonEmptyParts[0] + '/' // Only one folder - // Get the last two parts - const lastTwo = nonEmptyParts.slice(-2) - return lastTwo.join('/') + '/' -} - -export const getBasename = (pathStr: string, parts: number = 1) => { - // 'unixify' path - pathStr = pathStr.replace(/[/\\]+/g, '/') // replace any / or \ or \\ with / - const allParts = pathStr.split('/') // split on / - if (allParts.length === 0) return pathStr - return allParts.slice(-parts).join('/') -} - - - -// Open file utility function -export const voidOpenFileFn = ( - uri: URI, - accessor: ReturnType, - range?: [number, number] -) => { - const commandService = accessor.get('ICommandService') - const editorService = accessor.get('ICodeEditorService') - - // Get editor selection from CodeSelection range - let editorSelection = undefined; - - // If we have a selection, create an editor selection from the range - if (range) { - editorSelection = { - startLineNumber: range[0], - startColumn: 1, - endLineNumber: range[1], - endColumn: Number.MAX_SAFE_INTEGER, - }; - } - - // open the file - commandService.executeCommand('vscode.open', uri).then(() => { - - // select the text - setTimeout(() => { - if (!editorSelection) return; - - const editor = editorService.getActiveCodeEditor() - if (!editor) return; - - editor.setSelection(editorSelection) - editor.revealRange(editorSelection, ScrollType.Immediate) - - }, 50) // needed when document was just opened and needs to initialize - - }) - -}; - - -export const SelectedFiles = ( - { type, selections, setSelections, showProspectiveSelections, messageIdx, }: - | { type: 'past', selections: StagingSelectionItem[]; setSelections?: undefined, showProspectiveSelections?: undefined, messageIdx: number, } - | { type: 'staging', selections: StagingSelectionItem[]; setSelections: ((newSelections: StagingSelectionItem[]) => void), showProspectiveSelections?: boolean, messageIdx?: number } -) => { - - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const modelReferenceService = accessor.get('IVoidModelService') - - - - - // state for tracking prospective files - const { uri: currentURI } = useActiveURI() - const [recentUris, setRecentUris] = useState([]) - const maxRecentUris = 10 - const maxProspectiveFiles = 3 - useEffect(() => { // handle recent files - if (!currentURI) return - setRecentUris(prev => { - const withoutCurrent = prev.filter(uri => uri.fsPath !== currentURI.fsPath) // remove duplicates - const withCurrent = [currentURI, ...withoutCurrent] - return withCurrent.slice(0, maxRecentUris) - }) - }, [currentURI]) - const [prospectiveSelections, setProspectiveSelections] = useState([]) - - - // handle prospective files - useEffect(() => { - const computeRecents = async () => { - const prospectiveURIs = recentUris - .filter(uri => !selections.find(s => s.type === 'File' && s.uri.fsPath === uri.fsPath)) - .slice(0, maxProspectiveFiles) - - const answer: StagingSelectionItem[] = [] - for (const uri of prospectiveURIs) { - answer.push({ - type: 'File', - uri: uri, - language: (await modelReferenceService.getModelSafe(uri)).model?.getLanguageId() || 'plaintext', - state: { wasAddedAsCurrentFile: false }, - }) - } - return answer - } - - // add a prospective file if type === 'staging' and if the user is in a file, and if the file is not selected yet - if (type === 'staging' && showProspectiveSelections) { - computeRecents().then((a) => setProspectiveSelections(a)) - } - else { - setProspectiveSelections([]) - } - }, [recentUris, selections, type, showProspectiveSelections]) - - - const allSelections = [...selections, ...prospectiveSelections] - - if (allSelections.length === 0) { - return null - } - - return ( -
- - {allSelections.map((selection, i) => { - - const isThisSelectionProspective = i > selections.length - 1 - - const thisKey = selection.type === 'CodeSelection' ? selection.type + selection.language + selection.range + selection.state.wasAddedAsCurrentFile + selection.uri.fsPath - : selection.type === 'File' ? selection.type + selection.language + selection.state.wasAddedAsCurrentFile + selection.uri.fsPath - : selection.type === 'Folder' ? selection.type + selection.language + selection.state + selection.uri.fsPath - : i - - const SelectionIcon = ( - selection.type === 'File' ? File - : selection.type === 'Folder' ? Folder - : selection.type === 'CodeSelection' ? Text - : (undefined as never) - ) - - return
- {/* tooltip for file path */} - - {/* summarybox */} -
{ - if (type !== 'staging') return; // (never) - if (isThisSelectionProspective) { // add prospective selection to selections - setSelections([...selections, selection]) - } - else if (selection.type === 'File') { // open files - voidOpenFileFn(selection.uri, accessor); - - const wasAddedAsCurrentFile = selection.state.wasAddedAsCurrentFile - if (wasAddedAsCurrentFile) { - // make it so the file is added permanently, not just as the current file - const newSelection: StagingSelectionItem = { ...selection, state: { ...selection.state, wasAddedAsCurrentFile: false } } - setSelections([ - ...selections.slice(0, i), - newSelection, - ...selections.slice(i + 1) - ]) - } - } - else if (selection.type === 'CodeSelection') { - voidOpenFileFn(selection.uri, accessor, selection.range); - } - else if (selection.type === 'Folder') { - // TODO!!! reveal in tree - } - }} - > - {} - - { // file name and range - getBasename(selection.uri.fsPath) - + (selection.type === 'CodeSelection' ? ` (${selection.range[0]}-${selection.range[1]})` : '') - } - - {selection.type === 'File' && selection.state.wasAddedAsCurrentFile && messageIdx === undefined && currentURI?.fsPath === selection.uri.fsPath ? - - {`(Current File)`} - - : null - } - - {type === 'staging' && !isThisSelectionProspective ? // X button -
{ - e.stopPropagation(); // don't open/close selection - if (type !== 'staging') return; - setSelections([...selections.slice(0, i), ...selections.slice(i + 1)]) - }} - > - -
- : <> - } -
-
-
- - })} - - -
- - ) -} - - -type ToolHeaderParams = { - icon?: React.ReactNode; - title: React.ReactNode; - desc1: React.ReactNode; - desc1OnClick?: () => void; - desc2?: React.ReactNode; - isError?: boolean; - info?: string; - desc1Info?: string; - isRejected?: boolean; - numResults?: number; - hasNextPage?: boolean; - children?: React.ReactNode; - bottomChildren?: React.ReactNode; - onClick?: () => void; - desc2OnClick?: () => void; - isOpen?: boolean; - className?: string; -} - -const ToolHeaderWrapper = ({ - icon, - title, - desc1, - desc1OnClick, - desc1Info, - desc2, - numResults, - hasNextPage, - children, - info, - bottomChildren, - isError, - onClick, - desc2OnClick, - isOpen, - isRejected, - className, // applies to the main content -}: ToolHeaderParams) => { - - const [isOpen_, setIsOpen] = useState(false); - const isExpanded = isOpen !== undefined ? isOpen : isOpen_ - - const isDropdown = children !== undefined // null ALLOWS dropdown - const isClickable = !!(isDropdown || onClick) - - const isDesc1Clickable = !!desc1OnClick - - const desc1HTML = {desc1} - - return (
-
- {/* header */} -
-
- {/* left */} -
- {/* title eg "> Edited File" */} -
{ - if (isDropdown) { setIsOpen(v => !v); } - if (onClick) { onClick(); } - }} - > - {isDropdown && ()} - {title} - - {!isDesc1Clickable && desc1HTML} -
- {isDesc1Clickable && desc1HTML} -
- - {/* right */} -
- - {info && } - - {isError && } - {isRejected && } - {desc2 && - {desc2} - } - {numResults !== undefined && ( - - {`${numResults}${hasNextPage ? '+' : ''} result${numResults !== 1 ? 's' : ''}`} - - )} -
-
-
- {/* children */} - {
- {children} -
} -
- {bottomChildren} -
); -}; - - - -const EditTool = ({ toolMessage, threadId, messageIdx, content }: Parameters>[0] & { content: string }) => { - const accessor = useAccessor() - const isError = false - const isRejected = toolMessage.type === 'rejected' - - const title = getTitle(toolMessage) - - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - - const { rawParams, params, name } = toolMessage - const desc1OnClick = () => voidOpenFileFn(params.uri, accessor) - const componentParams: ToolHeaderParams = { title, desc1, desc1OnClick, desc1Info, isError, icon, isRejected, } - - - const editToolType = toolMessage.name === 'edit_file' ? 'diff' : 'rewrite' - if (toolMessage.type === 'running_now' || toolMessage.type === 'tool_request') { - componentParams.children = - - - // JumpToFileButton removed in favor of FileLinkText - } - else if (toolMessage.type === 'success' || toolMessage.type === 'rejected' || toolMessage.type === 'tool_error') { - // add apply box - const applyBoxId = getApplyBoxId({ - threadId: threadId, - messageIdx: messageIdx, - tokenIdx: 'N/A', - }) - componentParams.desc2 = - - // add children - componentParams.children = - - - - if (toolMessage.type === 'success' || toolMessage.type === 'rejected') { - const { result } = toolMessage - componentParams.bottomChildren = - {result?.lintErrors?.map((error, i) => ( -
Lines {error.startLineNumber}-{error.endLineNumber}: {error.message}
- ))} -
- } - else if (toolMessage.type === 'tool_error') { - // error - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } - } - - return -} - -const SimplifiedToolHeader = ({ - title, - children, -}: { - title: string; - children?: React.ReactNode; -}) => { - const [isOpen, setIsOpen] = useState(false); - const isDropdown = children !== undefined; - return ( -
-
- {/* header */} -
{ - if (isDropdown) { setIsOpen(v => !v); } - }} - > - {isDropdown && ( - - )} -
- {title} -
-
- {/* children */} - {
- {children} -
} -
-
- ); -}; - - - - -const UserMessageComponent = ({ chatMessage, messageIdx, isCheckpointGhost, currCheckpointIdx, _scrollToBottom }: { chatMessage: ChatMessage & { role: 'user' }, messageIdx: number, currCheckpointIdx: number | undefined, isCheckpointGhost: boolean, _scrollToBottom: (() => void) | null }) => { - - const accessor = useAccessor() - const chatThreadsService = accessor.get('IChatThreadService') - - // global state - let isBeingEdited = false - let stagingSelections: StagingSelectionItem[] = [] - let setIsBeingEdited = (_: boolean) => { } - let setStagingSelections = (_: StagingSelectionItem[]) => { } - - if (messageIdx !== undefined) { - const _state = chatThreadsService.getCurrentMessageState(messageIdx) - isBeingEdited = _state.isBeingEdited - stagingSelections = _state.stagingSelections - setIsBeingEdited = (v) => chatThreadsService.setCurrentMessageState(messageIdx, { isBeingEdited: v }) - setStagingSelections = (s) => chatThreadsService.setCurrentMessageState(messageIdx, { stagingSelections: s }) - } - - - // local state - const mode: ChatBubbleMode = isBeingEdited ? 'edit' : 'display' - const [isFocused, setIsFocused] = useState(false) - const [isHovered, setIsHovered] = useState(false) - const [isDisabled, setIsDisabled] = useState(false) - const [textAreaRefState, setTextAreaRef] = useState(null) - const textAreaFnsRef = useRef(null) - // initialize on first render, and when edit was just enabled - const _mustInitialize = useRef(true) - const _justEnabledEdit = useRef(false) - useEffect(() => { - const canInitialize = mode === 'edit' && textAreaRefState - const shouldInitialize = _justEnabledEdit.current || _mustInitialize.current - if (canInitialize && shouldInitialize) { - setStagingSelections( - (chatMessage.selections || []).map(s => { // quick hack so we dont have to do anything more - if (s.type === 'File') return { ...s, state: { ...s.state, wasAddedAsCurrentFile: false, } } - else return s - }) - ) - - if (textAreaFnsRef.current) - textAreaFnsRef.current.setValue(chatMessage.displayContent || '') - - textAreaRefState.focus(); - - _justEnabledEdit.current = false - _mustInitialize.current = false - } - - }, [chatMessage, mode, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current]) - - const onOpenEdit = () => { - setIsBeingEdited(true) - chatThreadsService.setCurrentlyFocusedMessageIdx(messageIdx) - _justEnabledEdit.current = true - } - const onCloseEdit = () => { - setIsFocused(false) - setIsHovered(false) - setIsBeingEdited(false) - chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) - - } - - const EditSymbol = mode === 'display' ? Pencil : X - - - let chatbubbleContents: React.ReactNode - if (mode === 'display') { - chatbubbleContents = <> - - {chatMessage.displayContent} - - } - else if (mode === 'edit') { - - const onSubmit = async () => { - - if (isDisabled) return; - if (!textAreaRefState) return; - if (messageIdx === undefined) return; - - // cancel any streams on this thread - const threadId = chatThreadsService.state.currentThreadId - - await chatThreadsService.abortRunning(threadId) - - // update state - setIsBeingEdited(false) - chatThreadsService.setCurrentlyFocusedMessageIdx(undefined) - - // stream the edit - const userMessage = textAreaRefState.value; - try { - await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, messageIdx, threadId }) - } catch (e) { - console.error('Error while editing message:', e) - } - await chatThreadsService.focusCurrentChat() - requestAnimationFrame(() => _scrollToBottom?.()) - } - - const onAbort = async () => { - const threadId = chatThreadsService.state.currentThreadId - await chatThreadsService.abortRunning(threadId) - } - - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - onCloseEdit() - } - if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { - onSubmit() - } - } - - if (!chatMessage.content) { // don't show if empty and not loading (if loading, want to show). - return null - } - - chatbubbleContents = - setIsDisabled(!text)} - onFocus={() => { - setIsFocused(true) - chatThreadsService.setCurrentlyFocusedMessageIdx(messageIdx); - }} - onBlur={() => { - setIsFocused(false) - }} - onKeyDown={onKeyDown} - fnsRef={textAreaFnsRef} - multiline={true} - /> - - } - - const isMsgAfterCheckpoint = currCheckpointIdx !== undefined && currCheckpointIdx === messageIdx - 1 - - return
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
{ if (mode === 'display') { onOpenEdit() } }} - > - {chatbubbleContents} -
- - - -
- { - if (mode === 'display') { - onOpenEdit() - } else if (mode === 'edit') { - onCloseEdit() - } - }} - /> -
- - -
- -} - -const SmallProseWrapper = ({ children }: { children: React.ReactNode }) => { - return
- {children} -
-} - -const ProseWrapper = ({ children }: { children: React.ReactNode }) => { - return
- {children} -
-} -const AssistantMessageComponent = ({ chatMessage, isCheckpointGhost, isCommitted, messageIdx }: { chatMessage: ChatMessage & { role: 'assistant' }, isCheckpointGhost: boolean, messageIdx: number, isCommitted: boolean }) => { - - const accessor = useAccessor() - const chatThreadsService = accessor.get('IChatThreadService') - - const reasoningStr = chatMessage.reasoning?.trim() || null - const hasReasoning = !!reasoningStr - const isDoneReasoning = !!chatMessage.displayContent - const thread = chatThreadsService.getCurrentThread() - - - const chatMessageLocation: ChatMessageLocation = { - threadId: thread.id, - messageIdx: messageIdx, - } - - const isEmpty = !chatMessage.displayContent && !chatMessage.reasoning - if (isEmpty) return null - - return <> - {/* reasoning token */} - {hasReasoning && -
- - - - - -
- } - - {/* assistant message */} - {chatMessage.displayContent && -
- - - -
- } - - -} - -const ReasoningWrapper = ({ isDoneReasoning, isStreaming, children }: { isDoneReasoning: boolean, isStreaming: boolean, children: React.ReactNode }) => { - const isDone = isDoneReasoning || !isStreaming - const isWriting = !isDone - const [isOpen, setIsOpen] = useState(isWriting) - useEffect(() => { - if (!isWriting) setIsOpen(false) // if just finished reasoning, close - }, [isWriting]) - return : ''} isOpen={isOpen} onClick={() => setIsOpen(v => !v)}> - -
- {children} -
-
-
-} - - - - -// should either be past or "-ing" tense, not present tense. Eg. when the LLM searches for something, the user expects it to say "I searched for X" or "I am searching for X". Not "I search X". - -const loadingTitleWrapper = (item: React.ReactNode): React.ReactNode => { - return - {item} - - -} - -const titleOfBuiltinToolName = { - 'read_file': { done: 'Read file', proposed: 'Read file', running: loadingTitleWrapper('Reading file') }, - 'ls_dir': { done: 'Inspected folder', proposed: 'Inspect folder', running: loadingTitleWrapper('Inspecting folder') }, - 'get_dir_tree': { done: 'Inspected folder tree', proposed: 'Inspect folder tree', running: loadingTitleWrapper('Inspecting folder tree') }, - 'search_pathnames_only': { done: 'Searched by file name', proposed: 'Search by file name', running: loadingTitleWrapper('Searching by file name') }, - 'search_for_files': { done: 'Searched', proposed: 'Search', running: loadingTitleWrapper('Searching') }, - 'create_file_or_folder': { done: `Created`, proposed: `Create`, running: loadingTitleWrapper(`Creating`) }, - 'delete_file_or_folder': { done: `Deleted`, proposed: `Delete`, running: loadingTitleWrapper(`Deleting`) }, - 'edit_file': { done: `Edited file`, proposed: 'Edit file', running: loadingTitleWrapper('Editing file') }, - 'rewrite_file': { done: `Wrote file`, proposed: 'Write file', running: loadingTitleWrapper('Writing file') }, - 'run_command': { done: `Ran terminal`, proposed: 'Run terminal', running: loadingTitleWrapper('Running terminal') }, - 'run_persistent_command': { done: `Ran terminal`, proposed: 'Run terminal', running: loadingTitleWrapper('Running terminal') }, - - 'open_persistent_terminal': { done: `Opened terminal`, proposed: 'Open terminal', running: loadingTitleWrapper('Opening terminal') }, - 'kill_persistent_terminal': { done: `Killed terminal`, proposed: 'Kill terminal', running: loadingTitleWrapper('Killing terminal') }, - - 'read_lint_errors': { done: `Read lint errors`, proposed: 'Read lint errors', running: loadingTitleWrapper('Reading lint errors') }, - 'search_in_file': { done: 'Searched in file', proposed: 'Search in file', running: loadingTitleWrapper('Searching in file') }, -} as const satisfies Record - - -const getTitle = (toolMessage: Pick): React.ReactNode => { - const t = toolMessage - - // non-built-in title - if (!builtinToolNames.includes(t.name as BuiltinToolName)) { - // descriptor of Running or Ran etc - const descriptor = - t.type === 'success' ? 'Called' - : t.type === 'running_now' ? 'Calling' - : t.type === 'tool_request' ? 'Call' - : t.type === 'rejected' ? 'Call' - : t.type === 'invalid_params' ? 'Call' - : t.type === 'tool_error' ? 'Call' - : 'Call' - - - const title = `${descriptor} ${toolMessage.mcpServerName || 'MCP'}` - if (t.type === 'running_now' || t.type === 'tool_request') - return loadingTitleWrapper(title) - return title - } - - // built-in title - else { - const toolName = t.name as BuiltinToolName - if (t.type === 'success') return titleOfBuiltinToolName[toolName].done - if (t.type === 'running_now') return titleOfBuiltinToolName[toolName].running - return titleOfBuiltinToolName[toolName].proposed - } -} - - -const toolNameToDesc = (toolName: BuiltinToolName, _toolParams: BuiltinToolCallParams[BuiltinToolName] | undefined, accessor: ReturnType): { - desc1: React.ReactNode, - desc1Info?: string, -} => { - - if (!_toolParams) { - return { desc1: '', }; - } - - const x = { - 'read_file': () => { - const toolParams = _toolParams as BuiltinToolCallParams['read_file'] - return { - desc1: getBasename(toolParams.uri.fsPath), - desc1Info: getRelative(toolParams.uri, accessor), - }; - }, - 'ls_dir': () => { - const toolParams = _toolParams as BuiltinToolCallParams['ls_dir'] - return { - desc1: getFolderName(toolParams.uri.fsPath), - desc1Info: getRelative(toolParams.uri, accessor), - }; - }, - 'search_pathnames_only': () => { - const toolParams = _toolParams as BuiltinToolCallParams['search_pathnames_only'] - return { - desc1: `"${toolParams.query}"`, - } - }, - 'search_for_files': () => { - const toolParams = _toolParams as BuiltinToolCallParams['search_for_files'] - return { - desc1: `"${toolParams.query}"`, - } - }, - 'search_in_file': () => { - const toolParams = _toolParams as BuiltinToolCallParams['search_in_file']; - return { - desc1: `"${toolParams.query}"`, - desc1Info: getRelative(toolParams.uri, accessor), - }; - }, - 'create_file_or_folder': () => { - const toolParams = _toolParams as BuiltinToolCallParams['create_file_or_folder'] - return { - desc1: toolParams.isFolder ? getFolderName(toolParams.uri.fsPath) ?? '/' : getBasename(toolParams.uri.fsPath), - desc1Info: getRelative(toolParams.uri, accessor), - } - }, - 'delete_file_or_folder': () => { - const toolParams = _toolParams as BuiltinToolCallParams['delete_file_or_folder'] - return { - desc1: toolParams.isFolder ? getFolderName(toolParams.uri.fsPath) ?? '/' : getBasename(toolParams.uri.fsPath), - desc1Info: getRelative(toolParams.uri, accessor), - } - }, - 'rewrite_file': () => { - const toolParams = _toolParams as BuiltinToolCallParams['rewrite_file'] - return { - desc1: getBasename(toolParams.uri.fsPath), - desc1Info: getRelative(toolParams.uri, accessor), - } - }, - 'edit_file': () => { - const toolParams = _toolParams as BuiltinToolCallParams['edit_file'] - return { - desc1: getBasename(toolParams.uri.fsPath), - desc1Info: getRelative(toolParams.uri, accessor), - } - }, - 'run_command': () => { - const toolParams = _toolParams as BuiltinToolCallParams['run_command'] - return { - desc1: `"${toolParams.command}"`, - } - }, - 'run_persistent_command': () => { - const toolParams = _toolParams as BuiltinToolCallParams['run_persistent_command'] - return { - desc1: `"${toolParams.command}"`, - } - }, - 'open_persistent_terminal': () => { - const toolParams = _toolParams as BuiltinToolCallParams['open_persistent_terminal'] - return { desc1: '' } - }, - 'kill_persistent_terminal': () => { - const toolParams = _toolParams as BuiltinToolCallParams['kill_persistent_terminal'] - return { desc1: toolParams.persistentTerminalId } - }, - 'get_dir_tree': () => { - const toolParams = _toolParams as BuiltinToolCallParams['get_dir_tree'] - return { - desc1: getFolderName(toolParams.uri.fsPath) ?? '/', - desc1Info: getRelative(toolParams.uri, accessor), - } - }, - 'read_lint_errors': () => { - const toolParams = _toolParams as BuiltinToolCallParams['read_lint_errors'] - return { - desc1: getBasename(toolParams.uri.fsPath), - desc1Info: getRelative(toolParams.uri, accessor), - } - } - } - - try { - return x[toolName]?.() || { desc1: '' } - } - catch { - return { desc1: '' } - } -} - -const ToolRequestAcceptRejectButtons = ({ toolName }: { toolName: ToolName }) => { - const accessor = useAccessor() - const chatThreadsService = accessor.get('IChatThreadService') - const metricsService = accessor.get('IMetricsService') - const voidSettingsService = accessor.get('IVoidSettingsService') - const voidSettingsState = useSettingsState() - - const onAccept = useCallback(() => { - try { // this doesn't need to be wrapped in try/catch anymore - const threadId = chatThreadsService.state.currentThreadId - chatThreadsService.approveLatestToolRequest(threadId) - metricsService.capture('Tool Request Accepted', {}) - } catch (e) { console.error('Error while approving message in chat:', e) } - }, [chatThreadsService, metricsService]) - - const onReject = useCallback(() => { - try { - const threadId = chatThreadsService.state.currentThreadId - chatThreadsService.rejectLatestToolRequest(threadId) - } catch (e) { console.error('Error while approving message in chat:', e) } - metricsService.capture('Tool Request Rejected', {}) - }, [chatThreadsService, metricsService]) - - const approveButton = ( - - ) - - const cancelButton = ( - - ) - - const approvalType = isABuiltinToolName(toolName) ? approvalTypeOfBuiltinToolName[toolName] : 'MCP tools' - const approvalToggle = approvalType ?
- -
: null - - return
- {approveButton} - {cancelButton} - {approvalToggle} -
-} - -export const ToolChildrenWrapper = ({ children, className }: { children: React.ReactNode, className?: string }) => { - return
-
- {children} -
-
-} -export const CodeChildren = ({ children, className }: { children: React.ReactNode, className?: string }) => { - return
-
- {children} -
-
-} - -export const ListableToolItem = ({ name, onClick, isSmall, className, showDot }: { name: React.ReactNode, onClick?: () => void, isSmall?: boolean, className?: string, showDot?: boolean }) => { - return
- {showDot === false ? null :
} -
{name}
-
-} - - - -const EditToolChildren = ({ uri, code, type }: { uri: URI | undefined, code: string, type: 'diff' | 'rewrite' }) => { - - const content = type === 'diff' ? - - : - - return
- - {content} - -
- -} - - -const LintErrorChildren = ({ lintErrors }: { lintErrors: LintErrorItem[] }) => { - return
- {lintErrors.map((error, i) => ( -
Lines {error.startLineNumber}-{error.endLineNumber}: {error.message}
- ))} -
-} - -const BottomChildren = ({ children, title }: { children: React.ReactNode, title: string }) => { - const [isOpen, setIsOpen] = useState(false); - if (!children) return null; - return ( -
-
setIsOpen(o => !o)} - style={{ background: 'none' }} - > - - {title} -
-
-
- {children} -
-
-
- ); -} - - -const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr, toolName, threadId }: { threadId: string, applyBoxId: string, uri: URI, codeStr: string, toolName: 'edit_file' | 'rewrite_file' }) => { - const { streamState } = useEditToolStreamState({ applyBoxId, uri }) - return
- {/* */} - {/* */} - {streamState === 'idle-no-changes' && } - -
-} - - - -const InvalidTool = ({ toolName, message, mcpServerName }: { toolName: ToolName, message: string, mcpServerName: string | undefined }) => { - const accessor = useAccessor() - const title = getTitle({ name: toolName, type: 'invalid_params', mcpServerName }) - const desc1 = 'Invalid parameters' - const icon = null - const isError = true - const componentParams: ToolHeaderParams = { title, desc1, isError, icon } - - componentParams.children = - - {message} - - - return -} - -const CanceledTool = ({ toolName, mcpServerName }: { toolName: ToolName, mcpServerName: string | undefined }) => { - const accessor = useAccessor() - const title = getTitle({ name: toolName, type: 'rejected', mcpServerName }) - const desc1 = '' - const icon = null - const isRejected = true - const componentParams: ToolHeaderParams = { title, desc1, icon, isRejected } - return -} - - -const CommandTool = ({ toolMessage, type, threadId }: { threadId: string } & ({ - toolMessage: Exclude, { type: 'invalid_params' }> - type: 'run_command' -} | { - toolMessage: Exclude, { type: 'invalid_params' }> - type: | 'run_persistent_command' -})) => { - const accessor = useAccessor() - - const commandService = accessor.get('ICommandService') - const terminalToolsService = accessor.get('ITerminalToolService') - const toolsService = accessor.get('IToolsService') - const isError = false - const title = getTitle(toolMessage) - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - const streamState = useChatThreadsStreamState(threadId) - - const divRef = useRef(null) - - const isRejected = toolMessage.type === 'rejected' - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - - const effect = async () => { - if (streamState?.isRunning !== 'tool') return - if (type !== 'run_command' || toolMessage.type !== 'running_now') return; - - // wait for the interruptor so we know it's running - - await streamState?.interrupt - const container = divRef.current; - if (!container) return; - - const terminal = terminalToolsService.getTemporaryTerminal(toolMessage.params.terminalId); - if (!terminal) return; - - try { - terminal.attachToElement(container); - terminal.setVisible(true) - } catch { - } - - // Listen for size changes of the container and keep the terminal layout in sync. - const resizeObserver = new ResizeObserver((entries) => { - const height = entries[0].borderBoxSize[0].blockSize; - const width = entries[0].borderBoxSize[0].inlineSize; - if (typeof terminal.layout === 'function') { - terminal.layout({ width, height }); - } - }); - - resizeObserver.observe(container); - return () => { terminal.detachFromElement(); resizeObserver?.disconnect(); } - } - - useEffect(() => { - effect() - }, [terminalToolsService, toolMessage, toolMessage.type, type]); - - if (toolMessage.type === 'success') { - const { result } = toolMessage - - // it's unclear that this is a button and not an icon. - // componentParams.desc2 = { terminalToolsService.openTerminal(terminalId) }} - // /> - - let msg: string - if (type === 'run_command') msg = toolsService.stringOfResult['run_command'](toolMessage.params, result) - else msg = toolsService.stringOfResult['run_persistent_command'](toolMessage.params, result) - - if (type === 'run_persistent_command') { - componentParams.info = persistentTerminalNameOfId(toolMessage.params.persistentTerminalId) - } - - componentParams.children = -
- -
-
- } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } - else if (toolMessage.type === 'running_now') { - if (type === 'run_command') - componentParams.children =
- } - else if (toolMessage.type === 'rejected' || toolMessage.type === 'tool_request') { - } - - return <> - - -} - -type WrapperProps = { toolMessage: Exclude, { type: 'invalid_params' }>, messageIdx: number, threadId: string } -const MCPToolWrapper = ({ toolMessage }: WrapperProps) => { - const accessor = useAccessor() - const mcpService = accessor.get('IMCPService') - - const title = getTitle(toolMessage) - const desc1 = removeMCPToolNamePrefix(toolMessage.name) - const icon = null - - - if (toolMessage.type === 'running_now') return null // do not show running - - const isError = false - const isRejected = toolMessage.type === 'rejected' - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isRejected, } - - const paramsStr = JSON.stringify(params, null, 2) - componentParams.desc2 = - - componentParams.info = !toolMessage.mcpServerName ? 'MCP tool not found' : undefined - - // Add copy inputs button in desc2 - - - if (toolMessage.type === 'success' || toolMessage.type === 'tool_request') { - const { result } = toolMessage - const resultStr = result ? mcpService.stringifyResult(result) : 'null' - componentParams.children = - - - - - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } - - return - -} - -type ResultWrapper = (props: WrapperProps) => React.ReactNode - -const builtinToolNameToComponent: { [T in BuiltinToolName]: { resultWrapper: ResultWrapper, } } = { - 'read_file': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - - const title = getTitle(toolMessage) - - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor); - const icon = null - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const isError = false - const isRejected = toolMessage.type === 'rejected' - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - let range: [number, number] | undefined = undefined - if (toolMessage.params.startLine !== null || toolMessage.params.endLine !== null) { - const start = toolMessage.params.startLine === null ? `1` : `${toolMessage.params.startLine}` - const end = toolMessage.params.endLine === null ? `` : `${toolMessage.params.endLine}` - const addStr = `(${start}-${end})` - componentParams.desc1 += ` ${addStr}` - range = [params.startLine || 1, params.endLine || 1] - } - - if (toolMessage.type === 'success') { - const { result } = toolMessage - componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor, range) } - if (result.hasNextPage && params.pageNumber === 1) // first page - componentParams.desc2 = `(truncated after ${Math.round(MAX_FILE_CHARS_PAGE) / 1000}k)` - else if (params.pageNumber > 1) // subsequent pages - componentParams.desc2 = `(part ${params.pageNumber})` - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - // JumpToFileButton removed in favor of FileLinkText - componentParams.bottomChildren = - - {result} - - - } - - return - }, - }, - 'get_dir_tree': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - - const title = getTitle(toolMessage) - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const isError = false - const isRejected = toolMessage.type === 'rejected' - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - if (params.uri) { - const rel = getRelative(params.uri, accessor) - if (rel) componentParams.info = `Only search in ${rel}` - } - - if (toolMessage.type === 'success') { - const { result } = toolMessage - componentParams.children = - - - - - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } - - return - - } - }, - 'ls_dir': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const explorerService = accessor.get('IExplorerService') - const title = getTitle(toolMessage) - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const isError = false - const isRejected = toolMessage.type === 'rejected' - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - if (params.uri) { - const rel = getRelative(params.uri, accessor) - if (rel) componentParams.info = `Only search in ${rel}` - } - - if (toolMessage.type === 'success') { - const { result } = toolMessage - componentParams.numResults = result.children?.length - componentParams.hasNextPage = result.hasNextPage - componentParams.children = !result.children || (result.children.length ?? 0) === 0 ? undefined - : - {result.children.map((child, i) => ( { - voidOpenFileFn(child.uri, accessor) - // commandService.executeCommand('workbench.view.explorer'); // open in explorer folders view instead - // explorerService.select(child.uri, true); - }} - />))} - {result.hasNextPage && - - } - - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } - - return - } - }, - 'search_pathnames_only': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const isError = false - const isRejected = toolMessage.type === 'rejected' - const title = getTitle(toolMessage) - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - if (params.includePattern) { - componentParams.info = `Only search in ${params.includePattern}` - } - - if (toolMessage.type === 'success') { - const { result, rawParams } = toolMessage - componentParams.numResults = result.uris.length - componentParams.hasNextPage = result.hasNextPage - componentParams.children = result.uris.length === 0 ? undefined - : - {result.uris.map((uri, i) => ( { voidOpenFileFn(uri, accessor) }} - />))} - {result.hasNextPage && - - } - - - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } - - return - } - }, - 'search_for_files': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const isError = false - const isRejected = toolMessage.type === 'rejected' - const title = getTitle(toolMessage) - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - if (params.searchInFolder || params.isRegex) { - let info: string[] = [] - if (params.searchInFolder) { - const rel = getRelative(params.searchInFolder, accessor) - if (rel) info.push(`Only search in ${rel}`) - } - if (params.isRegex) { info.push(`Uses regex search`) } - componentParams.info = info.join('; ') - } - - if (toolMessage.type === 'success') { - const { result, rawParams } = toolMessage - componentParams.numResults = result.uris.length - componentParams.hasNextPage = result.hasNextPage - componentParams.children = result.uris.length === 0 ? undefined - : - {result.uris.map((uri, i) => ( { voidOpenFileFn(uri, accessor) }} - />))} - {result.hasNextPage && - - } - - - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } - return - } - }, - - 'search_in_file': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor(); - const toolsService = accessor.get('IToolsService'); - const title = getTitle(toolMessage); - const isError = false - const isRejected = toolMessage.type === 'rejected' - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor); - const icon = null; - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const { rawParams, params } = toolMessage; - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected }; - - const infoarr: string[] = [] - const uriStr = getRelative(params.uri, accessor) - if (uriStr) infoarr.push(uriStr) - if (params.isRegex) infoarr.push('Uses regex search') - componentParams.info = infoarr.join('; ') - - if (toolMessage.type === 'success') { - const { result } = toolMessage; // result is array of snippets - componentParams.numResults = result.lines.length; - componentParams.children = result.lines.length === 0 ? undefined : - - -
-								{toolsService.stringOfResult['search_in_file'](params, result)}
-							
-
-
- } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage; - componentParams.bottomChildren = - - {result} - - - } - - return ; - } - }, - - 'read_lint_errors': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - - const title = getTitle(toolMessage) - - const { uri } = toolMessage.params ?? {} - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const isError = false - const isRejected = toolMessage.type === 'rejected' - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - componentParams.info = getRelative(uri, accessor) // full path - - if (toolMessage.type === 'success') { - const { result } = toolMessage - componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } - if (result.lintErrors) - componentParams.children = - else - componentParams.children = `No lint errors found.` - - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - // JumpToFileButton removed in favor of FileLinkText - componentParams.bottomChildren = - - {result} - - - } - - return - }, - }, - - // --- - - 'create_file_or_folder': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const isError = false - const isRejected = toolMessage.type === 'rejected' - const title = getTitle(toolMessage) - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - - - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - componentParams.info = getRelative(params.uri, accessor) // full path - - if (toolMessage.type === 'success') { - const { result } = toolMessage - componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } - } - else if (toolMessage.type === 'rejected') { - componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - if (params) { componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } } - componentParams.bottomChildren = - - {result} - - - } - else if (toolMessage.type === 'running_now') { - // nothing more is needed - } - else if (toolMessage.type === 'tool_request') { - // nothing more is needed - } - - return - } - }, - 'delete_file_or_folder': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const isFolder = toolMessage.params?.isFolder ?? false - const isError = false - const isRejected = toolMessage.type === 'rejected' - const title = getTitle(toolMessage) - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const icon = null - - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - componentParams.info = getRelative(params.uri, accessor) // full path - - if (toolMessage.type === 'success') { - const { result } = toolMessage - componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } - } - else if (toolMessage.type === 'rejected') { - componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - if (params) { componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } } - componentParams.bottomChildren = - - {result} - - - } - else if (toolMessage.type === 'running_now') { - const { result } = toolMessage - componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } - } - else if (toolMessage.type === 'tool_request') { - const { result } = toolMessage - componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } - } - - return - } - }, - 'rewrite_file': { - resultWrapper: (params) => { - return - } - }, - 'edit_file': { - resultWrapper: (params) => { - return - } - }, - - // --- - - 'run_command': { - resultWrapper: (params) => { - return - } - }, - - 'run_persistent_command': { - resultWrapper: (params) => { - return - } - }, - 'open_persistent_terminal': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const terminalToolsService = accessor.get('ITerminalToolService') - - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const title = getTitle(toolMessage) - const icon = null - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const isError = false - const isRejected = toolMessage.type === 'rejected' - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - const relativePath = params.cwd ? getRelative(URI.file(params.cwd), accessor) : '' - componentParams.info = relativePath ? `Running in ${relativePath}` : undefined - - if (toolMessage.type === 'success') { - const { result } = toolMessage - const { persistentTerminalId } = result - componentParams.desc1 = persistentTerminalNameOfId(persistentTerminalId) - componentParams.onClick = () => terminalToolsService.focusPersistentTerminal(persistentTerminalId) - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } +import { ErrorDisplay } from './ErrorDisplay.js'; +import { TextAreaFns, VoidInputBox2, } from '../util/inputs.js'; +import { PastThreadsList } from './SidebarThreadSelector.js'; +import { VOID_CTRL_L_ACTION_ID } from '../../../actionIDs.js'; +import { VOID_OPEN_SETTINGS_ACTION_ID } from '../../../voidSettingsPane.js'; +import { isFeatureNameDisabled } from '../../../../../../../platform/void/common/voidSettingsTypes.js'; +import { ProviderName } from '../../../../../../../platform/void/common/voidSettingsTypes.js'; +import { WarningBox } from '../void-settings-tsx/WarningBox.js'; +import { getModelCapabilities } from '../../../../../../../platform/void/common/modelInference.js'; +import { Check, Image, X } from 'lucide-react'; +import { ChatAttachment, StagingSelectionItem } from '../../../../../../../platform/void/common/chatThreadServiceTypes.js'; +import ErrorBoundary from './ErrorBoundary.js'; +import { getBasename } from './SidebarChatShared.js'; +import { IconLoading, VoidChatArea } from './SidebarChatUI.js'; +import { EditToolSoFar } from './SidebarChatTools.js'; +import { ChatBubble } from './SidebarChatBubbles.js'; +import { CommandBarInChat, TokenUsageSpoiler, HistoryCompressionIndicator } from './SidebarChatCommandBar.js'; - return - }, - }, - 'kill_persistent_terminal': { - resultWrapper: ({ toolMessage }) => { - const accessor = useAccessor() - const commandService = accessor.get('ICommandService') - const terminalToolsService = accessor.get('ITerminalToolService') - - const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) - const title = getTitle(toolMessage) - const icon = null - - if (toolMessage.type === 'tool_request') return null // do not show past requests - if (toolMessage.type === 'running_now') return null // do not show running - - const isError = false - const isRejected = toolMessage.type === 'rejected' - const { rawParams, params } = toolMessage - const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } - - if (toolMessage.type === 'success') { - const { persistentTerminalId } = params - componentParams.desc1 = persistentTerminalNameOfId(persistentTerminalId) - componentParams.onClick = () => terminalToolsService.focusPersistentTerminal(persistentTerminalId) - } - else if (toolMessage.type === 'tool_error') { - const { result } = toolMessage - componentParams.bottomChildren = - - {result} - - - } - return - }, - }, +const scrollToBottom = (divRef: { current: HTMLElement | null }) => { + if (divRef.current) { + divRef.current.scrollTop = divRef.current.scrollHeight; + } }; +const ScrollToBottomContainer = ({ + children, + className, + style, + scrollContainerRef +}: { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; + scrollContainerRef: React.MutableRefObject; +}) => { + const BOTTOM_THRESHOLD_PX = 32; -const Checkpoint = ({ message, threadId, messageIdx, isCheckpointGhost, threadIsRunning }: { message: CheckpointEntry, threadId: string; messageIdx: number, isCheckpointGhost: boolean, threadIsRunning: boolean }) => { - const accessor = useAccessor() - const chatThreadService = accessor.get('IChatThreadService') - const streamState = useFullChatThreadsStreamState() - - const isRunning = useChatThreadsStreamState(threadId)?.isRunning - const isDisabled = useMemo(() => { - if (isRunning) return true - return !!Object.keys(streamState).find((threadId2) => streamState[threadId2]?.isRunning) - }, [isRunning, streamState]) + const [isAtBottom, setIsAtBottom] = useState(true); + const isAtBottomRef = useRef(true); - return
-
{ - if (threadIsRunning) return - if (isDisabled) return - chatThreadService.jumpToCheckpointBeforeMessageIdx({ - threadId, - messageIdx, - jumpToUserModified: messageIdx === (chatThreadService.state.allThreads[threadId]?.messages.length ?? 0) - 1 - }) - }} - {...isDisabled ? { - 'data-tooltip-id': 'void-tooltip', - 'data-tooltip-content': `Disabled ${isRunning ? 'when running' : 'because another thread is running'}`, - 'data-tooltip-place': 'top', - } : {}} - > - Checkpoint -
-
-} + const divRef = scrollContainerRef; + const contentRef = useRef(null); + const computeIsAtBottom = useCallback(() => { + const div = divRef.current; + if (!div) return true; + return (div.scrollHeight - div.clientHeight - div.scrollTop) <= BOTTOM_THRESHOLD_PX; + }, [divRef]); -type ChatBubbleMode = 'display' | 'edit' -type ChatBubbleProps = { - chatMessage: ChatMessage, - messageIdx: number, - isCommitted: boolean, - chatIsRunning: IsRunningType, - threadId: string, - currCheckpointIdx: number | undefined, - _scrollToBottom: (() => void) | null, -} + const setBottomState = useCallback((v: boolean) => { + isAtBottomRef.current = v; + setIsAtBottom(v); + }, []); -const ChatBubble = (props: ChatBubbleProps) => { - return - <_ChatBubble {...props} /> - -} + const scrollToBottomNow = useCallback(() => { + const div = divRef.current; + if (!div) return; -const _ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, messageIdx, chatIsRunning, _scrollToBottom }: ChatBubbleProps) => { - const role = chatMessage.role + + requestAnimationFrame(() => { + const d = divRef.current; + if (!d) return; + d.scrollTop = d.scrollHeight; + }); + }, [divRef]); - const isCheckpointGhost = messageIdx > (currCheckpointIdx ?? Infinity) && !chatIsRunning // whether to show as gray (if chat is running, for good measure just dont show any ghosts) + const onScroll = useCallback(() => { + setBottomState(computeIsAtBottom()); + }, [computeIsAtBottom, setBottomState]); - if (role === 'user') { - return - } - else if (role === 'assistant') { - return - } - else if (role === 'tool') { + + useLayoutEffect(() => { + scrollToBottomNow(); + setBottomState(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - if (chatMessage.type === 'invalid_params') { - return
- -
+ + useLayoutEffect(() => { + if (isAtBottomRef.current) { + scrollToBottomNow(); } + }, [children, scrollToBottomNow]); - const toolName = chatMessage.name - const isBuiltInTool = isABuiltinToolName(toolName) - const ToolResultWrapper = isBuiltInTool ? builtinToolNameToComponent[toolName]?.resultWrapper as ResultWrapper - : MCPToolWrapper as ResultWrapper - - if (ToolResultWrapper) - return <> -
- -
- {chatMessage.type === 'tool_request' ? -
- -
: null} - - return null - } - - else if (role === 'interrupted_streaming_tool') { - return
- -
- } - - else if (role === 'checkpoint') { - return - } - -} - -const CommandBarInChat = () => { - const { stateOfURI: commandBarStateOfURI, sortedURIs: sortedCommandBarURIs } = useCommandBarState() - const numFilesChanged = sortedCommandBarURIs.length - - const accessor = useAccessor() - const editCodeService = accessor.get('IEditCodeService') - const commandService = accessor.get('ICommandService') - const chatThreadsState = useChatThreadsState() - const commandBarState = useCommandBarState() - const chatThreadsStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId) - - // ( - // - // ) - - const [fileDetailsOpenedState, setFileDetailsOpenedState] = useState<'auto-opened' | 'auto-closed' | 'user-opened' | 'user-closed'>('auto-closed'); - const isFileDetailsOpened = fileDetailsOpenedState === 'auto-opened' || fileDetailsOpenedState === 'user-opened'; - - + + useEffect(() => { - // close the file details if there are no files - // this converts 'user-closed' to 'auto-closed' - if (numFilesChanged === 0) { - setFileDetailsOpenedState('auto-closed') - } - // open the file details if it hasnt been closed - if (numFilesChanged > 0 && fileDetailsOpenedState !== 'user-closed') { - setFileDetailsOpenedState('auto-opened') - } - }, [fileDetailsOpenedState, setFileDetailsOpenedState, numFilesChanged]) - - - const isFinishedMakingThreadChanges = ( - // there are changed files - commandBarState.sortedURIs.length !== 0 - // none of the files are streaming - && commandBarState.sortedURIs.every(uri => !commandBarState.stateOfURI[uri.fsPath]?.isStreaming) - ) - - // ======== status of agent ======== - // This icon answers the question "is the LLM doing work on this thread?" - // assume it is single threaded for now - // green = Running - // orange = Requires action - // dark = Done - - const threadStatus = ( - chatThreadsStreamState?.isRunning === 'awaiting_user' ? { title: 'Needs Approval', color: 'yellow', } as const - : chatThreadsStreamState?.isRunning ? { title: 'Running', color: 'orange', } as const - : { title: 'Done', color: 'dark', } as const - ) - - - const threadStatusHTML = - - - // ======== info about changes ======== - // num files changed - // acceptall + rejectall - // popup info about each change (each with num changes + acceptall + rejectall of their own) - - const numFilesChangedStr = numFilesChanged === 0 ? 'No files with changes' - : `${sortedCommandBarURIs.length} file${numFilesChanged === 1 ? '' : 's'} with changes` - - + const div = divRef.current; + const content = contentRef.current; + if (!div || !content) return; + const handleContentChange = () => { + if (isAtBottomRef.current) { + scrollToBottomNow(); + } + }; - const acceptRejectAllButtons =
- { - sortedCommandBarURIs.forEach(uri => { - editCodeService.acceptOrRejectAllDiffAreas({ - uri, - removeCtrlKs: true, - behavior: "reject", - _addToHistory: true, - }); - }); - }} - data-tooltip-id='void-tooltip' - data-tooltip-place='top' - data-tooltip-content='Reject all' - /> - - { - sortedCommandBarURIs.forEach(uri => { - editCodeService.acceptOrRejectAllDiffAreas({ - uri, - removeCtrlKs: true, - behavior: "accept", - _addToHistory: true, - }); - }); - }} - data-tooltip-id='void-tooltip' - data-tooltip-place='top' - data-tooltip-content='Accept all' - /> - - - -
- - - // !select-text cursor-auto - const fileDetailsContent =
- {sortedCommandBarURIs.map((uri, i) => { - const basename = getBasename(uri.fsPath) - - const { sortedDiffIds, isStreaming } = commandBarStateOfURI[uri.fsPath] ?? {} - const isFinishedMakingFileChanges = !isStreaming - - const numDiffs = sortedDiffIds?.length || 0 - - const fileStatus = (isFinishedMakingFileChanges - ? { title: 'Done', color: 'dark', } as const - : { title: 'Running', color: 'orange', } as const - ) - - const fileNameHTML =
voidOpenFileFn(uri, accessor)} - > - {/* */} - {basename} -
- + let mo: MutationObserver | null = null; + if (typeof MutationObserver !== 'undefined') { + mo = new MutationObserver(handleContentChange); + mo.observe(content, { childList: true, subtree: true, characterData: true }); + } + return () => { + ro?.disconnect(); + mo?.disconnect(); + }; + }, [divRef, scrollToBottomNow]); - const detailsContent =
- {numDiffs} diff{numDiffs !== 1 ? 's' : ''} + return ( +
+ {} +
+ {children}
+
+ ); +}; - const acceptRejectButtons =
- {/* */} - { editCodeService.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior: "reject", _addToHistory: true, }); }} - data-tooltip-id='void-tooltip' - data-tooltip-place='top' - data-tooltip-content='Reject file' - - /> - { editCodeService.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior: "accept", _addToHistory: true, }); }} - data-tooltip-id='void-tooltip' - data-tooltip-place='top' - data-tooltip-content='Accept file' - /> - -
+const ProseWrapper = ({ children }: { children: React.ReactNode }) => { + return
+prose-p:leading-normal +prose-ol:leading-normal +prose-ul:leading-normal - return ( - // name, details -
-
- {fileNameHTML} - {detailsContent} -
-
- {acceptRejectButtons} - {fileStatusHTML} -
-
- ) - })} +max-w-none +' + > + {children}
+} - const fileDetailsButton = ( - - ) +export const SidebarChat = () => { - return ( - <> - {/* file details */} -
-
- {fileDetailsContent} -
-
- {/* main content */} + const initiallySuggestedPromptsHTML =
+ {[ + 'Summarize my codebase', + 'How do types work in Rust?', + 'Create a .voidrules file for me' + ].map((text, index) => (
onSubmit(text)} > -
- {fileDetailsButton} -
-
- {acceptRejectAllButtons} - {threadStatusHTML} -
+ {text}
- - ) -} - - - -const EditToolSoFar = ({ toolCallSoFar, }: { toolCallSoFar: RawToolCallObj }) => { - - if (!isABuiltinToolName(toolCallSoFar.name)) return null - - const accessor = useAccessor() - - const uri = toolCallSoFar.rawParams.uri ? URI.file(toolCallSoFar.rawParams.uri) : undefined - - const title = titleOfBuiltinToolName[toolCallSoFar.name].proposed - - const uriDone = toolCallSoFar.doneParams.includes('uri') - const desc1 = - {uriDone ? - getBasename(toolCallSoFar.rawParams['uri'] ?? 'unknown') - : `Generating`} - - - - const desc1OnClick = () => { uri && voidOpenFileFn(uri, accessor) } - - // If URI has not been specified - return - - - - -} - + ))} +
-export const SidebarChat = () => { const textAreaRef = useRef(null) const textAreaFnsRef = useRef(null) @@ -2909,11 +217,57 @@ export const SidebarChat = () => { // ----- SIDEBAR CHAT state (local) ----- + const [attachments, setAttachments] = useState([]); + // state of current message const initVal = '' const [instructionsAreEmpty, setInstructionsAreEmpty] = useState(!initVal) - const isDisabled = instructionsAreEmpty || !!isFeatureNameDisabled('Chat', settingsState) + const chatModelSelection = settingsState.modelSelectionOfFeature['Chat']; + const overridesOfModel = settingsState.overridesOfModel; + const supportsImages = useMemo(() => { + if (!chatModelSelection) return false; + try { + const caps = getModelCapabilities(chatModelSelection.providerName as ProviderName, chatModelSelection.modelName, overridesOfModel); + return !!caps.inputModalities?.includes('image'); + } catch { + return false; + } + }, [chatModelSelection, overridesOfModel]); + + const hideEncryptedReasoning = useMemo(() => { + if (!chatModelSelection) return false; + try { + const providerName = chatModelSelection.providerName as ProviderName; + const modelName = chatModelSelection.modelName; + + + let fromCaps = false; + try { + const caps = getModelCapabilities(providerName, modelName, overridesOfModel); + const rc = caps.reasoningCapabilities as any; + if (rc && typeof rc === 'object' && rc.hideEncryptedReasoning !== undefined) { + fromCaps = !!rc.hideEncryptedReasoning; + } + } catch { /* ignore */ } + + + try { + const cp: any = (settingsState as any).customProviders?.[providerName]; + const ov: any = cp?.modelCapabilityOverrides?.[modelName]; + const rcOv: any = ov?.reasoningCapabilities; + if (rcOv && typeof rcOv === 'object' && 'hideEncryptedReasoning' in rcOv) { + return !!rcOv.hideEncryptedReasoning; + } + } catch { /* ignore */ } + + return fromCaps; + } catch { + return false; + } + }, [chatModelSelection, overridesOfModel, settingsState.customProviders]); + + const isDisabled = (instructionsAreEmpty && attachments.length === 0) || !!isFeatureNameDisabled('Chat', settingsState) const sidebarRef = useRef(null) const scrollContainerRef = useRef(null) @@ -2926,18 +280,20 @@ export const SidebarChat = () => { // send message to LLM const userMessage = _forceSubmit || textAreaRef.current?.value || '' + const attachmentsToSend = _forceSubmit ? [] : attachments; try { - await chatThreadsService.addUserMessageAndStreamResponse({ userMessage, threadId }) + await chatThreadsService.addUserMessageAndStreamResponse({ userMessage, threadId, attachments: attachmentsToSend.length ? attachmentsToSend : undefined }) } catch (e) { console.error('Error while sending message in chat:', e) } - setSelections([]) // clear staging + setSelections([]) + if (!_forceSubmit) setAttachments([]) textAreaFnsRef.current?.setValue('') - textAreaRef.current?.focus() // focus input after submit + textAreaRef.current?.focus() - }, [chatThreadsService, isDisabled, isRunning, textAreaRef, textAreaFnsRef, setSelections, settingsState]) + }, [attachments, chatThreadsService, isDisabled, isRunning, textAreaRef, textAreaFnsRef, setSelections, settingsState, selections]) const onAbort = async () => { const threadId = currentThread.id @@ -2948,9 +304,6 @@ export const SidebarChat = () => { const threadId = currentThread.id const currCheckpointIdx = chatThreadsState.allThreads[threadId]?.state?.currCheckpointIdx ?? undefined // if not exist, treat like checkpoint is last message (infinity) - - - // resolve mount info const isResolved = chatThreadsState.allThreads[threadId]?.state.mountedInfo?.mountedIsResolvedRef.current useEffect(() => { @@ -2959,15 +312,9 @@ export const SidebarChat = () => { textAreaRef: textAreaRef, scrollToBottom: () => scrollToBottom(scrollContainerRef), }) - }, [chatThreadsState, threadId, textAreaRef, scrollContainerRef, isResolved]) - - - const previousMessagesHTML = useMemo(() => { - // const lastMessageIdx = previousMessages.findLastIndex(v => v.role !== 'checkpoint') - // tool request shows up as Editing... if in progress return previousMessages.map((message, i) => { return { chatIsRunning={isRunning} threadId={threadId} _scrollToBottom={() => scrollToBottom(scrollContainerRef)} + hideEncryptedReasoning={hideEncryptedReasoning} /> }) - }, [previousMessages, threadId, currCheckpointIdx, isRunning]) + }, [previousMessages, threadId, currCheckpointIdx, isRunning, hideEncryptedReasoning]) const streamingChatIdx = previousMessagesHTML.length - const currStreamingMessageHTML = reasoningSoFar || displayContentSoFar || isRunning ? + const currStreamingMessageHTML = displayContentSoFar || isRunning ? { threadId={threadId} _scrollToBottom={null} + hideEncryptedReasoning={hideEncryptedReasoning} /> : null - // the tool currently being generated const generatingTool = toolIsGenerating ? toolCallSoFar.name === 'edit_file' || toolCallSoFar.name === 'rewrite_file' ? { {/* previous messages */} {previousMessagesHTML} {currStreamingMessageHTML} - {/* Generating tool */} {generatingTool} @@ -3035,7 +382,6 @@ export const SidebarChat = () => { {} : null} - {/* error message */} {latestError === undefined ? null :
@@ -3051,18 +397,93 @@ export const SidebarChat = () => { } - const onChangeText = useCallback((newStr: string) => { setInstructionsAreEmpty(!newStr) }, [setInstructionsAreEmpty]) + const onKeyDown = useCallback((e: KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { + if (e.key === 'Enter' && !e.shiftKey) { onSubmit() } else if (e.key === 'Escape' && isRunning) { onAbort() } }, [onSubmit, onAbort, isRunning]) + const fileDialogService = accessor.get('IFileDialogService'); + + const getImageMimeTypeForUri = useCallback((uri: URI): string => { + const path = (uri.fsPath || uri.path || '').toLowerCase(); + if (path.endsWith('.png')) return 'image/png'; + if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; + if (path.endsWith('.webp')) return 'image/webp'; + if (path.endsWith('.gif')) return 'image/gif'; + if (path.endsWith('.bmp')) return 'image/bmp'; + if (path.endsWith('.svg')) return 'image/svg+xml'; + return 'application/octet-stream'; + }, []) + + const onAttachImages = useCallback(async () => { + if (!supportsImages) return; + try { + const uris = await fileDialogService.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'] }], + }); + if (!uris || !uris.length) return; + setAttachments(prev => { + const existing = new Set(prev.map(a => a.uri.toString())); + const next = [...prev]; + for (const uri of uris) { + const key = uri.toString(); + if (existing.has(key)) continue; + next.push({ + kind: 'image', + uri, + mimeType: getImageMimeTypeForUri(uri), + name: getBasename(uri.fsPath || uri.path || '', 1), + }); + } + return next; + }); + } catch (err) { + console.error('Failed to attach images', err); + } + }, [fileDialogService, getImageMimeTypeForUri, setAttachments, supportsImages]) + + const onRemoveAttachment = useCallback((uri: URI) => { + setAttachments(prev => prev.filter(a => a.uri.toString() !== uri.toString())); + }, [setAttachments]) + + const attachmentsPreview = attachments.length ? ( +
+ {attachments.map(att => ( + + ))} +
+ ) : null + + const attachButton = supportsImages ? ( + + ) : null + const inputChatArea = onSubmit()} @@ -3070,11 +491,12 @@ export const SidebarChat = () => { isStreaming={!!isRunning} isDisabled={isDisabled} showSelections={true} - // showProspectiveSelections={previousMessagesHTML.length === 0} selections={selections} setSelections={setSelections} onClickAnywhere={() => { textAreaRef.current?.focus() }} + rightBottomExtras={attachButton} > + {attachmentsPreview} { fnsRef={textAreaFnsRef} multiline={true} /> - - const isLandingPage = previousMessages.length === 0 - - const initiallySuggestedPromptsHTML =
- {[ - 'Summarize my codebase', - 'How do types work in Rust?', - 'Create a .voidrules file for me' - ].map((text, index) => ( -
onSubmit(text)} - > - {text} + // ======== pinned ACP plan (shown above command bar) ======== + const currentThreadForPlan = chatThreadsState.allThreads[threadId] + const pinnedPlanItems = settingsState.globalSettings.showAcpPlanInChat === false + ? [] + : (currentThreadForPlan?.state?.acpPlan?.items ?? []) + + const pinnedPlanHTML = pinnedPlanItems.length ? ( +
+
Plan
+
+ {pinnedPlanItems.map((it, idx) => ( +
+
+ {it.state === 'pending' &&
} + {it.state === 'running' &&
} + {it.state === 'done' && } + {it.state === 'error' && } +
+ {it.text} +
+ ))}
- ))} -
- - +
+ ) : null const threadPageInput =
+ {pinnedPlanHTML} + +
@@ -3134,7 +566,7 @@ export const SidebarChat = () => { {landingPageInput} - {Object.keys(chatThreadsState.allThreads).length > 1 ? // show if there are threads + {Object.keys(chatThreadsState.allThreads).length > 1 ?
Previous Threads
@@ -3147,25 +579,10 @@ export const SidebarChat = () => { }
- - // const threadPageContent =
- // {/* Thread content */} - //
- //
- // - // {messagesHTML} - // - //
- // - // {inputForm} - // - //
- //
const threadPageContent =
- {messagesHTML} @@ -3174,13 +591,9 @@ export const SidebarChat = () => {
- return ( - - {isLandingPage ? - landingPageContent - : threadPageContent} + + {isLandingPage ? landingPageContent : threadPageContent} ) } diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatBubbles.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatBubbles.tsx new file mode 100644 index 00000000000..5841c575aa1 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatBubbles.tsx @@ -0,0 +1,609 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ +import React, { KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useAccessor, useChatThreadsStreamState, useFullChatThreadsStreamState } from '../util/services.js'; +import { ChatMarkdownRender, ChatMessageLocation } from '../markdown/ChatMarkdownRender.js'; +import { ProseWrapper } from './SidebarChatUI.js'; +import { ChatMessage, StagingSelectionItem, CheckpointEntry } from '../../../../../../../platform/void/common/chatThreadServiceTypes.js'; +import { TextAreaFns, VoidInputBox2 } from '../util/inputs.js'; +import { + ToolHeaderWrapper, + InvalidTool, + toolNameToComponent, + ResultWrapper, + ToolRequestAcceptRejectButtons, + DynamicToolHeader, + CanceledTool, + SkippedTool +} from './SidebarChatTools.js'; +import { ErrorBoundary } from './ErrorBoundary.js'; +import { getChatMessageMarkdown, getAssistantTurnInfo, getAssistantTurnMarkdown } from './SidebarChatShared.js'; +import { SelectedFiles, VoidChatArea } from './SidebarChatUI.js'; +import { CopyButton } from '../markdown/ApplyBlockHoverButtons.js'; +import { IsRunningType } from '../../../ChatExecutionEngine.js'; +import { ToolName, isAToolName } from '../../../../common/prompt/prompts.js'; +import { Pencil, X, Image } from 'lucide-react'; + +export const ENCRYPTED_REASONING_PLACEHOLDER = 'Reasoning content is encrypted by the provider and cannot be displayed'; + +export const ReasoningSpoiler = ({ reasoning, anthropicReasoning }: { reasoning: string; anthropicReasoning: any[] | null }) => { + const [open, setOpen] = useState(false); + + const text = useMemo(() => { + if (reasoning && reasoning.trim()) return reasoning; + if (anthropicReasoning && anthropicReasoning.length) { + return anthropicReasoning.map((r: any) => (r && typeof r.thinking === 'string') ? r.thinking : '').join('\n').trim(); + } + return ''; + }, [reasoning, anthropicReasoning]); + + if (!text) return null; + + const preview = text.slice(0, 120).replace(/\s+/g, ' '); + + return ( +
+ + {open && ( +
+ {text} +
+ )} +
+ ); +}; + +export const UserMessageComponent = ({ + chatMessage, + messageIdx, + isCheckpointGhost, + currCheckpointIdx, + _scrollToBottom, +}: { + chatMessage: ChatMessage & { role: 'user' }, + messageIdx: number, + currCheckpointIdx: number | undefined, + isCheckpointGhost: boolean, + _scrollToBottom: (() => void) | null +}) => { + + // Hidden messages (like skipped tool notifications) + const isHidden = 'hidden' in chatMessage && !!(chatMessage as any).hidden; + const [isHiddenOpen, setIsHiddenOpen] = useState(false); + const toggleHidden = useCallback(() => { + setIsHiddenOpen(v => !v); + _scrollToBottom?.(); + }, [_scrollToBottom]); + + const accessor = useAccessor(); + const chatThreadsService = accessor.get('IChatThreadService'); + + // global state + let isBeingEdited = false; + let stagingSelections: StagingSelectionItem[] = []; + let setIsBeingEdited = (_: boolean) => { }; + let setStagingSelections = (_: StagingSelectionItem[]) => { }; + + if (messageIdx !== undefined) { + const _state = chatThreadsService.getCurrentMessageState(messageIdx); + isBeingEdited = _state.isBeingEdited; + stagingSelections = _state.stagingSelections; + setIsBeingEdited = (v) => chatThreadsService.setCurrentMessageState(messageIdx, { isBeingEdited: v }); + setStagingSelections = (s) => chatThreadsService.setCurrentMessageState(messageIdx, { stagingSelections: s }); + } + + // local state + const mode: ChatBubbleMode = isBeingEdited ? 'edit' : 'display'; + const [isFocused, setIsFocused] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isDisabled, setIsDisabled] = useState(false); + const [textAreaRefState, setTextAreaRef] = useState(null); + const textAreaFnsRef = useRef(null); + + // initialize on first render, and when edit was just enabled + const _mustInitialize = useRef(true); + const _justEnabledEdit = useRef(false); + + useEffect(() => { + const canInitialize = mode === 'edit' && textAreaRefState; + const shouldInitialize = !isHidden && (_justEnabledEdit.current || _mustInitialize.current); + if (canInitialize && shouldInitialize) { + setStagingSelections( + (chatMessage.selections || []).map(s => { // quick hack so we dont have to do anything more + if (s.type === 'File') return { ...s, state: { ...s.state, wasAddedAsCurrentFile: false } }; + else return s; + }) + ); + + if (textAreaFnsRef.current) + textAreaFnsRef.current.setValue(chatMessage.displayContent || ''); + + textAreaRefState.focus(); + + _justEnabledEdit.current = false; + _mustInitialize.current = false; + } + + }, [chatMessage, mode, isHidden, _justEnabledEdit, textAreaRefState, textAreaFnsRef.current, _justEnabledEdit.current, _mustInitialize.current]); + + // Render hidden variant after hooks are declared to keep hook order stable + if (isHidden) { + const body = typeof chatMessage.content === 'string' + ? chatMessage.content + : (chatMessage.displayContent ?? ''); + + // Hide the special hidden "skip" user message entirely (both ACP and non-ACP), + // because we show the skip outcome in the tool output instead. + const normalized = String(body ?? '').trim().toLowerCase(); + if (normalized === 'skip' || normalized.startsWith('skip ')) { + return null; + } + + // Fallback: still render other hidden user messages (if any) + return ( +
+ +
+
{body}
+
+
+
+ ); + } + + const onOpenEdit = () => { + setIsBeingEdited(true); + chatThreadsService.setCurrentlyFocusedMessageIdx(messageIdx); + _justEnabledEdit.current = true; + }; + const onCloseEdit = () => { + setIsFocused(false); + setIsHovered(false); + setIsBeingEdited(false); + chatThreadsService.setCurrentlyFocusedMessageIdx(undefined); + }; + + const EditSymbol = mode === 'display' ? Pencil : X; + const messageMarkdown = getChatMessageMarkdown(chatMessage); + const showControls = isHovered || (isFocused && mode === 'edit'); + + let chatbubbleContents: React.ReactNode; + if (mode === 'display') { + chatbubbleContents = <> + + {chatMessage.displayContent} + {chatMessage.attachments && chatMessage.attachments.length > 0 && ( +
+ {chatMessage.attachments.map((att, i) => { + return ( +
+ + {att.name || att.uri?.fsPath || 'Unnamed attachment'} +
+ ); + })} +
+ )} + ; + } + else if (mode === 'edit') { + + const onSubmit = async () => { + + if (isDisabled) return; + if (!textAreaRefState) return; + if (messageIdx === undefined) return; + + // cancel any streams on this thread + const threadId = chatThreadsService.state.currentThreadId; + + await chatThreadsService.abortRunning(threadId); + + // update state + setIsBeingEdited(false); + chatThreadsService.setCurrentlyFocusedMessageIdx(undefined); + + // stream the edit + const userMessage = textAreaRefState.value; + try { + await chatThreadsService.editUserMessageAndStreamResponse({ userMessage, messageIdx, threadId }); + } catch (e) { + console.error('Error while editing message:', e); + } + await chatThreadsService.focusCurrentChat(); + requestAnimationFrame(() => _scrollToBottom?.()); + }; + + const onAbort = async () => { + const threadId = chatThreadsService.state.currentThreadId; + await chatThreadsService.abortRunning(threadId); + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onCloseEdit(); + } + if (e.key === 'Enter' && !e.shiftKey) { + onSubmit(); + } + }; + + if (!chatMessage.content) { // don't show if empty and not loading (if loading, want to show). + return null; + } + + chatbubbleContents = + setIsDisabled(!text)} + onFocus={() => { + setIsFocused(true); + chatThreadsService.setCurrentlyFocusedMessageIdx(messageIdx); + }} + onBlur={() => { + setIsFocused(false); + }} + onKeyDown={onKeyDown} + fnsRef={textAreaFnsRef} + multiline={true} + /> + ; + } + + const isMsgAfterCheckpoint = currCheckpointIdx !== undefined && currCheckpointIdx === messageIdx - 1; + + return
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
{ if (mode === 'display') { onOpenEdit(); } }} + > + {chatbubbleContents} +
+ +
+ {mode === 'display' && messageMarkdown && ( +
+ +
+ )} + { + if (mode === 'display') { + onOpenEdit(); + } else if (mode === 'edit') { + onCloseEdit(); + } + }} + /> +
+
; +}; + +export const AssistantMessageComponent = ({ + chatMessage, + isCheckpointGhost, + isCommitted, + messageIdx, + hideEncryptedReasoning, +}: { + chatMessage: ChatMessage & { role: 'assistant' }; + isCheckpointGhost: boolean; + messageIdx: number; + isCommitted: boolean; + hideEncryptedReasoning?: boolean; +}) => { + const accessor = useAccessor(); + const chatThreadsService = accessor.get('IChatThreadService'); + const thread = chatThreadsService.getCurrentThread(); + + const chatMessageLocation: ChatMessageLocation = { + threadId: thread.id, + messageIdx, + }; + + const displayContent = (chatMessage.displayContent || '').trimEnd(); + + const hasText = !!displayContent; + + const hasReasoning = + !!(chatMessage.reasoning && chatMessage.reasoning.trim()) || + !!(chatMessage.anthropicReasoning && chatMessage.anthropicReasoning.length); + + const reasoningIsEncryptedPlaceholder = + typeof chatMessage.reasoning === 'string' && + chatMessage.reasoning.trim() === ENCRYPTED_REASONING_PLACEHOLDER; + + // Only hide reasoning when it's the provider "encrypted" placeholder (not normal reasoning) + const showReasoning = hasReasoning && !(hideEncryptedReasoning && reasoningIsEncryptedPlaceholder); + + if (!hasText && !showReasoning) return null; + + return ( +
+ {showReasoning ? ( + + ) : null} + + {hasText && ( + +
+ +
+
+ )} +
+ ); +}; + +export const Checkpoint = ({ message, threadId, messageIdx, isCheckpointGhost, threadIsRunning }: { message: CheckpointEntry, threadId: string; messageIdx: number, isCheckpointGhost: boolean, threadIsRunning: boolean }) => { + const accessor = useAccessor(); + const chatThreadService = accessor.get('IChatThreadService'); + const streamState = useFullChatThreadsStreamState(); + + const isRunning = useChatThreadsStreamState(threadId)?.isRunning; + const isDisabled = useMemo(() => { + if (isRunning) return true; + return !!Object.keys(streamState).find((threadId2) => streamState[threadId2]?.isRunning); + }, [isRunning, streamState]); + + return
+
{ + if (threadIsRunning) return; + if (isDisabled) return; + chatThreadService.jumpToCheckpointBeforeMessageIdx({ + threadId, + messageIdx, + jumpToUserModified: messageIdx === (chatThreadService.state.allThreads[threadId]?.messages.length ?? 0) - 1 + }); + }} + {...isDisabled ? { + 'data-tooltip-id': 'void-tooltip', + 'data-tooltip-content': `Disabled ${isRunning ? 'when running' : 'because another thread is running'}`, + 'data-tooltip-place': 'top', + } : {}} + > + Checkpoint +
+
; +}; + +type ChatBubbleMode = 'display' | 'edit'; +type ChatBubbleProps = { + chatMessage: ChatMessage, + messageIdx: number, + isCommitted: boolean, + chatIsRunning: IsRunningType, + threadId: string, + currCheckpointIdx: number | undefined, + _scrollToBottom: (() => void) | null, + hideEncryptedReasoning?: boolean, +}; + +export const ChatBubble = (props: ChatBubbleProps) => { + return + <_ChatBubble {...props} /> + ; +}; + +const _ChatBubble = ({ threadId, chatMessage, currCheckpointIdx, isCommitted, messageIdx, chatIsRunning, _scrollToBottom, hideEncryptedReasoning }: ChatBubbleProps) => { + const accessor = useAccessor(); + const chatThreadsService = accessor.get('IChatThreadService'); + const thread = chatThreadsService.getCurrentThread(); + + const role = chatMessage.role; + + const isCheckpointGhost = + messageIdx > (currCheckpointIdx ?? Infinity) && !chatIsRunning; // whether to show as gray + + + const turnInfo = getAssistantTurnInfo(thread.messages, messageIdx); + const showCopyFooter = + !!turnInfo && + turnInfo.lastNonCheckpointIdx === messageIdx && + !chatIsRunning; + + const copyMarkdown = showCopyFooter ? getAssistantTurnMarkdown(thread.messages, messageIdx) : ''; + + const copyFooter = showCopyFooter && copyMarkdown ? ( +
+ +
+ ) : null; + + let bubble: React.ReactNode = null; + + if (role === 'user') { + bubble = ( + + ); + } + else if (role === 'assistant') { + bubble = ( + + ); + } + else if (role === 'tool') { + + const isSkipped = + (chatMessage as any).type === 'skipped' || + (!!(chatMessage as any).result && typeof (chatMessage as any).result === 'object' && ( + (chatMessage as any).result._skipped === true || (chatMessage as any).result.skipped === true + )) || + (!!(chatMessage as any).rawOutput && typeof (chatMessage as any).rawOutput === 'object' && ( + (chatMessage as any).rawOutput._skipped === true || (chatMessage as any).rawOutput.skipped === true + )); + + if (isSkipped) { + bubble = ( +
+ +
+ ); + } + else if ((chatMessage as any).type === 'invalid_params') { + bubble = ( +
+ +
+ ); + } + else { + // Narrow the chatMessage.name to ToolName for indexing typed maps + const nameAsTool = (chatMessage as any).name as ToolName; + const ToolResultWrapper = toolNameToComponent[nameAsTool]?.resultWrapper as ResultWrapper; + + // Check if this is a dynamic (MCP) tool + const isDynamicTool = !isAToolName((chatMessage as any).name); + + if (ToolResultWrapper || isDynamicTool) { + bubble = ( + <> +
+ {ToolResultWrapper ? + + : + // For dynamic tools, show a simple tool header + + } +
+ {(chatMessage as any).type === 'tool_request' ? +
+ +
: null} + + ); + } else { + bubble = null; + } + } + } + else if (role === 'interrupted_streaming_tool') { + bubble = ( +
+ +
+ ); + } + else if (role === 'checkpoint') { + bubble = ( + + ); + } + + if (!bubble) return null; + + return ( + <> + {bubble} + {copyFooter} + + ); +}; diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatCommandBar.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatCommandBar.tsx new file mode 100644 index 00000000000..38ea50cee63 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatCommandBar.tsx @@ -0,0 +1,310 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import React, { useState, useEffect, useCallback } from 'react'; +import { useAccessor } from '../util/services.js'; +import { useChatThreadsState, useChatThreadsStreamState, useCommandBarState } from '../util/services.js'; +import { IconShell1 } from '../markdown/ApplyBlockHoverButtons.js'; +import { Check, X } from 'lucide-react'; +import { LLMTokenUsage } from '../../../../../../../platform/void/common/sendLLMMessageTypes.js'; +import { getBasename, voidOpenFileFn } from './SidebarChatShared.js'; +import { StatusIndicator } from '../markdown/ApplyBlockHoverButtons.js'; + + +export const HistoryCompressionIndicator = () => { + const chatThreadsState = useChatThreadsState(); + const threadId = chatThreadsState.currentThreadId; + const thread = chatThreadsState.allThreads[threadId]; + const info = thread?.state?.historyCompression; + if (!info || !info.hasCompressed) return null; + + const before = info.approxTokensBefore; + const after = info.approxTokensAfter; + const ratio = before > 0 ? Math.round((after / before) * 100) : null; + const format = (n: number) => n.toLocaleString?.() ?? String(n); + + return ( +
+
+ History compressed + + {info.summarizedMessageCount} msg → ~{format(after)} tokens{ratio !== null ? ` (${ratio}% of original)` : ''} + +
+
+ ); +}; + +export const TokenUsageSpoiler = () => { + const chatThreadsState = useChatThreadsState(); + const threadId = chatThreadsState.currentThreadId; + const thread = chatThreadsState.allThreads[threadId]; + const usage = thread?.state?.tokenUsageSession; + const last = (thread?.state as any)?.tokenUsageLastRequest as (LLMTokenUsage | undefined); + const limits = (thread?.state as any)?.tokenUsageLastRequestLimits as ({ maxInputTokens: number } | undefined); + const total = usage ? (usage.input + usage.cacheCreation + usage.cacheRead + usage.output) : 0; + const hasUsage = !!usage && total > 0; + + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + setIsOpen(false); + }, [threadId]); + + if (!hasUsage) return null; + + const format = (n: number) => n.toLocaleString?.() ?? String(n); + const formatPct = (v: number) => `${(Math.round(v * 10) / 10).toFixed(1)}%`; + const lastPct = (last && limits && limits.maxInputTokens > 0) + ? (last.input / limits.maxInputTokens) * 100 + : null; + + return ( +
+ + {isOpen && ( +
+ {last && ( +
+ Last request + + input {format(last.input)} + {limits?.maxInputTokens && lastPct !== null + ? ` (~${formatPct(lastPct)} of ${format(limits.maxInputTokens)})` + : ''} + +
+ )} +
Input{format(usage!.input)}
+
Cache creation{format(usage!.cacheCreation)}
+
Cache read{format(usage!.cacheRead)}
+
Output{format(usage!.output)}
+
+ )} +
+ ); +}; + +export const CommandBarInChat = (): React.ReactElement => { + const { stateOfURI: commandBarStateOfURI, sortedURIs: sortedCommandBarURIs } = useCommandBarState(); + const numFilesChanged = sortedCommandBarURIs.length; + + const accessor = useAccessor(); + const editCodeService = accessor.get('IEditCodeService'); + const commandService = accessor.get('ICommandService'); + const chatThreadsState = useChatThreadsState(); + const commandBarState = useCommandBarState(); + const chatThreadsStreamState = useChatThreadsStreamState(chatThreadsState.currentThreadId); + + const [fileDetailsOpenedState, setFileDetailsOpenedState] = useState<'auto-opened' | 'auto-closed' | 'user-opened' | 'user-closed'>('auto-closed'); + const isFileDetailsOpened = fileDetailsOpenedState === 'auto-opened' || fileDetailsOpenedState === 'user-opened'; + + useEffect(() => { + if (numFilesChanged === 0) { + setFileDetailsOpenedState('auto-closed'); + } + if (numFilesChanged > 0 && fileDetailsOpenedState !== 'user-closed') { + setFileDetailsOpenedState('auto-opened'); + } + }, [fileDetailsOpenedState, setFileDetailsOpenedState, numFilesChanged]); + + const isFinishedMakingThreadChanges = ( + commandBarState.sortedURIs.length !== 0 + && commandBarState.sortedURIs.every(uri => !commandBarState.stateOfURI[uri.fsPath]?.isStreaming) + ); + + const threadStatus = ( + chatThreadsStreamState?.isRunning === 'awaiting_user' ? { title: 'Needs Approval', color: 'yellow', } as const + : chatThreadsStreamState?.isRunning ? { title: 'Running', color: 'orange', } as const + : { title: 'Done', color: 'dark', } as const + ); + + const threadStatusHTML = ; + + const numFilesChangedStr = numFilesChanged === 0 ? 'No files with changes' + : `${sortedCommandBarURIs.length} file${numFilesChanged === 1 ? '' : 's'} with changes`; + + const acceptRejectAllButtons =
+ { + sortedCommandBarURIs.forEach(uri => { + editCodeService.acceptOrRejectAllDiffAreas({ + uri, + removeCtrlKs: true, + behavior: "reject", + _addToHistory: true, + }); + }); + }} + data-tooltip-id='void-tooltip' + data-tooltip-place='top' + data-tooltip-content='Reject all' + /> + + { + sortedCommandBarURIs.forEach(uri => { + editCodeService.acceptOrRejectAllDiffAreas({ + uri, + removeCtrlKs: true, + behavior: "accept", + _addToHistory: true, + }); + }); + }} + data-tooltip-id='void-tooltip' + data-tooltip-place='top' + data-tooltip-content='Accept all' + /> +
; + + const fileDetailsContent =
+ {sortedCommandBarURIs.map((uri, i) => { + const basename = getBasename(uri.fsPath); + + const { sortedDiffIds, isStreaming } = commandBarStateOfURI[uri.fsPath] ?? {}; + const isFinishedMakingFileChanges = !isStreaming; + + const numDiffs = sortedDiffIds?.length || 0; + + const fileStatus = (isFinishedMakingFileChanges + ? { title: 'Done', color: 'dark', } as const + : { title: 'Running', color: 'orange', } as const + ); + + const fileNameHTML =
voidOpenFileFn(uri, accessor)} + > + {basename} +
; + + const detailsContent =
+ {numDiffs} diff{numDiffs !== 1 ? 's' : ''} +
; + + const acceptRejectButtons =
+ { editCodeService.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior: "reject", _addToHistory: true, }); }} + data-tooltip-id='void-tooltip' + data-tooltip-place='top' + data-tooltip-content='Reject file' + /> + { editCodeService.acceptOrRejectAllDiffAreas({ uri, removeCtrlKs: true, behavior: "accept", _addToHistory: true, }); }} + data-tooltip-id='void-tooltip' + data-tooltip-place='top' + data-tooltip-content='Accept file' + /> +
; + + const fileStatusHTML = ; + + return ( +
+
+ {fileNameHTML} + {detailsContent} +
+
+ {acceptRejectButtons} + {fileStatusHTML} +
+
+ ); + })} +
; + + const fileDetailsButton = ( + + ); + + return ( + <> +
+
+ {fileDetailsContent} +
+
+
+
+ {fileDetailsButton} +
+
+ {acceptRejectAllButtons} + {threadStatusHTML} +
+
+ + ); +}; diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatShared.ts b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatShared.ts new file mode 100644 index 00000000000..52a023168c6 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatShared.ts @@ -0,0 +1,171 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ +import { URI } from '../../../../../../../base/common/uri.js'; +import { ScrollType } from '../../../../../../../editor/common/editorCommon.js'; +import { ChatMessage } from '../../../../../../../platform/void/common/chatThreadServiceTypes.js'; + +export type AccessorLike = { get: (serviceId: string) => any }; + +export const getRelative = (uri: URI, accessor: AccessorLike) => { + const workspaceContextService = accessor.get('IWorkspaceContextService'); + let path: string = ''; + + const isInside = workspaceContextService.isInsideWorkspace(uri); + if (isInside) { + const f = workspaceContextService + .getWorkspace() + .folders.find((f: any) => uri.fsPath?.startsWith(f.uri.fsPath)); + if (f) { + path = uri.fsPath?.replace(f.uri.fsPath, '') || ''; + } else { + path = uri.fsPath || ''; + } + } else { + path = uri.fsPath || ''; + } + return path || undefined; +}; + +export const getFolderName = (pathStr: string | undefined) => { + if (!pathStr) return ''; + pathStr = pathStr.replace(/[/\\]+/g, '/'); + const parts = pathStr.split('/'); + const nonEmptyParts = parts.filter(p => p.length > 0); + if (nonEmptyParts.length === 0) return '/'; + if (nonEmptyParts.length === 1) return nonEmptyParts[0] + '/'; + return nonEmptyParts.slice(-2).join('/') + '/'; +}; + +export const getBasename = (pathStr: string | undefined, parts: number = 1) => { + if (!pathStr) return ''; + pathStr = pathStr.replace(/[/\\]+/g, '/'); + const allParts = pathStr.split('/'); + if (allParts.length === 0) return pathStr; + return allParts.slice(-parts).join('/'); +}; + +export const getChatMessageMarkdown = (chatMessage: ChatMessage): string => { + const display = (chatMessage as any).displayContent; + if (typeof display === 'string' && display.length > 0) { + return display; + } + const content = (chatMessage as any).content; + if (typeof content === 'string') { + return content; + } + return ''; +}; + + +export const getAssistantTurnInfo = (messages: ChatMessage[], idx: number) => { + if (!Array.isArray(messages) || idx < 0 || idx >= messages.length) return undefined; + + + let prevUserIdx = -1; + for (let i = idx; i >= 0; i--) { + if (messages[i]?.role === 'user') { + prevUserIdx = i; + break; + } + } + + + let nextUserIdx = messages.length; + for (let i = idx + 1; i < messages.length; i++) { + if (messages[i]?.role === 'user') { + nextUserIdx = i; + break; + } + } + + const start = prevUserIdx + 1; + const end = nextUserIdx; // end exclusive + + + const containsAssistant = messages.slice(start, end).some(m => m?.role === 'assistant'); + if (!containsAssistant) return undefined; + + + let lastNonCheckpointIdx = -1; + for (let i = end - 1; i >= start; i--) { + if (messages[i]?.role !== 'checkpoint') { + lastNonCheckpointIdx = i; + break; + } + } + if (lastNonCheckpointIdx === -1) return undefined; + + return { start, end, lastNonCheckpointIdx }; +}; + + +export const getAssistantTurnMarkdown = (messages: ChatMessage[], idx: number): string => { + const info = getAssistantTurnInfo(messages, idx); + if (!info) return ''; + + let markdown = ''; + + for (let i = info.start; i < info.end; i++) { + const m = messages[i]; + if (!m) continue; + + if (m.role === 'assistant') { + const text = getChatMessageMarkdown(m).trim(); + if (text) markdown += `${text}\n\n`; + continue; + } + + if (m.role === 'tool') { + const toolName = (m as any).name || 'tool'; + const toolContent = getChatMessageMarkdown(m); + markdown += `**Tool (${toolName}):**\n\`\`\`\n${toolContent}\n\`\`\`\n\n`; + continue; + } + + if (m.role === 'interrupted_streaming_tool') { + const toolName = (m as any).name || 'tool'; + markdown += `**Tool (${toolName}) canceled**\n\n`; + continue; + } + } + + return markdown.trim(); +}; + + +export const getAssistantResponseMarkdown = (messages: ChatMessage[], startIdx: number): string => { + return getAssistantTurnMarkdown(messages, startIdx); +}; + +export const voidOpenFileFn = ( + uri: URI, + accessor: AccessorLike, + range?: [number, number], + _scrollToBottom?: () => void, +) => { + const commandService = accessor.get('ICommandService'); + const editorService = accessor.get('ICodeEditorService'); + + let editorSelection = undefined as any; + if (range) { + editorSelection = { + startLineNumber: range[0], + startColumn: 1, + endLineNumber: range[1], + endColumn: Number.MAX_SAFE_INTEGER, + }; + } + + commandService.executeCommand('vscode.open', uri).then(() => { + setTimeout(() => { + if (!editorSelection) return; + const editor = editorService.getActiveCodeEditor(); + if (!editor) return; + editor.setSelection(editorSelection); + editor.revealRange(editorSelection, ScrollType.Immediate); + _scrollToBottom?.(); + }, 50); + }); +}; diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatTools.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatTools.tsx new file mode 100644 index 00000000000..d9e52e1bd24 --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatTools.tsx @@ -0,0 +1,2053 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + + +import React, { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react'; +import { useAccessor, useChatThreadsStreamState, } from '../util/services.js'; + +import { AlertTriangle, Ban, ChevronRight, CircleEllipsis } from 'lucide-react'; + +import { ToolApprovalTypeSwitch } from '../void-settings-tsx/Settings.js'; +import { VoidSwitch } from '../util/inputs.js'; +import { ToolName, toolNames } from '../../../../common/prompt/prompts.js'; +import { approvalTypeOfToolName } from '../../../../../../../platform/void/common/toolsServiceTypes.js'; +import { isDangerousTerminalCommand } from '../../../../common/toolsService.js'; + +import { CopyButton, EditToolAcceptRejectButtonsHTML, useEditToolStreamState } from '../markdown/ApplyBlockHoverButtons.js'; +import { ChatMessage, ToolMessage, } from '../../../../../../../platform/void/common/chatThreadServiceTypes.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { getBasename, getRelative, getFolderName, voidOpenFileFn } from './SidebarChatShared.js'; +import { IconLoading, ToolChildrenWrapper, CodeChildren, ListableToolItem } from './SidebarChatUI.js'; +import { LintErrorItem, ToolCallParams, ShallowDirectoryItem } from '../../../../../../../platform/void/common/toolsServiceTypes.js'; +import { ChatMarkdownRender, getApplyBoxId } from '../markdown/ChatMarkdownRender.js'; +import { RawToolCallObj } from '../../../../../../../platform/void/common/sendLLMMessageTypes.js'; +import { persistentTerminalNameOfId } from '../../../terminalToolService.js'; +import { BlockCode } from '../util/inputs.js'; +import { MAX_FILE_CHARS_PAGE } from '../../../../../../../platform/void/common/prompt/constants.js'; + +const USER_CANCELED_TOOL_LABEL = 'User canceled tool'; + +const applyCanceledUi = (componentParams: ToolHeaderParams, toolMessage: any) => { + if (toolMessage?.type !== 'rejected') return; + componentParams.isRejected = true; + componentParams.rejectedTooltip = USER_CANCELED_TOOL_LABEL; + // show a visible label in header if nothing else is shown on the right + if (componentParams.desc2 === undefined) { + componentParams.desc2 = USER_CANCELED_TOOL_LABEL; + } +}; + +export const getReadFileRange = (params: any): string => { + const startLine = params?.start_line ?? params?.startLine; + const endLine = params?.end_line ?? params?.endLine; + const linesCount = params?.lines_count ?? params?.linesCount; + + if (!startLine && !endLine && !linesCount) { + return '[all]'; + } + + if (linesCount) { + const start = startLine ? Number(startLine) : 1; + const end = start + Number(linesCount) - 1; + return `[${start}-${end}]`; + } + + if (startLine || endLine) { + const start = startLine ? Number(startLine) : 1; + const end = endLine ? Number(endLine) : '...'; + return `[${start}-${end}]`; + } + + return ''; +}; + +export const loadingTitleWrapper = (item: React.ReactNode): React.ReactNode => { + return + {item} + + ; +}; + +export const titleOfToolName = { + 'read_file': { + done: (params: any) => `Read file ${getReadFileRange(params)}`, + proposed: (params: any) => `Read file ${getReadFileRange(params)}`, + running: (params: any) => loadingTitleWrapper(`Reading file ${getReadFileRange(params)}`) + }, + 'ls_dir': { done: 'Inspected folder', proposed: 'Inspect folder', running: loadingTitleWrapper('Inspecting folder') }, + 'get_dir_tree': { done: 'Inspected folder tree', proposed: 'Inspect folder tree', running: loadingTitleWrapper('Inspecting folder tree') }, + 'search_pathnames_only': { done: 'Searched by file name', proposed: 'Search by file name', running: loadingTitleWrapper('Searching by file name') }, + 'search_for_files': { done: 'Searched', proposed: 'Search', running: loadingTitleWrapper('Searching') }, + 'create_file_or_folder': { done: `Created`, proposed: `Create`, running: loadingTitleWrapper(`Creating`) }, + 'delete_file_or_folder': { done: `Deleted`, proposed: `Delete`, running: loadingTitleWrapper(`Deleting`) }, + 'rewrite_file': { done: `Wrote file`, proposed: 'Write file', running: loadingTitleWrapper('Writing file') }, + 'run_command': { + done: 'Run terminal', + proposed: 'Run terminal', + running: loadingTitleWrapper('Run terminal'), + }, + 'run_persistent_command': { + done: 'Run terminal', + proposed: 'Run terminal', + running: loadingTitleWrapper('Run terminal'), + }, + + 'open_persistent_terminal': { done: `Opened terminal`, proposed: 'Open terminal', running: loadingTitleWrapper('Opening terminal') }, + 'kill_persistent_terminal': { done: `Killed terminal`, proposed: 'Kill terminal', running: loadingTitleWrapper('Killing terminal') }, + + 'read_lint_errors': { done: `Read lint errors`, proposed: 'Read lint errors', running: loadingTitleWrapper('Reading lint errors') }, + 'search_in_file': { done: 'Searched in file', proposed: 'Search in file', running: loadingTitleWrapper('Searching in file') }, + 'edit_file': { done: 'Previewed edit', proposed: 'Edit file (preview)', running: loadingTitleWrapper('Preparing preview') }, +} as const; + +export const getTitle = (toolMessage: Pick): React.ReactNode => { + const t = toolMessage; + if (!toolNames.includes(t.name as ToolName)) return t.name; + + const toolName = t.name as ToolName; + const toolConfig = titleOfToolName[toolName]; + + if (t.type === 'success') { + if (typeof toolConfig.done === 'function') { + return toolConfig.done(t.rawParams); + } + return toolConfig.done; + } + + if (t.type === 'running_now') { + if (typeof toolConfig.running === 'function') { + return toolConfig.running(t.rawParams); + } + return toolConfig.running; + } + + return typeof toolConfig.proposed === 'function' ? toolConfig.proposed(t.rawParams) : toolConfig.proposed; +}; + +export const toolNameToDesc = (toolName: ToolName, _toolParams: any, accessor: any): { + desc1: React.ReactNode, + desc1Info?: string, +} => { + if (!_toolParams) { + return { desc1: '' }; + } + + const x = { + 'read_file': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + return { + desc1: fsPath ? getBasename(fsPath) : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + 'ls_dir': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + return { + desc1: fsPath ? (getFolderName(fsPath) ?? '/') : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + + // --- SEARCH TOOLS: do NOT show query in header (params will be shown in children area) + 'search_pathnames_only': () => { + return { desc1: '' }; + }, + 'search_for_files': () => { + return { desc1: '' }; + }, + 'search_in_file': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + return { + desc1: fsPath ? getBasename(fsPath) : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + + 'create_file_or_folder': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + const isFolder = toolParams?.isFolder ?? false; + return { + desc1: fsPath + ? (isFolder ? (getFolderName(fsPath) ?? '/') : getBasename(fsPath)) + : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + 'delete_file_or_folder': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + const isFolder = toolParams?.isFolder ?? false; + return { + desc1: fsPath + ? (isFolder ? (getFolderName(fsPath) ?? '/') : getBasename(fsPath)) + : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + 'rewrite_file': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + return { + desc1: fsPath ? getBasename(fsPath) : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + + 'run_command': () => ({ desc1: '' }), + 'run_persistent_command': () => ({ desc1: '' }), + 'open_persistent_terminal': () => ({ desc1: '' }), + 'kill_persistent_terminal': () => { + const toolParams = _toolParams as any; + return { desc1: toolParams?.persistentTerminalId ?? '' }; + }, + + 'get_dir_tree': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + return { + desc1: fsPath ? (getFolderName(fsPath) ?? '/') : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + 'read_lint_errors': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + return { + desc1: fsPath ? getBasename(fsPath) : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + + 'edit_file': () => { + const toolParams = _toolParams as any; + const uri = getUriFromToolParams(toolParams); + const fsPath = uri?.fsPath; + return { + desc1: fsPath ? getBasename(fsPath) : '', + desc1Info: uri ? getRelative(uri, accessor) : undefined, + }; + }, + }; + + try { + return (x as any)[toolName]?.() || { desc1: '' }; + } catch { + return { desc1: '' }; + } +}; + +export const ProseWrapper = ({ children }: { children: React.ReactNode }) => { + return
+ {children} +
+}; + +export type ToolHeaderParams = { + icon?: React.ReactNode; + title: React.ReactNode; + desc1: React.ReactNode; + desc1OnClick?: () => void; + desc2?: React.ReactNode; + isError?: boolean; + info?: string; + desc1Info?: string; + + isRejected?: boolean; + rejectedTooltip?: string; + + numResults?: number; + hasNextPage?: boolean; + + subChildren?: React.ReactNode; + children?: React.ReactNode; + bottomChildren?: React.ReactNode; + + onClick?: () => void; + desc2OnClick?: () => void; + + /** Controlled open state (optional). */ + isOpen?: boolean; + + /** If flips false->true, uncontrolled spoiler will auto-open once. */ + defaultIsOpen?: boolean; + + className?: string; +}; + +export const ToolHeaderWrapper = ({ + icon, + title, + desc1, + desc1OnClick, + desc1Info, + desc2, + numResults, + hasNextPage, + subChildren, + children, + info, + bottomChildren, + isError, + onClick, + desc2OnClick, + isOpen, + defaultIsOpen, + isRejected, + rejectedTooltip, + className, +}: ToolHeaderParams) => { + + const hasDropdownChildren = children !== undefined && children !== null; + + const [isOpen_, setIsOpen] = useState(() => !!defaultIsOpen); + const isExpanded = isOpen !== undefined ? isOpen : isOpen_; + + const isDropdown = hasDropdownChildren; + const isClickable = !!(isDropdown || onClick); + const isDesc1Clickable = !!desc1OnClick; + + // Auto-open once when defaultIsOpen flips false -> true (uncontrolled only) + const prevDefaultRef = useRef(!!defaultIsOpen); + useEffect(() => { + if (isOpen !== undefined) return; + const prev = prevDefaultRef.current; + const next = !!defaultIsOpen; + prevDefaultRef.current = next; + if (!prev && next) setIsOpen(true); + }, [defaultIsOpen, isOpen]); + + const desc1HTML = ( + { + e.stopPropagation(); + desc1OnClick?.(); + }} + {...desc1Info ? { + 'data-tooltip-id': 'void-tooltip', + 'data-tooltip-content': desc1Info, + 'data-tooltip-place': 'top', + 'data-tooltip-delay-show': 1000, + } : {}} + > + {desc1} + + ); + + return ( +
+
+ {/* header */} +
+
+ {/* left */} +
+
{ + if (isDropdown && isOpen === undefined) { setIsOpen(v => !v); } + onClick?.(); + }} + > + {isDropdown && ( + + )} + {title} + {!isDesc1Clickable && desc1HTML} +
+ {isDesc1Clickable && desc1HTML} +
+ + {/* right */} +
+ {info && ( + + )} + + {isError && ( + + )} + {isRejected && ( + + )} + {desc2 && ( + { e.stopPropagation(); desc2OnClick?.(); }}> + {desc2} + + )} + {numResults !== undefined && ( + + {`${numResults}${hasNextPage ? '+' : ''} result${numResults !== 1 ? 's' : ''}`} + + )} +
+
+
+ + {/* always-visible under-header block */} + {subChildren !== undefined && subChildren !== null && ( +
+ {subChildren} +
+ )} + + {/* children (collapsible result) */} + {hasDropdownChildren && isExpanded && ( +
+ {children} +
+ )} +
+ + {bottomChildren} +
+ ); +}; + +export const ToolRequestAcceptRejectButtons = ({ toolName }: { toolName: ToolName }) => { + const accessor = useAccessor(); + const chatThreadsService = accessor.get('IChatThreadService'); + const metricsService = accessor.get('IMetricsService'); + const voidSettingsService = accessor.get('IVoidSettingsService'); + + const isAcp = !!voidSettingsService.state.globalSettings.useAcp; + + const onAccept = useCallback(() => { + try { + const threadId = chatThreadsService.state.currentThreadId; + chatThreadsService.approveLatestToolRequest(threadId); + metricsService.capture('Tool Request Accepted', {}); + } catch (e) { + console.error('Error while approving message in chat:', e); + } + }, [chatThreadsService, metricsService]); + + const onReject = useCallback(() => { + try { + const threadId = chatThreadsService.state.currentThreadId; + + // Always mark tool as rejected so it is struck-through in UI + chatThreadsService.rejectLatestToolRequest(threadId); + + // ACP: additionally abort the run (old behavior) + if (isAcp) { + void chatThreadsService.abortRunning(threadId); + } + } catch (e) { + console.error('Error while rejecting tool request:', e); + } + metricsService.capture('Tool Request Rejected', {}); + }, [chatThreadsService, metricsService, isAcp]); + + const onSkip = useCallback(() => { + try { + const threadId = chatThreadsService.state.currentThreadId; + + // Skip != Cancel: + // skip should mark tool as "skipped" (NOT "rejected"), so it won't show "User canceled tool". + // This works for both ACP and non-ACP. + chatThreadsService.skipLatestToolRequest(threadId); + } catch (e) { + console.error('Error while skipping tool request:', e); + } + metricsService.capture('Tool Request Skipped', {}); + }, [chatThreadsService, metricsService]); + + const [showSkipButton, setShowSkipButton] = useState(false); + + useEffect(() => { + const timeoutId = setTimeout(() => { + setShowSkipButton(true); + }, 10000); + + return () => clearTimeout(timeoutId); + }, []); + + const approveButton = ( + + ); + + const cancelButton = ( + + ); + + const skipButton = ( + + ); + + const approvalType = approvalTypeOfToolName[toolName]; + let alwaysRequireManualApproval = false; + if (approvalType === 'terminal' && (toolName === 'run_command' || toolName === 'run_persistent_command')) { + try { + const threadId = chatThreadsService.state.currentThreadId; + const thread = chatThreadsService.state.allThreads[threadId]; + const lastMsg = thread?.messages[thread.messages.length - 1]; + if (lastMsg && lastMsg.role === 'tool' && lastMsg.type === 'tool_request' && lastMsg.name === toolName) { + const cmd = typeof (lastMsg.params as any)?.command === 'string' ? (lastMsg.params as any).command : undefined; + if (cmd && isDangerousTerminalCommand(cmd)) { + alwaysRequireManualApproval = true; + } + } + } catch { + // best-effort only + } + } + + if (!approvalType && voidSettingsService.state.globalSettings.mcpAutoApprove) { + return
+ {toolName} + (auto-approved) +
; + } + + if (approvalType && !alwaysRequireManualApproval && voidSettingsService.state.globalSettings.autoApprove?.[approvalType]) { + return
+ {toolName} + (auto-approved) +
; + } + + const approvalToggle = approvalType ? +
+ { + const threadId = chatThreadsService.state.currentThreadId; + chatThreadsService.approveLatestToolRequest(threadId); + metricsService.capture('Tool Request Accepted', {}); + }} + /> +
: + (!approvalType &&
+ { + voidSettingsService.setGlobalSetting('mcpAutoApprove', newVal); + if (newVal) { + const threadId = chatThreadsService.state.currentThreadId; + chatThreadsService.approveLatestToolRequest(threadId); + metricsService.capture('Tool Request Accepted', {}); + } + }} + /> + Auto-approve +
); + + const shouldShowSkipButton = approvalTypeOfToolName[toolName] !== undefined || showSkipButton; + + return
+ {approveButton} + {cancelButton} + {shouldShowSkipButton && skipButton} + {approvalToggle} +
; +}; + +export const BottomChildren = ({ children, title }: { children: React.ReactNode, title: string }) => { + const [isOpen, setIsOpen] = useState(false); + if (!children) return null; + + return ( +
+
setIsOpen(o => !o)} + style={{ background: 'none' }} + > + + {title} +
+ + {isOpen && ( +
+
+ {children} +
+
+ )} +
+ ); +}; + +export const DynamicToolHeader = ({ toolMessage }: { toolMessage: any }) => { + const title = getTitle(toolMessage); + const desc1 = ''; + const icon = null; + const isError = toolMessage.type === 'tool_error'; + const isRejected = toolMessage.type === 'rejected'; + const componentParams: ToolHeaderParams = { title, desc1, isError, icon, isRejected }; + applyCanceledUi(componentParams, toolMessage); + + if (toolMessage.type === 'success') { + componentParams.children = ( + + + {toolMessage.displayContent || toolMessage.content || (typeof toolMessage.result === 'string' ? toolMessage.result : JSON.stringify(toolMessage.result, null, 2))} + + + ); + } else if (toolMessage.type === 'tool_error') { + componentParams.children = ( + + + {toolMessage.result} + + + ); + } else if (toolMessage.type === 'running_now') { + componentParams.children = ( + +
+ {toolMessage.displayContent || toolMessage.content} +
+
+ ); + } + return ; +}; + +export const InvalidTool = ({ toolName, message }: { toolName: ToolName, message: string }) => { + const accessor = useAccessor(); + const title = getTitle({ name: toolName, type: 'invalid_params', rawParams: {} }); + const desc1 = 'Invalid parameters'; + const icon = null; + const isError = true; + const componentParams: ToolHeaderParams = { title, desc1, isError, icon }; + + componentParams.children = + + {message} + + ; + return ; +}; + +export const CanceledTool = ({ toolName }: { toolName: ToolName }) => { + const accessor = useAccessor(); + const title = getTitle({ name: toolName, type: 'rejected', rawParams: {} }); + const desc1 = ''; + const icon = null; + + const componentParams: ToolHeaderParams = { + title, + desc1, + icon, + isRejected: true, + desc2: USER_CANCELED_TOOL_LABEL, + rejectedTooltip: USER_CANCELED_TOOL_LABEL, + }; + + return ; +}; + +export const SkippedTool = ({ toolMessage }: { toolMessage: any }) => { + const accessor = useAccessor(); + + const title = getTitle(toolMessage); + + // Try to show the same desc1/tooltip as normal tools (file name, folder, etc.) + let desc1: React.ReactNode = ''; + let desc1Info: string | undefined = undefined; + try { + const name = toolMessage?.name; + if (toolNames.includes(name as ToolName)) { + const tn = name as ToolName; + const paramsAny = (toolMessage as any).params ?? (toolMessage as any).rawParams ?? {}; + const d = toolNameToDesc(tn, paramsAny, accessor); + desc1 = d.desc1; + desc1Info = d.desc1Info; + } + } catch { + // best-effort only + } + + return ( + + ); +}; + +export const LintErrorChildren = ({ lintErrors }: { lintErrors: LintErrorItem[] }) => { + return
+ {lintErrors.map((error, i) => ( +
Lines {error.startLineNumber}-{error.endLineNumber}: {error.message}
+ ))} +
; +}; + +export const EditToolChildren = ({ uri, code }: { uri: URI | undefined, code: string }) => { + return
+ + + +
; +}; + +export const EditToolHeaderButtons = ({ applyBoxId, uri, codeStr, toolName, threadId }: { threadId: string, applyBoxId: string, uri: URI | undefined, codeStr: string, toolName: 'edit_file' | 'rewrite_file' }) => { + const { streamState } = uri ? useEditToolStreamState({ applyBoxId, uri }) : { streamState: 'idle-no-changes' }; + return
+ {streamState === 'idle-no-changes' && } + {uri && } +
; +}; + +export const EditToolSoFar = ({ toolCallSoFar, }: { toolCallSoFar: RawToolCallObj }) => { + const accessor = useAccessor(); + const uri = toolCallSoFar.rawParams.uri ? URI.file(toolCallSoFar.rawParams.uri) : undefined; + const streamingToolName = toolCallSoFar.name as ToolName; + const toolConfig = titleOfToolName[streamingToolName]; + const title = typeof toolConfig.proposed === 'function' ? toolConfig.proposed(toolCallSoFar.rawParams) : toolConfig.proposed; + const uriDone = toolCallSoFar.doneParams.includes('uri'); + const desc1 = + {uriDone ? getBasename(toolCallSoFar.rawParams['uri'] ?? 'unknown') : `Generating`} + + ; + const desc1OnClick = () => { uri && voidOpenFileFn(uri, accessor); }; + return + { + const raw = toolCallSoFar.rawParams as any; + return raw?.updated_snippet ?? raw?.original_snippet ?? raw?.new_content ?? ''; + })()} /> + + ; +}; + + +export const CommandTool = ({ toolMessage, type, threadId }: { threadId: string } & ({ + toolMessage: Exclude, { type: 'invalid_params' }>; + type: 'run_command'; +} | { + toolMessage: Exclude, { type: 'invalid_params' }>; + type: 'run_persistent_command'; +})) => { + const accessor = useAccessor(); + const terminalToolsService = accessor.get('ITerminalToolService'); + const toolsService = accessor.get('IToolsService'); + const chatThreadsService = accessor.get('IChatThreadService'); + + + + const streamHookAny = useChatThreadsStreamState(threadId) as any; + const threadStreamState: any = streamHookAny?.streamState ?? streamHookAny; + + const title = getTitle(toolMessage); + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor); + const isRejected = toolMessage.type === 'rejected'; + + const componentParams: ToolHeaderParams = { + title, + desc1, + desc1Info, + isError: false, + icon: null, + isRejected + }; + applyCanceledUi(componentParams, toolMessage); + + const commandStr = useMemo(() => { + try { + if (type === 'run_command') { + return (toolMessage.params as ToolCallParams['run_command']).command; + } + return (toolMessage.params as ToolCallParams['run_persistent_command']).command; + } catch { + return ''; + } + }, [toolMessage, type]); + + const onSkipRunningCommand = useCallback(() => { + try { + chatThreadsService.skipRunningTool(threadId); + } catch (e) { + console.error('Error while skipping running tool:', e); + } + }, [chatThreadsService, threadId]); + + // ---- IMPORTANT: force rerender while running (non-ACP can update terminalId/output by mutation) ---- + const [pollTick, setPollTick] = useState(0); + useEffect(() => { + if (toolMessage.type !== 'running_now') return; + const id = setInterval(() => setPollTick(v => v + 1), 250); + return () => clearInterval(id); + }, [toolMessage.type]); + + const [attachFailed, setAttachFailed] = useState(false); + const terminalContainerRef = useRef(null); + + + const streamStateContent = useMemo((): string => { + try { + if (!threadStreamState) return ''; + if (threadStreamState.isRunning !== 'tool') return ''; + + const toolInfo = threadStreamState.toolInfo; + if (!toolInfo) return ''; + if (toolInfo.toolName !== type) return ''; + + + const msgId = (toolMessage as any)?.id; + const infoId = toolInfo?.id; + + if (msgId && infoId) { + if (msgId !== infoId) return ''; + } else { + const infoCmd = toolInfo?.toolParams?.command; + if (typeof infoCmd === 'string' && typeof commandStr === 'string' && infoCmd !== commandStr) return ''; + } + + const c = toolInfo?.content; + return typeof c === 'string' ? c : ''; + } catch { + return ''; + } + }, [threadStreamState, toolMessage, type, commandStr, pollTick]); + + const tmpTerminalId: string | undefined = useMemo(() => { + const p: any = (toolMessage as any)?.params ?? {}; + const r: any = (toolMessage as any)?.result ?? {}; + const ro: any = (toolMessage as any)?.rawOutput ?? {}; + const rp: any = (toolMessage as any)?.rawParams ?? {}; + + + const toolInfoParams: any = threadStreamState?.toolInfo?.toolParams ?? {}; + + const candidates = [ + p.terminalId, p.tmpTerminalId, p.temporaryTerminalId, + r.terminalId, r.tmpTerminalId, r.temporaryTerminalId, + ro.terminalId, ro.tmpTerminalId, ro.temporaryTerminalId, + rp.terminalId, rp.tmpTerminalId, rp.temporaryTerminalId, + + toolInfoParams.terminalId, + toolInfoParams.tmpTerminalId, + toolInfoParams.temporaryTerminalId, + ]; + + for (const c of candidates) { + if (typeof c === 'string' && c.trim()) return c.trim(); + } + return undefined; + // pollTick forces re-evaluation even if objects were mutated without state updates + }, [toolMessage, pollTick, threadStreamState]); + + const attachableTerminal = useMemo(() => { + if (type !== 'run_command') return undefined; + if (toolMessage.type !== 'running_now') return undefined; + if (!tmpTerminalId) return undefined; + return terminalToolsService.getTemporaryTerminal(tmpTerminalId); + }, [terminalToolsService, tmpTerminalId, toolMessage.type, type]); + + useEffect(() => { + if (!attachableTerminal) return; + + const container = terminalContainerRef.current; + if (!container) return; + + try { + if (typeof (attachableTerminal as any).attachToElement !== 'function') { + setAttachFailed(true); + return; + } + (attachableTerminal as any).attachToElement(container); + (attachableTerminal as any).setVisible(true); + setAttachFailed(false); + } catch { + setAttachFailed(true); + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + const height = entries[0].borderBoxSize[0].blockSize; + const width = entries[0].borderBoxSize[0].inlineSize; + if (typeof (attachableTerminal as any).layout === 'function') { + (attachableTerminal as any).layout({ width, height }); + } + }); + resizeObserver.observe(container); + + return () => { + try { (attachableTerminal as any).detachFromElement?.(); } catch { } + try { resizeObserver.disconnect(); } catch { } + }; + }, [attachableTerminal]); + const commandBlock = commandStr + ?
{commandStr}
+ : null; + + // Avoid showing engine placeholder as "output" + const sanitizeRunningPlaceholder = (s: unknown): string => { + if (typeof s !== 'string') return ''; + const t = s.trim(); + if (!t) return ''; + if (t === 'value not received yet...' || t === 'running...') return ''; + return s; + }; + + const streamingText = useMemo(() => { + const candidates: unknown[] = [ + + streamStateContent, + + (toolMessage as any)?.result?.output, + (toolMessage as any)?.rawOutput?.output, + sanitizeRunningPlaceholder((toolMessage as any)?.displayContent), + sanitizeRunningPlaceholder((toolMessage as any)?.content), + (toolMessage as any)?.result?.text, + (toolMessage as any)?.rawOutput?.text, + ]; + + for (const c of candidates) { + if (typeof c === 'string' && c.length > 0) return c; + } + return ''; + }, [toolMessage, pollTick, streamStateContent]); + + const TAIL_LIMIT = 6000; + const displayStreamingText = useMemo(() => { + if (!streamingText) return ''; + if (toolMessage.type !== 'running_now') return streamingText; + if (streamingText.length <= TAIL_LIMIT) return streamingText; + + const tail = streamingText.slice(streamingText.length - TAIL_LIMIT); + return ( + `[showing last ${TAIL_LIMIT} chars of ${streamingText.length}]\n` + + `…\n` + + tail + ); + }, [streamingText, toolMessage.type]); + + const outputScrollRef = useRef(null); + useEffect(() => { + if (toolMessage.type !== 'running_now') return; + if (attachableTerminal) return; + + const el = outputScrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [attachableTerminal, displayStreamingText, toolMessage.type]); + + if (toolMessage.type === 'success') { + const { result } = toolMessage; + + let msg: string = + toolMessage.displayContent + ?? toolMessage.content + ?? (type === 'run_command' + ? toolsService.stringOfResult['run_command'](toolMessage.params, result) + : toolsService.stringOfResult['run_persistent_command'](toolMessage.params, result)); + + componentParams.children = ( + +
+ +
+
+ ); + + componentParams.bottomChildren = commandBlock; + return ; + } + + if (toolMessage.type === 'tool_error') { + componentParams.bottomChildren = ( + <> + {commandBlock} + + {String((toolMessage as any).result ?? '')} + + + ); + return ; + } + + if (toolMessage.type === 'running_now') { + if (type === 'run_command') { + componentParams.children = (attachableTerminal && !attachFailed) + ?
+ : ( + + +
+
+									{displayStreamingText || '(waiting for output...)'}
+								
+
+
+
+ ); + } else { + componentParams.children = ( + + +
+
+								{displayStreamingText || '(running...)'}
+							
+
+
+
+ ); + } + + componentParams.bottomChildren = ( + <> + {commandBlock} +
+ +
+ + ); + return ; + } + + componentParams.bottomChildren = commandBlock; + return ; +}; + +interface EditToolResult { + patch_unified?: string; + preview?: { + patch_unified?: string; + }; + applied?: boolean; + error?: string; + debug_cmd?: string | null; + debug_cmd_alt?: string | null; + lintErrors?: LintErrorItem[]; +} + +const getUriFromToolParams = (paramsAny: any): URI | undefined => { + if (!paramsAny) return undefined; + + // already URI + if (URI.isUri(paramsAny.uri)) return paramsAny.uri; + + // string -> URI.file + if (typeof paramsAny.uri === 'string' && paramsAny.uri.trim()) return URI.file(paramsAny.uri.trim()); + if (typeof paramsAny.path === 'string' && paramsAny.path.trim()) return URI.file(paramsAny.path.trim()); + + // fallback: sometimes stored elsewhere + if (typeof paramsAny.filePath === 'string' && paramsAny.filePath.trim()) return URI.file(paramsAny.filePath.trim()); + + return undefined; +}; + +export type ResultWrapper = (props: { toolMessage: Exclude, { type: 'invalid_params' }>, messageIdx: number, threadId: string }) => React.ReactNode +const EditTool = ( + { toolMessage, threadId, messageIdx, content }: Parameters>[0] & { content: string } +) => { + const accessor = useAccessor(); + const editCodeService = accessor.get('IEditCodeService'); + const languageService = accessor.get('ILanguageService'); + + const isError = false; + const isRejected = toolMessage.type === 'rejected'; + const isCanceled = toolMessage.type === 'rejected'; + + const title = getTitle(toolMessage); + + const paramsAny = (toolMessage as any).params ?? {}; + const uri = getUriFromToolParams(paramsAny); + const fsPath = uri?.fsPath ?? ''; + + // IMPORTANT: toolNameToDesc is now defensive too + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, paramsAny, accessor); + const icon = null; + + const { name } = toolMessage as any; + + const desc1OnClick = uri ? () => voidOpenFileFn(uri, accessor) : undefined; + + const componentParams: ToolHeaderParams = { + title, + desc1, + desc1OnClick, + desc1Info, + isError, + icon, + isRejected, + }; + + // Apply common canceled-UI (strike-through + label "User chancel by tool") + applyCanceledUi(componentParams, toolMessage); + + const [fallbackMsg, setFallbackMsg] = useState(() => { + if (!uri) return null; + return editCodeService?.getLastFallbackMessage?.(uri) ?? null; + }); + + useEffect(() => { + if (!uri) { + setFallbackMsg(null); + return; + } + setFallbackMsg(editCodeService?.getLastFallbackMessage?.(uri) ?? null); + }, [editCodeService, fsPath]); + + useEffect(() => { + if (!editCodeService?.onDidUseFallback) return; + if (!uri) return; + + const sub = editCodeService.onDidUseFallback((e: { uri: URI; message?: string }) => { + if (e?.uri?.fsPath && e.uri.fsPath === uri.fsPath) { + setFallbackMsg(e.message ?? 'LLM did not correctly provide an ORIGINAL code block'); + } + }); + return () => { sub?.dispose?.(); }; + }, [editCodeService, fsPath]); + + const language = uri ? (languageService.guessLanguageIdByFilepathOrFirstLine(uri) || 'plaintext') : 'plaintext'; + + if (toolMessage.type === 'running_now' || toolMessage.type === 'tool_request') { + componentParams.children = ( + + {content} + + ); + } else if ( + toolMessage.type === 'success' || + toolMessage.type === 'rejected' || + toolMessage.type === 'tool_error' || + toolMessage.type === 'skipped' + ) { + const applyBoxId = getApplyBoxId({ + threadId, + messageIdx, + tokenIdx: 'N/A', + }); + + // For skipped/canceled tools, do not show apply buttons + if (toolMessage.type !== 'skipped' && !isCanceled) { + componentParams.desc2 = ( + + ); + } + + if (toolMessage.type === 'success' || toolMessage.type === 'rejected') { + const blocks: React.ReactNode[] = []; + const result = toolMessage.result as EditToolResult | null; + const shouldShowFallback = !!fallbackMsg && result?.applied === false; + + if (shouldShowFallback) { + const cmd = result?.debug_cmd; + const cmdAlt = result?.debug_cmd_alt; + + blocks.push( + +
{fallbackMsg}
+ {cmd ?
{cmd}
: null} + {cmdAlt ?
{cmdAlt}
: null} +
+ ); + } + + if (result?.lintErrors && Array.isArray(result.lintErrors) && result.lintErrors.length > 0) { + blocks.push( + + {result.lintErrors.map((error: LintErrorItem, i: number) => ( +
+ Lines {error.startLineNumber}-{error.endLineNumber}: {error.message} +
+ ))} +
+ ); + } + + const patchUnified = result?.patch_unified || result?.preview?.patch_unified; + if (patchUnified && uri) { + const rel = getRelative(uri, accessor) || getBasename(uri.fsPath); + const normalizeRel = String(rel).replace(/^[\\/]+/, ''); + + const patchUnifiedWithRelativePaths = String(patchUnified) + .replace(/^---\s+a\/.*$/m, `--- a/${normalizeRel}`) + .replace(/^\+\+\+\s+b\/.*$/m, `+++ b/${normalizeRel}`); + + blocks.push( + + {patchUnifiedWithRelativePaths} + + ); + } + + if (blocks.length > 0) { + componentParams.bottomChildren = <>{blocks}; + } else if (result && 'error' in result && typeof result.error === 'string' && result.error.includes('original_snippet and updated_snippet are identical')) { + componentParams.bottomChildren = ( + +
+ No changes were made. The original and updated snippets are identical. +
+
+ ); + } + } else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage as any; + componentParams.bottomChildren = ( + + {String(result ?? '')} + + ); + } else if (toolMessage.type === 'skipped') { + componentParams.isRejected = true; + componentParams.desc2 = 'Skipped by user'; + componentParams.rejectedTooltip = 'Skipped by user'; + } + } + + return ; +}; + +type AnyResultWrapper = (props: any) => React.ReactNode +export const toolNameToComponent: Partial> = { + 'read_file': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor(); + const languageService = accessor.get('ILanguageService'); + + const isRejected = toolMessage.type === 'rejected'; + const isError = toolMessage.type === 'tool_error'; + + const paramsAny = (toolMessage as any).params ?? {}; + + // Robust URI extraction (ACP can sometimes deliver different shapes) + const uri: URI | undefined = URI.isUri(paramsAny?.uri) + ? paramsAny.uri + : (typeof paramsAny?.uri === 'string' ? URI.file(paramsAny.uri) : undefined); + + const normalizeRelPath = (s: string | undefined): string | undefined => { + if (!s) return undefined; + let t = String(s); + + // remove leading slashes/backslashes ("/src/..." -> "src/...") + t = t.replace(/^[\\/]+/, ''); + + // if empty -> workspace root + if (!t) return './'; + + // enforce "./" prefix + if (!t.startsWith('./') && !t.startsWith('.\\')) { + t = `./${t}`; + } + return t; + }; + + const relRaw = uri ? (getRelative(uri, accessor) ?? undefined) : undefined; + const relPath = normalizeRelPath(relRaw); + + // If getRelative() fails, fall back to basename but still keep "./" + const displayPath = relPath ?? (uri ? `./${getBasename(uri.fsPath)}` : './unknown'); + + // Parse numeric params (ACP might send numbers as strings) + const asNum = (v: any): number | undefined => { + if (typeof v === 'number' && Number.isFinite(v)) return v; + if (typeof v === 'string' && v.trim() && Number.isFinite(Number(v))) return Number(v); + return undefined; + }; + + const startLine = asNum(paramsAny?.startLine); + const endLine = asNum(paramsAny?.endLine); + const linesCount = asNum(paramsAny?.linesCount); + + let rangeLabel = 'all'; + let range: [number, number] | undefined = undefined; + + if (typeof linesCount === 'number' && linesCount > 0) { + const s = startLine ?? 1; + const e = s + linesCount - 1; + rangeLabel = `${s} - ${e}`; + range = [s, e]; + } else if (typeof startLine === 'number' || typeof endLine === 'number') { + const s = startLine ?? 1; + const totalNumLines = asNum((toolMessage as any)?.result?.totalNumLines); + const e = endLine ?? totalNumLines ?? s; + rangeLabel = `${s} - ${e}`; + range = [s, e]; + } + + // HEADER FORMAT: + // Read file [N - K] ./relative/path + // Read file [all] ./relative/path + const title = `Read file [${rangeLabel}]`; + + const language = uri + ? (languageService.guessLanguageIdByFilepathOrFirstLine(uri) || 'plaintext') + : 'plaintext'; + + const componentParams: ToolHeaderParams = { + title, + desc1: displayPath, + desc1Info: displayPath, + isError, + icon: null, + isRejected + }; + applyCanceledUi(componentParams, toolMessage); + + // Click header -> open file, and if we have a line range, reveal it + if (uri) { + componentParams.onClick = () => { voidOpenFileFn(uri, accessor, range); }; + } + + if (toolMessage.type === 'success') { + const resultAny = (toolMessage as any).result ?? {}; + + const textToShow = + (toolMessage as any).displayContent + || (toolMessage as any).content + || resultAny?.text + || resultAny?.fileContents + || ''; + + componentParams.children = {textToShow}; + + // Pagination hints (keep existing behavior) + if (resultAny.hasNextPage) { + if (typeof linesCount === 'number' && linesCount > 0) { + const s = startLine ?? 1; + const actualEnd = s + linesCount - 1; + const total = asNum(resultAny.totalNumLines) ?? actualEnd; + const nextStart = Math.min(actualEnd + 1, total); + componentParams.desc2 = `(more...) Next: start_line=${nextStart}, lines_count=${linesCount}. Total lines: ${total}.`; + } else if (paramsAny.pageNumber && paramsAny.pageNumber > 1) { + componentParams.desc2 = `(part ${paramsAny.pageNumber}) Next: page_number=${paramsAny.pageNumber + 1}.`; + } else { + componentParams.desc2 = `(truncated after ${Math.round(MAX_FILE_CHARS_PAGE / 1000)}k) Next: page_number=${(paramsAny.pageNumber ?? 1) + 1}.`; + } + } else if (paramsAny.pageNumber && paramsAny.pageNumber > 1) { + componentParams.desc2 = `(part ${paramsAny.pageNumber})`; + } + } else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage as any; + componentParams.bottomChildren = ( + + {String(result ?? '')} + + ); + } else if (toolMessage.type === 'running_now' || toolMessage.type === 'tool_request') { + const txt = (toolMessage as any).displayContent || (toolMessage as any).content || ''; + componentParams.children = ( + +
{txt}
+
+ ); + } + + return ; + }, + }, + 'edit_file': { + resultWrapper: ({ toolMessage, messageIdx, threadId }: any) => { + const accessor = useAccessor(); + const languageService = accessor.get('ILanguageService'); + + const paramsAny = (toolMessage as any).params ?? {}; + const uri = getUriFromToolParams(paramsAny); + + const language = uri + ? (languageService.guessLanguageIdByFilepathOrFirstLine(uri) || 'plaintext') + : 'plaintext'; + + if (toolMessage.type === 'tool_request' || toolMessage.type === 'running_now') { + const previewContent = + (toolMessage.result as any)?.preview?.after + ?? paramsAny?.updatedSnippet + ?? paramsAny?.originalSnippet + ?? toolMessage.content + ?? ''; + + return ; + } + + if (toolMessage.type === 'success') { + const resultAny = toolMessage.result as any; + const previewAfter = resultAny?.preview?.after ?? resultAny?.previewSample?.after ?? null; + + const contentToShow = + previewAfter + ?? paramsAny?.updatedSnippet + ?? paramsAny?.originalSnippet + ?? toolMessage.content + ?? ''; + + return ; + } + + if (toolMessage.type === 'tool_error') { + const fsPath = uri?.fsPath; + return ( + + ); + } + // rejected / skipped: let EditTool handle safely + const fallbackContent = + paramsAny?.updatedSnippet + ?? paramsAny?.originalSnippet + ?? toolMessage.content + ?? ''; + + return ; + } + }, + 'get_dir_tree': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + + const title = getTitle(toolMessage) + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) + const icon = null + const isError = false + const isRejected = toolMessage.type === 'rejected' + const { rawParams, params } = toolMessage + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } + applyCanceledUi(componentParams, toolMessage); + if (params.uri) { + const rel = getRelative(params.uri, accessor) + if (rel) componentParams.info = `Only search in ${rel}` + } + + if (toolMessage.type === 'success') { + const { result } = toolMessage + componentParams.children = + + + + + } + else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage + componentParams.bottomChildren = + + {result} + + + } + + return + + } + }, + 'ls_dir': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + const explorerService = accessor.get('IExplorerService') + const title = getTitle(toolMessage) + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) + const icon = null + + const isError = false + const isRejected = toolMessage.type === 'rejected' + const { rawParams, params } = toolMessage + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } + applyCanceledUi(componentParams, toolMessage); + if (params.uri) { + const rel = getRelative(params.uri, accessor) + if (rel) componentParams.info = `Only search in ${rel}` + } + + if (toolMessage.type === 'success') { + const { result } = toolMessage + componentParams.numResults = result.children?.length + componentParams.hasNextPage = result.hasNextPage + componentParams.children = !result.children || (result.children.length ?? 0) === 0 ? undefined + : + {result.children.map((child: ShallowDirectoryItem, i: number) => ( + { + voidOpenFileFn(child.uri, accessor) + // commandService.executeCommand('workbench.view.explorer'); // open in explorer folders view instead + // explorerService.select(child.uri, true); + }} + /> + ))} + {result.hasNextPage && + + } + + } + else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage + componentParams.bottomChildren = + + {result} + + + } + + return + } + }, + + 'search_pathnames_only': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor(); + + const title = getTitle(toolMessage); + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor); + const icon = null; + + const isRejected = toolMessage.type === 'rejected'; + const isError = toolMessage.type === 'tool_error'; + + const params = (toolMessage as any).params ?? {}; + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected }; + applyCanceledUi(componentParams, toolMessage); + const searchParams = + `Query: "${params?.query ?? ''}"` + + (params?.includePattern ? `, Pattern: "${params.includePattern}"` : ''); + + componentParams.subChildren = ( +
+ {searchParams} +
+ ); + + if (toolMessage.type === 'success') { + const result = (toolMessage as any).result ?? {}; + const uris: URI[] = Array.isArray(result?.uris) ? result.uris : []; + + componentParams.numResults = uris.length; + componentParams.hasNextPage = !!result?.hasNextPage; + + componentParams.children = ( + + {uris.length === 0 ? ( +
No results.
+ ) : ( + <> + {uris.map((uri: URI, i: number) => ( + { voidOpenFileFn(uri, accessor); }} + /> + ))} + {result?.hasNextPage && ( + + )} + + )} +
+ ); + } else if (toolMessage.type === 'tool_error') { + const result = (toolMessage as any).result; + componentParams.children = ( + + {String(result ?? '')} + + ); + } else if (toolMessage.type === 'running_now' || toolMessage.type === 'tool_request') { + componentParams.children = ( + +
+ {(toolMessage as any).displayContent || (toolMessage as any).content || ''} +
+
+ ); + } + + return ; + } + }, + + 'search_for_files': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor(); + + const title = getTitle(toolMessage); + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor); + const icon = null; + + const isRejected = toolMessage.type === 'rejected'; + const isError = toolMessage.type === 'tool_error'; + + const params = (toolMessage as any).params ?? {}; + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected }; + applyCanceledUi(componentParams, toolMessage); + const folderRel = params?.searchInFolder ? (getRelative(params.searchInFolder, accessor) ?? '') : ''; + const searchParams = + `Query: "${params?.query ?? ''}"` + + (folderRel ? `, Folder: "${folderRel}"` : '') + + (params?.isRegex ? `, Regex: true` : ''); + + componentParams.subChildren = ( +
+ {searchParams} +
+ ); + + if (toolMessage.type === 'success') { + const result = (toolMessage as any).result ?? {}; + const uris: URI[] = Array.isArray(result?.uris) ? result.uris : []; + + componentParams.numResults = uris.length; + componentParams.hasNextPage = !!result?.hasNextPage; + + componentParams.children = ( + + {uris.length === 0 ? ( +
No results.
+ ) : ( + <> + {uris.map((uri: URI, i: number) => ( + { voidOpenFileFn(uri, accessor); }} + /> + ))} + {result?.hasNextPage && ( + + )} + + )} +
+ ); + } else if (toolMessage.type === 'tool_error') { + const result = (toolMessage as any).result; + componentParams.children = ( + + {String(result ?? '')} + + ); + } else if (toolMessage.type === 'running_now' || toolMessage.type === 'tool_request') { + componentParams.children = ( + +
+ {(toolMessage as any).displayContent || (toolMessage as any).content || ''} +
+
+ ); + } + + return ; + } + }, + + 'search_in_file': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor(); + const toolsService = accessor.get('IToolsService'); + + const title = getTitle(toolMessage); + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor); + const icon = null; + + const isRejected = toolMessage.type === 'rejected'; + const isError = toolMessage.type === 'tool_error'; + + const params = (toolMessage as any).params ?? {}; + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected }; + applyCanceledUi(componentParams, toolMessage); + const uriStr = params?.uri ? (getRelative(params.uri, accessor) ?? '') : ''; + const searchParams = + `Query: "${params?.query ?? ''}"` + + (uriStr ? `, File: "${uriStr}"` : '') + + (params?.isRegex ? `, Regex: true` : ''); + + componentParams.subChildren = ( +
+ {searchParams} +
+ ); + + if (toolMessage.type === 'success') { + const result = (toolMessage as any).result ?? {}; + const lines = Array.isArray(result?.lines) ? result.lines : []; + + componentParams.numResults = lines.length; + + let resultStr = ''; + try { + resultStr = toolsService.stringOfResult['search_in_file'](params, result); + } catch { + resultStr = typeof result === 'string' ? result : JSON.stringify(result, null, 2); + } + + componentParams.children = ( + + {lines.length === 0 ? ( +
No matches.
+ ) : ( + +
{resultStr}
+
+ )} +
+ ); + } else if (toolMessage.type === 'tool_error') { + const result = (toolMessage as any).result; + componentParams.children = ( + + {String(result ?? '')} + + ); + } else if (toolMessage.type === 'running_now' || toolMessage.type === 'tool_request') { + componentParams.children = ( + +
+ {(toolMessage as any).displayContent || (toolMessage as any).content || ''} +
+
+ ); + } + + return ; + } + }, + + 'read_lint_errors': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + + const title = getTitle(toolMessage) + + const { uri } = toolMessage.params ?? {} + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) + const icon = null + + const isError = false + const isRejected = toolMessage.type === 'rejected' + const { rawParams, params } = toolMessage + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } + applyCanceledUi(componentParams, toolMessage); + componentParams.info = getRelative(uri, accessor) + + if (toolMessage.type === 'success') { + const { result } = toolMessage + componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } + if (result.lintErrors) + componentParams.children = + else + componentParams.children = `No lint errors found.` + + } + else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage + // JumpToFileButton removed in favor of FileLinkText + componentParams.bottomChildren = + + {result} + + + } + + return + }, + }, + + // --- + + 'create_file_or_folder': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + const isError = false + const isRejected = toolMessage.type === 'rejected' + const title = getTitle(toolMessage) + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) + const icon = null + + + const { rawParams, params } = toolMessage + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } + applyCanceledUi(componentParams, toolMessage); + componentParams.info = getRelative(params.uri, accessor) + + if (toolMessage.type === 'success') { + const { result } = toolMessage + componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } + } + else if (toolMessage.type === 'rejected') { + componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } + } + else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage + if (params) { componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } } + componentParams.bottomChildren = + + {result} + + + } + return + } + }, + 'delete_file_or_folder': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + const isFolder = toolMessage.params?.isFolder ?? false + const isError = false + const isRejected = toolMessage.type === 'rejected' + const title = getTitle(toolMessage) + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) + const icon = null + + const { rawParams, params } = toolMessage + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } + applyCanceledUi(componentParams, toolMessage); + componentParams.info = getRelative(params.uri, accessor) + + if (toolMessage.type === 'success') { + const { result } = toolMessage + componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } + } + else if (toolMessage.type === 'rejected') { + componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } + } + else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage + if (params) { componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } } + componentParams.bottomChildren = + + {result} + + + } + else if (toolMessage.type === 'running_now') { + const { result } = toolMessage + componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } + } + else if (toolMessage.type === 'tool_request') { + const { result } = toolMessage + componentParams.onClick = () => { voidOpenFileFn(params.uri, accessor) } + } + + return + } + }, + 'rewrite_file': { + resultWrapper: (params) => { + return + } + }, + 'run_command': { + resultWrapper: (params) => { + return + } + }, + + 'run_persistent_command': { + resultWrapper: (params) => { + return + } + }, + 'open_persistent_terminal': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const terminalToolsService = accessor.get('ITerminalToolService') + + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) + const title = getTitle(toolMessage) + const icon = null + const isError = false + const isRejected = toolMessage.type === 'rejected' + const { rawParams, params } = toolMessage + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } + applyCanceledUi(componentParams, toolMessage); + const relativePath = params.cwd ? getRelative(URI.file(params.cwd), accessor) : '' + componentParams.info = relativePath ? `Running in ${relativePath}` : undefined + + if (toolMessage.type === 'success') { + const { result } = toolMessage + const { persistentTerminalId } = result + componentParams.desc1 = persistentTerminalNameOfId(persistentTerminalId) + componentParams.onClick = () => terminalToolsService.focusPersistentTerminal(persistentTerminalId) + } + else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage + componentParams.bottomChildren = + + {result} + + + } + + return + }, + }, + 'kill_persistent_terminal': { + resultWrapper: ({ toolMessage }) => { + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + const terminalToolsService = accessor.get('ITerminalToolService') + + const { desc1, desc1Info } = toolNameToDesc(toolMessage.name, toolMessage.params, accessor) + const title = getTitle(toolMessage) + const icon = null + const isError = false + const isRejected = toolMessage.type === 'rejected' + const { rawParams, params } = toolMessage + const componentParams: ToolHeaderParams = { title, desc1, desc1Info, isError, icon, isRejected, } + applyCanceledUi(componentParams, toolMessage); + if (toolMessage.type === 'success') { + const { persistentTerminalId } = params + componentParams.desc1 = persistentTerminalNameOfId(persistentTerminalId) + componentParams.onClick = () => terminalToolsService.focusPersistentTerminal(persistentTerminalId) + } + else if (toolMessage.type === 'tool_error') { + const { result } = toolMessage + componentParams.bottomChildren = + + {result} + + + } + + return + }, + }, +}; diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatUI.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatUI.tsx new file mode 100644 index 00000000000..06f2a29e97b --- /dev/null +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarChatUI.tsx @@ -0,0 +1,563 @@ +/*-------------------------------------------------------------------------------------- + * Copyright 2025 Glass Devtools, Inc. All rights reserved. + * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. + *--------------------------------------------------------------------------------------*/ + +import React, { useState, useEffect, useMemo, useCallback, ButtonHTMLAttributes } from 'react'; +import { FeatureName } from '../../../../../../../platform/void/common/voidSettingsTypes.js'; +import { File, Folder, Text } from 'lucide-react'; +import { StagingSelectionItem } from '../../../../../../../platform/void/common/chatThreadServiceTypes.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { getBasename, voidOpenFileFn } from './SidebarChatShared.js'; +import { useAccessor, useActiveURI, useSettingsState } from '../util/services.js'; +import { BlockCode } from '../util/inputs.js'; +import { ModelDropdown, } from '../void-settings-tsx/ModelDropdown.js'; +import { VoidCustomDropdownBox } from '../util/inputs.js'; +import { ChatMode } from '../../../../../../../platform/void/common/voidSettingsTypes.js'; + +export const IconX = ({ size, className = '', ...props }: { size: number, className?: string } & React.SVGProps) => { + return ( + + + + ); +}; + +const IconSquare = ({ size, className = '' }: { size: number, className?: string }) => { + return ( + + + + ); +}; + + +export const IconWarning = ({ size, className = '' }: { size: number, className?: string }) => { + return ( + + + + ); +}; + + +export const IconLoading = ({ className = '' }: { className?: string }) => { + + const [loadingText, setLoadingText] = useState('.'); + + useEffect(() => { + let intervalId; + + // Function to handle the animation + const toggleLoadingText = () => { + if (loadingText === '...') { + setLoadingText('.'); + } else { + setLoadingText(loadingText + '.'); + } + }; + + // Start the animation loop + intervalId = setInterval(toggleLoadingText, 300); + + // Cleanup function to clear the interval when component unmounts + return () => clearInterval(intervalId); + }, [loadingText, setLoadingText]); + + return
{loadingText}
; + +} + +const IconArrowUp = ({ size, className = '' }: { size: number, className?: string }) => { + return ( + + + + ); +}; + +type ButtonProps = ButtonHTMLAttributes +const DEFAULT_BUTTON_SIZE = 22; +export const ButtonSubmit = ({ className, disabled, ...props }: ButtonProps & Required>) => { + + return +} + +export const ButtonStop = ({ className, ...props }: ButtonHTMLAttributes) => { + return +} + +interface VoidChatAreaProps { + // Required + children: React.ReactNode; // This will be the input component + + // Form controls + onSubmit: () => void; + onAbort: () => void; + isStreaming: boolean; + isDisabled?: boolean; + divRef?: React.RefObject; + + // UI customization + className?: string; + showModelDropdown?: boolean; + showSelections?: boolean; + showProspectiveSelections?: boolean; + loadingIcon?: React.ReactNode; + + selections?: StagingSelectionItem[] + setSelections?: (s: StagingSelectionItem[]) => void + // selections?: any[]; + // onSelectionsChange?: (selections: any[]) => void; + + onClickAnywhere?: () => void; + // Optional close button + onClose?: () => void; + + featureName: FeatureName; + // Optional extra controls in the bottom-right (e.g., attach buttons) + rightBottomExtras?: React.ReactNode; +} + +export const VoidChatArea: React.FC = ({ + children, + onSubmit, + onAbort, + onClose, + onClickAnywhere, + divRef, + isStreaming = false, + isDisabled = false, + className = '', + showModelDropdown = true, + showSelections = false, + showProspectiveSelections = false, + selections, + setSelections, + featureName, + loadingIcon, + rightBottomExtras, +}) => { + return ( +
{ + onClickAnywhere?.() + }} + > + {/* Selections section */} + {showSelections && selections && setSelections && ( + + )} + + {/* Input section */} +
+ {children} + + {/* Close button (X) if onClose is provided */} + {onClose && ( +
+ +
+ )} +
+ + {/* Bottom row */} +
+ {showModelDropdown && ( +
+
+ {featureName === 'Chat' && } + +
+
+ )} + +
+ + {rightBottomExtras} + {isStreaming && loadingIcon} + + {isStreaming ? ( + + ) : ( + + )} +
+ +
+
+ ); +}; + +export const ToolChildrenWrapper = ({ children, className }: { children: React.ReactNode, className?: string }) => { + // NOTE: do NOT force select-none globally, it blocks copy/select in tool outputs. + return ( +
+
+ {children} +
+
+ ); +}; + +export const ProseWrapper = ({ children }: { children: React.ReactNode }) => { + return
+ {children} +
+}; + +export const CodeChildren = ({ children, className, language }: { children: React.ReactNode, className?: string, language?: string }) => { + const isString = typeof children === 'string'; + + if (!isString) { + return ( +
+ {children} +
+ ); + } + + // Otherwise use BlockCode for strings. + const codeString = children as string; + return ( +
+ +
+ ); +}; + +export const ListableToolItem = ({ name, onClick, isSmall, className, showDot }: { name: React.ReactNode, onClick?: () => void, isSmall?: boolean, className?: string, showDot?: boolean }) => { + return
+ {showDot === false ? null :
} +
{name}
+
+} + +export const SelectedFiles = ( + { type, selections, setSelections, showProspectiveSelections, messageIdx, }: + | { type: 'past', selections: StagingSelectionItem[]; setSelections?: undefined, showProspectiveSelections?: undefined, messageIdx: number, } + | { type: 'staging', selections: StagingSelectionItem[]; setSelections: ((newSelections: StagingSelectionItem[]) => void), showProspectiveSelections?: boolean, messageIdx?: number } +) => { + + const accessor = useAccessor() + const commandService = accessor.get('ICommandService') + const modelReferenceService = accessor.get('IVoidModelService') + const { uri: currentURI } = useActiveURI() + const [recentUris, setRecentUris] = useState([]) + const maxRecentUris = 10 + const maxProspectiveFiles = 3 + + useEffect(() => { + if (!currentURI) return + setRecentUris(prev => { + const withoutCurrent = prev.filter(uri => uri.fsPath !== currentURI.fsPath) + const withCurrent = [currentURI, ...withoutCurrent] + return withCurrent.slice(0, maxRecentUris) + }) + }, [currentURI]) + + const [prospectiveSelections, setProspectiveSelections] = useState([]) + + useEffect(() => { + const computeRecents = async () => { + const prospectiveURIs = recentUris + .filter(uri => !selections.find(s => s.type === 'File' && s.uri.fsPath === uri.fsPath)) + .slice(0, maxProspectiveFiles) + + const answer: StagingSelectionItem[] = [] + for (const uri of prospectiveURIs) { + answer.push({ + type: 'File', + uri: uri, + language: 'plaintext', + state: { wasAddedAsCurrentFile: false }, + }) + } + return answer + } + + if (type === 'staging' && showProspectiveSelections) { + computeRecents().then((a) => setProspectiveSelections(a)) + } + else { + setProspectiveSelections([]) + } + }, [recentUris, selections, type, showProspectiveSelections]) + + + const allSelections = [...selections, ...prospectiveSelections] + + if (allSelections.length === 0) { + return null + } + + return ( +
+ + {allSelections.map((selection, i) => { + + const isThisSelectionProspective = i > selections.length - 1 + + const thisKey = selection.type === 'CodeSelection' ? selection.type + selection.language + (selection as any)?.range + (selection as any)?.state?.wasAddedAsCurrentFile + selection.uri.fsPath + : selection.type === 'File' ? selection.type + selection.language + (selection as any)?.state?.wasAddedAsCurrentFile + selection.uri.fsPath + : selection.type === 'Folder' ? selection.type + selection.language + (selection as any)?.state + selection.uri.fsPath + : i + + const SelectionIcon = ( + selection.type === 'File' ? File + : selection.type === 'Folder' ? Folder + : selection.type === 'CodeSelection' ? Text + : (undefined as never) + ) + + return
+
{ + if (type !== 'staging') return; + if (isThisSelectionProspective) { + setSelections([...selections, selection]) + } + else if (selection.type === 'File') { + voidOpenFileFn(selection.uri, accessor); + + const wasAddedAsCurrentFile = (selection as any).state.wasAddedAsCurrentFile + if (wasAddedAsCurrentFile) { + const newSelection: StagingSelectionItem = { ...selection, state: { ...(selection as any).state, wasAddedAsCurrentFile: false } } + setSelections([ + ...selections.slice(0, i), + newSelection, + ...selections.slice(i + 1) + ]) + } + } + else if (selection.type === 'CodeSelection') { + voidOpenFileFn(selection.uri, accessor, (selection as any)?.range); + } + else if (selection.type === 'Folder') { + // TODO!!! reveal in tree + } + }} + > + {} + { + (() => { + if (selection.type === 'CodeSelection') { + return getBasename(selection.uri.path) + ` (${(selection as any)?.range?.[0]}-${(selection as any)?.range?.[1]})` + } else if (selection.type === 'File') { + return getBasename(selection.uri.path) + } else if (selection.type === 'Folder') { + return getBasename(selection.uri.path) + '/' + } + return (selection as any)?.uri?.path || 'Unknown' + })() + } + {selection.type === 'File' && (selection as any)?.state?.wasAddedAsCurrentFile && messageIdx === undefined ? + + {`(Current File)`} + + : null + } + + {type === 'staging' && !isThisSelectionProspective ? +
{ + e.stopPropagation(); + if (type !== 'staging') return; + setSelections([...selections.slice(0, i), ...selections.slice(i + 1)]) + }} + > + +
+ : <>} +
+
+ })} +
+ ) +} + +const nameOfChatMode = { + 'normal': 'Chat', + 'gather': 'Gather', + 'agent': 'Agent', +} + +const detailOfChatMode = { + 'normal': 'Normal chat', + 'gather': 'Reads files, but can\'t edit', + 'agent': 'Edits files and uses tools', +} + +const ChatModeDropdown = ({ className }: { className: string }) => { + const accessor = useAccessor() + + const voidSettingsService = accessor.get('IVoidSettingsService') + const settingsState = useSettingsState() + + const options: ChatMode[] = useMemo(() => ['normal', 'gather', 'agent'], []) + + const onChangeOption = useCallback((newVal: ChatMode) => { + voidSettingsService.setGlobalSetting('chatMode', newVal) + }, [voidSettingsService]) + + return nameOfChatMode[val]} + getOptionDropdownName={(val) => nameOfChatMode[val]} + getOptionDropdownDetail={(val) => detailOfChatMode[val]} + getOptionsEqual={(a, b) => a === b} + /> + +} diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx index a6147461a16..69349be3139 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/SidebarThreadSelector.tsx @@ -3,12 +3,12 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import { useMemo, useState } from 'react'; -import { CopyButton, IconShell1 } from '../markdown/ApplyBlockHoverButtons.js'; -import { useAccessor, useChatThreadsState, useChatThreadsStreamState, useFullChatThreadsStreamState, useSettingsState } from '../util/services.js'; -import { IconX } from './SidebarChat.js'; -import { Check, Copy, Icon, LoaderCircle, MessageCircleQuestion, Trash2, UserCheck, X } from 'lucide-react'; -import { IsRunningType, ThreadType } from '../../../chatThreadService.js'; +import { useState } from 'react'; +import { IconShell1 } from '../markdown/ApplyBlockHoverButtons.js'; +import { useAccessor, useChatThreadsState, useFullChatThreadsStreamState } from '../util/services.js'; +import { Check, Copy, LoaderCircle, MessageCircleQuestion, Trash2, X } from 'lucide-react'; +import { IsRunningType } from '../../../ChatExecutionEngine.js'; +import { ThreadType } from '../../../chatThreadService.js'; const numInitialThreads = 3 @@ -85,10 +85,6 @@ export const PastThreadsList = ({ className = '' }: { className?: string }) => { ); }; - - - - // Format date to display as today, yesterday, or date const formatDate = (date: Date) => { const now = new Date(); @@ -114,7 +110,6 @@ const formatTime = (date: Date) => { }); }; - const DuplicateButton = ({ threadId }: { threadId: string }) => { const accessor = useAccessor() const chatThreadsService = accessor.get('IChatThreadService') @@ -127,7 +122,6 @@ const DuplicateButton = ({ threadId }: { threadId: string }) => { data-tooltip-content='Duplicate thread' > - } const TrashButton = ({ threadId }: { threadId: string }) => { @@ -182,31 +176,6 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni const accessor = useAccessor() const chatThreadsService = accessor.get('IChatThreadService') - // const settingsState = useSettingsState() - // const convertService = accessor.get('IConvertToLLMMessageService') - // const chatMode = settingsState.globalSettings.chatMode - // const modelSelection = settingsState.modelSelectionOfFeature?.Chat ?? null - // const copyChatButton = { - // const { messages } = await convertService.prepareLLMChatMessages({ - // chatMessages: currentThread.messages, - // chatMode, - // modelSelection, - // }) - // return JSON.stringify(messages, null, 2) - // }} - // toolTipName={modelSelection === null ? 'Copy As Messages Payload' : `Copy As ${displayInfoOfProviderName(modelSelection.providerName).title} Payload`} - // /> - - - // const currentThread = chatThreadsService.getCurrentThread() - // const copyChatButton2 = { - // return JSON.stringify(currentThread.messages, null, 2) - // }} - // toolTipName={`Copy As Void Chat`} - // /> - let firstMsg = null; const firstUserMsgIdx = pastThread.messages.findIndex((msg) => msg.role === 'user'); @@ -220,9 +189,6 @@ const PastThreadElement = ({ pastThread, idx, hoveredIdx, setHoveredIdx, isRunni const numMessages = pastThread.messages.filter((msg) => msg.role === 'assistant' || msg.role === 'user').length; const detailsHTML = {numMessages} {` `} diff --git a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/index.tsx b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/index.tsx index 64143bfdfdf..0a14e583837 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/index.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/sidebar-tsx/index.tsx @@ -7,5 +7,3 @@ import { mountFnGenerator } from '../util/mountFnGenerator.js' import { Sidebar } from './Sidebar.js' export const mountSidebar = mountFnGenerator(Sidebar) - - diff --git a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx index d877e1102fb..71f005c2b0c 100644 --- a/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx +++ b/src/vs/workbench/contrib/void/browser/react/src/util/inputs.tsx @@ -3,28 +3,103 @@ * Licensed under the Apache License, Version 2.0. See LICENSE.txt for more information. *--------------------------------------------------------------------------------------*/ -import React, { forwardRef, ForwardRefExoticComponent, MutableRefObject, RefAttributes, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import React, { forwardRef, ForwardRefExoticComponent, RefAttributes, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { IInputBoxStyles, InputBox } from '../../../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../../../../platform/theme/browser/defaultStyles.js'; import { SelectBox } from '../../../../../../../base/browser/ui/selectBox/selectBox.js'; import { IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { Checkbox } from '../../../../../../../base/browser/ui/toggle/toggle.js'; - import { CodeEditorWidget } from '../../../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js' import { useAccessor } from './services.js'; import { ITextModel } from '../../../../../../../editor/common/model.js'; import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js'; import { inputBackground, inputForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; -import { useFloating, autoUpdate, offset, flip, shift, size, autoPlacement } from '@floating-ui/react'; +import { useFloating, autoUpdate, offset, flip, shift, size,} from '@floating-ui/react'; import { URI } from '../../../../../../../base/common/uri.js'; -import { getBasename, getFolderName } from '../sidebar-tsx/SidebarChat.js'; -import { ChevronRight, File, Folder, FolderClosed, LucideProps } from 'lucide-react'; -import { StagingSelectionItem } from '../../../../common/chatThreadServiceTypes.js'; -import { DiffEditorWidget } from '../../../../../../../editor/browser/widget/diffEditor/diffEditorWidget.js'; -import { extractSearchReplaceBlocks, ExtractedSearchReplaceBlock } from '../../../../common/helpers/extractCodeFromResult.js'; -import { IAccessibilitySignalService } from '../../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; -import { IEditorProgressService } from '../../../../../../../platform/progress/common/progress.js'; -import { detectLanguage } from '../../../../common/helpers/languageHelpers.js'; +import { getBasename } from '../sidebar-tsx/SidebarChatShared.js'; +import { ChevronRight, File, Folder, LucideProps } from 'lucide-react'; +import { StagingSelectionItem } from '../../../../../../../platform/void/common/chatThreadServiceTypes.js'; + +const jetBrainsMonoFontFamily = `'JetBrains Mono', JetBrainsMono, monospace`; +const jetBrainsMonoFontStyleElementId = 'void-jetbrains-mono-font-face'; +let jetBrainsMonoFontStylesInjected = false; + +let _cachedWorkspaceFoldersKey: string | null = null; +let _cachedSortedWorkspaceFolders: readonly any[] = []; + +function getSortedWorkspaceFolders(accessor: ReturnType) { + const workspaceService = accessor.get('IWorkspaceContextService'); + const folders = workspaceService.getWorkspace().folders ?? []; + + const key = folders.map((f: any) => f.uri?.fsPath ?? '').join('|'); + if (key !== _cachedWorkspaceFoldersKey) { + _cachedWorkspaceFoldersKey = key; + _cachedSortedWorkspaceFolders = [...folders].sort( + (a: any, b: any) => (b.uri.fsPath.length - a.uri.fsPath.length) + ); + } + + return _cachedSortedWorkspaceFolders; +} + +const ensureJetBrainsMonoFontFace = () => { + if (jetBrainsMonoFontStylesInjected || typeof document === 'undefined') { + return; + } + + if (document.getElementById(jetBrainsMonoFontStyleElementId)) { + jetBrainsMonoFontStylesInjected = true; + return; + } + + const style = document.createElement('style'); + style.id = jetBrainsMonoFontStyleElementId; + style.textContent = ` +@font-face { + font-family: 'JetBrains Mono'; + src: url('assets/fonts/JetBrainsMono-Regular.woff2') format('woff2'); + font-style: normal; + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('assets/fonts/JetBrainsMono-Italic.woff2') format('woff2'); + font-style: italic; + font-weight: 400; + font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('assets/fonts/JetBrainsMono-Medium.woff2') format('woff2'); + font-style: normal; + font-weight: 500; + font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('assets/fonts/JetBrainsMono-MediumItalic.woff2') format('woff2'); + font-style: italic; + font-weight: 500; + font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('assets/fonts/JetBrainsMono-Bold.woff2') format('woff2'); + font-style: normal; + font-weight: 700; + font-display: swap; +} +@font-face { + font-family: 'JetBrains Mono'; + src: url('assets/fonts/JetBrainsMono-BoldItalic.woff2') format('woff2'); + font-style: italic; + font-weight: 700; + font-display: swap; +}`; + document.head.appendChild(style); + jetBrainsMonoFontStylesInjected = true; +} // type guard @@ -71,116 +146,91 @@ type Option = { const isSubsequence = (text: string, pattern: string): boolean => { + const t = text.toLowerCase(); + const p = pattern.toLowerCase(); + if (p === '') return true; - text = text.toLowerCase() - pattern = pattern.toLowerCase() - - if (pattern === '') return true; - if (text === '') return false; - if (pattern.length > text.length) return false; - - const seq: boolean[][] = Array(pattern.length + 1) - .fill(null) - .map(() => Array(text.length + 1).fill(false)); - - for (let j = 0; j <= text.length; j++) { - seq[0][j] = true; + let j = 0; + for (let i = 0; i < t.length && j < p.length; i++) { + if (t[i] === p[j]) j++; } - - for (let i = 1; i <= pattern.length; i++) { - for (let j = 1; j <= text.length; j++) { - if (pattern[i - 1] === text[j - 1]) { - seq[i][j] = seq[i - 1][j - 1]; - } else { - seq[i][j] = seq[i][j - 1]; - } - } - } - return seq[pattern.length][text.length]; + return j === p.length; }; -const scoreSubsequence = (text: string, pattern: string): number => { - if (pattern === '') return 0; +const scoreSubsequenceLower = (textLower: string, patternLower: string): number => { + if (!patternLower) return 0; - text = text.toLowerCase(); - pattern = pattern.toLowerCase(); + const p = patternLower; + const m = p.length; - // We'll use dynamic programming to find the longest consecutive substring - const n = text.length; - const m = pattern.length; + const lps = new Array(m).fill(0); + for (let i = 1, len = 0; i < m; ) { + if (p[i] === p[len]) { + lps[i++] = ++len; + } else if (len > 0) { + len = lps[len - 1]; + } else { + lps[i++] = 0; + } + } - // This will track our maximum consecutive match length - let maxConsecutive = 0; + let j = 0; + let max = 0; - // For each starting position in the text - for (let i = 0; i < n; i++) { - // Check for matches starting from this position - let consecutiveCount = 0; + for (let i = 0; i < textLower.length; i++) { + const c = textLower[i]; - // For each character in the pattern - for (let j = 0; j < m; j++) { - // If we have a match and we're still within text bounds - if (i + j < n && text[i + j] === pattern[j]) { - consecutiveCount++; - } else { - // Break on first non-match - break; + while (j > 0 && p[j] !== c) { + j = lps[j - 1]; + } + if (p[j] === c) { + j++; + if (j > max) max = j; + if (j === m) { + j = lps[j - 1]; } } - - // Update our maximum - maxConsecutive = Math.max(maxConsecutive, consecutiveCount); } + return max; +}; - return maxConsecutive; -} - - -function getRelativeWorkspacePath(accessor: ReturnType, uri: URI): string { - const workspaceService = accessor.get('IWorkspaceContextService'); - const workspaceFolders = workspaceService.getWorkspace().folders; +const scoreSubsequence = (text: string, pattern: string): number => { + if (pattern === '') return 0; + return scoreSubsequenceLower(text.toLowerCase(), pattern.toLowerCase()); +}; - if (!workspaceFolders.length) { - return uri.fsPath; // No workspace folders, return original path +const getWorkspacePathInfo = ( + uri: URI, + sortedFolders: ReturnType +): { relativePath: string; workspaceFolderUri: URI | undefined } => { + if (!sortedFolders.length) { + return { relativePath: uri.fsPath, workspaceFolderUri: undefined }; } - // Sort workspace folders by path length (descending) to match the most specific folder first - const sortedFolders = [...workspaceFolders].sort((a, b) => - b.uri.fsPath.length - a.uri.fsPath.length - ); - - // Add trailing slash to paths for exact matching const uriPath = uri.fsPath.endsWith('/') ? uri.fsPath : uri.fsPath + '/'; - // Check if the URI is inside any workspace folder for (const folder of sortedFolders) { + const folderFsPath: string = folder.uri.fsPath; + const folderPath = folderFsPath.endsWith('/') ? folderFsPath : folderFsPath + '/'; - - const folderPath = folder.uri.fsPath.endsWith('/') ? folder.uri.fsPath : folder.uri.fsPath + '/'; if (uriPath.startsWith(folderPath)) { - // Calculate the relative path by removing the workspace folder path - let relativePath = uri.fsPath.slice(folder.uri.fsPath.length); - // Remove leading slash if present - if (relativePath.startsWith('/')) { - relativePath = relativePath.slice(1); - } - // console.log({ folderPath, relativePath, uriPath }); - - return relativePath; + let relativePath = uri.fsPath.slice(folderFsPath.length); + if (relativePath.startsWith('/')) relativePath = relativePath.slice(1); + return { relativePath, workspaceFolderUri: folder.uri }; } } - // URI is not in any workspace folder, return original path - return uri.fsPath; -} - + return { relativePath: uri.fsPath, workspaceFolderUri: undefined }; +}; +function getRelativeWorkspacePath(accessor: ReturnType, uri: URI): string { + const sortedFolders = getSortedWorkspaceFolders(accessor); + return getWorkspacePathInfo(uri, sortedFolders).relativePath; +} const numOptionsToShow = 100 - - // TODO make this unique based on other options const getAbbreviatedName = (relativePath: string) => { return getBasename(relativePath, 1) @@ -189,10 +239,7 @@ const getAbbreviatedName = (relativePath: string) => { const getOptionsAtPath = async (accessor: ReturnType, path: string[], optionText: string): Promise => { const toolsService = accessor.get('IToolsService') - - - - const searchForFilesOrFolders = async (t: string, searchFor: 'files' | 'folders') => { + const searchForFilesOrFolders = async (t: string, searchFor: 'files' | 'folders'): Promise => { try { const searchResults = (await (await toolsService.callTool.search_pathnames_only({ @@ -200,10 +247,11 @@ const getOptionsAtPath = async (accessor: ReturnType, path: includePattern: null, pageNumber: 1, })).result).uris + const sortedFolders = getSortedWorkspaceFolders(accessor); if (searchFor === 'files') { const res: Option[] = searchResults.map(uri => { - const relativePath = getRelativeWorkspacePath(accessor, uri) + const relativePath = getWorkspacePathInfo(uri, sortedFolders).relativePath return { leafNodeType: 'File', uri: uri, @@ -222,60 +270,35 @@ const getOptionsAtPath = async (accessor: ReturnType, path: for (const uri of searchResults) { if (!uri) continue; - // Get the full path and extract directories - const relativePath = getRelativeWorkspacePath(accessor, uri) + const { relativePath, workspaceFolderUri } = getWorkspacePathInfo(uri, sortedFolders); const pathParts = relativePath.split('/'); - // Get workspace info - const workspaceService = accessor.get('IWorkspaceContextService'); - const workspaceFolders = workspaceService.getWorkspace().folders; - - // Find the workspace folder containing this URI - let workspaceFolderUri: URI | undefined; - if (workspaceFolders.length) { - // Sort workspace folders by path length (descending) to match the most specific folder first - const sortedFolders = [...workspaceFolders].sort((a, b) => - b.uri.fsPath.length - a.uri.fsPath.length - ); - - // Find the containing workspace folder - for (const folder of sortedFolders) { - const folderPath = folder.uri.fsPath.endsWith('/') ? folder.uri.fsPath : folder.uri.fsPath + '/'; - const uriPath = uri.fsPath.endsWith('/') ? uri.fsPath : uri.fsPath + '/'; - - if (uriPath.startsWith(folderPath)) { - workspaceFolderUri = folder.uri; - break; - } - } - } - if (workspaceFolderUri) { // Add each directory and its parents to the map let currentPath = ''; for (let i = 0; i < pathParts.length - 1; i++) { currentPath = i === 0 ? `/${pathParts[i]}` : `${currentPath}/${pathParts[i]}`; - - // Create a proper directory URI const directoryUri = URI.joinPath( workspaceFolderUri, currentPath.startsWith('/') ? currentPath.substring(1) : currentPath ); - directoryMap.set(currentPath, directoryUri); } } } - // Convert map to array + return Array.from(directoryMap.entries()).map(([relativePath, uri]) => ({ leafNodeType: 'Folder', - uri: uri, - iconInMenu: Folder, // Folder + uri, + iconInMenu: Folder, fullName: relativePath, abbreviatedName: getAbbreviatedName(relativePath), })) satisfies Option[]; } + + // Fallback (should not reach due to narrow type), but keep TS happy + return [] } catch (error) { console.error('Error fetching directories:', error); return []; @@ -314,7 +337,6 @@ const getOptionsAtPath = async (accessor: ReturnType, path: } - if (generateNextOptionsAtPath) { nextOptionsAtPath = await generateNextOptionsAtPath(optionText) @@ -325,21 +347,25 @@ const getOptionsAtPath = async (accessor: ReturnType, path: nextOptionsAtPath = [...foldersResults, ...filesResults,] } + const qLower = optionText.trim().toLowerCase(); + const optionsAtPath = nextOptionsAtPath - .filter(o => isSubsequence(o.fullName, optionText)) - .sort((a, b) => { // this is a hack but good for now - const scoreA = scoreSubsequence(a.fullName, optionText); - const scoreB = scoreSubsequence(b.fullName, optionText); - return scoreB - scoreA; + .map(o => { + const fullLower = o.fullName.toLowerCase(); + return { + o, + fullLower, + score: scoreSubsequenceLower(fullLower, qLower), + }; }) - .slice(0, numOptionsToShow) // should go last because sorting/filtering should happen on all datapoints - - return optionsAtPath + .filter(x => isSubsequence(x.fullLower, qLower)) + .sort((a, b) => b.score - a.score) + .slice(0, numOptionsToShow) + .map(x => x.o); + return optionsAtPath; } - - export type TextAreaFns = { setValue: (v: string) => void, enable: () => void, disable: () => void } type InputBox2Props = { initValue?: string | null; @@ -354,9 +380,8 @@ type InputBox2Props = { onBlur?: (e: React.FocusEvent) => void; onChangeHeight?: (newHeight: number) => void; } -export const VoidInputBox2 = forwardRef(function X({ initValue, placeholder, multiline, enableAtToMention, fnsRef, className, onKeyDown, onFocus, onBlur, onChangeText }, ref) { - +export const VoidInputBox2 = forwardRef(function X({ initValue, placeholder, multiline, enableAtToMention, fnsRef, className, onKeyDown, onFocus, onBlur, onChangeText }, ref) { // mirrors whatever is in ref const accessor = useAccessor() @@ -446,8 +471,6 @@ export const VoidInputBox2 = forwardRef(fun chatThreadService.addNewStagingSelection(newSelection) } else { - - currentPathRef.current = JSON.stringify(newPath); const newOpts = await getOptionsAtPath(accessor, newPath, '') || [] if (currentPathRef.current !== JSON.stringify(newPath)) { return; } @@ -669,6 +692,7 @@ export const VoidInputBox2 = forwardRef(fun whileElementsMounted: autoUpdate, strategy: 'fixed', }); + useEffect(() => { if (!isMenuOpen) return; @@ -692,8 +716,6 @@ export const VoidInputBox2 = forwardRef(fun document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [isMenuOpen, refs.floating, refs.reference]); - // logic for @ to mention ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - const [isEnabled, setEnabled] = useState(true) @@ -703,14 +725,15 @@ export const VoidInputBox2 = forwardRef(fun r.style.height = 'auto' // set to auto to reset height, then set to new height - if (r.scrollHeight === 0) return requestAnimationFrame(adjustHeight) + if (r.scrollHeight === 0) { + requestAnimationFrame(adjustHeight) + return + } const h = r.scrollHeight const newHeight = Math.min(h + 1, 500) // plus one to avoid scrollbar appearing when it shouldn't r.style.height = `${newHeight}px` }, []); - - const fns: TextAreaFns = useMemo(() => ({ setValue: (val) => { const r = textAreaRef.current @@ -723,16 +746,11 @@ export const VoidInputBox2 = forwardRef(fun disable: () => { setEnabled(false) }, }), [onChangeText, adjustHeight]) - - useEffect(() => { if (initValue) fns.setValue(initValue) }, [initValue]) - - - return <>