diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 9c1e1e0e87a8f..2ea27460de982 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -39,28 +39,28 @@ ], "policies": [ { - "key": "chat.mcp.gallery.serviceUrl", - "name": "McpGalleryServiceUrl", - "category": "InteractiveSession", - "minimumVersion": "1.101", + "key": "extensions.gallery.serviceUrl", + "name": "ExtensionGalleryServiceUrl", + "category": "Extensions", + "minimumVersion": "1.99", "localization": { "description": { - "key": "mcp.gallery.serviceUrl", - "value": "Configure the MCP Gallery service URL to connect to" + "key": "extensions.gallery.serviceUrl", + "value": "Configure the Marketplace service URL to connect to" } }, "type": "string", "default": "" }, { - "key": "extensions.gallery.serviceUrl", - "name": "ExtensionGalleryServiceUrl", - "category": "Extensions", - "minimumVersion": "1.99", + "key": "chat.mcp.gallery.serviceUrl", + "name": "McpGalleryServiceUrl", + "category": "InteractiveSession", + "minimumVersion": "1.101", "localization": { "description": { - "key": "extensions.gallery.serviceUrl", - "value": "Configure the Marketplace service URL to connect to" + "key": "mcp.gallery.serviceUrl", + "value": "Configure the MCP Gallery service URL to connect to" } }, "type": "string", @@ -286,6 +286,20 @@ }, "type": "boolean", "default": true + }, + { + "key": "workbench.browser.enableChatTools", + "name": "BrowserChatTools", + "category": "InteractiveSession", + "minimumVersion": "1.110", + "localization": { + "description": { + "key": "browser.enableChatTools", + "value": "When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser." + } + }, + "type": "boolean", + "default": false } ] } diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index c1bc21f212125..14e1bb6cb1020 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -17,6 +17,10 @@ --shadow-button-active: inset 0 1px 2px rgba(0, 0, 0, 0.1); --shadow-active-tab: 0 8px 12px rgba(0, 0, 0, 0.02); + /* Panel depth shadows cast onto the editor surface */ + --shadow-depth-x: 5px 0 10px -4px rgba(0, 0, 0, 0.05); + --shadow-depth-y: 0 5px 10px -4px rgba(0, 0, 0, 0.04); + --backdrop-blur-md: blur(20px) saturate(180%); --backdrop-blur-lg: blur(40px) saturate(180%); } @@ -25,16 +29,17 @@ .monaco-workbench.vs-dark { --backdrop-blur-md: blur(20px) saturate(180%) brightness(0.55); --backdrop-blur-lg: blur(40px) saturate(180%) brightness(0.55); -} - -/* Stealth Shadows - shadow-based depth for UI elements, controlled by workbench.stealthShadows.enabled */ -/* Activity Bar */ -.monaco-workbench.vs .part.activitybar { - z-index: 50; - position: relative; + --shadow-depth-x: 5px 0 12px -4px rgba(0, 0, 0, 0.14); + --shadow-depth-y: 0 5px 12px -4px rgba(0, 0, 0, 0.10); } +/* Stealth Shadows - panels appear to float above the editor. + * Instead of z-index on panels (which breaks webviews, iframes, sashes), + * the editor draws its own "received shadow" via a ::after pseudo-element. + * The surrounding panels stay at default stacking — no z-index needed. */ + +/* Activity Bar - only needs shadow when sidebar is hidden */ .monaco-workbench.nosidebar .part.activitybar { box-shadow: var(--shadow-md); } @@ -43,94 +48,61 @@ box-shadow: var(--shadow-md); } -/* Sidebar */ -.monaco-workbench.vs .part.sidebar { - box-shadow: var(--shadow-md); - z-index: 40; - position: relative; -} - -.monaco-workbench.sidebar-right.vs .part.sidebar { - box-shadow: var(--shadow-md); -} - -.monaco-workbench.vs .part.auxiliarybar { - box-shadow: var(--shadow-md); - z-index: 35; - position: relative; -} - -/* Ensure iframe containers in pane-body render above sidebar z-index */ -.monaco-workbench.vs > div[data-keybinding-context] { - z-index: 50 !important; -} - -/* Ensure in-editor pane iframes render below sidebar z-index */ -.monaco-workbench.vs > div[data-parent-flow-to-element-id] { - z-index: 0 !important; -} - - -/* Ensure webview containers render above sidebar z-index */ -.monaco-workbench.vs .part.sidebar .webview, -.monaco-workbench.vs .part.sidebar .webview-container, -.monaco-workbench.vs .part.auxiliarybar .webview, -.monaco-workbench.vs .part.auxiliarybar .webview-container { - position: relative; - z-index: 50; - transform: translateZ(0); -} - -/* Panel */ -.monaco-workbench.vs .part.panel { - box-shadow: var(--shadow-md); - position: relative; -} - -.monaco-workbench.panel-position-left.vs .part.panel { - box-shadow: var(--shadow-md); -} - -.monaco-workbench.panel-position-right.vs .part.panel { - box-shadow: var(--shadow-md); -} - .monaco-pane-view .split-view-view:first-of-type > .pane > .pane-header { border-top: 1px solid var(--vscode-sideBarSectionHeader-border) !important; } -/* Sashes - ensure they extend full height and are above other panels */ -.monaco-workbench.vs .monaco-sash { - z-index: 35; +/* Editor - the ::after pseudo-element draws inset shadows on each edge, + * creating the illusion that sidebar, panel, and auxiliarybar float above it. */ +.monaco-workbench.vs .part.editor { + position: relative; } -.monaco-workbench.vs .monaco-sash.vertical { - z-index: 40; +.monaco-workbench.vs .part.editor::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 10; + box-shadow: + inset var(--shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04), + inset 0 calc(-1 * 5px) 10px -4px rgba(0, 0, 0, 0.05); } -.monaco-workbench.vs .monaco-sash.vertical:nth-child(2) { - z-index: 45; +/* When sidebar is on the right, flip the stronger shadow to the right edge */ +.monaco-workbench.sidebar-right.vs .part.editor::after { + box-shadow: + inset 5px 0 10px -4px rgba(0, 0, 0, 0.04), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05), + inset 0 calc(-1 * 5px) 10px -4px rgba(0, 0, 0, 0.05); } -.monaco-workbench.vs .monaco-sash.horizontal { - z-index: 35; +/* Panel positions: strengthen the shadow on whichever edge faces the panel */ +.monaco-workbench.panel-position-left.vs .part.editor::after { + box-shadow: + inset var(--shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04); } -/* Editor */ -.monaco-workbench.vs .part.editor { - position: relative; +.monaco-workbench.panel-position-right.vs .part.editor::after { + box-shadow: + inset 5px 0 10px -4px rgba(0, 0, 0, 0.04), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.05); } +.monaco-workbench.panel-position-top.vs .part.editor::after { + box-shadow: + inset var(--shadow-depth-x), + inset calc(-1 * 5px) 0 10px -4px rgba(0, 0, 0, 0.04), + inset 0 var(--shadow-depth-y); +} .monaco-workbench.vs .part.editor > .content .editor-group-container > .title { box-shadow: none; - position: relative; - z-index: 10; } .monaco-workbench.vs .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: inset var(--shadow-active-tab); - position: relative; - z-index: 5; } .monaco-workbench.vs .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { @@ -636,10 +608,6 @@ /* Notebook */ -.monaco-workbench .notebookOverlay.notebook-editor { - z-index: 35 !important; -} - .monaco-workbench .notebookOverlay .monaco-list-row .cell-editor-part:before { box-shadow: inset var(--shadow-sm); border-radius: var(--radius-md); diff --git a/extensions/theme-seti/build/update-icon-theme.js b/extensions/theme-seti/build/update-icon-theme.js index 366e7f37dd673..d4da96611081b 100644 --- a/extensions/theme-seti/build/update-icon-theme.js +++ b/extensions/theme-seti/build/update-icon-theme.js @@ -47,7 +47,11 @@ const inheritIconFromLanguage = { "jsonl": 'json', "postcss": 'css', "django-html": 'html', - "blade": 'php' + "blade": 'php', + "prompt": 'markdown', + "instructions": 'markdown', + "chatagent": 'markdown', + "skill": 'markdown' }; const ignoreExtAssociation = { diff --git a/package.json b/package.json index f8a9d07778bbb..f0de36f12a677 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "1b8e5b3448f8be12228bc9bee56b42a5f95158e7", + "distro": "2d57806cfc4400f602f114b4bfc0fb17ce7e1a32", "author": { "name": "Microsoft Corporation" }, @@ -249,4 +249,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} +} \ No newline at end of file diff --git a/src/vs/code/electron-browser/workbench/workbench-dev.html b/src/vs/code/electron-browser/workbench/workbench-dev.html index 13ff778a58cdf..8ccafe7816e1f 100644 --- a/src/vs/code/electron-browser/workbench/workbench-dev.html +++ b/src/vs/code/electron-browser/workbench/workbench-dev.html @@ -65,6 +65,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/code/electron-browser/workbench/workbench.html b/src/vs/code/electron-browser/workbench/workbench.html index dda0dd75b77e4..ce51984cd542d 100644 --- a/src/vs/code/electron-browser/workbench/workbench.html +++ b/src/vs/code/electron-browser/workbench/workbench.html @@ -63,6 +63,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 4330686d2e3de..a871c7a2b8fb0 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -787,7 +787,8 @@ export class ActionList extends Disposable { availableHeight = widgetTop > 0 ? windowHeight - widgetTop - padding : windowHeight * 0.7; } - const maxHeight = Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight); + const viewportMaxHeight = Math.floor(targetWindow.innerHeight * 0.4); + const maxHeight = Math.min(Math.max(availableHeight, this._actionLineHeight * 3 + filterHeight), viewportMaxHeight); const height = Math.min(listHeight + filterHeight, maxHeight); return height - filterHeight; } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index db714398cdaad..fb796867b8bc2 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatDebug: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatDebug.d.ts', - version: 1 + version: 2 }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 9dceeac3a2b24..604626bc35ced 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -1,94 +1,73 @@ # AI Customizations – Design Document -This document describes the current AI customization experience in this branch: a management editor and tree view that surface items across worktree, user, and extension storage. +This document describes the AI customization experience: a management editor and tree view that surface customization items (agents, skills, instructions, prompts, hooks, MCP servers) across workspace, user, and extension storage. -## Current Architecture +## Architecture -### File Structure (Agentic) +### File Structure + +The management editor lives in `vs/workbench` (shared between core VS Code and sessions): ``` -src/vs/sessions/contrib/aiCustomizationManagement/browser/ +src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagement.contribution.ts # Commands + context menus ├── aiCustomizationManagement.ts # IDs + context keys ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list -├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) +├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl ├── customizationCreatorService.ts # AI-guided creation flow ├── mcpListWidget.ts # MCP servers section -├── SPEC.md # Feature specification +├── aiCustomizationIcons.ts # Icons └── media/ └── aiCustomizationManagement.css +src/vs/workbench/contrib/chat/common/ +└── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService interface +``` + +The tree view and overview live in `vs/sessions` (sessions window only): + +``` src/vs/sessions/contrib/aiCustomizationTreeView/browser/ ├── aiCustomizationTreeView.contribution.ts # View + actions ├── aiCustomizationTreeView.ts # IDs + menu IDs ├── aiCustomizationTreeViewViews.ts # Tree data source + view -├── aiCustomizationTreeViewIcons.ts # Icons -├── SPEC.md # Feature specification +├── aiCustomizationOverviewView.ts # Overview view (counts + deep links) └── media/ └── aiCustomizationTreeView.css ``` ---- - -## Service Alignment (Required) - -AI customizations must lean on existing VS Code services with well-defined interfaces. This avoids duplicated parsing logic, keeps discovery consistent across the workbench, and ensures prompt/hook behavior stays authoritative. - -Browser compatibility is required. Do not use Node.js APIs; rely on VS Code services that work in browser contexts. - -Key services to rely on: -- Prompt discovery, parsing, and lifecycle: [src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts](../workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) -- Active session scoping for worktree filtering: [src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts](../workbench/contrib/chat/browser/agentSessions/agentSessionsService.ts) -- MCP servers and tool access: [src/vs/workbench/contrib/mcp/common/mcpService.ts](../workbench/contrib/mcp/common/mcpService.ts) -- MCP management and gallery: [src/vs/platform/mcp/common/mcpManagement.ts](../platform/mcp/common/mcpManagement.ts) -- Chat models and session state: [src/vs/workbench/contrib/chat/common/chatService/chatService.ts](../workbench/contrib/chat/common/chatService/chatService.ts) -- File and model plumbing: [src/vs/platform/files/common/files.ts](../platform/files/common/files.ts), [src/vs/editor/common/services/resolverService.ts](../editor/common/services/resolverService.ts) +Sessions-specific overrides: -The active worktree comes from `IActiveSessionService` and is the source of truth for any workspace/worktree scoping. - -In the agentic workbench, prompt discovery is scoped by an agentic prompt service override that uses the active session root for workspace folders. See [src/vs/sessions/contrib/chat/browser/promptsService.ts](contrib/chat/browser/promptsService.ts). - -## Implemented Experience - -### Management Editor (Current) - -- A singleton editor surfaces Agents, Skills, Instructions, Prompts, Hooks, MCP Servers, and Models. -- Prompts-based sections use a grouped list (Worktree/User/Extensions) with search, context menus, and an embedded editor. -- Embedded editor uses a full `CodeEditorWidget` and auto-commits worktree files on exit (agent session workflow). -- Creation supports manual or AI-guided flows; AI-guided creation opens a new chat with hidden system instructions. - -### Tree View (Current) - -- Unified sidebar tree with Type -> Storage -> File hierarchy. -- Auto-expands categories to reveal storage groups. -- Context menus provide Open and Run Prompt. -- Creation actions are centralized in the management editor. - -### Additional Surfaces (Current) +``` +src/vs/sessions/contrib/chat/browser/ +└── aiCustomizationWorkspaceService.ts # Sessions workspace service override +src/vs/sessions/contrib/sessions/browser/ +├── customizationCounts.ts # Source count utilities +└── customizationsToolbar.contribution.ts # Sidebar customization links +``` -- Overview view provides counts and deep-links into the management editor. -- Management list groups by storage with empty states, git status, and path copy actions. +### IAICustomizationWorkspaceService ---- +The `IAICustomizationWorkspaceService` interface controls per-window behavior: -## AI Feature Gating +| Property | Core VS Code | Sessions Window | +|----------|-------------|----------| +| `managementSections` | All sections except Models | Same | +| `visibleStorageSources` | workspace, user, extension, plugin | workspace, user only | +| `preferManualCreation` | `false` (opens file externally) | `true` (embedded editor) | +| `activeProjectRoot` | First workspace folder | Active session worktree | -All commands and UI must respect `ChatContextKeys.enabled`: +## Key Services -```typescript -All entry points (view contributions, commands) respect `ChatContextKeys.enabled`. -``` +- **Prompt discovery**: `IPromptsService` — parsing, lifecycle, storage enumeration +- **MCP servers**: `IMcpService` — server list, tool access +- **Active worktree**: `IActiveSessionService` — source of truth for workspace scoping (sessions only) +- **File operations**: `IFileService`, `ITextModelService` — file and model plumbing ---- +Browser compatibility is required — no Node.js APIs. -## References +## Feature Gating -- [Settings Editor](../src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts) -- [Keybindings Editor](../src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts) -- [Webview Editor](../src/vs/workbench/contrib/webviewPanel/browser/webviewEditorInput.ts) -- [AI Customization Management (agentic)](../src/vs/sessions/contrib/aiCustomizationManagement/browser/) -- [AI Customization Overview View](../src/vs/sessions/contrib/aiCustomizationManagement/browser/aiCustomizationOverviewView.ts) -- [AI Customization Tree View (agentic)](../src/vs/sessions/contrib/aiCustomizationTreeView/browser/) -- [IPromptsService](../src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts) +All commands and UI respect `ChatContextKeys.enabled` and the `chat.customizationsMenu.enabled` setting. diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 4853d21aaf6e6..707a1090912f3 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -243,7 +243,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements this._hide(); }, 0); })); - this._store.add(this._editor.onDidFocusEditorWidget(() => this._onSelectionChanged())); + this._store.add(this._editor.onDidFocusEditorText(() => this._onSelectionChanged())); } private _isWidgetTarget(target: EventTarget | Element | null): boolean { @@ -266,7 +266,7 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements } private _onSelectionChanged(): void { - if (this._mouseDown || !this._editor.hasWidgetFocus()) { + if (this._mouseDown || !this._editor.hasTextFocus()) { return; } @@ -331,6 +331,12 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements return; } + // Only steal focus when the editor text area itself is focused, + // not when an overlay widget (e.g. find widget) has focus + if (!this._editor.hasTextFocus()) { + return; + } + // Don't focus if a modifier key is pressed alone if (e.keyCode === KeyCode.Ctrl || e.keyCode === KeyCode.Shift || e.keyCode === KeyCode.Alt || e.keyCode === KeyCode.Meta) { return; diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index d2e6fcf1933d6..ef3874912b6f7 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -27,7 +27,7 @@ import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/ import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IPromptsService, PromptsStorage, IAgentSkill, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { agentIcon, extensionIcon, instructionsIcon, promptIcon, skillIcon, userIcon, workspaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, extensionIcon, instructionsIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; import { IAsyncDataSource, ITreeNode, ITreeRenderer, ITreeContextMenuEvent } from '../../../../base/browser/ui/tree/tree.js'; import { FuzzyScore } from '../../../../base/common/filters.js'; @@ -387,18 +387,21 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource = { [PromptsStorage.local]: workspaceIcon, [PromptsStorage.user]: userIcon, [PromptsStorage.extension]: extensionIcon, + [PromptsStorage.plugin]: pluginIcon, }; const storageSuffixes: Record = { [PromptsStorage.local]: 'workspace', [PromptsStorage.user]: 'user', [PromptsStorage.extension]: 'extensions', + [PromptsStorage.plugin]: 'plugins', }; return { diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 32d2236832b09..02b43d147dbbb 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -6,6 +6,7 @@ import { derived, IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ISessionsManagementService } from '../../sessions/browser/sessionsManagementService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { CustomizationCreatorService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.js'; @@ -43,7 +44,11 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization AICustomizationManagementSection.Prompts, AICustomizationManagementSection.Hooks, AICustomizationManagementSection.McpServers, - AICustomizationManagementSection.Models, + ]; + + readonly visibleStorageSources: readonly PromptsStorage[] = [ + PromptsStorage.local, + PromptsStorage.user, ]; readonly preferManualCreation = true; diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 56cb11e5e1e5e..b23633e013fdb 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -38,11 +38,9 @@ } /* Editor */ +/* Height constraints are driven by MIN_EDITOR_HEIGHT / MAX_EDITOR_HEIGHT in newChatViewPane.ts */ .sessions-chat-editor { padding: 0 6px 6px 6px; - height: 50px; - min-height: 50px; - max-height: 200px; flex-shrink: 1; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index b50e9249afe1d..9d9c6c67b25b6 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -66,6 +66,8 @@ import { getErrorMessage } from '../../../../base/common/errors.js'; import { SlashCommandHandler } from './slashCommands.js'; const STORAGE_KEY_LAST_MODEL = 'sessions.selectedModel'; +const MIN_EDITOR_HEIGHT = 50; +const MAX_EDITOR_HEIGHT = 200; // #region --- Chat Welcome Widget --- @@ -376,6 +378,7 @@ class NewChatWidget extends Disposable { private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { const editorContainer = this._editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); + editorContainer.style.height = `${MIN_EDITOR_HEIGHT}px`; const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); const textModel = this._register(this.modelService.createModel('', null, uri, true)); @@ -436,9 +439,17 @@ class NewChatWidget extends Disposable { } })); - this._register(this._editor.onDidContentSizeChange(() => { + let previousHeight = -1; + this._register(this._editor.onDidContentSizeChange(e => { + if (!e.contentHeightChanged) { + return; + } const contentHeight = this._editor.getContentHeight(); - const clampedHeight = Math.min(200, Math.max(50, contentHeight)); + const clampedHeight = Math.min(MAX_EDITOR_HEIGHT, Math.max(MIN_EDITOR_HEIGHT, contentHeight)); + if (clampedHeight === previousHeight) { + return; + } + previousHeight = clampedHeight; this._editorContainer.style.height = `${clampedHeight}px`; this._editor.layout(); })); diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts index 401961417c0be..361c7e736a330 100644 --- a/src/vs/sessions/contrib/chat/browser/slashCommands.ts +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -126,13 +126,6 @@ export class SlashCommandHandler extends Disposable { executeImmediately: true, execute: openSection(AICustomizationManagementSection.McpServers), }); - this._slashCommands.push({ - command: 'models', - detail: localize('slashCommand.models', "View and manage models"), - sortText: 'z3_models', - executeImmediately: true, - execute: openSection(AICustomizationManagementSection.Models), - }); } private _registerDecorations(): void { diff --git a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts index dd874c3e86c71..5b528d0d4cc1f 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationCounts.ts @@ -8,14 +8,29 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn import { IPromptsService, PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; + export interface ISourceCounts { readonly workspace: number; readonly user: number; readonly extension: number; } -export function getSourceCountsTotal(counts: ISourceCounts): number { - return counts.workspace + counts.user + counts.extension; +const storageToCountKey: Partial> = { + [PromptsStorage.local]: 'workspace', + [PromptsStorage.user]: 'user', + [PromptsStorage.extension]: 'extension', +}; + +export function getSourceCountsTotal(counts: ISourceCounts, workspaceService: IAICustomizationWorkspaceService): number { + let total = 0; + for (const storage of workspaceService.visibleStorageSources) { + const key = storageToCountKey[storage]; + if (key) { + total += counts[key]; + } + } + return total; } export async function getPromptSourceCounts(promptsService: IPromptsService, promptType: PromptsType): Promise { @@ -43,7 +58,7 @@ export async function getSkillSourceCounts(promptsService: IPromptsService): Pro }; } -export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService): Promise { +export async function getCustomizationTotalCount(promptsService: IPromptsService, mcpService: IMcpService, workspaceService: IAICustomizationWorkspaceService): Promise { const [agentCounts, skillCounts, instructionCounts, promptCounts, hookCounts] = await Promise.all([ getPromptSourceCounts(promptsService, PromptsType.agent), getSkillSourceCounts(promptsService), @@ -52,10 +67,10 @@ export async function getCustomizationTotalCount(promptsService: IPromptsService getPromptSourceCounts(promptsService, PromptsType.hook), ]); - return getSourceCountsTotal(agentCounts) - + getSourceCountsTotal(skillCounts) - + getSourceCountsTotal(instructionCounts) - + getSourceCountsTotal(promptCounts) - + getSourceCountsTotal(hookCounts) + return getSourceCountsTotal(agentCounts, workspaceService) + + getSourceCountsTotal(skillCounts, workspaceService) + + getSourceCountsTotal(instructionCounts, workspaceService) + + getSourceCountsTotal(promptCounts, workspaceService) + + getSourceCountsTotal(hookCounts, workspaceService) + mcpService.servers.get().length; } diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index f2e6d9e45e71b..041463a5977bc 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -21,7 +21,7 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { Menus } from '../../../browser/menus.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon, extensionIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, workspaceIcon, userIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../base/common/actions.js'; import { $, append } from '../../../../base/browser/dom.js'; @@ -32,6 +32,7 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { getPromptSourceCounts, getSkillSourceCounts, getSourceCountsTotal, ISourceCounts } from './customizationCounts.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; interface ICustomizationItemConfig { readonly id: string; @@ -85,13 +86,6 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ section: AICustomizationManagementSection.McpServers, getCount: (_lm, mcp) => Promise.resolve(mcp.servers.get().length), }, - { - id: 'sessions.customization.models', - label: localize('models', "Models"), - icon: Codicon.vm, - section: AICustomizationManagementSection.Models, - getCount: (lm) => Promise.resolve(lm.getLanguageModelIds().length), - }, ]; /** @@ -113,6 +107,7 @@ class CustomizationLinkViewItem extends ActionViewItem { @IMcpService private readonly _mcpService: IMcpService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly _activeSessionService: ISessionsManagementService, + @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, ) { super(undefined, action, { ...options, icon: false, label: false }); this._viewItemDisposables = this._register(new DisposableStore()); @@ -182,7 +177,7 @@ class CustomizationLinkViewItem extends ActionViewItem { private _renderSourceCounts(container: HTMLElement, counts: ISourceCounts): void { container.textContent = ''; - const total = getSourceCountsTotal(counts); + const total = getSourceCountsTotal(counts, this._workspaceService); container.classList.toggle('hidden', total === 0); if (total === 0) { return; @@ -191,7 +186,6 @@ class CustomizationLinkViewItem extends ActionViewItem { const sources: { count: number; icon: ThemeIcon; title: string }[] = [ { count: counts.workspace, icon: workspaceIcon, title: localize('workspaceCount', "{0} from workspace", counts.workspace) }, { count: counts.user, icon: userIcon, title: localize('userCount', "{0} from user", counts.user) }, - { count: counts.extension, icon: extensionIcon, title: localize('extensionCount', "{0} from extensions", counts.extension) }, ]; for (const source of sources) { diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 06d4a6c566eea..b8ec6ff4e1dad 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -30,6 +30,7 @@ import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbenc import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ISessionsManagementService } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -77,6 +78,7 @@ export class AgenticSessionsViewPane extends ViewPane { @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IHostService private readonly hostService: IHostService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); } @@ -231,7 +233,7 @@ export class AgenticSessionsViewPane extends ViewPane { let updateCountRequestId = 0; const updateHeaderTotalCount = async () => { const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService); + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService); if (requestId !== updateCountRequestId) { return; } diff --git a/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css b/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css new file mode 100644 index 0000000000000..9883b93456e88 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/media/welcomeOverlay.css @@ -0,0 +1,103 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* ---- Welcome Overlay (blocks the sessions window) ---- */ + +.sessions-welcome-overlay { + position: absolute; + inset: 0; + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + background: var(--vscode-editor-background); + opacity: 1; + transition: opacity 200ms ease-out; +} + +.sessions-welcome-overlay.sessions-welcome-overlay-dismissed { + opacity: 0; + pointer-events: none; +} + +/* ---- Card (borderless, centered content) ---- */ + +.sessions-welcome-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + text-align: center; +} + +/* ---- Header ---- */ + +.sessions-welcome-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.sessions-welcome-header .sessions-welcome-icon .codicon { + font-size: 96px; + color: var(--vscode-descriptionForeground); +} + +.sessions-welcome-header h2 { + margin: 0; + font-size: 22px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.sessions-welcome-header .sessions-welcome-subtitle { + margin: 0; + font-size: 14px; + color: var(--vscode-descriptionForeground); +} + +/* ---- Action Area ---- */ + +.sessions-welcome-action-area { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + width: 320px; +} + +.sessions-welcome-action-area .monaco-button { + width: 100%; + padding: 10px 16px; + font-size: 14px; +} + +/* ---- Spinner ---- */ + +.sessions-welcome-spinner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 13px; + color: var(--vscode-descriptionForeground); +} + +.sessions-welcome-spinner .codicon-loading { + animation: sessions-spin 1.5s linear infinite; +} + +@keyframes sessions-spin { + to { transform: rotate(360deg); } +} + +/* ---- Error ---- */ + +.sessions-welcome-error { + margin: 0; + font-size: 12px; + color: var(--vscode-errorForeground); +} diff --git a/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts new file mode 100644 index 0000000000000..2addf1c60a961 --- /dev/null +++ b/src/vs/sessions/contrib/welcome/browser/welcome.contribution.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/welcomeOverlay.css'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { $, append } from '../../../../base/browser/dom.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { IExtensionService } from '../../../../workbench/services/extensions/common/extensions.js'; +import { ChatEntitlement, ChatEntitlementService, IChatEntitlementService } from '../../../../workbench/services/chat/common/chatEntitlementService.js'; +import { CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +const WELCOME_COMPLETE_KEY = 'workbench.agentsession.welcomeComplete'; + +class SessionsWelcomeOverlay extends Disposable { + + private readonly overlay: HTMLElement; + + constructor( + container: HTMLElement, + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @ICommandService private readonly commandService: ICommandService, + @IExtensionService private readonly extensionService: IExtensionService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this.overlay = append(container, $('.sessions-welcome-overlay')); + this.overlay.setAttribute('role', 'dialog'); + this.overlay.setAttribute('aria-modal', 'true'); + this.overlay.setAttribute('aria-label', localize('welcomeOverlay.aria', "Sign in to use Sessions")); + this._register(toDisposable(() => this.overlay.remove())); + + const card = append(this.overlay, $('.sessions-welcome-card')); + + // Header — large icon + title, centered + const header = append(card, $('.sessions-welcome-header')); + const iconEl = append(header, $('span.sessions-welcome-icon')); + iconEl.appendChild(renderIcon(Codicon.agent)); + append(header, $('h2', undefined, localize('welcomeTitle', "Sign in to use Sessions"))); + append(header, $('p.sessions-welcome-subtitle', undefined, localize('welcomeSubtitle', "Agent-powered development"))); + + // Action area + const actionArea = append(card, $('.sessions-welcome-action-area')); + const actionButton = this._register(new Button(actionArea, { ...defaultButtonStyles })); + actionButton.label = localize('sessions.getStarted', "Get Started"); + + const spinnerContainer = append(actionArea, $('.sessions-welcome-spinner')); + spinnerContainer.style.display = 'none'; + + const errorContainer = append(actionArea, $('p.sessions-welcome-error')); + errorContainer.style.display = 'none'; + + this._register(actionButton.onDidClick(() => this._runSetup(actionButton, spinnerContainer, errorContainer))); + + // Focus the button so the overlay traps keyboard input + actionButton.focus(); + } + + private async _runSetup(button: Button, spinner: HTMLElement, error: HTMLElement): Promise { + button.enabled = false; + error.style.display = 'none'; + + spinner.textContent = ''; + spinner.appendChild(renderIcon(Codicon.loading)); + append(spinner, $('span', undefined, localize('sessions.settingUp', "Setting up…"))); + spinner.style.display = ''; + + try { + const success = await this.commandService.executeCommand(CHAT_SETUP_SUPPORT_ANONYMOUS_ACTION_ID, { + dialogIcon: Codicon.agent, + dialogTitle: this.chatEntitlementService.anonymous ? + localize('sessions.startUsingSessions', "Start using Sessions") : + localize('sessions.signinRequired', "Sign in to use Sessions") + }); + + if (success) { + spinner.textContent = ''; + spinner.appendChild(renderIcon(Codicon.loading)); + append(spinner, $('span', undefined, localize('sessions.restarting', "Completing setup…"))); + + this.logService.info('[sessions welcome] Restarting extension host after setup completion'); + const stopped = await this.extensionService.stopExtensionHosts( + localize('sessionsWelcome.restart', "Completing sessions setup") + ); + if (stopped) { + await this.extensionService.startExtensionHosts(); + } + } else { + button.enabled = true; + spinner.style.display = 'none'; + } + } catch (err) { + this.logService.error('[sessions welcome] Setup failed:', err); + error.textContent = localize('sessions.setupError', "Something went wrong. Please try again."); + error.style.display = ''; + button.enabled = true; + spinner.style.display = 'none'; + } + } + + dismiss(): void { + this.overlay.classList.add('sessions-welcome-overlay-dismissed'); + const handle = setTimeout(() => this.dispose(), 200); + this._register(toDisposable(() => clearTimeout(handle))); + } +} + +class SessionsWelcomeContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsWelcome'; + + private readonly overlayRef = this._register(new MutableDisposable()); + private readonly watcherRef = this._register(new MutableDisposable()); + + constructor( + @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IProductService private readonly productService: IProductService, + @IStorageService private readonly storageService: IStorageService, + ) { + super(); + + if (!this.productService.defaultChatAgent?.chatExtensionId) { + return; + } + + const isFirstLaunch = !this.storageService.getBoolean(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION, false); + if (isFirstLaunch) { + this.showOverlay(); + } else { + this.showOverlayIfNeeded(); + } + } + + private showOverlayIfNeeded(): void { + if (this._needsChatSetup()) { + this.showOverlay(); + } else { + this.watchForRegressions(); + } + } + + private watchForRegressions(): void { + let wasComplete = !this._needsChatSetup(); + this.watcherRef.value = autorun(reader => { + this.chatEntitlementService.sentimentObs.read(reader); + this.chatEntitlementService.entitlementObs.read(reader); + + const needsSetup = this._needsChatSetup(); + if (wasComplete && needsSetup) { + this.showOverlay(); + } + wasComplete = !needsSetup; + }); + } + + private _needsChatSetup(): boolean { + const { sentiment, entitlement } = this.chatEntitlementService; + if ( + !sentiment?.installed || // Extension not installed: run setup to install + sentiment?.disabled || // Extension disabled: run setup to enable + entitlement === ChatEntitlement.Available || // Entitlement available: run setup to sign up + ( + entitlement === ChatEntitlement.Unknown && // Entitlement unknown: run setup to sign in / sign up + !this.chatEntitlementService.anonymous // unless anonymous access is enabled + ) + ) { + return true; + } + + return false; + } + + private showOverlay(): void { + if (this.overlayRef.value) { + return; + } + + this.watcherRef.clear(); + this.overlayRef.value = new DisposableStore(); + + const overlay = this.overlayRef.value.add(this.instantiationService.createInstance( + SessionsWelcomeOverlay, + this.layoutService.mainContainer, + )); + + // When setup completes (observables flip), dismiss and watch again + this.overlayRef.value.add(autorun(reader => { + this.chatEntitlementService.sentimentObs.read(reader); + this.chatEntitlementService.entitlementObs.read(reader); + + if (!this._needsChatSetup()) { + this.storageService.store(WELCOME_COMPLETE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + overlay.dismiss(); + this.overlayRef.clear(); + this.watchForRegressions(); + } + })); + } +} + +registerWorkbenchContribution2(SessionsWelcomeContribution.ID, SessionsWelcomeContribution, WorkbenchPhase.BlockRestore); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'workbench.action.resetSessionsWelcome', + title: localize2('resetSessionsWelcome', "Reset Sessions Welcome"), + category: Categories.Developer, + f1: true, + }); + } + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.remove(WELCOME_COMPLETE_KEY, StorageScope.APPLICATION); + } +}); diff --git a/src/vs/sessions/electron-browser/sessions-dev.html b/src/vs/sessions/electron-browser/sessions-dev.html index 56f1b22575beb..f453fb51b7fe7 100644 --- a/src/vs/sessions/electron-browser/sessions-dev.html +++ b/src/vs/sessions/electron-browser/sessions-dev.html @@ -65,6 +65,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/sessions/electron-browser/sessions.html b/src/vs/sessions/electron-browser/sessions.html index afb0a45e67ec7..de2f45b136e5c 100644 --- a/src/vs/sessions/electron-browser/sessions.html +++ b/src/vs/sessions/electron-browser/sessions.html @@ -63,6 +63,7 @@ tokenizeToString notebookChatEditController richScreenReaderContent + chatDebugTokenizer ; "/> diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 614af22140066..34cffa0159b21 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -212,6 +212,7 @@ import './contrib/configuration/browser/configuration.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; import './contrib/logs/browser/logs.contribution.js'; +import './contrib/welcome/browser/welcome.contribution.js'; //#endregion diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index b1b56427b5be9..a5dfeb3c1d6c0 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2076,6 +2076,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatDebugEventTextContent: extHostTypes.ChatDebugEventTextContent, ChatDebugMessageContentType: extHostTypes.ChatDebugMessageContentType, ChatDebugEventMessageContent: extHostTypes.ChatDebugEventMessageContent, + ChatDebugEventToolCallContent: extHostTypes.ChatDebugEventToolCallContent, + ChatDebugEventModelTurnContent: extHostTypes.ChatDebugEventModelTurnContent, ChatRequestEditorData: extHostTypes.ChatRequestEditorData, ChatRequestNotebookData: extHostTypes.ChatRequestNotebookData, ChatReferenceBinaryData: extHostTypes.ChatReferenceBinaryData, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ec57ef1c9a40b..3fc7572a3a99c 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1470,7 +1470,33 @@ export interface IChatDebugEventMessageContentDto { readonly sections: readonly IChatDebugMessageSectionDto[]; } -export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto; +export interface IChatDebugEventToolCallContentDto { + readonly kind: 'toolCall'; + readonly toolName: string; + readonly result?: 'success' | 'error'; + readonly durationInMillis?: number; + readonly input?: string; + readonly output?: string; +} + +export interface IChatDebugEventModelTurnContentDto { + readonly kind: 'modelTurn'; + readonly requestName: string; + readonly model?: string; + readonly status?: string; + readonly durationInMillis?: number; + readonly timeToFirstTokenInMillis?: number; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cachedTokens?: number; + readonly totalTokens?: number; + readonly errorMessage?: string; + readonly sections?: readonly IChatDebugMessageSectionDto[]; +} + +export type IChatDebugResolvedEventContentDto = IChatDebugEventTextContentDto | IChatDebugEventMessageContentDto | IChatDebugEventToolCallContentDto | IChatDebugEventModelTurnContentDto; export interface ExtHostChatDebugShape { $provideChatDebugLog(handle: number, sessionResource: UriComponents, token: CancellationToken): Promise; diff --git a/src/vs/workbench/api/common/extHostChatDebug.ts b/src/vs/workbench/api/common/extHostChatDebug.ts index bf9b6495b845e..4790d2141a5e0 100644 --- a/src/vs/workbench/api/common/extHostChatDebug.ts +++ b/src/vs/workbench/api/common/extHostChatDebug.ts @@ -252,6 +252,38 @@ export class ExtHostChatDebug extends Disposable implements ExtHostChatDebugShap sections: msg.sections.map(s => ({ name: s.name, content: s.content })), }; } + case 'toolCallContent': { + const tc = result as vscode.ChatDebugEventToolCallContent; + return { + kind: 'toolCall', + toolName: tc.toolName, + result: tc.result === ChatDebugToolCallResult.Success ? 'success' + : tc.result === ChatDebugToolCallResult.Error ? 'error' + : undefined, + durationInMillis: tc.durationInMillis, + input: tc.input, + output: tc.output, + }; + } + case 'modelTurnContent': { + const mt = result as vscode.ChatDebugEventModelTurnContent; + return { + kind: 'modelTurn', + requestName: mt.requestName, + model: mt.model, + status: mt.status, + durationInMillis: mt.durationInMillis, + timeToFirstTokenInMillis: mt.timeToFirstTokenInMillis, + maxInputTokens: mt.maxInputTokens, + maxOutputTokens: mt.maxOutputTokens, + inputTokens: mt.inputTokens, + outputTokens: mt.outputTokens, + cachedTokens: mt.cachedTokens, + totalTokens: mt.totalTokens, + errorMessage: mt.errorMessage, + sections: mt.sections?.map(s => ({ name: s.name, content: s.content })), + }; + } default: return undefined; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 890d4cfc677c6..56f751321425b 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3716,6 +3716,40 @@ export class ChatDebugEventMessageContent { } } +export class ChatDebugEventToolCallContent { + readonly _kind = 'toolCallContent'; + toolName: string; + result?: ChatDebugToolCallResult; + durationInMillis?: number; + input?: string; + output?: string; + + constructor(toolName: string) { + this.toolName = toolName; + } +} + +export class ChatDebugEventModelTurnContent { + readonly _kind = 'modelTurnContent'; + requestName: string; + model?: string; + status?: string; + durationInMillis?: number; + timeToFirstTokenInMillis?: number; + maxInputTokens?: number; + maxOutputTokens?: number; + inputTokens?: number; + outputTokens?: number; + cachedTokens?: number; + totalTokens?: number; + errorMessage?: string; + sections?: ChatDebugMessageSection[]; + + constructor(requestName: string) { + this.requestName = requestName; + } +} + export class ChatSessionChangedFile { constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } } diff --git a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts index 36ccd6f2af856..a97e459fe3c12 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsActions.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsActions.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/notificationsActions.css'; -import { INotificationViewItem } from '../../../common/notifications.js'; +import { INotificationViewItem, NotificationsPosition } from '../../../common/notifications.js'; import { localize } from '../../../../nls.js'; import { Action } from '../../../../base/common/actions.js'; import { CLEAR_NOTIFICATION, EXPAND_NOTIFICATION, COLLAPSE_NOTIFICATION, CLEAR_ALL_NOTIFICATIONS, HIDE_NOTIFICATIONS_CENTER, TOGGLE_DO_NOT_DISTURB_MODE, TOGGLE_DO_NOT_DISTURB_MODE_BY_SOURCE } from './notificationsCommands.js'; @@ -19,11 +19,21 @@ const clearAllIcon = registerIcon('notifications-clear-all', Codicon.clearAll, l export const hideIcon = registerIcon('notifications-hide', Codicon.chevronDown, localize('hideIcon', 'Icon for the hide action in notifications.')); export const hideUpIcon = registerIcon('notifications-hide-up', Codicon.chevronUp, localize('hideUpIcon', 'Icon for the hide action in notifications when positioned at the top.')); const expandIcon = registerIcon('notifications-expand', Codicon.chevronUp, localize('expandIcon', 'Icon for the expand action in notifications.')); +const expandDownIcon = registerIcon('notifications-expand-down', Codicon.chevronDown, localize('expandDownIcon', 'Icon for the expand action in notifications when the notification center is at the top.')); const collapseIcon = registerIcon('notifications-collapse', Codicon.chevronDown, localize('collapseIcon', 'Icon for the collapse action in notifications.')); +const collapseUpIcon = registerIcon('notifications-collapse-up', Codicon.chevronUp, localize('collapseUpIcon', 'Icon for the collapse action in notifications when the notification center is at the top.')); const configureIcon = registerIcon('notifications-configure', Codicon.gear, localize('configureIcon', 'Icon for the configure action in notifications.')); const doNotDisturbIcon = registerIcon('notifications-do-not-disturb', Codicon.bellSlash, localize('doNotDisturbIcon', 'Icon for the mute all action in notifications.')); export const positionIcon = registerIcon('notifications-position', Codicon.arrowSwap, localize('positionIcon', 'Icon for the position action in notifications.')); +export function getNotificationExpandIcon(position: NotificationsPosition): ThemeIcon { + return position === NotificationsPosition.TOP_RIGHT ? expandDownIcon : expandIcon; +} + +export function getNotificationCollapseIcon(position: NotificationsPosition): ThemeIcon { + return position === NotificationsPosition.TOP_RIGHT ? collapseUpIcon : collapseIcon; +} + export class ClearNotificationAction extends Action { static readonly ID = CLEAR_NOTIFICATION; diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index 6512492cae4f4..7c01c6202c395 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -14,8 +14,8 @@ import { ActionRunner, IAction, IActionRunner, Separator, toAction } from '../.. import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { dispose, DisposableStore, Disposable } from '../../../../base/common/lifecycle.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; -import { INotificationViewItem, NotificationViewItem, NotificationViewItemContentChangeKind, INotificationMessage, ChoiceAction } from '../../../common/notifications.js'; -import { ClearNotificationAction, ExpandNotificationAction, CollapseNotificationAction, ConfigureNotificationAction } from './notificationsActions.js'; +import { INotificationViewItem, NotificationViewItem, NotificationViewItemContentChangeKind, INotificationMessage, ChoiceAction, NotificationsSettings, getNotificationsPosition } from '../../../common/notifications.js'; +import { ClearNotificationAction, ExpandNotificationAction, CollapseNotificationAction, ConfigureNotificationAction, getNotificationExpandIcon, getNotificationCollapseIcon } from './notificationsActions.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; import { INotificationService, NotificationsFilter, Severity, isNotificationSource } from '../../../../platform/notification/common/notification.js'; @@ -32,6 +32,7 @@ import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import type { IManagedHover } from '../../../../base/browser/ui/hover/hover.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export class NotificationsListDelegate implements IListVirtualDelegate { @@ -316,6 +317,16 @@ export class NotificationTemplateRenderer extends Disposable { private static expandNotificationAction: ExpandNotificationAction; private static collapseNotificationAction: CollapseNotificationAction; + private static updateExpandCollapseIcons(configurationService: IConfigurationService): void { + if (!NotificationTemplateRenderer.expandNotificationAction) { + return; + } + + const position = getNotificationsPosition(configurationService); + NotificationTemplateRenderer.expandNotificationAction.class = ThemeIcon.asClassName(getNotificationExpandIcon(position)); + NotificationTemplateRenderer.collapseNotificationAction.class = ThemeIcon.asClassName(getNotificationCollapseIcon(position)); + } + private static readonly SEVERITIES = [Severity.Info, Severity.Warning, Severity.Error]; private readonly inputDisposables = this._register(new DisposableStore()); @@ -328,6 +339,7 @@ export class NotificationTemplateRenderer extends Disposable { @IKeybindingService private readonly keybindingService: IKeybindingService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @IHoverService private readonly hoverService: IHoverService, + @IConfigurationService configurationService: IConfigurationService, ) { super(); @@ -335,7 +347,14 @@ export class NotificationTemplateRenderer extends Disposable { NotificationTemplateRenderer.closeNotificationAction = instantiationService.createInstance(ClearNotificationAction, ClearNotificationAction.ID, ClearNotificationAction.LABEL); NotificationTemplateRenderer.expandNotificationAction = instantiationService.createInstance(ExpandNotificationAction, ExpandNotificationAction.ID, ExpandNotificationAction.LABEL); NotificationTemplateRenderer.collapseNotificationAction = instantiationService.createInstance(CollapseNotificationAction, CollapseNotificationAction.ID, CollapseNotificationAction.LABEL); + NotificationTemplateRenderer.updateExpandCollapseIcons(configurationService); } + + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(NotificationsSettings.NOTIFICATIONS_POSITION)) { + NotificationTemplateRenderer.updateExpandCollapseIcons(configurationService); + } + })); } setInput(notification: INotificationViewItem): void { diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts index 359ef56102026..be1d5c35ab09c 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserView.contribution.ts @@ -26,6 +26,7 @@ import { isLocalhostAuthority } from '../../../../platform/url/common/trustedDom import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { PolicyCategory } from '../../../../base/common/policy.js'; import { URI } from '../../../../base/common/uri.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { logBrowserOpen } from '../../../../platform/browserView/common/browserViewTelemetry.js'; @@ -165,7 +166,19 @@ Registry.as(ConfigurationExtensions.Configuration).regis markdownDescription: localize( { comment: ['This is the description for a setting.'], key: 'browser.enableChatTools' }, 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.' - ) + ), + policy: { + name: 'BrowserChatTools', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.110', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'browser.enableChatTools', + value: localize('browser.enableChatTools', 'When enabled, chat agents can use browser tools to open and interact with pages in the Integrated Browser.') + } + }, + } }, 'workbench.browser.dataStorage': { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts index bd911b192fada..aaa19f44e2577 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts @@ -24,7 +24,7 @@ export const ClickBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, selector: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts index 15feb72f5ae86..b9288091fd568 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts @@ -24,7 +24,7 @@ export const DragElementToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, fromSelector: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts index deed4c4b38b55..6e118343190ed 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts @@ -24,7 +24,7 @@ export const HandleDialogBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, acceptModal: { type: 'boolean', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts index 16c3ed50c9ee6..46e4b65488de4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts @@ -24,7 +24,7 @@ export const HoverElementToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, selector: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts index dbad2b31af63f..aabb81819aaab 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts @@ -24,7 +24,7 @@ export const NavigateBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID to navigate, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID to navigate, acquired from context or the open tool.` }, type: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts index f7b379e926fdd..63aa66cc7075d 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts @@ -26,7 +26,7 @@ export const ReadBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID to read, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID to read, acquired from context or the open tool.` }, }, required: ['pageId'], diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts index 0e3d46a1b8adb..bcc288f07ea30 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts @@ -25,7 +25,7 @@ export const RunPlaywrightCodeToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, code: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index e0664e36a32f4..831ca2bf8426f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -25,7 +25,7 @@ export const ScreenshotBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID to capture, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID to capture, acquired from context or the open tool.` }, selector: { type: 'string', diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts index a6e156e91a5ef..c3e8300c1b06b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts @@ -24,7 +24,7 @@ export const TypeBrowserToolData: IToolData = { properties: { pageId: { type: 'string', - description: `The browser page ID, acquired from context or ${OpenPageToolId}.` + description: `The browser page ID, acquired from context or the open tool.` }, text: { type: 'string', diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index f3e775e42a5d8..4d2fd7a57eb50 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -61,3 +61,8 @@ export const userIcon = registerIcon('ai-customization-user', Codicon.account, l * Icon for extension storage. */ export const extensionIcon = registerIcon('ai-customization-extension', Codicon.extensions, localize('aiCustomizationExtensionIcon', "Icon for extension-contributed items.")); + +/** + * Icon for plugin storage. + */ +export const pluginIcon = registerIcon('ai-customization-plugin', Codicon.plug, localize('aiCustomizationPluginIcon', "Icon for plugin-contributed items.")); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 89a0cd78d0466..5be1a768214db 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -19,7 +19,7 @@ import { WorkbenchList } from '../../../../../platform/list/browser/listService. import { IListVirtualDelegate, IListRenderer, IListContextMenuEvent } from '../../../../../base/browser/ui/list/list.js'; import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon } from './aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, userIcon, workspaceIcon, extensionIcon, pluginIcon } from './aiCustomizationIcons.js'; import { AICustomizationManagementItemMenuId, AICustomizationManagementSection } from './aiCustomizationManagement.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; import { defaultButtonStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; @@ -265,6 +265,10 @@ class AICustomizationItemRenderer implements IListRenderer item.storage === PromptsStorage.local); const userItems = allItems.filter(item => item.storage === PromptsStorage.user); const extensionItems = allItems.filter(item => item.storage === PromptsStorage.extension); + const pluginItems = allItems.filter(item => item.storage === PromptsStorage.plugin); const mapToListItem = (item: IPromptPath): IAICustomizationListItem => { const filename = basename(item.uri); @@ -836,6 +841,7 @@ export class AICustomizationListWidget extends Disposable { items.push(...workspaceItems.map(mapToListItem)); items.push(...userItems.map(mapToListItem)); items.push(...extensionItems.map(mapToListItem)); + items.push(...pluginItems.map(mapToListItem)); } // Sort items by name @@ -923,11 +929,13 @@ export class AICustomizationListWidget extends Disposable { this.logService.info(`[AICustomizationListWidget] filterItems: allItems=${this.allItems.length}, matched=${totalBeforeFilter}`); // Group items by storage + const visibleSources = new Set(this.workspaceService.visibleStorageSources); const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ { storage: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, { storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, { storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, - ]; + { storage: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + ].filter(g => visibleSources.has(g.storage)); for (const item of matchedItems) { const group = groups.find(g => g.storage === item.storage); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index faa022a5e4666..5e95b733bbbfb 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -5,7 +5,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; @@ -28,7 +28,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { PROMPT_LANGUAGE_ID, INSTRUCTIONS_LANGUAGE_ID, AGENT_LANGUAGE_ID, SKILL_LANGUAGE_ID, PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ChatConfiguration } from '../../common/constants.js'; @@ -37,7 +37,6 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { basename } from '../../../../../base/common/resources.js'; import { Schemas } from '../../../../../base/common/network.js'; import { isWindows, isMacintosh } from '../../../../../base/common/platform.js'; -import { ResourceContextKey } from '../../../../common/contextkeys.js'; //#region Editor Registration @@ -196,8 +195,8 @@ registerAction2(class extends Action2 { const fileName = basename(uri); const storage = extractStorage(context); - // Extension files cannot be deleted - if (storage === PromptsStorage.extension) { + // Extension and plugin files cannot be deleted + if (storage === PromptsStorage.extension || storage === PromptsStorage.plugin) { await dialogService.info( localize('cannotDeleteExtension', "Cannot Delete Extension File"), localize('cannotDeleteExtensionDetail', "Files provided by extensions cannot be deleted. You can disable the extension if you no longer want to use this customization.") @@ -271,30 +270,11 @@ class AICustomizationManagementActionsContribution extends Disposable implements constructor() { super({ id: AICustomizationManagementCommands.OpenEditor, - title: localize2('openAICustomizations', "Open Chat Customizations"), - shortTitle: localize2('aiCustomizations', "Chat Customizations"), + title: localize2('openAICustomizations', "Open Chat Customizations (Preview)"), + shortTitle: localize2('aiCustomizations', "Chat Customizations (Preview)"), category: CHAT_CATEGORY, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), f1: true, - menu: [ - { - id: MenuId.GlobalActivity, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), - group: '2_configuration', - order: 4, - }, - { - id: MenuId.EditorContent, - when: ContextKeyExpr.and( - ChatContextKeys.enabled, - ContextKeyExpr.or( - ContextKeyExpr.equals(ResourceContextKey.LangId.key, PROMPT_LANGUAGE_ID), - ContextKeyExpr.equals(ResourceContextKey.LangId.key, INSTRUCTIONS_LANGUAGE_ID), - ContextKeyExpr.equals(ResourceContextKey.LangId.key, AGENT_LANGUAGE_ID), - ContextKeyExpr.equals(ResourceContextKey.LangId.key, SKILL_LANGUAGE_ID), - ), - ), - }], }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index b5fd79c2fbeab..33d40453b83e0 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -361,7 +361,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.editorDisposables.add(this.listWidget.onDidSelectItem(item => { if (this.workspaceService.preferManualCreation) { const isWorkspaceFile = item.storage === PromptsStorage.local; - const isReadOnly = item.storage === PromptsStorage.extension; + const isReadOnly = item.storage === PromptsStorage.extension || item.storage === PromptsStorage.plugin; this.showEmbeddedEditor(item.uri, item.name, isWorkspaceFile, isReadOnly); } else { this.editorService.openEditor({ resource: item.uri }); @@ -453,9 +453,6 @@ export class AICustomizationManagementEditor extends EditorPane { // Persist selection this.storageService.store(AI_CUSTOMIZATION_MANAGEMENT_SELECTED_SECTION_KEY, section, StorageScope.PROFILE, StorageTarget.USER); - // Update editor tab title - this.updateEditorTitle(); - // Update content visibility this.updateContentVisibility(); @@ -465,13 +462,6 @@ export class AICustomizationManagementEditor extends EditorPane { } } - private updateEditorTitle(): void { - const sectionItem = this.sections.find(s => s.id === this.selectedSection); - if (sectionItem && this.input instanceof AICustomizationManagementEditorInput) { - this.input.setSectionLabel(sectionItem.label); - } - } - private updateContentVisibility(): void { const isEditorMode = this.viewMode === 'editor'; const isMcpDetailMode = this.viewMode === 'mcpDetail'; @@ -578,9 +568,6 @@ export class AICustomizationManagementEditor extends EditorPane { await super.setInput(input, options, context, token); - // Set initial editor tab title - this.updateEditorTitle(); - if (this.dimension) { this.layout(this.dimension); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts index 1ee4543ddacc1..819107dfcebda 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.ts @@ -22,8 +22,6 @@ export class AICustomizationManagementEditorInput extends EditorInput { private static _instance: AICustomizationManagementEditorInput | undefined; - private _sectionLabel: string | undefined; - /** * Gets or creates the singleton instance of this input. */ @@ -47,20 +45,7 @@ export class AICustomizationManagementEditorInput extends EditorInput { } override getName(): string { - if (this._sectionLabel) { - return localize('aiCustomizationManagementEditorNameWithSection', "Customizations: {0}", this._sectionLabel); - } - return localize('aiCustomizationManagementEditorName', "Customizations"); - } - - /** - * Updates the section label shown in the editor tab title. - */ - setSectionLabel(label: string): void { - if (this._sectionLabel !== label) { - this._sectionLabel = label; - this._onDidChangeLabel.fire(); - } + return localize('aiCustomizationManagementEditorName', "Chat Customizations"); } override getIcon(): ThemeIcon { diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts index 450961e960144..8c2cdc5228452 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationWorkspaceService.ts @@ -8,6 +8,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { @@ -52,6 +53,13 @@ class AICustomizationWorkspaceService implements IAICustomizationWorkspaceServic AICustomizationManagementSection.McpServers, ]; + readonly visibleStorageSources: readonly PromptsStorage[] = [ + PromptsStorage.local, + PromptsStorage.user, + PromptsStorage.extension, + PromptsStorage.plugin, + ]; + readonly preferManualCreation = false; async commitFiles(_projectRoot: URI, _fileUris: URI[]): Promise { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 97ca2db45d3b1..37f7907162471 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1242,7 +1242,8 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.ChatCustomizationMenuEnabled]: { type: 'boolean', - description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customization Menu is shown in the Manage menu and Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), + tags: ['preview'], + description: nls.localize('chat.aiCustomizationMenu.enabled', "Controls whether the Chat Customizations editor is available in the Command Palette. When disabled, the Chat Customizations editor and related commands are hidden."), default: true, } } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts index 70860d47632a8..19e3cda7612b4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugDetailPanel.ts @@ -22,6 +22,8 @@ import { IChatDebugEvent, IChatDebugService } from '../../common/chatDebugServic import { formatEventDetail } from './chatDebugEventDetailRenderer.js'; import { renderCustomizationDiscoveryContent, fileListToPlainText } from './chatCustomizationDiscoveryRenderer.js'; import { renderUserMessageContent, renderAgentResponseContent, messageEventToPlainText, renderResolvedMessageContent, resolvedMessageToPlainText } from './chatDebugMessageContentRenderer.js'; +import { renderToolCallContent, toolCallContentToPlainText } from './chatDebugToolCallContentRenderer.js'; +import { renderModelTurnContent, modelTurnContentToPlainText } from './chatDebugModelTurnContentRenderer.js'; const $ = DOM.$; @@ -120,11 +122,27 @@ export class ChatDebugDetailPanel extends Disposable { ); this.detailDisposables.add(contentDisposables); this.contentContainer.appendChild(contentEl); + } else if (resolved && resolved.kind === 'toolCall') { + this.currentDetailText = toolCallContentToPlainText(resolved); + const languageService = this.instantiationService.invokeFunction(accessor => accessor.get(ILanguageService)); + const { element: contentEl, disposables: contentDisposables } = await renderToolCallContent(resolved, languageService); + if (this.currentDetailEventId !== event.id) { + // Another event was selected while we were rendering + contentDisposables.dispose(); + return; + } + this.detailDisposables.add(contentDisposables); + this.contentContainer.appendChild(contentEl); } else if (resolved && resolved.kind === 'message') { this.currentDetailText = resolvedMessageToPlainText(resolved); const { element: contentEl, disposables: contentDisposables } = renderResolvedMessageContent(resolved); this.detailDisposables.add(contentDisposables); this.contentContainer.appendChild(contentEl); + } else if (resolved && resolved.kind === 'modelTurn') { + this.currentDetailText = modelTurnContentToPlainText(resolved); + const { element: contentEl, disposables: contentDisposables } = renderModelTurnContent(resolved); + this.detailDisposables.add(contentDisposables); + this.contentContainer.appendChild(contentEl); } else if (event.kind === 'userMessage') { this.currentDetailText = messageEventToPlainText(event); const { element: contentEl, disposables: contentDisposables } = renderUserMessageContent(event); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts index b7dfe35052eb1..976da3108506b 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChart.ts @@ -5,7 +5,7 @@ // Barrel re-export — keeps existing imports stable. // Data model + graph building (stable): -export { buildFlowGraph, filterFlowNodes, sliceFlowNodes } from './chatDebugFlowGraph.js'; +export { buildFlowGraph, filterFlowNodes, sliceFlowNodes, mergeDiscoveryNodes, mergeToolCallNodes } from './chatDebugFlowGraph.js'; export type { FlowNode, FlowFilterOptions, FlowSliceResult, FlowLayout, FlowChartRenderResult, LayoutNode, LayoutEdge, SubgraphRect } from './chatDebugFlowGraph.js'; // Layout + rendering export { layoutFlowGraph, renderFlowChartSVG } from './chatDebugFlowLayout.js'; diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts index 334d5d0456785..5be099a53b4e3 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowChartView.ts @@ -21,7 +21,7 @@ import { IChatService } from '../../common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { setupBreadcrumbKeyboardNavigation, TextBreadcrumbItem } from './chatDebugTypes.js'; import { ChatDebugFilterState, bindFilterContextKeys } from './chatDebugFilters.js'; -import { buildFlowGraph, filterFlowNodes, sliceFlowNodes, layoutFlowGraph, renderFlowChartSVG, FlowChartRenderResult } from './chatDebugFlowChart.js'; +import { buildFlowGraph, filterFlowNodes, sliceFlowNodes, mergeDiscoveryNodes, mergeToolCallNodes, layoutFlowGraph, renderFlowChartSVG, FlowChartRenderResult } from './chatDebugFlowChart.js'; import { ChatDebugDetailPanel } from './chatDebugDetailPanel.js'; const $ = DOM.$; @@ -76,6 +76,9 @@ export class ChatDebugFlowChartView extends Disposable { // Collapse state — persists across refreshes, resets on session change private readonly collapsedNodeIds = new Set(); + // Expanded merged-discovery nodes — persists across refreshes, resets on session change + private readonly expandedMergedIds = new Set(); + // Pagination state private visibleLimit: number = PAGE_SIZE; @@ -165,6 +168,7 @@ export class ChatDebugFlowChartView extends Disposable { this.hasUserPanned = false; this.focusedElementId = undefined; this.collapsedNodeIds.clear(); + this.expandedMergedIds.clear(); this.visibleLimit = PAGE_SIZE; this.detailPanel.hide(); } @@ -235,7 +239,8 @@ export class ChatDebugFlowChartView extends Disposable { } const slice = sliceFlowNodes(filtered, this.visibleLimit); - const layout = layoutFlowGraph(slice.nodes, { collapsedIds: this.collapsedNodeIds }); + const merged = mergeToolCallNodes(mergeDiscoveryNodes(slice.nodes)); + const layout = layoutFlowGraph(merged, { collapsedIds: this.collapsedNodeIds, expandedMergedIds: this.expandedMergedIds }); this.renderResult = renderFlowChartSVG(layout); this.svgWrapper = DOM.append(this.content, $('.chat-debug-flowchart-svg-wrapper')); @@ -385,6 +390,15 @@ export class ChatDebugFlowChartView extends Disposable { this.load(); } + private toggleMergedDiscovery(mergedId: string): void { + if (this.expandedMergedIds.has(mergedId)) { + this.expandedMergedIds.delete(mergedId); + } else { + this.expandedMergedIds.add(mergedId); + } + this.load(); + } + private focusFirstElement(): void { if (!this.renderResult) { return; @@ -489,6 +503,12 @@ export class ChatDebugFlowChartView extends Disposable { // Walk up from the click target to find a focusable element let target = e.target as Element | null; while (target && target !== this.content) { + // Merged-discovery expand toggle + const mergedId = target.getAttribute?.('data-merged-id'); + if (mergedId) { + this.toggleMergedDiscovery(mergedId); + return; + } const subgraphId = target.getAttribute?.('data-subgraph-id'); if (subgraphId) { this.toggleSubgraph(subgraphId); diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts index b2cf11e212980..6cfe024552c64 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowGraph.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { localize } from '../../../../../nls.js'; import { IChatDebugEvent } from '../../common/chatDebugService.js'; // ---- Data model ---- @@ -19,6 +20,8 @@ export interface FlowNode { readonly isError?: boolean; readonly created: number; readonly children: FlowNode[]; + /** Present on merged discovery nodes: the individual nodes that were merged. */ + readonly mergedNodes?: FlowNode[]; } export interface FlowFilterOptions { @@ -37,6 +40,10 @@ export interface LayoutNode { readonly y: number; readonly width: number; readonly height: number; + /** Number of individual nodes merged into this one (for discovery merging). */ + readonly mergedCount?: number; + /** Whether the merged node is currently expanded (individual nodes shown to the right). */ + readonly isMergedExpanded?: boolean; } export interface LayoutEdge { @@ -160,12 +167,16 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { function toFlowNode(event: IChatDebugEvent): FlowNode { const children = event.id ? idToChildren.get(event.id) : undefined; + // Remap generic events with well-known names to their proper kind + // so they get correct styling and sublabel treatment. + const effectiveKind = getEffectiveKind(event); + // For subagent invocations, enrich with description from the // filtered-out completion sibling, or fall back to the event's own field. - let sublabel = getEventSublabel(event); + let sublabel = getEventSublabel(event, effectiveKind); let tooltip = getEventTooltip(event); let description: string | undefined; - if (event.kind === 'subagentInvocation') { + if (effectiveKind === 'subagentInvocation') { description = getSubagentDescription(event); if (description) { sublabel = truncateLabel(description, 30) + (sublabel ? ` \u00b7 ${sublabel}` : ''); @@ -180,9 +191,9 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { return { id: event.id ?? `event-${events.indexOf(event)}`, - kind: event.kind, + kind: effectiveKind, category: event.kind === 'generic' ? event.category : undefined, - label: getEventLabel(event), + label: getEventLabel(event, effectiveKind), sublabel, description, tooltip, @@ -192,70 +203,7 @@ export function buildFlowGraph(events: readonly IChatDebugEvent[]): FlowNode[] { }; } - return mergeModelTurns(roots.map(toFlowNode)); -} - -/** - * Absorbs model turn nodes into the subsequent sibling node. - * - * Each model turn represents an LLM call that decides what to do next - * (call tools, respond, etc.). Rather than showing model turns as separate - * boxes, we merge their metadata (token count, LLM latency) into the next - * node's sublabel and tooltip so the diagram stays compact while - * preserving the correlation. - */ -function mergeModelTurns(nodes: FlowNode[]): FlowNode[] { - const result: FlowNode[] = []; - let pendingModelTurn: FlowNode | undefined; - - for (const node of nodes) { - if (node.kind === 'modelTurn') { - pendingModelTurn = node; - continue; - } - - const merged = applyModelTurnInfo(node, pendingModelTurn); - pendingModelTurn = undefined; - result.push(merged); - } - - // If the last node was a model turn with no successor, keep it - if (pendingModelTurn) { - result.push(pendingModelTurn); - } - - return result; -} - -/** - * Enriches a node with model turn metadata and recursively - * merges model turns within its children. - */ -function applyModelTurnInfo(node: FlowNode, modelTurn: FlowNode | undefined): FlowNode { - const mergedChildren = node.children.length > 0 ? mergeModelTurns(node.children) : node.children; - - if (!modelTurn) { - return mergedChildren !== node.children ? { ...node, children: mergedChildren } : node; - } - - // Build compact annotation from model turn info (e.g. "500 tok · LLM 2.3s") - const annotation = modelTurn.sublabel; - const newSublabel = annotation - ? (node.sublabel ? `${node.sublabel} \u00b7 ${annotation}` : annotation) - : node.sublabel; - - // Enrich tooltip with model turn details - const modelTooltip = modelTurn.tooltip ?? (modelTurn.label !== 'Model Turn' ? modelTurn.label : undefined); - const newTooltip = modelTooltip - ? (node.tooltip ? `${node.tooltip}\n\nModel: ${modelTooltip}` : `Model: ${modelTooltip}`) - : node.tooltip; - - return { - ...node, - sublabel: newSublabel, - tooltip: newTooltip, - children: mergedChildren, - }; + return roots.map(toFlowNode); } // ---- Flow node filtering ---- @@ -386,59 +334,280 @@ export function sliceFlowNodes(nodes: readonly FlowNode[], maxCount: number): Fl return { nodes: sliced, totalCount, shownCount }; } +// ---- Discovery node merging ---- + +function isDiscoveryNode(node: FlowNode): boolean { + return node.kind === 'generic' && node.category === 'discovery'; +} + +/** + * Merges consecutive prompt-discovery nodes (generic events with + * `category === 'discovery'`) into a single summary node. + * + * The merged node always stays in the graph and carries the individual + * nodes in `mergedNodes`. Expansion (showing the individual nodes to the + * right) is handled at the layout level. + * + * Operates recursively on children. + */ +export function mergeDiscoveryNodes( + nodes: readonly FlowNode[], +): FlowNode[] { + const result: FlowNode[] = []; + + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + + // Non-discovery node: recurse into children and pass through. + if (!isDiscoveryNode(node)) { + const mergedChildren = mergeDiscoveryNodes(node.children); + result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node); + i++; + continue; + } + + // Accumulate a run of consecutive discovery nodes. + const run: FlowNode[] = [node]; + let j = i + 1; + while (j < nodes.length && isDiscoveryNode(nodes[j])) { + run.push(nodes[j]); + j++; + } + + if (run.length < 2) { + // Single discovery node — nothing to merge. + result.push(node); + i = j; + continue; + } + + // Build a stable id from the first node so the expand state persists. + const mergedId = `merged-discovery:${run[0].id}`; + + // Build a merged summary node. + const labels = run.map(n => n.label); + const uniqueLabels = [...new Set(labels)]; + const summaryLabel = uniqueLabels.length <= 2 + ? uniqueLabels.join(', ') + : localize('discoveryMergedLabel', "{0} +{1} more", uniqueLabels[0], run.length - 1); + + result.push({ + id: mergedId, + kind: 'generic', + category: 'discovery', + label: summaryLabel, + sublabel: localize('discoveryStepsCount', "{0} discovery steps", run.length), + tooltip: run.map(n => n.label + (n.sublabel ? `: ${n.sublabel}` : '')).join('\n'), + created: run[0].created, + children: [], + mergedNodes: run, + }); + i = j; + } + + return result; +} + +// ---- Tool call node merging ---- + +function isToolCallNode(node: FlowNode): boolean { + return node.kind === 'toolCall'; +} + +/** + * Returns the tool name from a tool-call node's label. + * Tool call labels are set to `event.toolName` (possibly with a leading + * emoji prefix stripped), so the label itself is the canonical tool name. + */ +function getToolName(node: FlowNode): string { + return node.label; +} + +/** + * Merges consecutive tool-call nodes that invoke the same tool into a + * single summary node. + * + * This mirrors `mergeDiscoveryNodes`: the merged node carries the + * individual nodes in `mergedNodes` and expansion is handled at the + * layout level. + * + * Operates recursively on children. + */ +export function mergeToolCallNodes( + nodes: readonly FlowNode[], +): FlowNode[] { + const result: FlowNode[] = []; + + let i = 0; + while (i < nodes.length) { + const node = nodes[i]; + + // Non-tool-call node: recurse into children and pass through. + if (!isToolCallNode(node)) { + const mergedChildren = mergeToolCallNodes(node.children); + result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node); + i++; + continue; + } + + // Accumulate a run of consecutive tool-call nodes with the same tool name. + const toolName = getToolName(node); + const run: FlowNode[] = [node]; + let j = i + 1; + while (j < nodes.length && isToolCallNode(nodes[j]) && getToolName(nodes[j]) === toolName) { + run.push(nodes[j]); + j++; + } + + if (run.length < 2) { + // Single tool call — recurse into children, nothing to merge. + const mergedChildren = mergeToolCallNodes(node.children); + result.push(mergedChildren !== node.children ? { ...node, children: mergedChildren } : node); + i = j; + continue; + } + + // Build a stable id from the first node so the expand state persists. + const mergedId = `merged-toolCall:${run[0].id}`; + + result.push({ + id: mergedId, + kind: 'toolCall', + label: toolName, + sublabel: localize('toolCallsCount', "{0} calls", run.length), + tooltip: run.map(n => n.label + (n.sublabel ? `: ${n.sublabel}` : '')).join('\n'), + created: run[0].created, + children: [], + mergedNodes: run, + }); + i = j; + } + + return result; +} + // ---- Event helpers ---- -function getEventLabel(event: IChatDebugEvent): string { - switch (event.kind) { - case 'userMessage': { - const firstLine = event.message.split('\n')[0]; - return firstLine.length > 40 ? firstLine.substring(0, 37) + '...' : firstLine; +/** + * Remaps generic events with well-known names (e.g. "User message", + * "Agent response") to their proper typed kind so they receive + * correct colors, labels, and sublabel treatment in the flow chart. + */ +function getEffectiveKind(event: IChatDebugEvent): IChatDebugEvent['kind'] { + if (event.kind === 'generic') { + const name = event.name.toLowerCase().replace(/[\s_-]+/g, ''); + if (name === 'usermessage' || name === 'userprompt' || name === 'user' || name.startsWith('usermessage')) { + return 'userMessage'; + } + if (name === 'response' || name.startsWith('agentresponse') || name.startsWith('assistantresponse') || name.startsWith('modelresponse')) { + return 'agentResponse'; + } + const cat = event.category?.toLowerCase(); + if (cat === 'user' || cat === 'usermessage') { + return 'userMessage'; } + if (cat === 'response' || cat === 'agentresponse') { + return 'agentResponse'; + } + } + return event.kind; +} + +function getEventLabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent['kind']): string { + const kind = effectiveKind ?? event.kind; + switch (kind) { + case 'userMessage': + return localize('userLabel', "User"); case 'modelTurn': - return event.model ?? 'Model Turn'; + return event.kind === 'modelTurn' ? (event.model ?? localize('modelTurnLabel', "Model Turn")) : localize('modelTurnLabel', "Model Turn"); case 'toolCall': - return event.toolName; + return event.kind === 'toolCall' ? event.toolName : event.kind === 'generic' ? event.name : ''; case 'subagentInvocation': - return event.agentName; - case 'agentResponse': - return 'Response'; + return event.kind === 'subagentInvocation' ? event.agentName : ''; + case 'agentResponse': { + if (event.kind === 'agentResponse') { + return event.message || localize('responseLabel', "Response"); + } + // Remapped generic event — extract model name from parenthesized suffix + // e.g. "Agent response (claude-opus-4.5)" → "claude-opus-4.5" + if (event.kind === 'generic') { + const match = /\(([^)]+)\)\s*$/.exec(event.name); + if (match) { + return match[1]; + } + } + return localize('responseLabel', "Response"); + } case 'generic': - return event.name; + return event.kind === 'generic' ? event.name : ''; } } -function getEventSublabel(event: IChatDebugEvent): string | undefined { - switch (event.kind) { +function getEventSublabel(event: IChatDebugEvent, effectiveKind?: IChatDebugEvent['kind']): string | undefined { + const kind = effectiveKind ?? event.kind; + switch (kind) { case 'modelTurn': { const parts: string[] = []; - if (event.totalTokens) { - parts.push(`${event.totalTokens} tokens`); + if (event.kind === 'modelTurn' && event.totalTokens) { + parts.push(localize('tokenCount', "{0} tokens", event.totalTokens)); } - if (event.durationInMillis) { + if (event.kind === 'modelTurn' && event.durationInMillis) { parts.push(formatDuration(event.durationInMillis)); } return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; } case 'toolCall': { const parts: string[] = []; - if (event.result) { + if (event.kind === 'toolCall' && event.result) { parts.push(event.result); } - if (event.durationInMillis) { + if (event.kind === 'toolCall' && event.durationInMillis) { parts.push(formatDuration(event.durationInMillis)); } return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; } case 'subagentInvocation': { const parts: string[] = []; - if (event.status) { + if (event.kind === 'subagentInvocation' && event.status) { parts.push(event.status); } - if (event.durationInMillis) { + if (event.kind === 'subagentInvocation' && event.durationInMillis) { parts.push(formatDuration(event.durationInMillis)); } return parts.length > 0 ? parts.join(' \u00b7 ') : undefined; } + case 'userMessage': + case 'agentResponse': { + // For proper typed events, prefer the first section's content + // (which has the actual message text) over the `message` field + // (which is a short summary/name). Fall back to `message` when + // no sections are available. For remapped generic events, use + // the details property. + let text: string | undefined; + if (event.kind === 'userMessage' || event.kind === 'agentResponse') { + text = event.sections[0]?.content || event.message; + } else if (event.kind === 'generic') { + text = event.details; + } + if (!text) { + return undefined; + } + // Find the first non-empty line (content may start with newlines) + const lines = text.split('\n'); + let firstLine = ''; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed) { + firstLine = trimmed; + break; + } + } + if (!firstLine) { + return undefined; + } + return firstLine.length > 60 ? firstLine.substring(0, 57) + '...' : firstLine; + } default: return undefined; } @@ -472,14 +641,14 @@ function getEventTooltip(event: IChatDebugEvent): string | undefined { const parts: string[] = [event.toolName]; if (event.input) { const input = event.input.trim(); - parts.push(`Input: ${input.length > TOOLTIP_MAX_LENGTH ? input.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : input}`); + parts.push(localize('tooltipInput', "Input: {0}", input.length > TOOLTIP_MAX_LENGTH ? input.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : input)); } if (event.output) { const output = event.output.trim(); - parts.push(`Output: ${output.length > TOOLTIP_MAX_LENGTH ? output.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : output}`); + parts.push(localize('tooltipOutput', "Output: {0}", output.length > TOOLTIP_MAX_LENGTH ? output.substring(0, TOOLTIP_MAX_LENGTH) + '\u2026' : output)); } if (event.result) { - parts.push(`Result: ${event.result}`); + parts.push(localize('tooltipResult', "Result: {0}", event.result)); } return parts.join('\n'); } @@ -489,13 +658,13 @@ function getEventTooltip(event: IChatDebugEvent): string | undefined { parts.push(event.description); } if (event.status) { - parts.push(`Status: ${event.status}`); + parts.push(localize('tooltipStatus', "Status: {0}", event.status)); } if (event.toolCallCount !== undefined) { - parts.push(`Tool calls: ${event.toolCallCount}`); + parts.push(localize('tooltipToolCalls', "Tool calls: {0}", event.toolCallCount)); } if (event.modelTurnCount !== undefined) { - parts.push(`Model turns: ${event.modelTurnCount}`); + parts.push(localize('tooltipModelTurns', "Model turns: {0}", event.modelTurnCount)); } return parts.join('\n'); } @@ -512,16 +681,16 @@ function getEventTooltip(event: IChatDebugEvent): string | undefined { parts.push(event.model); } if (event.totalTokens) { - parts.push(`Tokens: ${event.totalTokens}`); + parts.push(localize('tooltipTokens', "Tokens: {0}", event.totalTokens)); } if (event.inputTokens) { - parts.push(`Input tokens: ${event.inputTokens}`); + parts.push(localize('tooltipInputTokens', "Input tokens: {0}", event.inputTokens)); } if (event.outputTokens) { - parts.push(`Output tokens: ${event.outputTokens}`); + parts.push(localize('tooltipOutputTokens', "Output tokens: {0}", event.outputTokens)); } if (event.durationInMillis) { - parts.push(`Duration: ${formatDuration(event.durationInMillis)}`); + parts.push(localize('tooltipDuration', "Duration: {0}", formatDuration(event.durationInMillis))); } return parts.length > 0 ? parts.join('\n') : undefined; } diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts index 5e730d0309c2e..4b35620ab67db 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugFlowLayout.ts @@ -9,6 +9,7 @@ import { FlowLayout, FlowNode, LayoutEdge, LayoutNode, SubgraphRect, FlowChartRe // ---- Layout constants ---- const NODE_HEIGHT = 36; +const MESSAGE_NODE_HEIGHT = 52; const NODE_MIN_WIDTH = 140; const NODE_MAX_WIDTH = 320; const NODE_PADDING_H = 16; @@ -23,6 +24,7 @@ const CANVAS_PADDING = 24; const PARALLEL_GAP_X = 40; const SUBGRAPH_HEADER_HEIGHT = 22; const GUTTER_WIDTH = 3; +const MERGED_TOGGLE_WIDTH = 36; // ---- Layout internals ---- @@ -41,6 +43,14 @@ interface ChildGroup { readonly children: FlowNode[]; } +/** Deferred expansion of a merged-discovery node, resolved in pass 2. */ +interface PendingExpansion { + /** The merged summary LayoutNode (already placed). */ + readonly mergedNode: LayoutNode; + /** The individual FlowNodes to expand to the right. */ + readonly children: readonly FlowNode[]; +} + // ---- Parallel detection ---- /** Max time gap (ms) between subagent `created` timestamps to consider them parallel. */ @@ -137,6 +147,10 @@ function groupChildren(children: FlowNode[]): ChildGroup[] { // ---- Layout engine ---- +function isMessageKind(kind: IChatDebugEvent['kind']): boolean { + return kind === 'userMessage' || kind === 'agentResponse'; +} + function measureNodeWidth(label: string, sublabel?: string): number { const charWidth = 7; const labelWidth = label.length * charWidth + NODE_PADDING_H * 2; @@ -174,6 +188,8 @@ function layoutGroups( prevExitNodes: LayoutNode[], result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, collapsedIds?: ReadonlySet, + expandedMergedIds?: ReadonlySet, + pendingExpansions?: PendingExpansion[], ): { exitNodes: LayoutNode[]; maxWidth: number; endY: number } { let currentY = startY; let maxWidth = 0; @@ -181,7 +197,7 @@ function layoutGroups( for (const group of groups) { if (group.type === 'parallel') { - const pg = layoutParallelGroup(group.children, startX, currentY, depth, collapsedIds); + const pg = layoutParallelGroup(group.children, startX, currentY, depth, collapsedIds, expandedMergedIds, pendingExpansions); result.nodes.push(...pg.nodes); result.edges.push(...pg.edges); result.subgraphs.push(...pg.subgraphs); @@ -196,7 +212,7 @@ function layoutGroups( currentY += pg.height + NODE_GAP_Y; } else { for (const child of group.children) { - const sub = layoutSubtree(child, startX, currentY, depth, collapsedIds); + const sub = layoutSubtree(child, startX, currentY, depth, collapsedIds, expandedMergedIds, pendingExpansions); result.nodes.push(...sub.nodes); result.edges.push(...sub.edges); result.subgraphs.push(...sub.subgraphs); @@ -226,32 +242,123 @@ function makeEdge(from: LayoutNode, to: LayoutNode): LayoutEdge { * Lays out a list of flow nodes in a top-down vertical flow. * Parallel subagent invocations are arranged side by side. */ -export function layoutFlowGraph(roots: FlowNode[], options?: { collapsedIds?: ReadonlySet }): FlowLayout { +export function layoutFlowGraph(roots: FlowNode[], options?: { collapsedIds?: ReadonlySet; expandedMergedIds?: ReadonlySet }): FlowLayout { if (roots.length === 0) { return { nodes: [], edges: [], subgraphs: [], width: 0, height: 0 }; } const collapsedIds = options?.collapsedIds; + const expandedMergedIds = options?.expandedMergedIds; const groups = groupChildren(roots); + const pendingExpansions: PendingExpansion[] = []; const result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] } = { nodes: [], edges: [], subgraphs: [], }; - const { maxWidth, endY } = layoutGroups(groups, CANVAS_PADDING, CANVAS_PADDING, 0, [], result, collapsedIds); - const width = maxWidth + CANVAS_PADDING * 2; - const height = endY - NODE_GAP_Y + CANVAS_PADDING; + // Pass 1: layout the main vertical flow; expanded merged nodes only + // place their summary node and defer children to pendingExpansions. + const { maxWidth, endY } = layoutGroups(groups, CANVAS_PADDING, CANVAS_PADDING, 0, [], result, collapsedIds, expandedMergedIds, pendingExpansions); + + // Pass 2: resolve deferred expansions — place children to the right, + // far enough to clear all existing nodes/subgraphs in the Y range. + resolvePendingExpansions(pendingExpansions, result); + + let width = maxWidth + CANVAS_PADDING * 2; + let height = endY - NODE_GAP_Y + CANVAS_PADDING; + + // Expand canvas to cover any nodes that float outside the main flow. + for (const n of result.nodes) { + width = Math.max(width, n.x + n.width + CANVAS_PADDING); + height = Math.max(height, n.y + n.height + CANVAS_PADDING); + } centerLayout(result as FlowLayout & { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, width / 2); return { nodes: result.nodes, edges: result.edges, subgraphs: result.subgraphs, width, height }; } -function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, collapsedIds?: ReadonlySet): SubtreeLayout { - const nodeWidth = measureNodeWidth(node.label, node.sublabel); +/** + * Pass 2: For each pending expansion, compute the Y range the children + * will occupy, scan all already-placed nodes and subgraphs for the max + * right edge overlapping that range, and place the entire column of + * children to the right of that edge. + */ +function resolvePendingExpansions( + pendingExpansions: PendingExpansion[], + result: { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[] }, +): void { + for (const expansion of pendingExpansions) { + const { mergedNode, children } = expansion; + + // Compute the Y range the children will occupy. + const childrenTotalHeight = children.length * NODE_HEIGHT + (children.length - 1) * NODE_GAP_Y; + const rangeTop = mergedNode.y; + const rangeBottom = mergedNode.y + childrenTotalHeight; + + // Find the max right edge of any existing node or subgraph + // that overlaps this Y range. + let maxRightX = mergedNode.x + mergedNode.width; + for (const n of result.nodes) { + if (n.y + n.height > rangeTop && n.y < rangeBottom) { + maxRightX = Math.max(maxRightX, n.x + n.width); + } + } + for (const sg of result.subgraphs) { + if (sg.y + sg.height > rangeTop && sg.y < rangeBottom) { + maxRightX = Math.max(maxRightX, sg.x + sg.width); + } + } + + const expandX = maxRightX + PARALLEL_GAP_X; + let expandY = mergedNode.y; + let expandMaxWidth = 0; + + const childNodes: LayoutNode[] = []; + for (const child of children) { + const childWidth = measureNodeWidth(child.label, child.sublabel); + const childNode: LayoutNode = { + id: child.id, + kind: child.kind, + label: child.label, + sublabel: child.sublabel, + tooltip: child.tooltip, + isError: child.isError, + x: expandX, + y: expandY, + width: childWidth, + height: NODE_HEIGHT, + }; + childNodes.push(childNode); + result.nodes.push(childNode); + expandMaxWidth = Math.max(expandMaxWidth, childWidth); + expandY += NODE_HEIGHT + NODE_GAP_Y; + } + + // Horizontal edge from merged node to first child + result.edges.push({ + fromX: mergedNode.x + mergedNode.width, + fromY: mergedNode.y + mergedNode.height / 2, + toX: expandX, + toY: childNodes[0].y + childNodes[0].height / 2, + }); + + // Vertical edges between consecutive children + for (let k = 0; k < childNodes.length - 1; k++) { + result.edges.push(makeEdge(childNodes[k], childNodes[k + 1])); + } + } +} + +function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, collapsedIds?: ReadonlySet, expandedMergedIds?: ReadonlySet, pendingExpansions?: PendingExpansion[]): SubtreeLayout { + const isMerged = (node.mergedNodes?.length ?? 0) >= 2; + const isMergedExpanded = isMerged && expandedMergedIds?.has(node.id); + const mergedExtra = isMerged ? MERGED_TOGGLE_WIDTH : 0; + const nodeWidth = measureNodeWidth(node.label, node.sublabel) + mergedExtra; const isSubagent = node.kind === 'subagentInvocation'; const isCollapsed = isSubagent && collapsedIds?.has(node.id); + const nodeHeight = isMessageKind(node.kind) && node.sublabel ? MESSAGE_NODE_HEIGHT : NODE_HEIGHT; const layoutNode: LayoutNode = { id: node.id, @@ -263,7 +370,9 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, x: startX, y: y, width: nodeWidth, - height: NODE_HEIGHT, + height: nodeHeight, + mergedCount: isMerged ? node.mergedNodes!.length : undefined, + isMergedExpanded, }; const result: SubtreeLayout = { @@ -271,11 +380,19 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, edges: [], subgraphs: [], width: nodeWidth, - height: NODE_HEIGHT, + height: nodeHeight, entryNode: layoutNode, exitNodes: [layoutNode], }; + // Expanded merged discovery: defer child placement to pass 2. + // Only emit the merged summary node now; children will be placed + // to the right after all main-flow nodes have been positioned. + if (isMergedExpanded && pendingExpansions) { + pendingExpansions.push({ mergedNode: layoutNode, children: node.mergedNodes! }); + return result; + } + if (node.children.length === 0 && !isCollapsed) { return result; } @@ -284,7 +401,7 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, if (isCollapsed) { const collapsedHeight = SUBGRAPH_HEADER_HEIGHT + SUBGRAPH_PADDING * 2; const totalChildCount = countDescendants(node); - const sgY = (y + NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y / 2; + const sgY = (y + nodeHeight + NODE_GAP_Y) - NODE_GAP_Y / 2; const headerLabel = subgraphHeaderLabel(node); const sgWidth = Math.max(NODE_MIN_WIDTH, measureSubgraphHeaderWidth(headerLabel)) + SUBGRAPH_PADDING * 2; result.subgraphs.push({ @@ -300,12 +417,12 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, // Draw a connecting edge from the node to the collapsed subgraph result.edges.push({ fromX: startX + nodeWidth / 2, - fromY: y + NODE_HEIGHT, + fromY: y + nodeHeight, toX: startX - SUBGRAPH_PADDING + sgWidth / 2, toY: sgY, }); result.width = Math.max(nodeWidth, sgWidth); - result.height = NODE_HEIGHT + NODE_GAP_Y + collapsedHeight; + result.height = nodeHeight + NODE_GAP_Y + collapsedHeight; return result; } @@ -317,13 +434,13 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, const indentX = isSubagent ? SUBGRAPH_PADDING : 0; const groups = groupChildren(node.children); - let childStartY = y + NODE_HEIGHT + NODE_GAP_Y; + let childStartY = y + nodeHeight + NODE_GAP_Y; if (isSubagent) { childStartY += SUBGRAPH_HEADER_HEIGHT; } const { exitNodes, maxWidth, endY } = layoutGroups( - groups, startX + indentX, childStartY, childDepth, [layoutNode], result, collapsedIds, + groups, startX + indentX, childStartY, childDepth, [layoutNode], result, collapsedIds, expandedMergedIds, pendingExpansions, ); const totalChildrenHeight = endY - childStartY - NODE_GAP_Y; @@ -335,7 +452,7 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, result.subgraphs.push({ label: headerLabel, x: startX - SUBGRAPH_PADDING, - y: (y + NODE_HEIGHT + NODE_GAP_Y) - NODE_GAP_Y / 2, + y: (y + nodeHeight + NODE_GAP_Y) - NODE_GAP_Y / 2, width: sgContentWidth + SUBGRAPH_PADDING * 2, height: totalChildrenHeight + SUBGRAPH_HEADER_HEIGHT + NODE_GAP_Y, depth, @@ -344,13 +461,13 @@ function layoutSubtree(node: FlowNode, startX: number, y: number, depth: number, } result.width = Math.max(nodeWidth, maxWidth + indentX * 2, isSubagent ? sgContentWidth + indentX * 2 : 0); - result.height = NODE_HEIGHT + NODE_GAP_Y + totalChildrenHeight + (isSubagent ? SUBGRAPH_HEADER_HEIGHT : 0); + result.height = nodeHeight + NODE_GAP_Y + totalChildrenHeight + (isSubagent ? SUBGRAPH_HEADER_HEIGHT : 0); result.exitNodes = exitNodes; return result; } -function layoutParallelGroup(children: FlowNode[], startX: number, y: number, depth: number, collapsedIds?: ReadonlySet): { +function layoutParallelGroup(children: FlowNode[], startX: number, y: number, depth: number, collapsedIds?: ReadonlySet, expandedMergedIds?: ReadonlySet, pendingExpansions?: PendingExpansion[]): { nodes: LayoutNode[]; edges: LayoutEdge[]; subgraphs: SubgraphRect[]; @@ -364,7 +481,7 @@ function layoutParallelGroup(children: FlowNode[], startX: number, y: number, de let maxHeight = 0; for (const child of children) { - const subtree = layoutSubtree(child, 0, y, depth, collapsedIds); + const subtree = layoutSubtree(child, 0, y, depth, collapsedIds, expandedMergedIds, pendingExpansions); subtreeLayouts.push(subtree); totalWidth += subtree.width; maxHeight = Math.max(maxHeight, subtree.height); @@ -577,19 +694,58 @@ function renderSubgraphs(svg: SVGElement, subgraphs: readonly SubgraphRect[], fo function renderEdges(svg: SVGElement, edges: readonly LayoutEdge[]): void { const strokeAttrs = { fill: 'none', stroke: 'var(--vscode-descriptionForeground)', 'stroke-width': EDGE_STROKE_WIDTH, 'stroke-linecap': 'round' }; + // allow-any-unicode-next-line + const r = 6; // corner radius for 90° bends for (const edge of edges) { const midY = (edge.fromY + edge.toY) / 2; - svg.appendChild(svgEl('path', { - ...strokeAttrs, - d: `M ${edge.fromX} ${edge.fromY} C ${edge.fromX} ${midY}, ${edge.toX} ${midY}, ${edge.toX} ${edge.toY}`, - })); + let d: string; + const isHorizontal = edge.fromY === edge.toY; + + if (isHorizontal) { + // Horizontally aligned: straight line (used by expanded merged nodes) + d = `M ${edge.fromX} ${edge.fromY} L ${edge.toX} ${edge.toY}`; + } else if (edge.fromX === edge.toX) { + // Vertically aligned: straight line + d = `M ${edge.fromX} ${edge.fromY} L ${edge.toX} ${edge.toY}`; + } else { + // allow-any-unicode-next-line + // Orthogonal routing: down, 90° horizontal, 90° down + const dx = edge.toX - edge.fromX; + const signX = dx > 0 ? 1 : -1; + const absDx = Math.abs(dx); + const cr = Math.min(r, absDx / 2, (edge.toY - edge.fromY) / 4); + + d = `M ${edge.fromX} ${edge.fromY}` + // Down to first bend + + ` L ${edge.fromX} ${midY - cr}` + // allow-any-unicode-next-line + // 90° arc turning horizontal + + ` Q ${edge.fromX} ${midY}, ${edge.fromX + signX * cr} ${midY}` + // Horizontal to second bend + + ` L ${edge.toX - signX * cr} ${midY}` + // allow-any-unicode-next-line + // 90° arc turning down + + ` Q ${edge.toX} ${midY}, ${edge.toX} ${midY + cr}` + // Down to target + + ` L ${edge.toX} ${edge.toY}`; + } - const a = 5; // arrowhead size + svg.appendChild(svgEl('path', { ...strokeAttrs, d })); + + // Arrowhead: right-pointing for horizontal edges, down-pointing otherwise + const a = 5; + let arrowD: string; + if (isHorizontal) { + const signX = edge.toX > edge.fromX ? 1 : -1; + arrowD = `M ${edge.toX - signX * a * 1.5} ${edge.toY - a} L ${edge.toX} ${edge.toY} L ${edge.toX - signX * a * 1.5} ${edge.toY + a}`; + } else { + arrowD = `M ${edge.toX - a} ${edge.toY - a * 1.5} L ${edge.toX} ${edge.toY} L ${edge.toX + a} ${edge.toY - a * 1.5}`; + } svg.appendChild(svgEl('path', { ...strokeAttrs, 'stroke-linejoin': 'round', - d: `M ${edge.toX - a} ${edge.toY - a * 1.5} L ${edge.toX} ${edge.toY} L ${edge.toX + a} ${edge.toY - a * 1.5}`, + d: arrowD, })); } } @@ -625,6 +781,21 @@ function renderNodes(svg: SVGElement, nodes: readonly LayoutNode[], focusableEle clipPath.appendChild(svgEl('rect', rectAttrs)); svg.appendChild(clipPath); + // Focus ring (hidden by default, shown on :focus via CSS) + const focusOffset = 3; + g.appendChild(svgEl('rect', { + class: 'chat-debug-flowchart-focus-ring', + x: node.x - focusOffset, + y: node.y - focusOffset, + width: node.width + focusOffset * 2, + height: node.height + focusOffset * 2, + rx: NODE_BORDER_RADIUS + focusOffset, + ry: NODE_BORDER_RADIUS + focusOffset, + fill: 'none', + stroke: 'var(--vscode-focusBorder)', + 'stroke-width': 2, + })); + // Node rectangle g.appendChild(svgEl('rect', { ...rectAttrs, fill: nodeFill, stroke: color, 'stroke-width': node.isError ? 2 : 1.5 })); @@ -633,20 +804,80 @@ function renderNodes(svg: SVGElement, nodes: readonly LayoutNode[], focusableEle // Label text const textX = node.x + NODE_PADDING_H; - if (node.sublabel) { + const isMessage = isMessageKind(node.kind); + if (isMessage && node.sublabel) { + // Message nodes: small header label + larger message text + const header = svgEl('text', { x: textX, y: node.y + NODE_PADDING_V + SUBLABEL_FONT_SIZE, 'font-size': SUBLABEL_FONT_SIZE, fill: 'var(--vscode-descriptionForeground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); + header.textContent = node.label; + g.appendChild(header); + + const msg = svgEl('text', { x: textX, y: node.y + node.height - NODE_PADDING_V - 2, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); + msg.textContent = node.sublabel; + g.appendChild(msg); + } else if (node.sublabel) { const label = svgEl('text', { x: textX, y: node.y + NODE_PADDING_V + FONT_SIZE, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); label.textContent = node.label; g.appendChild(label); - const sub = svgEl('text', { x: textX, y: node.y + NODE_HEIGHT - NODE_PADDING_V, 'font-size': SUBLABEL_FONT_SIZE, fill: 'var(--vscode-descriptionForeground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); + const sub = svgEl('text', { x: textX, y: node.y + node.height - NODE_PADDING_V, 'font-size': SUBLABEL_FONT_SIZE, fill: 'var(--vscode-descriptionForeground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); sub.textContent = node.sublabel; g.appendChild(sub); } else { - const label = svgEl('text', { x: textX, y: node.y + NODE_HEIGHT / 2 + FONT_SIZE / 2 - 1, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); + const label = svgEl('text', { x: textX, y: node.y + node.height / 2 + FONT_SIZE / 2 - 1, 'font-size': FONT_SIZE, fill: 'var(--vscode-foreground)', 'font-family': fontFamily, 'clip-path': `url(#${clipId})` }); label.textContent = node.label; g.appendChild(label); } + // Merged-discovery expand/collapse toggle on the right side + if (node.mergedCount) { + renderMergedToggle(g, node, color, fontFamily); + } + svg.appendChild(g); } } + +function renderMergedToggle(g: Element, node: LayoutNode, color: string, fontFamily: string): void { + const toggleX = node.x + node.width - MERGED_TOGGLE_WIDTH; + const toggleGroup = document.createElementNS(SVG_NS, 'g'); + toggleGroup.classList.add('chat-debug-flowchart-merged-toggle'); + toggleGroup.setAttribute('data-merged-id', node.id); + + // Separator line + toggleGroup.appendChild(svgEl('line', { + x1: toggleX, y1: node.y + 4, + x2: toggleX, y2: node.y + node.height - 4, + stroke: 'var(--vscode-descriptionForeground)', + 'stroke-width': 0.5, + opacity: 0.4, + })); + + // allow-any-unicode-next-line + // Expand chevron (▶ collapsed, ◀ expanded) + const chevronX = toggleX + MERGED_TOGGLE_WIDTH / 2; + const chevronY = node.y + node.height / 2; + const chevron = svgEl('text', { + x: chevronX, + y: chevronY + 4, + 'font-size': 9, + fill: color, + 'font-family': fontFamily, + 'text-anchor': 'middle', + cursor: 'pointer', + }); + // allow-any-unicode-next-line + chevron.textContent = node.isMergedExpanded ? '\u25C0' : '\u25B6'; // ◀ or ▶ + toggleGroup.appendChild(chevron); + + // Hit area for the toggle — invisible rect covering the toggle zone + toggleGroup.appendChild(svgEl('rect', { + x: toggleX, + y: node.y, + width: MERGED_TOGGLE_WIDTH, + height: node.height, + fill: 'transparent', + cursor: 'pointer', + })); + + g.appendChild(toggleGroup); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugModelTurnContentRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugModelTurnContentRenderer.ts new file mode 100644 index 0000000000000..cc88136bf2b3f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugModelTurnContentRenderer.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IChatDebugEventModelTurnContent } from '../../common/chatDebugService.js'; +import { renderCollapsibleSection } from './chatDebugCollapsible.js'; + +const $ = DOM.$; + +/** + * Render a resolved model turn content with structured display of + * request metadata, token usage, and timing. + */ +export function renderModelTurnContent(content: IChatDebugEventModelTurnContent): { element: HTMLElement; disposables: DisposableStore } { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + // Header: Model Turn + DOM.append(container, $('div.chat-debug-message-content-title', undefined, localize('chatDebug.modelTurn.title', "Model Turn"))); + + // Status summary line + const statusParts: string[] = []; + if (content.requestName) { + statusParts.push(content.requestName); + } + if (content.model) { + statusParts.push(content.model); + } + if (content.status) { + statusParts.push(content.status); + } + if (content.durationInMillis !== undefined) { + statusParts.push(localize('chatDebug.modelTurn.duration', "{0}ms", content.durationInMillis)); + } + if (statusParts.length > 0) { + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, statusParts.join(' \u00b7 '))); + } + + // Token usage details + const detailsContainer = DOM.append(container, $('div.chat-debug-model-turn-details')); + + if (content.inputTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.inputTokens', "Input tokens: {0}", content.inputTokens))); + } + if (content.outputTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.outputTokens', "Output tokens: {0}", content.outputTokens))); + } + if (content.cachedTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.cachedTokens', "Cached tokens: {0}", content.cachedTokens))); + } + if (content.totalTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.totalTokens', "Total tokens: {0}", content.totalTokens))); + } + if (content.timeToFirstTokenInMillis !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.ttft', "Time to first token: {0}ms", content.timeToFirstTokenInMillis))); + } + if (content.maxInputTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.maxInputTokens', "Max input tokens: {0}", content.maxInputTokens))); + } + if (content.maxOutputTokens !== undefined) { + DOM.append(detailsContainer, $('div', undefined, localize('chatDebug.modelTurn.maxOutputTokens', "Max output tokens: {0}", content.maxOutputTokens))); + } + if (content.errorMessage) { + DOM.append(detailsContainer, $('div.chat-debug-model-turn-error', undefined, localize('chatDebug.modelTurn.error', "Error: {0}", content.errorMessage))); + } + + // Collapsible sections (e.g., system prompt, user prompt, tools, response) + if (content.sections && content.sections.length > 0) { + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + DOM.append(sectionsContainer, $('div.chat-debug-message-sections-label', undefined, + localize('chatDebug.modelTurn.sections', "Sections ({0})", content.sections.length))); + + for (const section of content.sections) { + renderCollapsibleSection(sectionsContainer, section, disposables); + } + } + + return { element: container, disposables }; +} + +/** + * Convert a resolved model turn content to plain text for clipboard / editor output. + */ +export function modelTurnContentToPlainText(content: IChatDebugEventModelTurnContent): string { + const lines: string[] = []; + lines.push(localize('chatDebug.modelTurn.requestLabel', "Request: {0}", content.requestName)); + + if (content.model) { + lines.push(localize('chatDebug.modelTurn.modelLabel', "Model: {0}", content.model)); + } + if (content.status) { + lines.push(localize('chatDebug.modelTurn.statusLabel', "Status: {0}", content.status)); + } + if (content.durationInMillis !== undefined) { + lines.push(localize('chatDebug.modelTurn.durationLabel', "Duration: {0}ms", content.durationInMillis)); + } + if (content.timeToFirstTokenInMillis !== undefined) { + lines.push(localize('chatDebug.modelTurn.ttftLabel', "Time to first token: {0}ms", content.timeToFirstTokenInMillis)); + } + if (content.inputTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.inputTokensLabel', "Input tokens: {0}", content.inputTokens)); + } + if (content.outputTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.outputTokensLabel', "Output tokens: {0}", content.outputTokens)); + } + if (content.cachedTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.cachedTokensLabel', "Cached tokens: {0}", content.cachedTokens)); + } + if (content.totalTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.totalTokensLabel', "Total tokens: {0}", content.totalTokens)); + } + if (content.maxInputTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.maxInputTokensLabel', "Max input tokens: {0}", content.maxInputTokens)); + } + if (content.maxOutputTokens !== undefined) { + lines.push(localize('chatDebug.modelTurn.maxOutputTokensLabel', "Max output tokens: {0}", content.maxOutputTokens)); + } + if (content.errorMessage) { + lines.push(localize('chatDebug.modelTurn.errorLabel', "Error: {0}", content.errorMessage)); + } + + if (content.sections && content.sections.length > 0) { + lines.push(''); + for (const section of content.sections) { + lines.push(`--- ${section.name} ---`); + lines.push(section.content); + lines.push(''); + } + } + + return lines.join('\n'); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugToolCallContentRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugToolCallContentRenderer.ts new file mode 100644 index 0000000000000..f8e0618f271ba --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/chatDebugToolCallContentRenderer.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../../base/browser/dom.js'; +import { createTrustedTypesPolicy } from '../../../../../base/browser/trustedTypes.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +import { tokenizeToString } from '../../../../../editor/common/languages/textToHtmlTokenizer.js'; +import { IChatDebugEventToolCallContent } from '../../common/chatDebugService.js'; +import { setupCollapsibleToggle } from './chatDebugCollapsible.js'; + +const $ = DOM.$; + +const _ttpPolicy = createTrustedTypesPolicy('chatDebugTokenizer', { + createHTML(html: string) { + return html; + } +}); + +function tryParseJSON(text: string): { parsed: unknown; isJSON: true } | { isJSON: false } { + try { + return { parsed: JSON.parse(text), isJSON: true }; + } catch { + return { isJSON: false }; + } +} + +/** + * Render a collapsible section. When `tokenizedHtml` is provided the content + * is rendered as syntax-highlighted HTML; otherwise plain-text is used. + */ +function renderSection( + parent: HTMLElement, + label: string, + plainText: string, + tokenizedHtml: string | undefined, + disposables: DisposableStore, + initiallyCollapsed: boolean = false, +): void { + const sectionEl = DOM.append(parent, $('div.chat-debug-message-section')); + const header = DOM.append(sectionEl, $('div.chat-debug-message-section-header')); + const chevron = DOM.append(header, $('span.chat-debug-message-section-chevron')); + DOM.append(header, $('span.chat-debug-message-section-title', undefined, label)); + + const wrapper = DOM.append(sectionEl, $('div.chat-debug-message-section-content-wrapper')); + const contentEl = DOM.append(wrapper, $('pre.chat-debug-message-section-content')); + contentEl.tabIndex = 0; + + if (tokenizedHtml) { + const trustedHtml = _ttpPolicy?.createHTML(tokenizedHtml) ?? tokenizedHtml; + contentEl.innerHTML = trustedHtml as string; + } else { + contentEl.textContent = plainText; + } + + setupCollapsibleToggle(chevron, header, wrapper, disposables, initiallyCollapsed); +} + +/** + * Render a resolved tool call content with structured sections for + * tool name, status, duration, arguments, and output. + * Reuses the existing message content and collapsible section components. + * When JSON is detected in input/output, renders it with syntax highlighting + * using the editor's tokenization. + */ +export async function renderToolCallContent(content: IChatDebugEventToolCallContent, languageService: ILanguageService): Promise<{ element: HTMLElement; disposables: DisposableStore }> { + const disposables = new DisposableStore(); + const container = $('div.chat-debug-message-content'); + container.tabIndex = 0; + + // Header: tool name + DOM.append(container, $('div.chat-debug-message-content-title', undefined, content.toolName)); + + // Status summary line + const statusParts: string[] = []; + if (content.result) { + statusParts.push(content.result === 'success' + ? localize('chatDebug.toolCall.success', "Success") + : localize('chatDebug.toolCall.error', "Error")); + } + if (content.durationInMillis !== undefined) { + statusParts.push(localize('chatDebug.toolCall.duration', "{0}ms", content.durationInMillis)); + } + if (statusParts.length > 0) { + DOM.append(container, $('div.chat-debug-message-content-summary', undefined, statusParts.join(' \u00b7 '))); + } + + // Build collapsible sections for arguments and output + const sectionsContainer = DOM.append(container, $('div.chat-debug-message-sections')); + + if (content.input) { + const result = tryParseJSON(content.input); + const plainText = result.isJSON ? JSON.stringify(result.parsed, null, 2) : content.input; + const tokenizedHtml = result.isJSON + ? await tokenizeToString(languageService, plainText, 'json') + : undefined; + renderSection(sectionsContainer, localize('chatDebug.toolCall.arguments', "Arguments"), plainText, tokenizedHtml, disposables); + } + + if (content.output) { + const result = tryParseJSON(content.output); + const plainText = result.isJSON ? JSON.stringify(result.parsed, null, 2) : content.output; + const tokenizedHtml = result.isJSON + ? await tokenizeToString(languageService, plainText, 'json') + : undefined; + renderSection(sectionsContainer, localize('chatDebug.toolCall.output', "Output"), plainText, tokenizedHtml, disposables); + } + + return { element: container, disposables }; +} + +/** + * Convert a resolved tool call content to plain text for clipboard / editor output. + */ +export function toolCallContentToPlainText(content: IChatDebugEventToolCallContent): string { + const lines: string[] = []; + lines.push(localize('chatDebug.toolCall.toolLabel', "Tool: {0}", content.toolName)); + + if (content.result) { + lines.push(localize('chatDebug.toolCall.statusLabel', "Status: {0}", content.result)); + } + if (content.durationInMillis !== undefined) { + lines.push(localize('chatDebug.toolCall.durationLabel', "Duration: {0}ms", content.durationInMillis)); + } + + if (content.input) { + lines.push(''); + lines.push(`[${localize('chatDebug.toolCall.arguments', "Arguments")}]`); + try { + const parsed = JSON.parse(content.input); + lines.push(JSON.stringify(parsed, null, 2)); + } catch { + lines.push(content.input); + } + } + + if (content.output) { + lines.push(''); + lines.push(`[${localize('chatDebug.toolCall.output', "Output")}]`); + try { + const parsed = JSON.parse(content.output); + lines.push(JSON.stringify(parsed, null, 2)); + } catch { + lines.push(content.output); + } + } + + return lines.join('\n'); +} diff --git a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css index 5e70e6491f7b2..a9d0c0dc2d515 100644 --- a/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css +++ b/src/vs/workbench/contrib/chat/browser/chatDebug/media/chatDebug.css @@ -740,14 +740,19 @@ .chat-debug-flowchart-subgraph:hover rect.chat-debug-flowchart-subgraph-header { opacity: 0.25; } -.chat-debug-flowchart-node:focus { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: 2px; - border-radius: 6px; +.chat-debug-flowchart-focus-ring { + display: none; +} +.chat-debug-flowchart-node:focus, +.chat-debug-flowchart-node:focus-visible { + outline: none !important; +} +.chat-debug-flowchart-node:focus .chat-debug-flowchart-focus-ring { + display: inline; } .chat-debug-flowchart-subgraph-header:focus { outline: 2px solid var(--vscode-focusBorder); - outline-offset: 1px; + outline-offset: 2px; } .chat-debug-flowchart-empty { display: flex; diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts index 7e1830cee3505..f6d6f20dc9e4c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -162,6 +162,15 @@ export class ChatSlashCommandsContribution extends Disposable { } })); const enableAutoApprove = async (): Promise => { + const inspection = configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + if (inspection.policyValue !== undefined) { + if (inspection.policyValue === true) { + // Global auto-approve is already enabled by policy; nothing more to do. + return true; + } + notificationService.warn(nls.localize('autoApprove.policyManaged', "Global auto-approve is managed by your organization policy. Contact your administrator to change this setting.")); + return false; + } const alreadyOptedIn = storageService.getBoolean('chat.tools.global.autoApprove.optIn', StorageScope.APPLICATION, false); if (!alreadyOptedIn) { const result = await dialogService.prompt({ @@ -172,8 +181,7 @@ export class ChatSlashCommandsContribution extends Disposable { { label: nls.localize('autoApprove.cancel.button', 'Cancel'), run: () => false }, ], custom: { - disableCloseAction: true, - markdownDetails: [{ markdown: new MarkdownString(globalAutoApproveDescription.value) }], + markdownDetails: [{ markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }) }], } }); if (result.result !== true) { @@ -193,11 +201,22 @@ export class ChatSlashCommandsContribution extends Disposable { if (widget) { widget.acceptInput(trimmed); } + } else { + // Restore the prompt so the user doesn't lose their input + const widget = chatWidgetService.getWidgetBySessionResource(sessionResource); + if (widget) { + widget.setInput(trimmed); + } } } else { // /autoApprove — toggle const isEnabled = configurationService.getValue(ChatConfiguration.GlobalAutoApprove); if (isEnabled) { + const inspection = configurationService.inspect(ChatConfiguration.GlobalAutoApprove); + if (inspection.policyValue !== undefined) { + notificationService.warn(nls.localize('autoApprove.policyManaged', "Global auto-approve is managed by your organization policy. Contact your administrator to change this setting.")); + return; + } await configurationService.updateValue(ChatConfiguration.GlobalAutoApprove, false); notificationService.info(nls.localize('autoApprove.disabled', "Global auto-approve disabled — tools will require approval")); } else { @@ -207,7 +226,7 @@ export class ChatSlashCommandsContribution extends Disposable { }; this._store.add(slashCommandService.registerSlashCommand({ command: 'autoApprove', - detail: nls.localize('autoApprove', "Toggle global auto-approval of all tool calls"), + detail: nls.localize('autoApprove', "Toggle global auto-approval of all tool calls (alias: /yolo)"), sortText: 'z1_autoApprove', executeImmediately: false, silent: true, @@ -215,7 +234,7 @@ export class ChatSlashCommandsContribution extends Disposable { }, handleAutoApprove)); this._store.add(slashCommandService.registerSlashCommand({ command: 'yolo', - detail: nls.localize('yolo', "Toggle global auto-approval of all tool calls"), + detail: nls.localize('yolo', "Toggle global auto-approval of all tool calls (alias: /autoApprove)"), sortText: 'z1_yolo', executeImmediately: false, silent: true, diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 1c82947166de0..7f2559f5895ed 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -117,6 +117,11 @@ export interface IChatTipService { */ navigateToPreviousTip(): IChatTip | undefined; + /** + * Returns whether there are multiple eligible tips for navigation. + */ + hasMultipleTips(): boolean; + /** * Clears all dismissed tips so they can be shown again. */ @@ -976,6 +981,15 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._navigateTip(-1, this._contextKeyService); } + hasMultipleTips(): boolean { + if (!this._contextKeyService) { + return false; + } + + this._createSlashCommandsUsageTracker.syncContextKey(this._contextKeyService); + return this._hasNavigableTip(this._contextKeyService); + } + private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined { this._createSlashCommandsUsageTracker.syncContextKey(contextKeyService); if (!this._shownTip) { @@ -987,20 +1001,57 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + const candidate = this._getNavigableTip(direction, currentIndex, contextKeyService); + if (candidate) { + this._logTipTelemetry(this._shownTip.id, direction === 1 ? 'navigateNext' : 'navigatePrevious'); + this._shownTip = candidate; + this._tipRequestId = 'welcome'; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); + this._logTipTelemetry(candidate.id, 'shown'); + this._trackTipCommandClicks(candidate); + const tip = this._createTip(candidate); + this._onDidNavigateTip.fire(tip); + return tip; + } + + return undefined; + } + + private _hasNavigableTip(contextKeyService: IContextKeyService): boolean { + if (!this._shownTip) { + return false; + } + + const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); + if (currentIndex === -1) { + return false; + } + + return !!this._getNavigableTip(1, currentIndex, contextKeyService); + } + + private _getNavigableTip(direction: 1 | -1, currentIndex: number, contextKeyService: IContextKeyService): ITipDefinition | undefined { const dismissedIds = new Set(this._getDismissedTipIds()); + + let eligibleTipCount = 0; + for (const tip of TIP_CATALOG) { + if (!dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService)) { + eligibleTipCount++; + if (eligibleTipCount > 1) { + break; + } + } + } + + if (eligibleTipCount <= 1) { + return undefined; + } + for (let i = 1; i < TIP_CATALOG.length; i++) { const idx = ((currentIndex + direction * i) % TIP_CATALOG.length + TIP_CATALOG.length) % TIP_CATALOG.length; const candidate = TIP_CATALOG[idx]; if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { - this._logTipTelemetry(this._shownTip.id, direction === 1 ? 'navigateNext' : 'navigatePrevious'); - this._shownTip = candidate; - this._tipRequestId = 'welcome'; - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.APPLICATION, StorageTarget.USER); - this._logTipTelemetry(candidate.id, 'shown'); - this._trackTipCommandClicks(candidate); - const tip = this._createTip(candidate); - this._onDidNavigateTip.fire(tip); - return tip; + return candidate; } } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index 5113db79b5cfe..3f932d05506c2 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -27,6 +27,7 @@ import { ILabelService } from '../../../../../../platform/label/common/label.js' import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { PromptFileRewriter } from '../promptFileRewriter.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; +import { assertNever } from '../../../../../../base/common/assert.js'; /** * Options for the {@link askToSelectInstructions} function. @@ -507,6 +508,17 @@ export class PromptFilePickers { result.push({ type: 'separator', label: localize('separator.user', "User Data") }); result.push(...sortByLabel(await Promise.all(users.map(u => this._createPromptPickItem(u, buttons, getVisibility(u), token))))); } + + // Plugin files are read-only so only copy button is available + const plugins = await this._promptsService.listPromptFilesForStorage(options.type, PromptsStorage.plugin, token); + if (plugins.length) { + const pluginButtons: IQuickInputButton[] = []; + if (options.optionCopy !== false) { + pluginButtons.push(COPY_BUTTON); + } + result.push({ type: 'separator', label: localize('separator.plugins', "Plugins") }); + result.push(...sortByLabel(await Promise.all(plugins.map(p => this._createPromptPickItem(p, pluginButtons, getVisibility(p), token))))); + } return result; } @@ -552,6 +564,11 @@ export class PromptFilePickers { case PromptsStorage.user: tooltip = undefined; break; + case PromptsStorage.plugin: + tooltip = promptFile.name; + break; + default: + assertNever(promptFile); } let iconClass: string | undefined; if (visibility === false) { diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index a367b84a045b7..93f94e85917b9 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -79,10 +79,10 @@ export const globalAutoApproveDescription = localize2( '{Locked=\'](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)\'}', '{Locked=\'](https://code.visualstudio.com/docs/copilot/security)\'}', '{Locked=\'**\'}', - '{Locked=\'`#chat.autoReply#`\'}', + '{Locked=\'[`chat.autoReply`](command:workbench.action.openSettings?%5B%22chat.autoReply%22%5D)\'}', ] }, - 'Global auto approve also known as "YOLO mode" disables manual approval completely for _all tools in all workspaces_, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like [Codespaces](https://github.com/features/codespaces) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) have user keys forwarded into the container that could be compromised.\n\n**This feature disables [critical security protections](https://code.visualstudio.com/docs/copilot/security) and makes it much easier for an attacker to compromise the machine.**\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use `#chat.autoReply#`.' + 'Global auto approve also known as "YOLO mode" disables manual approval completely for _all tools in all workspaces_, allowing the agent to act fully autonomously. This is extremely dangerous and is *never* recommended, even containerized environments like [Codespaces](https://github.com/features/codespaces) and [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) have user keys forwarded into the container that could be compromised.\n\n**This feature disables [critical security protections](https://code.visualstudio.com/docs/copilot/security) and makes it much easier for an attacker to compromise the machine.**\n\nNote: This setting only controls tool approval and does not prevent the agent from asking questions. To automatically answer agent questions, use the [`chat.autoReply`](command:workbench.action.openSettings?%5B%22chat.autoReply%22%5D) setting.' ); export class LanguageModelToolsService extends Disposable implements ILanguageModelToolsService { @@ -1107,9 +1107,8 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo ], custom: { icon: Codicon.warning, - disableCloseAction: true, markdownDetails: [{ - markdown: new MarkdownString(globalAutoApproveDescription.value), + markdown: new MarkdownString(globalAutoApproveDescription.value, { isTrusted: { enabledCommands: ['workbench.action.openSettings'] } }), }], } }); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts index 03f160cdd7296..95b0edf8e267b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatProgressContentPart.ts @@ -151,6 +151,10 @@ export class ChatProgressSubPart extends Disposable { content: tooltip, style: HoverStyle.Pointer, })); + this._register(hoverService.setupDelayedHover(messageElement, { + content: tooltip, + style: HoverStyle.Pointer, + })); } append(this.domNode, iconElement); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index f715e54ab038f..892ed35e18d20 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -6,7 +6,6 @@ import './media/chatTipContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; -import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../../base/common/event.js'; import { onUnexpectedError } from '../../../../../../base/common/errors.js'; @@ -39,6 +38,7 @@ export class ChatTipContentPart extends Disposable { private readonly _toolbar = this._register(new MutableDisposable()); private readonly _inChatTipContextKey: IContextKey; + private readonly _multipleChatTipsContextKey: IContextKey; constructor( tip: IChatTip, @@ -60,10 +60,16 @@ export class ChatTipContentPart extends Disposable { this.domNode.setAttribute('aria-roledescription', localize('chatTipRoleDescription', "tip")); this._inChatTipContextKey = ChatContextKeys.inChatTip.bindTo(this._contextKeyService); + this._multipleChatTipsContextKey = ChatContextKeys.multipleChatTips.bindTo(this._contextKeyService); const focusTracker = this._register(dom.trackFocus(this.domNode)); this._register(focusTracker.onDidFocus(() => this._inChatTipContextKey.set(true))); this._register(focusTracker.onDidBlur(() => this._inChatTipContextKey.set(false))); - this._register({ dispose: () => this._inChatTipContextKey.reset() }); + this._register({ + dispose: () => { + this._inChatTipContextKey.reset(); + this._multipleChatTipsContextKey.reset(); + } + }); this._renderTip(tip); @@ -114,8 +120,8 @@ export class ChatTipContentPart extends Disposable { private _renderTip(tip: IChatTip): void { dom.clearNode(this.domNode); this._toolbar.clear(); + this._multipleChatTipsContextKey.set(this._chatTipService.hasMultipleTips()); - this.domNode.appendChild(renderIcon(Codicon.lightbulb)); const markdownContent = this._renderer.render(tip.content, { actionHandler: (link, md) => { this._handleTipAction(link, md).catch(onUnexpectedError); } }); @@ -168,6 +174,7 @@ registerAction2(class PreviousTipAction extends Action2 { id: 'workbench.action.chat.previousTip', title: localize2('chatTip.previous', "Previous tip"), icon: Codicon.chevronLeft, + precondition: ChatContextKeys.multipleChatTips, f1: false, menu: [{ id: MenuId.ChatTipToolbar, @@ -189,6 +196,7 @@ registerAction2(class NextTipAction extends Action2 { id: 'workbench.action.chat.nextTip', title: localize2('chatTip.next', "Next tip"), icon: Codicon.chevronRight, + precondition: ChatContextKeys.multipleChatTips, f1: false, menu: [{ id: MenuId.ChatTipToolbar, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 8652ea889d7a2..ac552db4d04ea 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -27,88 +27,89 @@ container-type: inline-size; } -/* container and header */ +/* input part wrapper */ .interactive-session .interactive-input-part > .chat-question-carousel-widget-container { width: 100%; position: relative; +} - .chat-question-carousel-content { - display: flex; - flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 8px 16px 10px 16px; - overflow: hidden; +/* container and header */ +.interactive-session .chat-question-carousel-container .chat-question-carousel-content { + display: flex; + flex-direction: column; + background: var(--vscode-chat-requestBackground); + padding: 8px 16px 10px 16px; + overflow: hidden; - .chat-question-header-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; + .chat-question-header-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + min-width: 0; + padding-bottom: 5px; + margin-left: -16px; + margin-right: -16px; + padding-left: 16px; + padding-right: 16px; + border-bottom: 1px solid var(--vscode-chat-requestBorder); + + .chat-question-title { + flex: 1; min-width: 0; - padding-bottom: 5px; - margin-left: -16px; - margin-right: -16px; - padding-left: 16px; - padding-right: 16px; - border-bottom: 1px solid var(--vscode-chat-requestBorder); - - .chat-question-title { - flex: 1; - min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; - font-weight: 500; - font-size: var(--vscode-chat-font-size-body-s); - margin: 0; - - .rendered-markdown { - a { - color: var(--vscode-textLink-foreground); - } - - a:hover, - a:active { - color: var(--vscode-textLink-activeForeground); - } + word-wrap: break-word; + overflow-wrap: break-word; + font-weight: 500; + font-size: var(--vscode-chat-font-size-body-s); + margin: 0; - p { - margin: 0; - } + .rendered-markdown { + a { + color: var(--vscode-textLink-foreground); } - .chat-question-title-main { - font-weight: 500; + a:hover, + a:active { + color: var(--vscode-textLink-activeForeground); } - .chat-question-title-subtitle { - font-weight: normal; - color: var(--vscode-descriptionForeground); + p { + margin: 0; } } - .chat-question-close-container { - flex-shrink: 0; + .chat-question-title-main { + font-weight: 500; + } - .monaco-button.chat-question-close { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none; - background: transparent !important; - color: var(--vscode-foreground) !important; - } + .chat-question-title-subtitle { + font-weight: normal; + color: var(--vscode-descriptionForeground); + } + } - .monaco-button.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; - } + .chat-question-close-container { + flex-shrink: 0; + + .monaco-button.chat-question-close { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent !important; + color: var(--vscode-foreground) !important; + } + + .monaco-button.chat-question-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; } } } } /* questions list and freeform area */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-input-container { +.interactive-session .chat-question-carousel-container .chat-question-input-container { display: flex; flex-direction: column; margin-top: 4px; @@ -294,7 +295,7 @@ } /* footer with step indicator and nav buttons */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-footer-row { +.interactive-session .chat-question-carousel-container .chat-question-footer-row { display: flex; justify-content: space-between; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index 43a9186f4978c..dba68be9b33e4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -82,11 +82,6 @@ color: var(--vscode-textLink-activeForeground); } -.chat-getting-started-tip-container .chat-tip-widget .codicon-lightbulb { - font-size: 12px; - color: var(--vscode-notificationsWarningIcon-foreground); -} - .chat-getting-started-tip-container .chat-tip-widget .rendered-markdown p { margin: 0; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 9b1dea8a0be3e..3238bb06704a9 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -2076,6 +2076,14 @@ export class ChatWidget extends Disposable implements IChatWidget { this.chatSuggestNextWidget.hide(); this.chatTipService.resetSession(); + // Switching sessions resets tip service state; clear any rendered tip so + // empty-state rendering picks a fresh, context-appropriate tip. + this._gettingStartedTipPartRef = undefined; + this._gettingStartedTipPart.clear(); + const tipContainer = this.inputPart.gettingStartedTipContainerElement; + dom.clearNode(tipContainer); + dom.setVisibility(false, tipContainer); + this._codeBlockModelCollection.clear(); this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 46f466578863d..57a0106718a43 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -3036,7 +3036,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const executeToolbarPadding = (this.executeToolbar.getItemsLength() - 1) * 4; const inputToolbarPadding = this.inputActionsToolbar.getItemsLength() ? (this.inputActionsToolbar.getItemsLength() - 1) * 4 : 0; const contextUsageWidth = dom.getTotalWidth(this.contextUsageWidgetContainer); - return executeToolbarWidth + executeToolbarPadding + contextUsageWidth + (this.options.renderInputToolbarBelowInput ? 0 : inputToolbarWidth + inputToolbarPadding); + const inputToolbarsPadding = 12; // pdading between input toolbar/execute toolbar/contextUsage. + return executeToolbarWidth + executeToolbarPadding + contextUsageWidth + (this.options.renderInputToolbarBelowInput ? 0 : inputToolbarWidth + inputToolbarPadding + inputToolbarsPadding); }; return { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index ce813638d782f..2eb6414caed91 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -116,7 +116,7 @@ function createModelAction( * 2. Promoted section (selected + recently used + featured models from control manifest) * - Available models sorted alphabetically, followed by unavailable models * - Unavailable models show upgrade/update/admin status - * 3. Other Models (collapsible toggle, sorted by vendor then name) + * 3. Other Models (collapsible toggle, available first, then sorted by vendor then name) * - Last item is "Manage Models..." (always visible during filtering) */ export function buildModelPickerItems( @@ -265,10 +265,15 @@ export function buildModelPickerItems( } } - // Render promoted section: sorted alphabetically by name + // Render promoted section: available first, then sorted alphabetically by name let hasShownActionLink = false; if (promotedItems.length > 0) { promotedItems.sort((a, b) => { + const aAvail = a.kind === 'available' ? 0 : 1; + const bAvail = b.kind === 'available' ? 0 : 1; + if (aAvail !== bAvail) { + return aAvail - bAvail; + } const aName = a.kind === 'available' ? a.model.metadata.name : a.entry.label; const bName = b.kind === 'available' ? b.model.metadata.name : b.entry.label; return aName.localeCompare(bName); @@ -291,6 +296,13 @@ export function buildModelPickerItems( otherModels = models .filter(m => !placed.has(m.identifier) && !placed.has(m.metadata.id)) .sort((a, b) => { + const aEntry = controlModels[a.metadata.id] ?? controlModels[a.identifier]; + const bEntry = controlModels[b.metadata.id] ?? controlModels[b.identifier]; + const aAvail = aEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, aEntry.minVSCodeVersion) ? 1 : 0; + const bAvail = bEntry?.minVSCodeVersion && !isVersionAtLeast(currentVSCodeVersion, bEntry.minVSCodeVersion) ? 1 : 0; + if (aAvail !== bAvail) { + return aAvail - bAvail; + } const aCopilot = a.metadata.vendor === 'copilot' ? 0 : 1; const bCopilot = b.metadata.vendor === 'copilot' ? 0 : 1; if (aCopilot !== bCopilot) { @@ -363,6 +375,22 @@ export function buildModelPickerItems( return items; } +export function getModelPickerAccessibilityProvider() { + return { + isChecked(element: IActionListItem) { + return element.kind === ActionListItemKind.Action ? !!element?.item?.checked : undefined; + }, + getRole: (element: IActionListItem) => { + switch (element.kind) { + case ActionListItemKind.Action: return 'menuitemradio'; + case ActionListItemKind.Separator: return 'separator'; + default: return 'separator'; + } + }, + getWidgetRole: () => 'menu', + } as const; +} + function createUnavailableModelItem( id: string, entry: IModelControlEntry, @@ -573,19 +601,7 @@ export class ModelPickerWidget extends Disposable { anchorElement, undefined, [], - { - isChecked(element) { - return element.kind === 'action' && !!element?.item?.checked; - }, - getRole: (e) => { - switch (e.kind) { - case 'action': return 'menuitemcheckbox'; - case 'separator': return 'separator'; - default: return 'separator'; - } - }, - getWidgetRole: () => 'menu', - }, + getModelPickerAccessibilityProvider(), listOptions ); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css b/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css index 17c3b7dd1c2de..225d4422d4d8b 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chatViewWelcome.css @@ -22,7 +22,7 @@ .chat-welcome-view .chat-welcome-view-message, .chat-welcome-view .chat-welcome-view-disclaimer, .chat-welcome-view .chat-welcome-view-tips { - visibility: hidden; + display: none; } } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 118eff3f41d14..9ab62c99bbf22 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -43,6 +43,7 @@ export namespace ChatContextKeys { export const inChatEditor = new RawContextKey('inChatEditor', false, { type: 'boolean', description: localize('inChatEditor', "Whether focus is in a chat editor.") }); export const inChatTodoList = new RawContextKey('inChatTodoList', false, { type: 'boolean', description: localize('inChatTodoList', "True when focus is in the chat todo list.") }); export const inChatTip = new RawContextKey('inChatTip', false, { type: 'boolean', description: localize('inChatTip', "True when focus is in a chat tip.") }); + export const multipleChatTips = new RawContextKey('multipleChatTips', false, { type: 'boolean', description: localize('multipleChatTips', "True when there are multiple chat tips available.") }); export const inChatTerminalToolOutput = new RawContextKey('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") }); export const chatModeKind = new RawContextKey('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") }); export const chatModeName = new RawContextKey('chatModeName', '', { type: 'string', description: localize('chatModeName', "The name of the current chat mode (e.g. 'Plan' for custom modes).") }); diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index b64f5ca45f656..642afcefb4f5c 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -7,6 +7,7 @@ import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; +import { PromptsStorage } from './promptSyntax/service/promptsService.js'; export const IAICustomizationWorkspaceService = createDecorator('aiCustomizationWorkspaceService'); @@ -46,6 +47,11 @@ export interface IAICustomizationWorkspaceService { */ readonly managementSections: readonly AICustomizationManagementSection[]; + /** + * The storage sources to show as groups in the customization list. + */ + readonly visibleStorageSources: readonly PromptsStorage[]; + /** * Whether the primary creation action should create a file directly */ diff --git a/src/vs/workbench/contrib/chat/common/chatDebugService.ts b/src/vs/workbench/contrib/chat/common/chatDebugService.ts index 4e83565a941a1..a4028bf9823b0 100644 --- a/src/vs/workbench/contrib/chat/common/chatDebugService.ts +++ b/src/vs/workbench/contrib/chat/common/chatDebugService.ts @@ -246,10 +246,44 @@ export interface IChatDebugEventMessageContent { readonly sections: readonly IChatDebugMessageSection[]; } +/** + * Structured tool call content for a resolved debug event. + * Contains the tool name, status, arguments, and output for rich rendering. + */ +export interface IChatDebugEventToolCallContent { + readonly kind: 'toolCall'; + readonly toolName: string; + readonly result?: 'success' | 'error'; + readonly durationInMillis?: number; + readonly input?: string; + readonly output?: string; +} + +/** + * Structured model turn content for a resolved debug event. + * Contains request metadata, token usage, and timing for rich rendering. + */ +export interface IChatDebugEventModelTurnContent { + readonly kind: 'modelTurn'; + readonly requestName: string; + readonly model?: string; + readonly status?: string; + readonly durationInMillis?: number; + readonly timeToFirstTokenInMillis?: number; + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly inputTokens?: number; + readonly outputTokens?: number; + readonly cachedTokens?: number; + readonly totalTokens?: number; + readonly errorMessage?: string; + readonly sections?: readonly IChatDebugMessageSection[]; +} + /** * Union of all resolved event content types. */ -export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent; +export type IChatDebugResolvedEventContent = IChatDebugEventTextContent | IChatDebugEventFileListContent | IChatDebugEventMessageContent | IChatDebugEventToolCallContent | IChatDebugEventModelTurnContent; /** * Provider interface for debug events. diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index ca181bd19d223..25a9ef1bb2769 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; -import { URI } from '../../../../base/common/uri.js'; +import { isUriComponents, URI } from '../../../../base/common/uri.js'; import { IOffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; import { localize } from '../../../../nls.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -450,16 +450,20 @@ export class CustomChatMode implements IChatMode { type IChatModeSourceData = | { readonly storage: PromptsStorage.extension; readonly extensionId: string; type?: ExtensionAgentSourceType } - | { readonly storage: PromptsStorage.local | PromptsStorage.user }; + | { readonly storage: PromptsStorage.local | PromptsStorage.user } + | { readonly storage: PromptsStorage.plugin; readonly pluginUri: URI }; function isChatModeSourceData(value: unknown): value is IChatModeSourceData { if (typeof value !== 'object' || value === null) { return false; } - const data = value as { storage?: unknown; extensionId?: unknown }; + const data = value as { storage?: unknown; extensionId?: unknown; pluginUri?: unknown }; if (data.storage === PromptsStorage.extension) { return typeof data.extensionId === 'string'; } + if (data.storage === PromptsStorage.plugin) { + return isUriComponents(data.pluginUri); + } return data.storage === PromptsStorage.local || data.storage === PromptsStorage.user; } @@ -470,6 +474,9 @@ function serializeChatModeSource(source: IAgentSource | undefined): IChatModeSou if (source.storage === PromptsStorage.extension) { return { storage: PromptsStorage.extension, extensionId: source.extensionId.value, type: source.type }; } + if (source.storage === PromptsStorage.plugin) { + return { storage: PromptsStorage.plugin, pluginUri: source.pluginUri }; + } return { storage: source.storage }; } @@ -480,6 +487,9 @@ function reviveChatModeSource(data: IChatModeSourceData | undefined): IAgentSour if (data.storage === PromptsStorage.extension) { return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(data.extensionId), type: data.type ?? ExtensionAgentSourceType.contribution }; } + if (data.storage === PromptsStorage.plugin) { + return { storage: PromptsStorage.plugin, pluginUri: URI.revive(data.pluginUri) }; + } return { storage: data.storage }; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 70a63b90a1eee..bdfb80a30cd18 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1409,7 +1409,7 @@ export interface IChatService { resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions): Promise; adoptRequest(sessionResource: URI, request: IChatRequestModel): Promise; removeRequest(sessionResource: URI, requestId: string): Promise; - cancelCurrentRequestForSession(sessionResource: URI): void; + cancelCurrentRequestForSession(sessionResource: URI, isInlineChat?: boolean): void; /** * Sets yieldRequested on the active request for the given session. */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 42b409dc8b31d..d92c02ee69576 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1434,20 +1434,22 @@ export class ChatService extends Disposable implements IChatService { request.response?.complete(); } - cancelCurrentRequestForSession(sessionResource: URI): void { + cancelCurrentRequestForSession(sessionResource: URI, isInlineChat?: boolean): void { this.trace('cancelCurrentRequestForSession', `session: ${sessionResource}`); const pendingRequest = this._pendingRequests.get(sessionResource); if (!pendingRequest) { const model = this._sessionModels.get(sessionResource); const requestInProgress = model?.requestInProgress.get(); const pendingRequestsCount = model?.getPendingRequests().length ?? 0; - this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { - source: 'chatService', - reason: 'noPendingRequest', - requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', - pendingRequests: pendingRequestsCount, - }); - this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); + if (!isInlineChat) { + this.telemetryService.publicLog2(ChatStopCancellationNoopEventName, { + source: 'chatService', + reason: 'noPendingRequest', + requestInProgress: requestInProgress === undefined ? 'unknown' : requestInProgress ? 'true' : 'false', + pendingRequests: pendingRequestsCount, + }); + this.info('cancelCurrentRequestForSession', `No pending request was found for session ${sessionResource}. requestInProgress=${requestInProgress ?? 'unknown'}, pendingRequests=${pendingRequestsCount}`); + } return; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index 51391adb6e7cd..27d1dcc37e16c 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -30,9 +30,12 @@ import { ChatConfiguration } from '../constants.js'; import { parseCopilotHooks } from '../promptSyntax/hookCompatibility.js'; import { parseClaudeHooks } from '../promptSyntax/hookClaudeCompat.js'; import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginCommand, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js'; -import { cloneAndChange } from '../../../../../base/common/objects.js'; +import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; import { IPluginInstallService } from './pluginInstallService.js'; import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js'; +import { Mutable } from '../../../../../base/common/types.js'; +import { IHookCommand } from '../promptSyntax/hookSchema.js'; +import { cloneAndChange } from '../../../../../base/common/objects.js'; const COMMAND_FILE_SUFFIX = '.md'; @@ -53,34 +56,140 @@ function mapParsedHooks(parsed: Map ({ type, hooks, originalId })); } -const copilotPluginFormatAdapter: IAgentPluginFormatAdapter = { - format: AgentPluginFormat.Copilot, - manifestPaths: ['plugin.json'], - hookConfigPaths: ['hooks.json'], - hookWatchPaths: ['hooks.json'], - parseHooks: (json, pluginUri, userHome) => mapParsedHooks(parseCopilotHooks(json, pluginUri, userHome)), -}; - -const claudePluginFormatAdapter: IAgentPluginFormatAdapter = { - format: AgentPluginFormat.Claude, - manifestPaths: ['.claude-plugin/plugin.json'], - hookConfigPaths: ['hooks/hooks.json'], - hookWatchPaths: ['hooks'], - parseHooks: (json, pluginUri, userHome) => { +/** + * Resolves the workspace folder that contains the plugin URI for cwd resolution, + * falling back to the first workspace folder for plugins outside the workspace. + */ +function resolveWorkspaceRoot(pluginUri: URI, workspaceContextService: IWorkspaceContextService): URI | undefined { + const defaultFolder = workspaceContextService.getWorkspace().folders[0]; + const folder = workspaceContextService.getWorkspaceFolder(pluginUri) ?? defaultFolder; + return folder?.uri; +} + +class CopilotPluginFormatAdapter implements IAgentPluginFormatAdapter { + readonly format = AgentPluginFormat.Copilot; + readonly manifestPaths = ['plugin.json']; + readonly hookConfigPaths = ['hooks.json']; + readonly hookWatchPaths = ['hooks.json']; + + constructor( + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { } + + parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService); + return mapParsedHooks(parseCopilotHooks(json, workspaceRoot, userHome)); + } +} + +/** + * Characters in a file path that require shell quoting to prevent + * word splitting or interpretation by common shells (bash, zsh, cmd, PowerShell). + */ +const shellUnsafeChars = /[\s&|<>()^;!`"']/; + +/** + * Replaces `${CLAUDE_PLUGIN_ROOT}` in a shell command string with the + * given fsPath. If the path contains characters that would break shell + * parsing (e.g. spaces), occurrences are wrapped in double-quotes. + * + * The token may be followed by additional path segments like + * `${CLAUDE_PLUGIN_ROOT}/scripts/run.sh`; the entire resulting path + * (including suffix) is quoted as one unit. + * + */ +export function shellQuotePluginRootInCommand(command: string, fsPath: string, token: string = '${CLAUDE_PLUGIN_ROOT}'): string { + if (!command.includes(token)) { + return command; + } + + if (!shellUnsafeChars.test(fsPath)) { + // Path is shell-safe; plain replacement is fine. + return command.replaceAll(token, fsPath); + } + + // Replace each token occurrence (plus any trailing path chars that form + // a single filesystem argument) with a properly double-quoted expansion. + const escapedToken = escapeRegExpCharacters(token); + const pattern = new RegExp( + // Capture an optional leading quote so we know if it's already quoted + `(["']?)` + escapedToken + `([\\w./\\\\~:-]*)`, + 'g', + ); + + return command.replace(pattern, (_match, leadingQuote: string, suffix: string) => { + const fullPath = fsPath + suffix; + if (leadingQuote) { + // Already inside quotes — don't add more, just expand. + return leadingQuote + fullPath; + } + // Wrap in double quotes, escaping any embedded double-quote chars. + return '"' + fullPath.replace(/"/g, '\\"') + '"'; + }); +} + +class ClaudePluginFormatAdapter implements IAgentPluginFormatAdapter { + readonly format = AgentPluginFormat.Claude; + readonly manifestPaths = ['.claude-plugin/plugin.json']; + readonly hookConfigPaths = ['hooks/hooks.json']; + readonly hookWatchPaths = ['hooks']; + + constructor( + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { } + + parseHooks(json: unknown, pluginUri: URI, userHome: string): IAgentPluginHook[] { + const token = '${CLAUDE_PLUGIN_ROOT}'; + const fsPath = pluginUri.fsPath; + const typedJson = json as { hooks?: Record }; + + const mutateHookCommand = (hook: Mutable): void => { + for (const field of ['command', 'windows', 'linux', 'osx'] as const) { + if (typeof hook[field] === 'string') { + hook[field] = shellQuotePluginRootInCommand(hook[field], fsPath, token); + } + } + + hook.env ??= {}; + hook.env.CLAUDE_PLUGIN_ROOT = fsPath; + }; + + for (const lifecycle of Object.values(typedJson.hooks ?? {})) { + if (!Array.isArray(lifecycle)) { + continue; + } + + for (const lifecycleEntry of lifecycle) { + if (!lifecycleEntry || typeof lifecycleEntry !== 'object') { + continue; + } + + const entry = lifecycleEntry as { hooks?: Mutable[] } & Mutable; + if (Array.isArray(entry.hooks)) { + for (const hook of entry.hooks) { + mutateHookCommand(hook); + } + } else { + mutateHookCommand(entry); + } + } + } + const replacer = (v: unknown): unknown => { return typeof v === 'string' ? v.replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginUri.fsPath) : undefined; }; - const { hooks, disabledAllHooks } = parseClaudeHooks(cloneAndChange(json, replacer), pluginUri, userHome); + const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService); + const { hooks, disabledAllHooks } = parseClaudeHooks(cloneAndChange(json, replacer), workspaceRoot, userHome); if (disabledAllHooks) { return []; } return mapParsedHooks(hooks); - }, -}; + } +} export class AgentPluginService extends Disposable implements IAgentPluginService { @@ -164,6 +273,7 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IPathService private readonly _pathService: IPathService, @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); this._pluginPathsConfig = observableConfigValue>(ChatConfiguration.PluginPaths, {}, _configurationService); @@ -303,10 +413,10 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent private async _detectPluginFormatAdapter(pluginUri: URI): Promise { const isInClaudeDirectory = pluginUri.path.split('/').includes('.claude'); if (isInClaudeDirectory || await this._pathExists(joinPath(pluginUri, '.claude-plugin', 'plugin.json'))) { - return claudePluginFormatAdapter; + return this._instantiationService.createInstance(ClaudePluginFormatAdapter); } - return copilotPluginFormatAdapter; + return this._instantiationService.createInstance(CopilotPluginFormatAdapter); } private async _pathExists(resource: URI): Promise { @@ -521,7 +631,11 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent for (const hooksUri of adapter.hookConfigPaths.map(path => joinPath(pluginUri, path))) { const json = await this._readJsonFile(hooksUri); if (json) { - return adapter.parseHooks(json, pluginUri, userHome); + try { + return adapter.parseHooks(json, pluginUri, userHome); + } catch (e) { + this._logService.info(`[ConfiguredAgentPluginDiscovery] Failed to parse hooks from ${hooksUri.toString()}:`, e); + } } } @@ -530,7 +644,11 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent if (manifest && typeof manifest === 'object') { const hooks = (manifest as Record)['hooks']; if (hooks && typeof hooks === 'object') { - return adapter.parseHooks({ hooks }, pluginUri, userHome); + try { + return adapter.parseHooks({ hooks }, pluginUri, userHome); + } catch (e) { + this._logService.info(`[ConfiguredAgentPluginDiscovery] Failed to parse hooks from manifest ${manifestPath.toString()}:`, e); + } } } } @@ -562,15 +680,14 @@ export class ConfiguredAgentPluginDiscovery extends Disposable implements IAgent const skills: IAgentPluginSkill[] = []; for (const child of stat.children) { - if (!child.isFile || extname(child.resource).toLowerCase() !== COMMAND_FILE_SUFFIX) { + const skillMd = URI.joinPath(child.resource, 'SKILL.md'); + if (!(await this._pathExists(skillMd))) { continue; } - const name = basename(child.resource).slice(0, -COMMAND_FILE_SUFFIX.length); - skills.push({ - uri: child.resource, - name, + uri: skillMd, + name: basename(child.resource), }); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index ed04e3aa641e2..7b301db8f2c2e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -111,6 +111,7 @@ export enum PromptFileSource { ConfigPersonal = 'config-personal', ExtensionContribution = 'extension-contribution', ExtensionAPI = 'extension-api', + Plugin = 'plugin', } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 23113daea69c1..a2ca1942cf04f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -72,7 +72,8 @@ export const IPromptsService = createDecorator('IPromptsService export enum PromptsStorage { local = 'local', user = 'user', - extension = 'extension' + extension = 'extension', + plugin = 'plugin', } /** @@ -87,7 +88,7 @@ export enum ExtensionAgentSourceType { * Represents a prompt path with its type. * This is used for both prompt files and prompt source folders. */ -export type IPromptPath = IExtensionPromptPath | ILocalPromptPath | IUserPromptPath; +export type IPromptPath = IExtensionPromptPath | ILocalPromptPath | IUserPromptPath | IPluginPromptPath; export interface IPromptPathBase { @@ -131,12 +132,20 @@ export interface IUserPromptPath extends IPromptPathBase { readonly storage: PromptsStorage.user; } +export interface IPluginPromptPath extends IPromptPathBase { + readonly storage: PromptsStorage.plugin; + readonly pluginUri: URI; +} + export type IAgentSource = { readonly storage: PromptsStorage.extension; readonly extensionId: ExtensionIdentifier; readonly type: ExtensionAgentSourceType; } | { readonly storage: PromptsStorage.local | PromptsStorage.user; +} | { + readonly storage: PromptsStorage.plugin; + readonly pluginUri: URI; }; /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index e42b4fde1f921..38866f18fb46f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -33,7 +33,7 @@ import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAU import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target, IPromptDiscoveryLogEntry } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPluginPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, IPromptSourceFolderResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target, IPromptDiscoveryLogEntry } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; @@ -44,6 +44,7 @@ import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { getCanonicalPluginCommandId, IAgentPlugin, IAgentPluginService } from '../../plugins/agentPluginService.js'; +import { assertNever } from '../../../../../../base/common/assert.js'; /** * Error thrown when a skill file is missing the required name attribute. @@ -152,7 +153,7 @@ export class PromptsService extends Disposable implements IPromptsService { private readonly _onDidContributedWhenChange = this._register(new Emitter()); private readonly _onDidPluginPromptFilesChange = this._register(new Emitter()); private readonly _onDidPluginHooksChange = this._register(new Emitter()); - private _pluginPromptFilesByType = new Map(); + private _pluginPromptFilesByType = new Map(); constructor( @ILogService public readonly logger: ILogService, @@ -246,14 +247,15 @@ export class PromptsService extends Disposable implements IPromptsService { ) { return autorun(reader => { const plugins = this.agentPluginService.plugins.read(reader); - const nextFiles: ILocalPromptPath[] = []; + const nextFiles: IPluginPromptPath[] = []; for (const plugin of plugins) { for (const item of getItems(plugin, reader)) { nextFiles.push({ uri: item.uri, - storage: PromptsStorage.local, + storage: PromptsStorage.plugin, type, name: getCanonicalPluginCommandId(plugin, item.name), + pluginUri: plugin.uri, }); } } @@ -476,6 +478,8 @@ export class PromptsService extends Disposable implements IPromptsService { return this.fileLocator.listFiles(type, PromptsStorage.local, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.local, type } satisfies ILocalPromptPath))); case PromptsStorage.user: return this.fileLocator.listFiles(type, PromptsStorage.user, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.user, type } satisfies IUserPromptPath))); + case PromptsStorage.plugin: + return this._pluginPromptFilesByType.get(type) ?? []; default: throw new Error(`[listPromptFilesForStorage] Unsupported prompt storage type: ${storage}`); } @@ -832,7 +836,8 @@ export class PromptsService extends Disposable implements IPromptsService { case PromptsStorage.extension: { return localize('extension.with.id', 'Extension: {0}', promptPath.extension.displayName ?? promptPath.extension.id); } - default: throw new Error('Unknown prompt storage type'); + case PromptsStorage.plugin: return localize('plugin.capitalized', 'Plugin'); + default: assertNever(promptPath, 'Unknown prompt storage type'); } } @@ -1108,6 +1113,7 @@ export class PromptsService extends Disposable implements IPromptsService { configWorkspace: number; extensionContribution: number; extensionAPI: number; + plugin: number; skippedDuplicateName: number; skippedMissingName: number; skippedMissingDescription: number; @@ -1127,6 +1133,7 @@ export class PromptsService extends Disposable implements IPromptsService { configWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom configured workspace skills.' }; extensionContribution: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension contributed skills.' }; extensionAPI: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of extension API provided skills.' }; + plugin: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of plugin provided skills.' }; skippedDuplicateName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to duplicate names.' }; skippedMissingName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing name attribute.' }; skippedMissingDescription: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing description attribute.' }; @@ -1148,6 +1155,7 @@ export class PromptsService extends Disposable implements IPromptsService { configPersonal: skillsBySource.get(PromptFileSource.ConfigPersonal) ?? 0, extensionContribution: skillsBySource.get(PromptFileSource.ExtensionContribution) ?? 0, extensionAPI: skillsBySource.get(PromptFileSource.ExtensionAPI) ?? 0, + plugin: skillsBySource.get(PromptFileSource.Plugin) ?? 0, skippedDuplicateName, skippedMissingName, skippedMissingDescription, @@ -1383,10 +1391,15 @@ export class PromptsService extends Disposable implements IPromptsService { const allSkills: Array = []; const discoveredSkills = await this.fileLocator.findAgentSkills(token); const extensionSkills = await this.getExtensionPromptFiles(PromptsType.skill, token); + const pluginSkills = this._pluginPromptFilesByType.get(PromptsType.skill) ?? []; allSkills.push(...discoveredSkills, ...extensionSkills.map((extPath) => ({ fileUri: extPath.uri, storage: extPath.storage, source: extPath.source === ExtensionAgentSourceType.contribution ? PromptFileSource.ExtensionContribution : PromptFileSource.ExtensionAPI + })), ...pluginSkills.map((p) => ({ + fileUri: p.uri, + storage: p.storage, + source: PromptFileSource.Plugin, }))); const getPriority = (skill: IResolvedPromptFile | IExtensionPromptPath): number => { @@ -1396,13 +1409,16 @@ export class PromptsService extends Disposable implements IPromptsService { if (skill.storage === PromptsStorage.user) { return 1; // personal } + if (skill.storage === PromptsStorage.plugin) { + return 2; // plugin + } if (skill.source === PromptFileSource.ExtensionAPI) { - return 2; + return 3; } if (skill.source === PromptFileSource.ExtensionContribution) { - return 3; + return 4; } - return 4; + return 5; }; // Stable sort; we should keep order consistent to the order in the user's configuration object allSkills.sort((a, b) => getPriority(a) - getPriority(b)); @@ -1751,6 +1767,11 @@ namespace IAgentSource { extensionId: promptPath.extension.identifier, type: promptPath.source }; + } else if (promptPath.storage === PromptsStorage.plugin) { + return { + storage: PromptsStorage.plugin, + pluginUri: promptPath.pluginUri + }; } else { return { storage: promptPath.storage diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index e39c5df086f98..b9d21246bba9c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -12,7 +12,7 @@ import { ActionListItemKind, IActionListItem } from '../../../../../../../platfo import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../../platform/commands/common/commands.js'; import { StateType } from '../../../../../../../platform/update/common/update.js'; -import { buildModelPickerItems } from '../../../../browser/widget/input/chatModelPicker.js'; +import { buildModelPickerItems, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, IModelControlEntry } from '../../../../common/languageModels.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../../services/chat/common/chatEntitlementService.js'; @@ -104,6 +104,13 @@ suite('buildModelPickerItems', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('accessibility provider uses radio semantics for model items', () => { + const provider = getModelPickerAccessibilityProvider(); + assert.strictEqual(provider.getRole({ kind: ActionListItemKind.Action } as IActionListItem), 'menuitemradio'); + assert.strictEqual(provider.getRole({ kind: ActionListItemKind.Separator } as IActionListItem), 'separator'); + assert.strictEqual(provider.getWidgetRole(), 'menu'); + }); + test('auto model always appears first', () => { const auto = createAutoModel(); const modelA = createModel('gpt-4o', 'GPT-4o'); @@ -374,6 +381,22 @@ suite('buildModelPickerItems', () => { assert.strictEqual(gptItem.disabled, true); }); + test('Other Models places unavailable models after available models', () => { + const auto = createAutoModel(); + const availableModel = createModel('zeta', 'Zeta'); + const unavailableModel = createModel('alpha', 'Alpha'); + const items = callBuild([auto, availableModel, unavailableModel], { + controlModels: { + 'alpha': { label: 'Alpha', minVSCodeVersion: '2.0.0', exists: true }, + }, + currentVSCodeVersion: '1.90.0', + }); + const actions = getActionItems(items); + const otherModelLabels = actions.slice(2).map(a => a.label!).filter(l => !l.includes('Manage Models')); + assert.deepStrictEqual(otherModelLabels, ['Zeta', 'Alpha']); + assert.strictEqual(actions.find(a => a.label === 'Alpha')?.disabled, true); + }); + test('no duplicate models across sections', () => { const auto = createAutoModel(); const modelA = createModel('gpt-4o', 'GPT-4o'); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/shellQuotePluginRootInCommand.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/shellQuotePluginRootInCommand.test.ts new file mode 100644 index 0000000000000..973c7f28de272 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/plugins/shellQuotePluginRootInCommand.test.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { shellQuotePluginRootInCommand } from '../../../common/plugins/agentPluginServiceImpl.js'; + +suite('shellQuotePluginRootInCommand', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const TOKEN = '${PLUGIN_ROOT}'; + + test('returns command unchanged when token is not present', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('echo hello', '/safe/path', TOKEN), + 'echo hello', + ); + }); + + test('plain replacement when path has no special characters', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/safe/path', TOKEN), + '/safe/path/run.sh', + ); + }); + + test('plain replacement for multiple occurrences with safe path', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/a && ${PLUGIN_ROOT}/b', '/safe', TOKEN), + '/safe/a && /safe/b', + ); + }); + + test('quotes path with spaces', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/path with spaces', TOKEN), + '"/path with spaces/run.sh"', + ); + }); + + test('quotes path with ampersand', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/path&dir', TOKEN), + '"/path&dir/run.sh"', + ); + }); + + test('quotes multiple occurrences with unsafe path', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/a && ${PLUGIN_ROOT}/b', '/my dir', TOKEN), + '"/my dir/a" && "/my dir/b"', + ); + }); + + test('does not double-quote when already in double quotes', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('"${PLUGIN_ROOT}/run.sh"', '/my dir', TOKEN), + '"/my dir/run.sh"', + ); + }); + + test('does not double-quote when already in single quotes', () => { + assert.strictEqual( + shellQuotePluginRootInCommand(`'\${PLUGIN_ROOT}/run.sh'`, '/my dir', TOKEN), + `'/my dir/run.sh'`, + ); + }); + + test('escapes embedded double-quote characters in path', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/path"with"quotes', TOKEN), + '"/path\\"with\\"quotes/run.sh"', + ); + }); + + test('handles token without trailing path suffix', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('cd ${PLUGIN_ROOT} && run', '/my dir', TOKEN), + 'cd "/my dir" && run', + ); + }); + + test('does not consume shell operators adjacent to token', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('cd ${PLUGIN_ROOT}&& echo ok', '/my dir', TOKEN), + 'cd "/my dir"&& echo ok', + ); + }); + + test('handles token at start, middle and end of command', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/a ${PLUGIN_ROOT}/b ${PLUGIN_ROOT}/c', '/sp ace', TOKEN), + '"/sp ace/a" "/sp ace/b" "/sp ace/c"', + ); + }); + + test('uses default CLAUDE_PLUGIN_ROOT token when not specified', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${CLAUDE_PLUGIN_ROOT}/run.sh', '/safe/path'), + '/safe/path/run.sh', + ); + }); + + test('uses default CLAUDE_PLUGIN_ROOT token with quoting', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${CLAUDE_PLUGIN_ROOT}/run.sh', '/my dir'), + '"/my dir/run.sh"', + ); + }); + + test('handles Windows-style paths with spaces', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}\\scripts\\run.bat', 'C:\\Program Files\\plugin', TOKEN), + '"C:\\Program Files\\plugin\\scripts\\run.bat"', + ); + }); + + test('handles path with parentheses', () => { + assert.strictEqual( + shellQuotePluginRootInCommand('${PLUGIN_ROOT}/run.sh', '/path(1)', TOKEN), + '"/path(1)/run.sh"', + ); + }); +}); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts index 52ea6e4631636..97d99bf403bda 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatSessionServiceImpl.ts @@ -86,7 +86,7 @@ export class InlineChatSessionServiceImpl implements IInlineChatSessionService { const store = new DisposableStore(); store.add(toDisposable(() => { - this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource); + this._chatService.cancelCurrentRequestForSession(chatModel.sessionResource, true); chatModel.editingSession?.reject(); this._sessions.delete(uri); this._onDidChangeSessions.fire(this); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts index 946f14bcbcd06..606acf234a1b4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts @@ -412,7 +412,7 @@ export class TerminalChatWidget extends Disposable { if (!model?.sessionResource) { return; } - this._chatService.cancelCurrentRequestForSession(model?.sessionResource); + this._chatService.cancelCurrentRequestForSession(model?.sessionResource, true); } async viewInChat(): Promise { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 085c43ed463e9..56f9617ea7bfd 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -627,14 +627,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (!confirmationPrompt?.options.length) { return undefined; } - const model = this._chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)[0]?.input.currentLanguageModel; - if (!model) { - return undefined; + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts); + let model = this._chatWidgetService.getWidgetsByLocations(ChatAgentLocation.Chat)[0]?.input.currentLanguageModel; + if (model) { + const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: model.replaceAll('copilot/', '') }); + model = models[0]; } - - const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: model.replaceAll('copilot/', '') }); - if (!models.length) { - return undefined; + if (!model) { + model = await this._getLanguageModel(); } const prompt = confirmationPrompt.prompt; const options = confirmationPrompt.options; @@ -648,13 +648,22 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._lastPromptMarker = currentMarker; this._lastPrompt = prompt; - const promptText = `Given the following confirmation prompt and options from a terminal output, which option is the default?\nPrompt: "${prompt}"\nOptions: ${JSON.stringify(options)}\nRespond with only the option string.`; - const response = await this._languageModelsService.sendChatRequest(models[0], new ExtensionIdentifier('core'), [ - { role: ChatMessageRole.User, content: [{ type: 'text', value: promptText }] } - ], {}, token); + let suggestedOption = ''; + if (model) { + try { + const promptText = `Given the following confirmation prompt and options from a terminal output, which option is the default?\nPrompt: "${prompt}"\nOptions: ${JSON.stringify(options)}\nRespond with only the option string.`; + const response = await this._languageModelsService.sendChatRequest(model, new ExtensionIdentifier('core'), [ + { role: ChatMessageRole.User, content: [{ type: 'text', value: promptText }] } + ], {}, token); + + suggestedOption = (await getTextResponseFromStream(response)).trim(); + } catch (err) { + this._logService.trace('OutputMonitor: Failed to get suggested option from model', err); + } + } else if (!autoReply) { + return undefined; + } - const suggestedOption = (await getTextResponseFromStream(response)).trim(); - const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts); let validOption: string; let index: number; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index cb4aa2c1dc081..5e37377e3dfce 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { detectsGenericPressAnyKeyPattern, detectsInputRequiredPattern, detectsNonInteractiveHelpPattern, detectsVSCodeTaskFinishMessage, matchTerminalPromptOption, OutputMonitor } from '../../browser/tools/monitoring/outputMonitor.js'; -import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IExecution, IPollingResult, OutputMonitorState } from '../../browser/tools/monitoring/types.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; @@ -19,6 +19,10 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { IToolInvocationContext } from '../../../../chat/common/tools/languageModelToolsService.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import { isNumber } from '../../../../../../base/common/types.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; +import { IChatWidgetService } from '../../../../chat/browser/chat.js'; suite('OutputMonitor', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -73,6 +77,12 @@ suite('OutputMonitor', () => { } ); instantiationService.stub(ITerminalLogService, new NullLogService()); + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: false + })); + instantiationService.stub(IChatWidgetService, { + getWidgetsByLocations: () => [] + }); cts = new CancellationTokenSource(); }); @@ -204,6 +214,77 @@ suite('OutputMonitor', () => { }); }); + test('auto reply sends first option when model lookup is unavailable', async () => { + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: true + })); + instantiationService.stub(ILanguageModelsService, { + selectLanguageModels: async () => [] + }); + + const monitorCts = new CancellationTokenSource(); + monitorCts.cancel(); + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), monitorCts.token, 'test command')); + + const outputMonitorWithPrivateMethod = monitor as unknown as { + [key: string]: ((prompt: { prompt: string; options: string[]; detectedRequestForFreeFormInput: boolean }, token: CancellationToken) => Promise<{ suggestedOption: string | { description: string; option: string }; sentToTerminal: boolean } | undefined>) | undefined; + }; + const optionResult = await outputMonitorWithPrivateMethod['_selectAndHandleOption']!({ + prompt: 'Continue?', + options: ['y', 'n'], + detectedRequestForFreeFormInput: false + }, CancellationToken.None); + await Event.toPromise(monitor.onDidFinishCommand); + monitorCts.dispose(); + + assert.strictEqual(sendTextCalled, true, 'sendText should be called when auto reply is enabled'); + assert.strictEqual(optionResult?.sentToTerminal, true, 'option should be auto-sent'); + assert.strictEqual(optionResult?.suggestedOption, 'y', 'first option should be used as fallback'); + }); + + test('auto reply uses fallback model to derive suggested option', async () => { + instantiationService.stub(IConfigurationService, new TestConfigurationService({ + [TerminalChatAgentToolsSettingId.AutoReplyToPrompts]: true + })); + + let fallbackModelRequested = false; + instantiationService.stub(ILanguageModelsService, { + selectLanguageModels: async (selector: { id?: string }) => { + if (selector.id === 'copilot-fast') { + fallbackModelRequested = true; + return ['copilot-fast']; + } + return []; + }, + sendChatRequest: async () => ({ + stream: (async function* () { + yield { type: 'text', value: 'n' }; + })(), + result: Promise.resolve(undefined) + }) + }); + + const monitorCts = new CancellationTokenSource(); + monitorCts.cancel(); + monitor = store.add(instantiationService.createInstance(OutputMonitor, execution, undefined, createTestContext('1'), monitorCts.token, 'test command')); + + const outputMonitorWithPrivateMethod = monitor as unknown as { + [key: string]: ((prompt: { prompt: string; options: string[]; detectedRequestForFreeFormInput: boolean }, token: CancellationToken) => Promise<{ suggestedOption: string | { description: string; option: string }; sentToTerminal: boolean } | undefined>) | undefined; + }; + const optionResult = await outputMonitorWithPrivateMethod['_selectAndHandleOption']!({ + prompt: 'Continue?', + options: ['y', 'n'], + detectedRequestForFreeFormInput: false + }, CancellationToken.None); + await Event.toPromise(monitor.onDidFinishCommand); + monitorCts.dispose(); + + assert.strictEqual(fallbackModelRequested, true, 'fallback model should be requested via _getLanguageModel'); + assert.strictEqual(sendTextCalled, true, 'sendText should be called when auto reply is enabled'); + assert.strictEqual(optionResult?.sentToTerminal, true, 'option should be auto-sent'); + assert.strictEqual(optionResult?.suggestedOption, 'n', 'suggested option should be derived from fallback model response'); + }); + suite('detectsInputRequiredPattern', () => { test('detects yes/no confirmation prompts (pairs and variants)', () => { assert.strictEqual(detectsInputRequiredPattern('Continue? (y/N) '), true); diff --git a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts index 6692adf6bcdfa..444777d13cd54 100644 --- a/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/promptFilePickers.fixture.ts @@ -115,6 +115,8 @@ async function renderPromptFilePickerFixture({ container, disposableStore, theme return promptsState.userPromptFiles.filter(file => file.type === type); case PromptsStorage.extension: return promptsState.extensionPromptFiles.filter(file => file.type === type); + case PromptsStorage.plugin: + return []; } } diff --git a/src/vs/workbench/test/browser/notificationsPosition.test.ts b/src/vs/workbench/test/browser/notificationsPosition.test.ts index 7b39370d37c73..fac02b907daf5 100644 --- a/src/vs/workbench/test/browser/notificationsPosition.test.ts +++ b/src/vs/workbench/test/browser/notificationsPosition.test.ts @@ -9,7 +9,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/comm import { DEFAULT_CUSTOM_TITLEBAR_HEIGHT } from '../../../platform/window/common/window.js'; import { NotificationsPosition, NotificationsSettings } from '../../common/notifications.js'; import { Codicon } from '../../../base/common/codicons.js'; -import { hideIcon, hideUpIcon } from '../../browser/parts/notifications/notificationsActions.js'; +import { hideIcon, hideUpIcon, getNotificationExpandIcon, getNotificationCollapseIcon } from '../../browser/parts/notifications/notificationsActions.js'; suite('Notifications Position', () => { @@ -142,4 +142,31 @@ suite('Notifications Position', () => { assert.strictEqual(Codicon.chevronUp.id, 'chevron-up'); }); }); + + suite('Expand/Collapse Notification Icons', () => { + + test('bottom-right expand uses notifications-expand icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.BOTTOM_RIGHT).id, 'notifications-expand'); + }); + + test('bottom-left expand uses notifications-expand icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.BOTTOM_LEFT).id, 'notifications-expand'); + }); + + test('top-right expand uses notifications-expand-down icon', () => { + assert.strictEqual(getNotificationExpandIcon(NotificationsPosition.TOP_RIGHT).id, 'notifications-expand-down'); + }); + + test('bottom-right collapse uses notifications-collapse icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.BOTTOM_RIGHT).id, 'notifications-collapse'); + }); + + test('bottom-left collapse uses notifications-collapse icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.BOTTOM_LEFT).id, 'notifications-collapse'); + }); + + test('top-right collapse uses notifications-collapse-up icon', () => { + assert.strictEqual(getNotificationCollapseIcon(NotificationsPosition.TOP_RIGHT).id, 'notifications-collapse-up'); + }); + }); }); diff --git a/src/vscode-dts/vscode.proposed.chatDebug.d.ts b/src/vscode-dts/vscode.proposed.chatDebug.d.ts index 604cc0f0ef710..f74f4e7ba1159 100644 --- a/src/vscode-dts/vscode.proposed.chatDebug.d.ts +++ b/src/vscode-dts/vscode.proposed.chatDebug.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 1 +// version: 2 declare module 'vscode' { /** @@ -142,6 +142,37 @@ declare module 'vscode' { */ durationInMillis?: number; + /** + * The number of cached input tokens reused from a previous request. + */ + cachedTokens?: number; + + /** + * The time in milliseconds from sending the request to receiving the + * first response token. + */ + timeToFirstTokenInMillis?: number; + + /** + * The maximum number of prompt/input tokens allowed for this request. + */ + maxInputTokens?: number; + + /** + * The maximum number of response/output tokens allowed for this request. + */ + maxOutputTokens?: number; + + /** + * The short name or label identifying this request (e.g., "panel/editAgent"). + */ + requestName?: string; + + /** + * The outcome status of the model turn (e.g., "success", "failure", "canceled"). + */ + status?: string; + /** * Create a new ChatDebugModelTurnEvent. * @param created The timestamp when the event was created. @@ -447,13 +478,130 @@ declare module 'vscode' { constructor(type: ChatDebugMessageContentType, message: string, sections: ChatDebugMessageSection[]); } + /** + * Structured tool call content for a resolved chat debug event, + * containing the tool name, status, arguments, and output for rich rendering. + */ + export class ChatDebugEventToolCallContent { + /** + * The name of the tool that was called. + */ + toolName: string; + + /** + * The outcome of the tool call (e.g., "success" or "error"). + */ + result?: ChatDebugToolCallResult; + + /** + * How long the tool call took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * The serialized input (arguments) passed to the tool. + */ + input?: string; + + /** + * The serialized output (result) returned by the tool. + */ + output?: string; + + /** + * Create a new ChatDebugEventToolCallContent. + * @param toolName The name of the tool that was called. + */ + constructor(toolName: string); + } + + /** + * Structured model turn content for a resolved chat debug event, + * containing request metadata, token usage, and timing for rich rendering. + */ + export class ChatDebugEventModelTurnContent { + /** + * The short name or label identifying this request (e.g., "panel/editAgent"). + */ + requestName: string; + + /** + * The identifier of the model used (e.g., "claude-sonnet-4.5"). + */ + model?: string; + + /** + * The outcome status of the model turn (e.g., "success", "failure", "canceled"). + */ + status?: string; + + /** + * How long the model turn took to complete, in milliseconds. + */ + durationInMillis?: number; + + /** + * The time in milliseconds from sending the request to receiving the + * first response token. + */ + timeToFirstTokenInMillis?: number; + + /** + * The maximum number of prompt/input tokens allowed for this request. + */ + maxInputTokens?: number; + + /** + * The maximum number of response/output tokens allowed for this request. + */ + maxOutputTokens?: number; + + /** + * The number of tokens in the input/prompt. + */ + inputTokens?: number; + + /** + * The number of tokens in the model's output/completion. + */ + outputTokens?: number; + + /** + * The number of cached input tokens reused from a previous request. + */ + cachedTokens?: number; + + /** + * The total number of tokens consumed (input + output). + */ + totalTokens?: number; + + /** + * An error message, if the model turn failed. + */ + errorMessage?: string; + + /** + * Optional structured sections containing the full request/response details + * (e.g., system prompt, user prompt, tools, response). + * Rendered as collapsible sections in the detail view alongside the metadata. + */ + sections?: ChatDebugMessageSection[]; + + /** + * Create a new ChatDebugEventModelTurnContent. + * @param requestName The short name identifying this request. + */ + constructor(requestName: string); + } + /** * Union of all resolved event content types. * Extensions may also return {@link ChatDebugUserMessageEvent} or * {@link ChatDebugAgentResponseEvent} from resolve, which will be * automatically converted to structured message content. */ - export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; + export type ChatDebugResolvedEventContent = ChatDebugEventTextContent | ChatDebugEventMessageContent | ChatDebugEventToolCallContent | ChatDebugEventModelTurnContent | ChatDebugUserMessageEvent | ChatDebugAgentResponseEvent; /** * Union of all chat debug event types. Each type is a class,